Skip to content

Commit 3ab079d

Browse files
jentykclaude
andcommitted
feat(exchange): add the exchange pairs service
The /public/v1/exchange/pairs collection endpoint had no service class, so currency pairs were unreachable from both the sync and async clients. Add PairsService and AsyncPairsService with the Pair model, and expose them as the `pairs` property on Exchange and AsyncExchange. Mixins follow what the OpenAPI spec documents for this endpoint: a GET collection (CollectionMixin) and a GET by id on /pairs/{id} (GetMixin). The collection-level bulk POST, PUT and DELETE operations take a PairBulkData envelope and have no matching mixin or precedent in this client, so they are left out of this change. Streaming support is tracked separately in MPT-24241. MPT-24264 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a9aece1 commit 3ab079d

4 files changed

Lines changed: 262 additions & 0 deletions

File tree

‎mpt_api_client/resources/exchange/exchange.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
AsyncCurrenciesService,
44
CurrenciesService,
55
)
6+
from mpt_api_client.resources.exchange.pairs import AsyncPairsService, PairsService
67

78

89
class Exchange:
@@ -16,6 +17,11 @@ def currencies(self) -> CurrenciesService:
1617
"""Currencies service."""
1718
return CurrenciesService(http_client=self.http_client)
1819

20+
@property
21+
def pairs(self) -> PairsService:
22+
"""Pairs service."""
23+
return PairsService(http_client=self.http_client)
24+
1925

2026
class AsyncExchange:
2127
"""Exchange MPT API Module."""
@@ -27,3 +33,8 @@ def __init__(self, *, http_client: AsyncHTTPClient):
2733
def currencies(self) -> AsyncCurrenciesService:
2834
"""Currencies service."""
2935
return AsyncCurrenciesService(http_client=self.http_client)
36+
37+
@property
38+
def pairs(self) -> AsyncPairsService:
39+
"""Pairs service."""
40+
return AsyncPairsService(http_client=self.http_client)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from mpt_api_client.http import AsyncService, Service
2+
from mpt_api_client.http.mixins import (
3+
AsyncCollectionMixin,
4+
AsyncGetMixin,
5+
CollectionMixin,
6+
GetMixin,
7+
)
8+
from mpt_api_client.models import Model
9+
from mpt_api_client.models.model import BaseModel
10+
11+
12+
class Pair(Model):
13+
"""Exchange currency pair resource.
14+
15+
Attributes:
16+
name: Pair name.
17+
external_id: External identifier of the pair.
18+
notes: Free-form notes for the pair.
19+
primary: Whether the pair is the primary one for its currencies.
20+
reverse: Reference to the pair holding the reverse conversion.
21+
source_currency: Currency converted from.
22+
destination_currency: Currency converted to.
23+
latest_rate: Most recent rate recorded for the pair.
24+
agreements: Number of agreements using the pair.
25+
rates: Number of rates recorded for the pair.
26+
status: Current status of the pair.
27+
revision: Revision number.
28+
audit: Audit information (created, updated events).
29+
"""
30+
31+
name: str | None
32+
external_id: str | None
33+
notes: str | None
34+
primary: bool | None
35+
reverse: BaseModel | None
36+
source_currency: BaseModel | None
37+
destination_currency: BaseModel | None
38+
latest_rate: BaseModel | None
39+
agreements: int | None
40+
rates: int | None
41+
status: str | None
42+
revision: int | None
43+
audit: BaseModel | None
44+
45+
46+
class PairsServiceConfig:
47+
"""Exchange Pairs service configuration."""
48+
49+
_endpoint = "/public/v1/exchange/pairs"
50+
_model_class = Pair
51+
_collection_key = "data"
52+
53+
54+
class PairsService(
55+
GetMixin[Pair],
56+
CollectionMixin[Pair],
57+
Service[Pair],
58+
PairsServiceConfig,
59+
):
60+
"""Exchange Pairs service."""
61+
62+
63+
class AsyncPairsService(
64+
AsyncGetMixin[Pair],
65+
AsyncCollectionMixin[Pair],
66+
AsyncService[Pair],
67+
PairsServiceConfig,
68+
):
69+
"""Async Exchange Pairs service."""

‎tests/unit/resources/exchange/test_exchange.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
CurrenciesService,
66
)
77
from mpt_api_client.resources.exchange.exchange import AsyncExchange, Exchange
8+
from mpt_api_client.resources.exchange.pairs import AsyncPairsService, PairsService
89

910

1011
@pytest.fixture
@@ -41,3 +42,17 @@ def test_async_exchange_currencies_property(async_exchange):
4142

