diff --git a/sdks/python/pmxt/__init__.py b/sdks/python/pmxt/__init__.py index 7c792ec3..45b331a0 100644 --- a/sdks/python/pmxt/__init__.py +++ b/sdks/python/pmxt/__init__.py @@ -97,6 +97,10 @@ MatchedClusterSort, FetchMatchedMarketClustersParams, FetchMatchedEventClustersParams, + FetchMatchedMarketsParams, + FetchMatchedPricesParams, + MatchedMarketPair, + MatchedPricePair, SortOption, SearchIn, OrderSide, @@ -264,6 +268,10 @@ def restart_server() -> None: "MatchedClusterSort", "FetchMatchedMarketClustersParams", "FetchMatchedEventClustersParams", + "FetchMatchedMarketsParams", + "FetchMatchedPricesParams", + "MatchedMarketPair", + "MatchedPricePair", "MarketFilterCriteria", "MarketFilterFunction", "EventFilterCriteria", diff --git a/sdks/python/pmxt/models.py b/sdks/python/pmxt/models.py index 96ad499c..b8567011 100644 --- a/sdks/python/pmxt/models.py +++ b/sdks/python/pmxt/models.py @@ -957,6 +957,18 @@ def __getattr__(self, name: str) -> Any: FetchMatchedEventClustersParams = MatchedEventClusterParams +class FetchMatchedMarketsParams(TypedDict, total=False): + """Parameters for the Router's legacy matched-market pair endpoint.""" + + min_difference: float + category: str + limit: int + relations: List[MatchRelation] + + +FetchMatchedPricesParams = FetchMatchedMarketsParams + + @dataclass class MatchedMarketCluster: """A connected cluster of semantically matched markets across venues.""" @@ -1015,6 +1027,25 @@ class MatchedEventCluster: """Pairwise match edges used to build the cluster when requested.""" +@dataclass +class MatchedMarketPair: + """A pair of semantically matched markets with comparable prices.""" + + market_a: UnifiedMarket + market_b: UnifiedMarket + price_difference: float + venue_a: str + venue_b: str + price_a: float + price_b: float + relation: Optional[MatchRelation] = None + confidence: Optional[float] = None + reasoning: Optional[str] = None + + +MatchedPricePair = MatchedMarketPair + + @dataclass class PriceComparison: """Side-by-side price comparison for an identity-matched market.""" diff --git a/sdks/python/pmxt/router.py b/sdks/python/pmxt/router.py index 00e0071c..4b81e282 100644 --- a/sdks/python/pmxt/router.py +++ b/sdks/python/pmxt/router.py @@ -16,6 +16,10 @@ EventMatchResult, MatchedEventCluster, MatchedMarketCluster, + FetchMatchedMarketsParams, + FetchMatchedPricesParams, + MatchedMarketPair, + MatchedPricePair, PriceComparison, ArbitrageOpportunity, MatchRelation, @@ -109,6 +113,21 @@ def _parse_event_cluster(raw: Dict[str, Any]) -> MatchedEventCluster: ) +def _parse_matched_market_pair(raw: Dict[str, Any]) -> MatchedMarketPair: + return MatchedMarketPair( + market_a=_parse_market(raw.get("marketA", {})), + market_b=_parse_market(raw.get("marketB", {})), + price_difference=raw.get("priceDifference", raw.get("spread", 0.0)), + venue_a=raw.get("venueA", raw.get("buyVenue", "")), + venue_b=raw.get("venueB", raw.get("sellVenue", "")), + price_a=raw.get("priceA", raw.get("buyPrice", 0.0)), + price_b=raw.get("priceB", raw.get("sellPrice", 0.0)), + relation=raw.get("relation"), + confidence=raw.get("confidence"), + reasoning=raw.get("reasoning"), + ) + + class Router(Exchange): """Cross-venue intelligence layer. @@ -441,6 +460,57 @@ def fetch_matched_event_clusters( raw = self._get_catalog_path("/v0/matched-event-clusters", params) return [_parse_event_cluster(c) for c in _extract_response_list(raw)] + def fetch_matched_markets( + self, + params: Optional[FetchMatchedMarketsParams] = None, + *, + min_difference: Optional[float] = None, + category: Optional[str] = None, + limit: Optional[int] = None, + relations: Optional[List[MatchRelation]] = None, + ) -> List[MatchedMarketPair]: + """Fetch pairs of semantically matched markets with comparable prices. + + Deprecated: use :meth:`fetch_market_matches` without a market ID instead. + """ + request: FetchMatchedMarketsParams = {} + if params: + request.update(params) + if min_difference is not None: + request["min_difference"] = min_difference + if category is not None: + request["category"] = category + if limit is not None: + request["limit"] = limit + if relations is not None: + request["relations"] = relations + + raw = super().fetch_matched_markets(request or None) + return [_parse_matched_market_pair(pair) for pair in raw or []] + + def fetch_matched_prices( + self, + params: Optional[FetchMatchedPricesParams] = None, + *, + min_difference: Optional[float] = None, + category: Optional[str] = None, + limit: Optional[int] = None, + relations: Optional[List[MatchRelation]] = None, + ) -> List[MatchedPricePair]: + """Deprecated: use :meth:`fetch_matched_markets` instead.""" + warnings.warn( + "fetch_matched_prices is deprecated, use fetch_matched_markets instead", + DeprecationWarning, + stacklevel=2, + ) + return self.fetch_matched_markets( + params, + min_difference=min_difference, + category=category, + limit=limit, + relations=relations, + ) + # ------------------------------------------------------------------ # Price comparison # ------------------------------------------------------------------ diff --git a/sdks/python/tests/test_router_sql_orderbook.py b/sdks/python/tests/test_router_sql_orderbook.py index b1332c4d..98edd197 100644 --- a/sdks/python/tests/test_router_sql_orderbook.py +++ b/sdks/python/tests/test_router_sql_orderbook.py @@ -13,7 +13,7 @@ from typing import Any, Dict import pmxt -from pmxt.models import SqlResult, SqlColumn, SqlMeta, OrderBook +from pmxt.models import SqlResult, SqlColumn, SqlMeta, OrderBook, MatchedMarketPair from pmxt.router import Router @@ -102,6 +102,97 @@ def test_fetch_order_book_returns_single_book(monkeypatch): assert book.asks[0].price == 0.6 +def test_fetch_matched_markets_uses_typed_router_override(monkeypatch): + router = _make_router() + payload = { + "success": True, + "data": [ + { + "marketA": { + "marketId": "market-a", + "title": "Market A", + "outcomes": [], + "volume24h": 10, + "liquidity": 20, + "url": "https://example.test/a", + "sourceExchange": "venue-a", + }, + "marketB": { + "marketId": "market-b", + "title": "Market B", + "outcomes": [], + "volume24h": 30, + "liquidity": 40, + "url": "https://example.test/b", + "sourceExchange": "venue-b", + }, + "priceDifference": 0.12, + "venueA": "venue-a", + "venueB": "venue-b", + "priceA": 0.44, + "priceB": 0.56, + "relation": "identity", + "confidence": 0.97, + "reasoning": "same resolution condition", + } + ], + } + calls = _install_call_api(monkeypatch, router, payload) + + result = router.fetch_matched_markets( + min_difference=0.1, + relations=["identity"], + ) + + assert calls[0]["url"] == f"{BASE_URL}/api/router/fetchMatchedMarkets" + assert calls[0]["body"]["args"] == [{"minDifference": 0.1, "relations": ["identity"]}] + assert isinstance(result[0], MatchedMarketPair) + assert result[0].market_a.market_id == "market-a" + assert result[0].market_b.source_exchange == "venue-b" + assert result[0].price_difference == 0.12 + + +def test_fetch_matched_prices_is_typed_alias(monkeypatch): + router = _make_router() + payload = { + "success": True, + "data": [ + { + "marketA": { + "marketId": "market-a", + "title": "Market A", + "outcomes": [], + "volume24h": 0, + "liquidity": 0, + "url": "https://example.test/a", + }, + "marketB": { + "marketId": "market-b", + "title": "Market B", + "outcomes": [], + "volume24h": 0, + "liquidity": 0, + "url": "https://example.test/b", + }, + "priceDifference": 0.05, + "venueA": "a", + "venueB": "b", + "priceA": 0.45, + "priceB": 0.5, + } + ], + } + calls = _install_call_api(monkeypatch, router, payload) + + import pytest + + with pytest.warns(DeprecationWarning): + result = router.fetch_matched_prices(limit=2) + + assert calls[0]["url"] == f"{BASE_URL}/api/router/fetchMatchedMarkets" + assert isinstance(result[0], MatchedMarketPair) + + def test_router_class_retains_all_methods(): """AST check: fetch_order_book/sql are real Router methods and all the pre-existing public methods are still defined on the class body.""" @@ -118,6 +209,8 @@ def test_router_class_retains_all_methods(): "fetch_event_matches", "fetch_matched_market_clusters", "fetch_matched_event_clusters", + "fetch_matched_markets", + "fetch_matched_prices", "compare_market_prices", "fetch_hedges", "fetch_arbitrage", @@ -132,3 +225,8 @@ def test_sql_types_exported_from_package_root(): assert pmxt.SqlResult is SqlResult assert pmxt.SqlColumn is SqlColumn assert pmxt.SqlMeta is SqlMeta + + +def test_matched_pair_types_exported_from_package_root(): + assert pmxt.MatchedMarketPair is MatchedMarketPair + assert pmxt.MatchedPricePair is MatchedMarketPair diff --git a/sdks/typescript/pmxt/models.ts b/sdks/typescript/pmxt/models.ts index 5db743cb..867d55e2 100644 --- a/sdks/typescript/pmxt/models.ts +++ b/sdks/typescript/pmxt/models.ts @@ -1148,6 +1148,41 @@ export interface MatchedEventCluster { rawMatches?: MatchedEventClusterEdge[]; } +/** Parameters for the Router's legacy matched-market pair endpoint. */ +export interface FetchMatchedMarketsParams { + /** Only return pairs whose price difference meets this threshold. */ + minDifference?: number; + + /** Filter source markets by category. */ + category?: string; + + /** Maximum number of source markets to scan. */ + limit?: number; + + /** Relation types to include (defaults to identity). */ + relations?: MatchRelation[]; +} + +/** @deprecated Use {@link FetchMatchedMarketsParams} instead. */ +export type FetchMatchedPricesParams = FetchMatchedMarketsParams; + +/** A pair of semantically matched markets with their comparable prices. */ +export interface MatchedMarketPair { + marketA: UnifiedMarket; + marketB: UnifiedMarket; + priceDifference: number; + venueA: string; + venueB: string; + priceA: number; + priceB: number; + relation?: MatchRelation; + confidence?: number; + reasoning?: string | null; +} + +/** @deprecated Use {@link MatchedMarketPair} instead. */ +export type MatchedPricePair = MatchedMarketPair; + /** Side-by-side price comparison for a matched market. */ export interface PriceComparison { /** The matched market. */ diff --git a/sdks/typescript/pmxt/router.ts b/sdks/typescript/pmxt/router.ts index 7cc9ca3d..dbcd6e7e 100644 --- a/sdks/typescript/pmxt/router.ts +++ b/sdks/typescript/pmxt/router.ts @@ -16,6 +16,10 @@ import { MatchedMarketClusterParams, MatchedEventCluster, MatchedMarketCluster, + FetchMatchedMarketsParams, + FetchMatchedPricesParams, + MatchedMarketPair, + MatchedPricePair, PriceComparison, ArbitrageOpportunity, UnifiedMarket, @@ -191,6 +195,21 @@ function parseMatchedEventCluster(raw: any): MatchedEventCluster { }; } +function parseMatchedMarketPair(raw: any): MatchedMarketPair { + return { + marketA: convertMarket(raw.marketA || {}), + marketB: convertMarket(raw.marketB || {}), + priceDifference: raw.priceDifference ?? raw.spread ?? 0, + venueA: raw.venueA ?? raw.buyVenue ?? '', + venueB: raw.venueB ?? raw.sellVenue ?? '', + priceA: raw.priceA ?? raw.buyPrice ?? 0, + priceB: raw.priceB ?? raw.sellPrice ?? 0, + relation: raw.relation, + confidence: raw.confidence, + reasoning: raw.reasoning ?? null, + }; +} + /** Options for creating a Router. */ export interface RouterOptions { /** PMXT API key (required for hosted mode). */ @@ -446,6 +465,22 @@ export class Router extends Exchange { } } + /** + * Fetch pairs of semantically matched markets with comparable prices. + * + * @deprecated Use {@link fetchMarketMatches} without a market ID instead. + */ + async fetchMatchedMarkets(params?: FetchMatchedMarketsParams): Promise { + const raw = await super.fetchMatchedMarkets(params); + return (raw || []).map(parseMatchedMarketPair); + } + + /** @deprecated Use {@link fetchMatchedMarkets} instead. */ + async fetchMatchedPrices(params?: FetchMatchedPricesParams): Promise { + logger.warn('fetchMatchedPrices is deprecated, use fetchMatchedMarkets instead'); + return this.fetchMatchedMarkets(params); + } + // ------------------------------------------------------------------ // Price comparison // ------------------------------------------------------------------ diff --git a/sdks/typescript/tests/router-sql-orderbook.test.ts b/sdks/typescript/tests/router-sql-orderbook.test.ts index 25b7592e..062d1cf3 100644 --- a/sdks/typescript/tests/router-sql-orderbook.test.ts +++ b/sdks/typescript/tests/router-sql-orderbook.test.ts @@ -1,4 +1,5 @@ import { Router } from "../pmxt/router"; +import type { MatchedMarketPair } from "../pmxt/models"; const PMXT_API_KEY = "test_pmxt_key_xxx"; const BASE_URL = "https://api.example.test"; @@ -103,3 +104,68 @@ describe("Router.fetchOrderBook", () => { expect(result.asks[0].price).toBe(0.6); }); }); + +describe("Router matched-market pair methods", () => { + const pairPayload = { + marketA: { + marketId: "market-a", + title: "Market A", + outcomes: [], + volume24h: 10, + liquidity: 20, + url: "https://example.test/a", + sourceExchange: "venue-a", + }, + marketB: { + marketId: "market-b", + title: "Market B", + outcomes: [], + volume24h: 30, + liquidity: 40, + url: "https://example.test/b", + sourceExchange: "venue-b", + }, + priceDifference: 0.12, + venueA: "venue-a", + venueB: "venue-b", + priceA: 0.44, + priceB: 0.56, + relation: "identity", + confidence: 0.97, + reasoning: "same resolution condition", + }; + + it("uses the typed Router override and converts nested markets", async () => { + const { captured } = installFetchSpy(() => + jsonResponse({ success: true, data: [pairPayload] }), + ); + const router = makeRouter(); + + const result: MatchedMarketPair[] = await router.fetchMatchedMarkets({ + minDifference: 0.1, + relations: ["identity"], + }); + + expect(Object.prototype.hasOwnProperty.call(Router.prototype, "fetchMatchedMarkets")).toBe(true); + expect(captured[0].url).toBe(`${BASE_URL}/api/router/fetchMatchedMarkets`); + expect(JSON.parse(captured[0].init?.body as string).args).toEqual([ + { minDifference: 0.1, relations: ["identity"] }, + ]); + expect(result[0].marketA.marketId).toBe("market-a"); + expect(result[0].marketB.sourceExchange).toBe("venue-b"); + expect(result[0].priceDifference).toBe(0.12); + }); + + it("keeps fetchMatchedPrices as a typed alias", async () => { + const { captured } = installFetchSpy(() => + jsonResponse({ success: true, data: [pairPayload] }), + ); + const router = makeRouter(); + + const result: MatchedMarketPair[] = await router.fetchMatchedPrices({ limit: 2 }); + + expect(Object.prototype.hasOwnProperty.call(Router.prototype, "fetchMatchedPrices")).toBe(true); + expect(captured[0].url).toBe(`${BASE_URL}/api/router/fetchMatchedMarkets`); + expect(result[0].venueA).toBe("venue-a"); + }); +});