Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions sdks/python/pmxt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@
MatchedClusterSort,
FetchMatchedMarketClustersParams,
FetchMatchedEventClustersParams,
FetchMatchedMarketsParams,
FetchMatchedPricesParams,
MatchedMarketPair,
MatchedPricePair,
SortOption,
SearchIn,
OrderSide,
Expand Down Expand Up @@ -264,6 +268,10 @@ def restart_server() -> None:
"MatchedClusterSort",
"FetchMatchedMarketClustersParams",
"FetchMatchedEventClustersParams",
"FetchMatchedMarketsParams",
"FetchMatchedPricesParams",
"MatchedMarketPair",
"MatchedPricePair",
"MarketFilterCriteria",
"MarketFilterFunction",
"EventFilterCriteria",
Expand Down
31 changes: 31 additions & 0 deletions sdks/python/pmxt/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
70 changes: 70 additions & 0 deletions sdks/python/pmxt/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
EventMatchResult,
MatchedEventCluster,
MatchedMarketCluster,
FetchMatchedMarketsParams,
FetchMatchedPricesParams,
MatchedMarketPair,
MatchedPricePair,
PriceComparison,
ArbitrageOpportunity,
MatchRelation,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
100 changes: 99 additions & 1 deletion sdks/python/tests/test_router_sql_orderbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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."""
Expand All @@ -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",
Expand All @@ -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
35 changes: 35 additions & 0 deletions sdks/typescript/pmxt/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
35 changes: 35 additions & 0 deletions sdks/typescript/pmxt/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
MatchedMarketClusterParams,
MatchedEventCluster,
MatchedMarketCluster,
FetchMatchedMarketsParams,
FetchMatchedPricesParams,
MatchedMarketPair,
MatchedPricePair,
PriceComparison,
ArbitrageOpportunity,
UnifiedMarket,
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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<MatchedMarketPair[]> {
const raw = await super.fetchMatchedMarkets(params);
return (raw || []).map(parseMatchedMarketPair);
}

/** @deprecated Use {@link fetchMatchedMarkets} instead. */
async fetchMatchedPrices(params?: FetchMatchedPricesParams): Promise<MatchedPricePair[]> {
logger.warn('fetchMatchedPrices is deprecated, use fetchMatchedMarkets instead');
return this.fetchMatchedMarkets(params);
}

// ------------------------------------------------------------------
// Price comparison
// ------------------------------------------------------------------
Expand Down
Loading