4243
assert isinstance(result, AsyncCurrenciesService)
4344
assert result.http_client is async_exchange.http_client
45+
46+
47+
def test_exchange_pairs_property(exchange):
48+
result = exchange.pairs
49+
50+
assert isinstance(result, PairsService)
51+
assert result.http_client is exchange.http_client
52+
53+
54+
def test_async_exchange_pairs_property(async_exchange):
55+
result = async_exchange.pairs
56+
57+
assert isinstance(result, AsyncPairsService)
58+
assert result.http_client is async_exchange.http_client
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import httpx
2+
import pytest
3+
import respx
4+
5+
from mpt_api_client.models.model import BaseModel
6+
from mpt_api_client.resources.exchange.pairs import AsyncPairsService, Pair, PairsService
7+
8+
9+
@pytest.fixture
10+
def pairs_service(http_client):
11+
return PairsService(http_client=http_client)
12+
13+
14+
@pytest.fixture
15+
def async_pairs_service(async_http_client):
16+
return AsyncPairsService(http_client=async_http_client)
17+
18+
19+
@pytest.fixture
20+
def pair_data():
21+
return {
22+
"id": "PAI-001",
23+
"name": "USD/EUR",
24+
"externalId": "EXT-001",
25+
"notes": "Primary conversion pair",
26+
"primary": True,
27+
"reverse": {"id": "PAI-002", "name": "EUR/USD"},
28+
"sourceCurrency": {"id": "CUR-001", "code": "USD"},
29+
"destinationCurrency": {"id": "CUR-002", "code": "EUR"},
30+
"latestRate": {"id": "RAT-001", "value": 0.92},
31+
"agreements": 3,
32+
"rates": 12,
33+
"status": "Active",
34+
"revision": 1,
35+
"audit": {"created": {"at": "2024-01-01T00:00:00Z"}},
36+
}
37+
38+
39+
@pytest.mark.parametrize("method", ["get", "fetch_page", "fetch_one", "iterate"])
40+
def test_mixins_present(pairs_service, method):
41+
result = hasattr(pairs_service, method)
42+
43+
assert result is True
44+
45+
46+
@pytest.mark.parametrize("method", ["get", "fetch_page", "fetch_one", "iterate"])
47+
def test_async_mixins_present(async_pairs_service, method):
48+
result = hasattr(async_pairs_service, method)
49+
50+
assert result is True
51+
52+
53+
def test_pairs_service_endpoint(pairs_service):
54+
result = pairs_service.build_path()
55+
56+
assert result == "/public/v1/exchange/pairs"
57+
58+
59+
def test_async_pairs_service_endpoint(async_pairs_service):
60+
result = async_pairs_service.build_path()
61+
62+
assert result == "/public/v1/exchange/pairs"
63+
64+
65+
def test_pair_primitive_fields(pair_data):
66+
result = Pair(pair_data)
67+
68+
assert result.to_dict() == pair_data
69+
70+
71+
@pytest.mark.parametrize("field", ["name", "external_id", "notes", "status"])
72+
def test_pair_string_fields(pair_data, field):
73+
result = Pair(pair_data)
74+
75+
assert isinstance(getattr(result, field), str)
76+
77+
78+
@pytest.mark.parametrize("field", ["agreements", "rates", "revision"])
79+
def test_pair_integer_fields(pair_data, field):
80+
result = Pair(pair_data)
81+
82+
assert isinstance(getattr(result, field), int)
83+
84+
85+
def test_pair_primary_field(pair_data):
86+
result = Pair(pair_data)
87+
88+
assert result.primary is True
89+
90+
91+
@pytest.mark.parametrize(
92+
"field",
93+
["reverse", "source_currency", "destination_currency", "latest_rate", "audit"],
94+
)
95+
def test_pair_nested_model_fields(pair_data, field):
96+
result = Pair(pair_data)
97+
98+
assert isinstance(getattr(result, field), BaseModel)
99+
100+
101+
@pytest.mark.parametrize("field", ["name", "external_id", "status", "audit"])
102+
def test_pair_optional_fields_absent(field):
103+
result = Pair({"id": "PAI-001"})
104+
105+
assert not hasattr(result, field)
106+
107+
108+
def test_pair_id_present():
109+
result = Pair({"id": "PAI-001"})
110+
111+
assert result.id == "PAI-001"
112+
113+
114+
def test_get_pair(pairs_service):
115+
pair_id = "PAI-001"
116+
expected_response = {"id": pair_id, "name": "USD/EUR", "primary": True}
117+
with respx.mock:
118+
respx.get(f"https://api.example.com/public/v1/exchange/pairs/{pair_id}").mock(
119+
return_value=httpx.Response(httpx.codes.OK, json=expected_response)
120+
)
121+
122+
result = pairs_service.get(pair_id)
123+
124+
assert result.to_dict() == expected_response
125+
126+
127+
async def test_async_get_pair(async_pairs_service):
128+
pair_id = "PAI-001"
129+
expected_response = {"id": pair_id, "name": "USD/EUR", "primary": True}
130+
with respx.mock:
131+
respx.get(f"https://api.example.com/public/v1/exchange/pairs/{pair_id}").mock(
132+
return_value=httpx.Response(httpx.codes.OK, json=expected_response)
133+
)
134+
135+
result = await async_pairs_service.get(pair_id)
136+
137+
assert result.to_dict() == expected_response
138+
139+
140+
def test_fetch_page_pairs(pairs_service):
141+
expected_pairs = [{"id": "PAI-001", "name": "USD/EUR"}, {"id": "PAI-002", "name": "EUR/USD"}]
142+
with respx.mock:
143+
respx.get("https://api.example.com/public/v1/exchange/pairs").mock(
144+
return_value=httpx.Response(
145+
httpx.codes.OK,
146+
json={"data": expected_pairs, "$meta": {"pagination": {"total": 2}}},
147+
)
148+
)
149+
150+
result = pairs_service.fetch_page()
151+
152+
assert [pair.to_dict() for pair in result] == expected_pairs
153+
154+
155+
async def test_async_fetch_page_pairs(async_pairs_service):
156+
expected_pairs = [{"id": "PAI-001", "name": "USD/EUR"}, {"id": "PAI-002", "name": "EUR/USD"}]
157+
with respx.mock:
158+
respx.get("https://api.example.com/public/v1/exchange/pairs").mock(
159+
return_value=httpx.Response(
160+
httpx.codes.OK,
161+
json={"data": expected_pairs, "$meta": {"pagination": {"total": 2}}},
162+
)
163+
)
164+
165+
result = await async_pairs_service.fetch_page()
166+
167+
assert [pair.to_dict() for pair in result] == expected_pairs

0 commit comments

Comments
 (0)