aress-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aress_sdk/__init__.py +278 -0
- aress_sdk/_resource.py +40 -0
- aress_sdk/_transport.py +271 -0
- aress_sdk/_types.py +71 -0
- aress_sdk/_version.py +3 -0
- aress_sdk/bonds/__init__.py +11 -0
- aress_sdk/bonds/models.py +63 -0
- aress_sdk/bonds/resource.py +70 -0
- aress_sdk/client.py +184 -0
- aress_sdk/config.py +79 -0
- aress_sdk/currencies/__init__.py +11 -0
- aress_sdk/currencies/models.py +31 -0
- aress_sdk/currencies/resource.py +41 -0
- aress_sdk/exceptions.py +167 -0
- aress_sdk/funds/__init__.py +87 -0
- aress_sdk/funds/models.py +686 -0
- aress_sdk/funds/resource.py +594 -0
- aress_sdk/ime/__init__.py +36 -0
- aress_sdk/ime/models.py +296 -0
- aress_sdk/ime/resource.py +218 -0
- aress_sdk/instruments/__init__.py +50 -0
- aress_sdk/instruments/models.py +364 -0
- aress_sdk/instruments/resource.py +399 -0
- aress_sdk/market/__init__.py +24 -0
- aress_sdk/market/models.py +94 -0
- aress_sdk/market/resource.py +56 -0
- aress_sdk/py.typed +0 -0
- aress_sdk/reports/__init__.py +30 -0
- aress_sdk/reports/models.py +160 -0
- aress_sdk/reports/resource.py +131 -0
- aress_sdk/reports/result_types.py +1855 -0
- aress_sdk/tradingview/__init__.py +24 -0
- aress_sdk/tradingview/models.py +131 -0
- aress_sdk/tradingview/resource.py +148 -0
- aress_sdk-0.1.0.dist-info/METADATA +200 -0
- aress_sdk-0.1.0.dist-info/RECORD +38 -0
- aress_sdk-0.1.0.dist-info/WHEEL +4 -0
- aress_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
aress_sdk/__init__.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Aress SDK — official Python client for the Aress Data API.
|
|
2
|
+
|
|
3
|
+
Fetch instruments, bonds, currencies and (preview) funds from the Iranian
|
|
4
|
+
capital market over the Aress Data HTTP API.
|
|
5
|
+
|
|
6
|
+
Quickstart::
|
|
7
|
+
|
|
8
|
+
from aress_sdk import AressClient
|
|
9
|
+
|
|
10
|
+
with AressClient("<your api key>") as client:
|
|
11
|
+
for currency in client.currencies.list():
|
|
12
|
+
print(currency.symbol, currency.farsi_name)
|
|
13
|
+
|
|
14
|
+
See :class:`AressClient` / :class:`AsyncAressClient` for the full surface.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .client import AressClient, AsyncAressClient
|
|
19
|
+
from .config import (
|
|
20
|
+
API_KEY_HEADER,
|
|
21
|
+
DEFAULT_BACKOFF_FACTOR,
|
|
22
|
+
DEFAULT_BASE_URL,
|
|
23
|
+
DEFAULT_MAX_RETRIES,
|
|
24
|
+
DEFAULT_TIMEOUT,
|
|
25
|
+
ClientConfig,
|
|
26
|
+
)
|
|
27
|
+
from .exceptions import (
|
|
28
|
+
AressAPIStatusError,
|
|
29
|
+
AressConnectionError,
|
|
30
|
+
AressError,
|
|
31
|
+
AressTimeoutError,
|
|
32
|
+
AressTransportError,
|
|
33
|
+
AuthenticationError,
|
|
34
|
+
BadRequestError,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
PermissionDeniedError,
|
|
37
|
+
RateLimitError,
|
|
38
|
+
ServerError,
|
|
39
|
+
UnprocessableEntityError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Domain models — re-exported for convenient typing/imports.
|
|
43
|
+
from .bonds import Bond, BondDailyTradeHistory
|
|
44
|
+
from .currencies import Currency, CurrencyDailyHistory
|
|
45
|
+
from .funds import (
|
|
46
|
+
CategoryAnnualPeriodAnalysis,
|
|
47
|
+
CategoryIssueRedeemSeriesItem,
|
|
48
|
+
CategoryIssueRedeemAnalysisItem,
|
|
49
|
+
CategoryMonthlyPeriodAnalysis,
|
|
50
|
+
CategoryPeriodAnalysis,
|
|
51
|
+
SubFund,
|
|
52
|
+
FundManager,
|
|
53
|
+
FundAnalysis,
|
|
54
|
+
FundNavAndReturnHistoryItem,
|
|
55
|
+
IndexReturnHistoryItem,
|
|
56
|
+
FundAnnualPeriodAnalysis,
|
|
57
|
+
FundAssetAllocation,
|
|
58
|
+
FundCategory,
|
|
59
|
+
FundFinancialRegime,
|
|
60
|
+
FundIssueRedeemSeriesItem,
|
|
61
|
+
FundMonthlyPeriodAnalysis,
|
|
62
|
+
FundNavHistoryItem,
|
|
63
|
+
FundPeriodAnalysis,
|
|
64
|
+
FundRecentAdjustedNavHistoryItem,
|
|
65
|
+
FundSummaryAnalysis,
|
|
66
|
+
FundType,
|
|
67
|
+
SubFundWithSummaryAnalysis,
|
|
68
|
+
SubFundFlowsSummary,
|
|
69
|
+
AumFlowPoint,
|
|
70
|
+
SubFundDailySeriesPoint,
|
|
71
|
+
FundMonthlyReturn,
|
|
72
|
+
RollingPeriodAnalysis,
|
|
73
|
+
RollingPeriodAnalysisVsBenchmark,
|
|
74
|
+
RollingPeriodDecomposition,
|
|
75
|
+
RollingPeriodWindowDecomposition,
|
|
76
|
+
RollingPeriodWindowAnalysis,
|
|
77
|
+
RollingPeriodWindowAnalysisVsBenchmark,
|
|
78
|
+
RollingWindow,
|
|
79
|
+
TotalIndexAnnualPeriodAnalysis,
|
|
80
|
+
TotalIndexMonthlyPeriodAnalysis,
|
|
81
|
+
)
|
|
82
|
+
from .instruments import (
|
|
83
|
+
DailyClientTypeHistory,
|
|
84
|
+
DailyIndexHistory,
|
|
85
|
+
DailyTradeHistory,
|
|
86
|
+
Index,
|
|
87
|
+
IndexReturnHistoryPoint,
|
|
88
|
+
Instrument,
|
|
89
|
+
OptionMarketLive,
|
|
90
|
+
OptionMarketLiveEntry,
|
|
91
|
+
OptionMarketLiveLeg,
|
|
92
|
+
PriceAdjustment,
|
|
93
|
+
RealtimeClientType,
|
|
94
|
+
RealtimeIndex,
|
|
95
|
+
RealtimeInstrument,
|
|
96
|
+
RealtimeMarket,
|
|
97
|
+
RealtimeOrderBook,
|
|
98
|
+
RealtimeOrderBookRow,
|
|
99
|
+
Sector,
|
|
100
|
+
ShareChange,
|
|
101
|
+
SubSector,
|
|
102
|
+
UnderlyingOptionMarketLive,
|
|
103
|
+
)
|
|
104
|
+
from .ime import (
|
|
105
|
+
ImeCdcContract,
|
|
106
|
+
ImeCdcDailyTradeHistory,
|
|
107
|
+
ImeOptionContract,
|
|
108
|
+
ImeCdcLive,
|
|
109
|
+
ImeClientType,
|
|
110
|
+
ImeListedInstrumentLive,
|
|
111
|
+
ImeMarketLive,
|
|
112
|
+
ImeOptionChainLive,
|
|
113
|
+
ImeOptionExpirationLive,
|
|
114
|
+
ImeOptionLegLive,
|
|
115
|
+
ImeOptionStrikeLive,
|
|
116
|
+
ImeOrderBookRow,
|
|
117
|
+
ImeUnderlyingOptionChainLive,
|
|
118
|
+
)
|
|
119
|
+
from .market import (
|
|
120
|
+
MarketListWindow,
|
|
121
|
+
RealtimeStockFlow,
|
|
122
|
+
RealtimeStockFlows,
|
|
123
|
+
SectorTradeValue,
|
|
124
|
+
SectorTradeValues,
|
|
125
|
+
StockPriceChange,
|
|
126
|
+
StockPriceChanges,
|
|
127
|
+
)
|
|
128
|
+
from .tradingview import (
|
|
129
|
+
TradingViewBar,
|
|
130
|
+
TradingViewConfig,
|
|
131
|
+
TradingViewExchange,
|
|
132
|
+
TradingViewHistory,
|
|
133
|
+
TradingViewSearchResult,
|
|
134
|
+
TradingViewSymbolInfo,
|
|
135
|
+
TradingViewSymbolType,
|
|
136
|
+
)
|
|
137
|
+
from .reports import (
|
|
138
|
+
AppliedFilter,
|
|
139
|
+
ReportBatchResultItem,
|
|
140
|
+
ReportCalculationResultType,
|
|
141
|
+
ReportCategory,
|
|
142
|
+
ReportFilter,
|
|
143
|
+
ReportFilterOption,
|
|
144
|
+
ReportMetadata,
|
|
145
|
+
ReportRequest,
|
|
146
|
+
ReportResult,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
__all__ = [
|
|
150
|
+
"__version__",
|
|
151
|
+
# clients
|
|
152
|
+
"AressClient",
|
|
153
|
+
"AsyncAressClient",
|
|
154
|
+
# config
|
|
155
|
+
"ClientConfig",
|
|
156
|
+
"API_KEY_HEADER",
|
|
157
|
+
"DEFAULT_BASE_URL",
|
|
158
|
+
"DEFAULT_TIMEOUT",
|
|
159
|
+
"DEFAULT_MAX_RETRIES",
|
|
160
|
+
"DEFAULT_BACKOFF_FACTOR",
|
|
161
|
+
# exceptions
|
|
162
|
+
"AressError",
|
|
163
|
+
"AressTransportError",
|
|
164
|
+
"AressConnectionError",
|
|
165
|
+
"AressTimeoutError",
|
|
166
|
+
"AressAPIStatusError",
|
|
167
|
+
"BadRequestError",
|
|
168
|
+
"AuthenticationError",
|
|
169
|
+
"PermissionDeniedError",
|
|
170
|
+
"NotFoundError",
|
|
171
|
+
"UnprocessableEntityError",
|
|
172
|
+
"RateLimitError",
|
|
173
|
+
"ServerError",
|
|
174
|
+
# instruments
|
|
175
|
+
"Sector",
|
|
176
|
+
"SubSector",
|
|
177
|
+
"Instrument",
|
|
178
|
+
"PriceAdjustment",
|
|
179
|
+
"ShareChange",
|
|
180
|
+
"DailyTradeHistory",
|
|
181
|
+
"DailyClientTypeHistory",
|
|
182
|
+
"Index",
|
|
183
|
+
"DailyIndexHistory",
|
|
184
|
+
"IndexReturnHistoryPoint",
|
|
185
|
+
# realtime
|
|
186
|
+
"RealtimeOrderBookRow",
|
|
187
|
+
"RealtimeOrderBook",
|
|
188
|
+
"RealtimeClientType",
|
|
189
|
+
"RealtimeInstrument",
|
|
190
|
+
"RealtimeIndex",
|
|
191
|
+
"RealtimeMarket",
|
|
192
|
+
"OptionMarketLiveLeg",
|
|
193
|
+
"OptionMarketLiveEntry",
|
|
194
|
+
"UnderlyingOptionMarketLive",
|
|
195
|
+
"OptionMarketLive",
|
|
196
|
+
# IME
|
|
197
|
+
"ImeCdcContract",
|
|
198
|
+
"ImeOptionContract",
|
|
199
|
+
"ImeCdcDailyTradeHistory",
|
|
200
|
+
"ImeOrderBookRow",
|
|
201
|
+
"ImeClientType",
|
|
202
|
+
"ImeCdcLive",
|
|
203
|
+
"ImeListedInstrumentLive",
|
|
204
|
+
"ImeMarketLive",
|
|
205
|
+
"ImeOptionLegLive",
|
|
206
|
+
"ImeOptionStrikeLive",
|
|
207
|
+
"ImeOptionExpirationLive",
|
|
208
|
+
"ImeUnderlyingOptionChainLive",
|
|
209
|
+
"ImeOptionChainLive",
|
|
210
|
+
# tradingview
|
|
211
|
+
"TradingViewExchange",
|
|
212
|
+
"TradingViewSymbolType",
|
|
213
|
+
"TradingViewConfig",
|
|
214
|
+
"TradingViewSearchResult",
|
|
215
|
+
"TradingViewSymbolInfo",
|
|
216
|
+
"TradingViewBar",
|
|
217
|
+
"TradingViewHistory",
|
|
218
|
+
# market lists
|
|
219
|
+
"MarketListWindow",
|
|
220
|
+
"StockPriceChange",
|
|
221
|
+
"StockPriceChanges",
|
|
222
|
+
"SectorTradeValue",
|
|
223
|
+
"SectorTradeValues",
|
|
224
|
+
"RealtimeStockFlow",
|
|
225
|
+
"RealtimeStockFlows",
|
|
226
|
+
# bonds
|
|
227
|
+
"Bond",
|
|
228
|
+
"BondDailyTradeHistory",
|
|
229
|
+
# currencies
|
|
230
|
+
"Currency",
|
|
231
|
+
"CurrencyDailyHistory",
|
|
232
|
+
# funds
|
|
233
|
+
"SubFund",
|
|
234
|
+
"FundType",
|
|
235
|
+
"FundCategory",
|
|
236
|
+
"FundFinancialRegime",
|
|
237
|
+
"RollingWindow",
|
|
238
|
+
"FundNavHistoryItem",
|
|
239
|
+
"FundAnalysis",
|
|
240
|
+
"FundNavAndReturnHistoryItem",
|
|
241
|
+
"IndexReturnHistoryItem",
|
|
242
|
+
"FundSummaryAnalysis",
|
|
243
|
+
"FundAssetAllocation",
|
|
244
|
+
"SubFundWithSummaryAnalysis",
|
|
245
|
+
"FundManager",
|
|
246
|
+
"SubFundFlowsSummary",
|
|
247
|
+
"AumFlowPoint",
|
|
248
|
+
"SubFundDailySeriesPoint",
|
|
249
|
+
"FundMonthlyReturn",
|
|
250
|
+
"FundRecentAdjustedNavHistoryItem",
|
|
251
|
+
"RollingPeriodWindowAnalysis",
|
|
252
|
+
"RollingPeriodAnalysis",
|
|
253
|
+
"RollingPeriodWindowAnalysisVsBenchmark",
|
|
254
|
+
"RollingPeriodAnalysisVsBenchmark",
|
|
255
|
+
"RollingPeriodDecomposition",
|
|
256
|
+
"RollingPeriodWindowDecomposition",
|
|
257
|
+
"FundIssueRedeemSeriesItem",
|
|
258
|
+
"CategoryIssueRedeemSeriesItem",
|
|
259
|
+
"CategoryIssueRedeemAnalysisItem",
|
|
260
|
+
"FundPeriodAnalysis",
|
|
261
|
+
"FundMonthlyPeriodAnalysis",
|
|
262
|
+
"FundAnnualPeriodAnalysis",
|
|
263
|
+
"CategoryPeriodAnalysis",
|
|
264
|
+
"CategoryMonthlyPeriodAnalysis",
|
|
265
|
+
"CategoryAnnualPeriodAnalysis",
|
|
266
|
+
"TotalIndexMonthlyPeriodAnalysis",
|
|
267
|
+
"TotalIndexAnnualPeriodAnalysis",
|
|
268
|
+
# reports
|
|
269
|
+
"ReportMetadata",
|
|
270
|
+
"ReportCategory",
|
|
271
|
+
"ReportFilter",
|
|
272
|
+
"ReportFilterOption",
|
|
273
|
+
"ReportRequest",
|
|
274
|
+
"ReportResult",
|
|
275
|
+
"AppliedFilter",
|
|
276
|
+
"ReportBatchResultItem",
|
|
277
|
+
"ReportCalculationResultType",
|
|
278
|
+
]
|
aress_sdk/_resource.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Base classes for resource clients.
|
|
2
|
+
|
|
3
|
+
Resources are thin: they build the request path/params, delegate the HTTP call
|
|
4
|
+
to a transport, and validate the JSON response into pydantic models. The model
|
|
5
|
+
parsing is shared here so the sync and async resources stay symmetric.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, List, Type, TypeVar
|
|
11
|
+
|
|
12
|
+
from pydantic import TypeAdapter
|
|
13
|
+
|
|
14
|
+
from ._transport import AsyncTransport, SyncTransport
|
|
15
|
+
from ._types import AressModel
|
|
16
|
+
|
|
17
|
+
M = TypeVar("M", bound=AressModel)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_one(model: Type[M], data: Any) -> M:
|
|
21
|
+
return model.model_validate(data)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_list(model: Type[M], data: Any) -> List[M]:
|
|
25
|
+
adapter: TypeAdapter = TypeAdapter(List[model]) # type: ignore[valid-type]
|
|
26
|
+
return adapter.validate_python(data)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SyncResource:
|
|
30
|
+
"""Base for synchronous resource clients."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
33
|
+
self._t = transport
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncResource:
|
|
37
|
+
"""Base for asynchronous resource clients."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, transport: AsyncTransport) -> None:
|
|
40
|
+
self._t = transport
|
aress_sdk/_transport.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""HTTP transport: thin wrappers over ``httpx`` clients.
|
|
2
|
+
|
|
3
|
+
Two transports share one retry/error-mapping policy:
|
|
4
|
+
|
|
5
|
+
* :class:`SyncTransport` — wraps :class:`httpx.Client`.
|
|
6
|
+
* :class:`AsyncTransport` — wraps :class:`httpx.AsyncClient`.
|
|
7
|
+
|
|
8
|
+
Resources call ``transport.get(path, params=...)`` and get back parsed JSON
|
|
9
|
+
(``dict``/``list``); non-2xx responses are turned into
|
|
10
|
+
:class:`~aress_sdk.exceptions.AressAPIStatusError` subclasses, and transport
|
|
11
|
+
failures into :class:`~aress_sdk.exceptions.AressTransportError` subclasses.
|
|
12
|
+
Retries (with exponential backoff) cover connection errors, timeouts, 5xx and
|
|
13
|
+
429.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Mapping, Optional
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from .config import ClientConfig
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
AressConnectionError,
|
|
26
|
+
AressTimeoutError,
|
|
27
|
+
AressTransportError,
|
|
28
|
+
error_from_response,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Status codes worth retrying (the request is read-only, so this is always safe).
|
|
32
|
+
_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clean_params(params: Optional[Mapping[str, Any]]) -> Optional[dict]:
|
|
36
|
+
"""Drop ``None`` values so optional filters become "not sent"."""
|
|
37
|
+
if not params:
|
|
38
|
+
return None
|
|
39
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _should_retry_status(status_code: int) -> bool:
|
|
43
|
+
return status_code in _RETRYABLE_STATUS
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _backoff_seconds(config: ClientConfig, attempt: int) -> float:
|
|
47
|
+
"""Exponential backoff: factor * 2**attempt (attempt is 0-based)."""
|
|
48
|
+
return config.backoff_factor * (2 ** attempt)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _wrap_transport_error(exc: httpx.HTTPError) -> AressTransportError:
|
|
52
|
+
request = getattr(exc, "request", None)
|
|
53
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
54
|
+
return AressTimeoutError(f"Request timed out: {exc}", request=request)
|
|
55
|
+
if isinstance(exc, httpx.ConnectError):
|
|
56
|
+
return AressConnectionError(f"Failed to connect to the API: {exc}", request=request)
|
|
57
|
+
return AressConnectionError(f"Transport error: {exc}", request=request)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _BaseTransport:
|
|
61
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
62
|
+
self._config = config
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def base_url(self) -> str:
|
|
66
|
+
return self._config.normalized_base_url()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def config(self) -> ClientConfig:
|
|
70
|
+
"""The client configuration, so resources can read per-endpoint budgets."""
|
|
71
|
+
return self._config
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SyncTransport(_BaseTransport):
|
|
75
|
+
"""Synchronous transport backed by :class:`httpx.Client`."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
config: ClientConfig,
|
|
80
|
+
*,
|
|
81
|
+
http_client: Optional[httpx.Client] = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__(config)
|
|
84
|
+
self._owns_client = http_client is None
|
|
85
|
+
self._client = http_client or httpx.Client(
|
|
86
|
+
base_url=config.normalized_base_url(),
|
|
87
|
+
timeout=config.timeout,
|
|
88
|
+
headers=config.default_headers(),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def get(self, path: str, *, params: Optional[Mapping[str, Any]] = None) -> Any:
|
|
92
|
+
clean = _clean_params(params)
|
|
93
|
+
last_exc: Optional[AressTransportError] = None
|
|
94
|
+
|
|
95
|
+
for attempt in range(self._config.max_retries + 1):
|
|
96
|
+
try:
|
|
97
|
+
response = self._client.get(
|
|
98
|
+
path, params=clean, headers=self._config.auth_headers()
|
|
99
|
+
)
|
|
100
|
+
except httpx.HTTPError as exc:
|
|
101
|
+
last_exc = _wrap_transport_error(exc)
|
|
102
|
+
if attempt < self._config.max_retries:
|
|
103
|
+
time.sleep(_backoff_seconds(self._config, attempt))
|
|
104
|
+
continue
|
|
105
|
+
raise last_exc
|
|
106
|
+
|
|
107
|
+
if response.is_success:
|
|
108
|
+
return _decode(response)
|
|
109
|
+
|
|
110
|
+
if _should_retry_status(response.status_code) and attempt < self._config.max_retries:
|
|
111
|
+
time.sleep(_backoff_seconds(self._config, attempt))
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
raise error_from_response(response)
|
|
115
|
+
|
|
116
|
+
# Unreachable, but keeps type checkers happy.
|
|
117
|
+
assert last_exc is not None
|
|
118
|
+
raise last_exc
|
|
119
|
+
|
|
120
|
+
def post(
|
|
121
|
+
self,
|
|
122
|
+
path: str,
|
|
123
|
+
*,
|
|
124
|
+
json: Optional[Any] = None,
|
|
125
|
+
timeout: Optional[float] = None,
|
|
126
|
+
) -> Any:
|
|
127
|
+
"""POST a JSON body and return the parsed JSON response.
|
|
128
|
+
|
|
129
|
+
Same retry/error policy as :meth:`get`. The reports calculation
|
|
130
|
+
endpoints are read-only despite using POST (the body carries filter
|
|
131
|
+
selections), so retrying transient failures is safe.
|
|
132
|
+
|
|
133
|
+
``timeout`` overrides the client-level timeout for this request only,
|
|
134
|
+
and applies **per attempt** — slow endpoints (report calculations) pass
|
|
135
|
+
their own budget rather than inflating it for the whole client.
|
|
136
|
+
"""
|
|
137
|
+
last_exc: Optional[AressTransportError] = None
|
|
138
|
+
request_timeout = self._config.timeout if timeout is None else timeout
|
|
139
|
+
|
|
140
|
+
for attempt in range(self._config.max_retries + 1):
|
|
141
|
+
try:
|
|
142
|
+
response = self._client.post(
|
|
143
|
+
path,
|
|
144
|
+
json=json,
|
|
145
|
+
timeout=request_timeout,
|
|
146
|
+
headers=self._config.auth_headers(),
|
|
147
|
+
)
|
|
148
|
+
except httpx.HTTPError as exc:
|
|
149
|
+
last_exc = _wrap_transport_error(exc)
|
|
150
|
+
if attempt < self._config.max_retries:
|
|
151
|
+
time.sleep(_backoff_seconds(self._config, attempt))
|
|
152
|
+
continue
|
|
153
|
+
raise last_exc
|
|
154
|
+
|
|
155
|
+
if response.is_success:
|
|
156
|
+
return _decode(response)
|
|
157
|
+
|
|
158
|
+
if _should_retry_status(response.status_code) and attempt < self._config.max_retries:
|
|
159
|
+
time.sleep(_backoff_seconds(self._config, attempt))
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
raise error_from_response(response)
|
|
163
|
+
|
|
164
|
+
assert last_exc is not None
|
|
165
|
+
raise last_exc
|
|
166
|
+
|
|
167
|
+
def close(self) -> None:
|
|
168
|
+
if self._owns_client:
|
|
169
|
+
self._client.close()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class AsyncTransport(_BaseTransport):
|
|
173
|
+
"""Asynchronous transport backed by :class:`httpx.AsyncClient`."""
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
config: ClientConfig,
|
|
178
|
+
*,
|
|
179
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
super().__init__(config)
|
|
182
|
+
self._owns_client = http_client is None
|
|
183
|
+
self._client = http_client or httpx.AsyncClient(
|
|
184
|
+
base_url=config.normalized_base_url(),
|
|
185
|
+
timeout=config.timeout,
|
|
186
|
+
headers=config.default_headers(),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def get(self, path: str, *, params: Optional[Mapping[str, Any]] = None) -> Any:
|
|
190
|
+
import asyncio
|
|
191
|
+
|
|
192
|
+
clean = _clean_params(params)
|
|
193
|
+
last_exc: Optional[AressTransportError] = None
|
|
194
|
+
|
|
195
|
+
for attempt in range(self._config.max_retries + 1):
|
|
196
|
+
try:
|
|
197
|
+
response = await self._client.get(
|
|
198
|
+
path, params=clean, headers=self._config.auth_headers()
|
|
199
|
+
)
|
|
200
|
+
except httpx.HTTPError as exc:
|
|
201
|
+
last_exc = _wrap_transport_error(exc)
|
|
202
|
+
if attempt < self._config.max_retries:
|
|
203
|
+
await asyncio.sleep(_backoff_seconds(self._config, attempt))
|
|
204
|
+
continue
|
|
205
|
+
raise last_exc
|
|
206
|
+
|
|
207
|
+
if response.is_success:
|
|
208
|
+
return _decode(response)
|
|
209
|
+
|
|
210
|
+
if _should_retry_status(response.status_code) and attempt < self._config.max_retries:
|
|
211
|
+
await asyncio.sleep(_backoff_seconds(self._config, attempt))
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
raise error_from_response(response)
|
|
215
|
+
|
|
216
|
+
assert last_exc is not None
|
|
217
|
+
raise last_exc
|
|
218
|
+
|
|
219
|
+
async def post(
|
|
220
|
+
self,
|
|
221
|
+
path: str,
|
|
222
|
+
*,
|
|
223
|
+
json: Optional[Any] = None,
|
|
224
|
+
timeout: Optional[float] = None,
|
|
225
|
+
) -> Any:
|
|
226
|
+
"""Async counterpart of :meth:`SyncTransport.post`; ``timeout``
|
|
227
|
+
overrides the client-level timeout for this request only, per attempt."""
|
|
228
|
+
import asyncio
|
|
229
|
+
|
|
230
|
+
last_exc: Optional[AressTransportError] = None
|
|
231
|
+
request_timeout = self._config.timeout if timeout is None else timeout
|
|
232
|
+
|
|
233
|
+
for attempt in range(self._config.max_retries + 1):
|
|
234
|
+
try:
|
|
235
|
+
response = await self._client.post(
|
|
236
|
+
path,
|
|
237
|
+
json=json,
|
|
238
|
+
timeout=request_timeout,
|
|
239
|
+
headers=self._config.auth_headers(),
|
|
240
|
+
)
|
|
241
|
+
except httpx.HTTPError as exc:
|
|
242
|
+
last_exc = _wrap_transport_error(exc)
|
|
243
|
+
if attempt < self._config.max_retries:
|
|
244
|
+
await asyncio.sleep(_backoff_seconds(self._config, attempt))
|
|
245
|
+
continue
|
|
246
|
+
raise last_exc
|
|
247
|
+
|
|
248
|
+
if response.is_success:
|
|
249
|
+
return _decode(response)
|
|
250
|
+
|
|
251
|
+
if _should_retry_status(response.status_code) and attempt < self._config.max_retries:
|
|
252
|
+
await asyncio.sleep(_backoff_seconds(self._config, attempt))
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
raise error_from_response(response)
|
|
256
|
+
|
|
257
|
+
assert last_exc is not None
|
|
258
|
+
raise last_exc
|
|
259
|
+
|
|
260
|
+
async def aclose(self) -> None:
|
|
261
|
+
if self._owns_client:
|
|
262
|
+
await self._client.aclose()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _decode(response: httpx.Response) -> Any:
|
|
266
|
+
try:
|
|
267
|
+
return response.json()
|
|
268
|
+
except ValueError as exc:
|
|
269
|
+
raise AressConnectionError(
|
|
270
|
+
f"Expected JSON from {response.request.url} but got non-JSON body"
|
|
271
|
+
) from exc
|
aress_sdk/_types.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared field types and the base response model.
|
|
2
|
+
|
|
3
|
+
The API serializes Jalali (Persian) dates as ISO ``YYYY-MM-DD`` strings (e.g.
|
|
4
|
+
``"1403-01-15"``). On the SDK side we parse them back into
|
|
5
|
+
:class:`jdatetime.date` so callers get real date objects with calendar
|
|
6
|
+
arithmetic, and serialize them back to the same ISO string.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import jdatetime
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, GetCoreSchemaHandler
|
|
15
|
+
from pydantic.json_schema import GetJsonSchemaHandler, JsonSchemaValue
|
|
16
|
+
from pydantic_core import core_schema
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _JalaliDatePydanticAnnotation:
|
|
20
|
+
"""Pydantic v2 support for :class:`jdatetime.date`.
|
|
21
|
+
|
|
22
|
+
Accepts a ``jdatetime.date`` or an ISO ``YYYY-MM-DD`` Jalali string and
|
|
23
|
+
serializes to the ISO string form.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def __get_pydantic_core_schema__(
|
|
28
|
+
cls, _source_type: Any, _handler: GetCoreSchemaHandler
|
|
29
|
+
) -> core_schema.CoreSchema:
|
|
30
|
+
def validate(value: Any) -> jdatetime.date:
|
|
31
|
+
if isinstance(value, jdatetime.date):
|
|
32
|
+
return value
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
try:
|
|
35
|
+
year, month, day = (int(part) for part in value.split("-"))
|
|
36
|
+
return jdatetime.date(year, month, day)
|
|
37
|
+
except (ValueError, TypeError) as exc:
|
|
38
|
+
raise ValueError(f"Invalid Jalali date: {value!r}") from exc
|
|
39
|
+
raise ValueError(f"Invalid Jalali date: {value!r}")
|
|
40
|
+
|
|
41
|
+
return core_schema.no_info_plain_validator_function(
|
|
42
|
+
validate,
|
|
43
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
44
|
+
lambda value: value.isoformat(),
|
|
45
|
+
return_schema=core_schema.str_schema(),
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def __get_pydantic_json_schema__(
|
|
51
|
+
cls, _schema: core_schema.CoreSchema, _handler: GetJsonSchemaHandler
|
|
52
|
+
) -> JsonSchemaValue:
|
|
53
|
+
return {"type": "string", "format": "date", "example": "1403-01-15"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: A Jalali calendar date (``jdatetime.date``) usable as a pydantic field type.
|
|
57
|
+
JalaliDate = Annotated[jdatetime.date, _JalaliDatePydanticAnnotation]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AressModel(BaseModel):
|
|
61
|
+
"""Base class for all response models returned by the SDK.
|
|
62
|
+
|
|
63
|
+
Configured to ignore unknown fields, so the SDK keeps working when the API
|
|
64
|
+
adds new fields to a response.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(
|
|
68
|
+
extra="ignore",
|
|
69
|
+
frozen=False,
|
|
70
|
+
populate_by_name=True,
|
|
71
|
+
)
|
aress_sdk/_version.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Bonds domain: bonds and their daily trade history."""
|
|
2
|
+
|
|
3
|
+
from .models import Bond, BondDailyTradeHistory
|
|
4
|
+
from .resource import AsyncBondsResource, BondsResource
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Bond",
|
|
8
|
+
"BondDailyTradeHistory",
|
|
9
|
+
"BondsResource",
|
|
10
|
+
"AsyncBondsResource",
|
|
11
|
+
]
|