Skip to content

Commit bb36722

Browse files
committed
v0.7.0: Add identifier resolution support for quote workflows
Expand quote and batch quote SDK ergonomics to accept symbols, Arabic names, and aliases while preserving legacy symbol compatibility through automatic backend fallback. Add typed resolution metadata, specialized resolution exceptions, refreshed docs/examples, and release metadata updates for a publish-ready 0.7.0. Made-with: Cursor
1 parent a644eae commit bb36722

9 files changed

Lines changed: 626 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ All notable changes to the `sahmk` Python SDK will be documented in this file.
44

55
This project follows [Semantic Versioning](https://semver.org/).
66

7+
## [0.7.0] — 2026-04-18
8+
9+
### Added
10+
11+
- Identifier-resolution support for quote endpoints in the SDK: `quote()` and `quotes()` now accept symbols, Arabic names, English names, and aliases when backend resolution is enabled
12+
- New typed resolution metadata: `IdentifierResolution`, `Quote.requested_identifier`, `Quote.resolved_symbol`, `Quote.resolution`, plus batch-level `resolved`, `ambiguous`, and `unknown`
13+
- New resolution-focused exceptions: `SahmkIdentifierResolutionError`, `SahmkAmbiguousIdentifierError`, and `SahmkUnknownIdentifierError`
14+
15+
### Changed
16+
17+
- `quotes()` now prefers the new `identifiers` backend parameter and transparently falls back to legacy `symbols` for older backends
18+
- Resolution-related exceptions now preserve backend-provided `status_code` and `error_code` semantics
19+
- Quote docs and examples now explicitly show both classic symbol usage and identifier-based usage
20+
21+
### Fixed
22+
23+
- Improved backward compatibility during backend rollout by adding automatic quotes parameter fallback instead of requiring immediate backend parity
24+
725
## [0.6.2] — 2026-04-12
826

927
### Added

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,42 @@ for q in client.quotes(["2222", "1120", "7010"])["quotes"]:
5353
print(f"{q['symbol']}: {q['price']}")
5454
```
5555

56+
## Identifier Resolution (Quotes)
57+
58+
`quote()` and `quotes()` accept either traditional symbols or resolvable identifiers:
59+
60+
- Symbol: `"2222"`
61+
- Arabic company name: `"أرامكو السعودية"`
62+
- English company name/alias: `"Aramco"`
63+
64+
Symbol input always works. Name/alias input requires backend identifier-resolution support.
65+
For batch quotes, the SDK first tries `identifiers=...`, then automatically falls back to
66+
legacy `symbols=...` when connected to older backends.
67+
68+
```python
69+
q1 = client.quote("2222") # classic symbol usage
70+
q2 = client.quote("أرامكو السعودية") # Arabic identifier
71+
q3 = client.quote("Aramco") # English alias
72+
73+
batch = client.quotes(["2222", "الراجحي", "SABIC"])
74+
for q in batch.quotes:
75+
print(q.requested_identifier, "=>", q.symbol)
76+
77+
if batch.ambiguous:
78+
print("Ambiguous:", batch.ambiguous)
79+
if batch.unknown:
80+
print("Unknown:", batch.unknown)
81+
```
82+
83+
When the backend returns resolution metadata, it is exposed on typed objects:
84+
85+
```python
86+
quote = client.quote("Aramco")
87+
print(quote.requested_identifier) # Aramco
88+
print(quote.resolved_symbol) # 2222
89+
print(quote.resolution.matched_by) # alias (if provided by API)
90+
```
91+
5692
## Production Reliability
5793

5894
- The client retries transient failures: **HTTP 429** and **5xx** errors.

examples/batch_quotes.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,18 @@
1616

1717
client = SahmkClient(API_KEY)
1818

19-
# Batch quotes — up to 50 symbols per request
20-
symbols = ["2222", "1120", "4191", "2010", "1010"]
21-
result = client.quotes(symbols)
19+
# Batch quotes — up to 50 identifiers.
20+
# Mix symbols and names/aliases (name/alias requires backend resolution support).
21+
identifiers = ["2222", "الراجحي", "SABIC", "2010", "7010"]
22+
result = client.quotes(identifiers)
2223

2324
print(f"{'Symbol':<10} {'Name':<25} {'Price':<10} {'Change %':<10}")
2425
print("-" * 55)
2526

2627
for q in result["quotes"]:
2728
print(f"{q['symbol']:<10} {q.get('name_en', ''):<25} {q['price']:<10} {q['change_percent']:<10}")
29+
30+
if result.ambiguous:
31+
print("\nAmbiguous identifiers:", result.ambiguous)
32+
if result.unknown:
33+
print("\nUnknown identifiers:", result.unknown)

examples/quote.py

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,31 @@
1616

1717
client = SahmkClient(API_KEY)
1818

19-
# Get Aramco quote
20-
quote = client.quote("2222")
21-
22-
print(f"Stock: {quote['name_en']} ({quote['symbol']})")
23-
print(f"Price: {quote['price']} SAR")
24-
print(f"Change: {quote['change']} ({quote['change_percent']}%)")
25-
print(f"Volume: {quote.get('volume', 'N/A')}")
26-
print(f"High: {quote.get('high', 'N/A')}")
27-
print(f"Low: {quote.get('low', 'N/A')}")
28-
print(f"Bid: {quote.get('bid', 'N/A')}")
29-
print(f"Ask: {quote.get('ask', 'N/A')}")
30-
31-
# Liquidity data (buy/sell flow)
32-
liq = quote.get("liquidity", {})
33-
if liq:
34-
print(f"Net Liquidity: {liq.get('net_value', 'N/A')} SAR")
19+
20+
def print_quote(label, quote):
21+
print(f"\n{label}")
22+
print(f"Stock: {quote['name_en']} ({quote['symbol']})")
23+
print(f"Price: {quote['price']} SAR")
24+
print(f"Change: {quote['change']} ({quote['change_percent']}%)")
25+
print(f"Volume: {quote.get('volume', 'N/A')}")
26+
print(f"High: {quote.get('high', 'N/A')}")
27+
print(f"Low: {quote.get('low', 'N/A')}")
28+
print(f"Bid: {quote.get('bid', 'N/A')}")
29+
print(f"Ask: {quote.get('ask', 'N/A')}")
30+
31+
liq = quote.get("liquidity", {})
32+
if liq:
33+
print(f"Net Liquidity: {liq.get('net_value', 'N/A')} SAR")
34+
35+
if quote.resolution:
36+
print(f"Resolved by: {quote.resolution.matched_by}")
37+
print(f"Input => Symbol: {quote.requested_identifier} => {quote.resolved_symbol}")
38+
39+
40+
# 1) Classic symbol usage (always supported)
41+
quote_by_symbol = client.quote("2222")
42+
print_quote("Quote by symbol", quote_by_symbol)
43+
44+
# 2) Name/alias usage (requires backend identifier-resolution support)
45+
quote_by_name = client.quote("Aramco")
46+
print_quote("Quote by name/alias", quote_by_name)

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
[build-system]
2-
requires = ["setuptools>=68", "wheel"]
2+
requires = ["setuptools>=68,<77", "wheel"]
33
build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "sahmk"
7-
version = "0.6.2"
7+
version = "0.7.0"
88
description = "Official Python SDK for Sahmk — Saudi market data and richer market workflows for developers."
99
readme = "README.md"
1010
requires-python = ">=3.9"
11-
license = "MIT"
11+
license = { text = "MIT" }
1212
authors = [
1313
{ name = "Sahmk", email = "developer@sahmk.sa" }
1414
]

sahmk/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
SahmkError,
44
SahmkRateLimitError,
55
SahmkInvalidIndexError,
6+
SahmkIdentifierResolutionError,
7+
SahmkAmbiguousIdentifierError,
8+
SahmkUnknownIdentifierError,
69
)
710
from .models import (
811
Quote,
912
BatchQuote,
1013
BatchQuotesResponse,
14+
IdentifierResolution,
1115
HistoricalResponse,
1216
OHLCV,
1317
MarketSummary,
@@ -31,15 +35,19 @@
3135
Liquidity,
3236
)
3337

34-
__version__ = "0.6.2"
38+
__version__ = "0.7.0"
3539
__all__ = [
3640
"SahmkClient",
3741
"SahmkError",
3842
"SahmkRateLimitError",
3943
"SahmkInvalidIndexError",
44+
"SahmkIdentifierResolutionError",
45+
"SahmkAmbiguousIdentifierError",
46+
"SahmkUnknownIdentifierError",
4047
"Quote",
4148
"BatchQuote",
4249
"BatchQuotesResponse",
50+
"IdentifierResolution",
4351
"HistoricalResponse",
4452
"OHLCV",
4553
"MarketSummary",

sahmk/client.py

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,77 @@ def __init__(self, message, response=None):
7575
)
7676

7777

78+
class SahmkIdentifierResolutionError(SahmkError):
79+
"""Base exception for identifier-resolution failures."""
80+
81+
def __init__(
82+
self,
83+
message,
84+
status_code=None,
85+
error_code=None,
86+
response=None,
87+
identifier=None,
88+
details=None,
89+
):
90+
super().__init__(
91+
message,
92+
status_code=status_code,
93+
error_code=error_code,
94+
response=response,
95+
)
96+
self.identifier = identifier
97+
self.details = details or {}
98+
99+
100+
class SahmkAmbiguousIdentifierError(SahmkIdentifierResolutionError):
101+
"""Raised when the input maps to multiple possible stocks."""
102+
103+
def __init__(
104+
self,
105+
message,
106+
response=None,
107+
identifier=None,
108+
candidates=None,
109+
details=None,
110+
status_code=400,
111+
error_code="AMBIGUOUS_IDENTIFIER",
112+
):
113+
merged_details = dict(details or {})
114+
if candidates is not None:
115+
merged_details.setdefault("candidates", candidates)
116+
super().__init__(
117+
message,
118+
status_code=status_code,
119+
error_code=error_code,
120+
response=response,
121+
identifier=identifier,
122+
details=merged_details,
123+
)
124+
self.candidates = merged_details.get("candidates", [])
125+
126+
127+
class SahmkUnknownIdentifierError(SahmkIdentifierResolutionError):
128+
"""Raised when an identifier cannot be resolved to any stock."""
129+
130+
def __init__(
131+
self,
132+
message,
133+
response=None,
134+
identifier=None,
135+
details=None,
136+
status_code=404,
137+
error_code="UNKNOWN_IDENTIFIER",
138+
):
139+
super().__init__(
140+
message,
141+
status_code=status_code,
142+
error_code=error_code,
143+
response=response,
144+
identifier=identifier,
145+
details=details,
146+
)
147+
148+
78149
class SahmkClient:
79150
"""
80151
SAHMK Developer API client.
@@ -223,11 +294,14 @@ def _header_int(name):
223294
@staticmethod
224295
def _build_api_error(response):
225296
"""Build a SahmkError from a non-200 response."""
297+
details = {}
226298
try:
227299
body = response.json()
228300
err = body.get("error", {})
229301
code = err.get("code", "UNKNOWN")
230302
message = err.get("message", response.text)
303+
if isinstance(err.get("details"), dict):
304+
details = err.get("details")
231305
except (ValueError, KeyError):
232306
code = "UNKNOWN"
233307
message = response.text
@@ -236,6 +310,31 @@ def _build_api_error(response):
236310
f"Invalid market index: {message}",
237311
response=response,
238312
)
313+
identifier = details.get("identifier") or details.get("input")
314+
if code in {"AMBIGUOUS_IDENTIFIER", "IDENTIFIER_AMBIGUOUS"}:
315+
candidates = details.get("candidates", [])
316+
candidate_text = ""
317+
if candidates:
318+
shown = candidates[:5]
319+
candidate_text = f" Candidates: {shown}"
320+
return SahmkAmbiguousIdentifierError(
321+
f"Ambiguous identifier '{identifier or '?'}': {message}.{candidate_text}",
322+
response=response,
323+
identifier=identifier,
324+
candidates=candidates,
325+
details=details,
326+
status_code=response.status_code,
327+
error_code=code,
328+
)
329+
if code in {"UNKNOWN_IDENTIFIER", "IDENTIFIER_NOT_FOUND", "INVALID_SYMBOL"}:
330+
return SahmkUnknownIdentifierError(
331+
f"Unknown identifier '{identifier or '?'}': {message}",
332+
response=response,
333+
identifier=identifier,
334+
details=details,
335+
status_code=response.status_code,
336+
error_code=code,
337+
)
239338
return SahmkError(
240339
f"API error {response.status_code}: {message}",
241340
status_code=response.status_code,
@@ -280,38 +379,67 @@ def _market_params(self, limit=None, index=None):
280379
# Quotes
281380
# -------------------------------------------------------------------------
282381

283-
def quote(self, symbol):
382+
def quote(self, identifier):
284383
"""
285-
Get a stock quote.
384+
Get a stock quote by symbol or resolvable identifier.
286385
287386
Args:
288-
symbol: Stock symbol (e.g., "2222" for Aramco)
387+
identifier: Stock symbol, Arabic name, English name, or alias
388+
(e.g., "2222", "أرامكو السعودية", "Aramco")
289389
290390
Returns:
291391
Quote object (supports dict-style access via [] for backwards compat)
292392
"""
293393
from .models import Quote
294-
data = self._request("GET", f"/quote/{symbol}/")
394+
data = self._request("GET", f"/quote/{identifier}/")
295395
return Quote.from_dict(data)
296396

297-
def quotes(self, symbols):
397+
@staticmethod
398+
def _is_legacy_quotes_param_error(exc):
399+
"""Detect old-backend validation errors when identifiers param is unknown."""
400+
if not isinstance(exc, SahmkError) or exc.status_code != 400:
401+
return False
402+
if exc.error_code in {
403+
"VALIDATION",
404+
"MISSING_SYMBOLS",
405+
"MISSING_PARAMETER",
406+
"UNKNOWN_PARAMETER",
407+
}:
408+
return True
409+
return "symbols" in str(exc).lower()
410+
411+
def quotes(self, identifiers):
298412
"""
299413
Get batch quotes for multiple stocks (Starter+ plan).
300414
301415
Args:
302-
symbols: List of stock symbols (up to 50)
416+
identifiers: List of symbols/names/aliases (up to 50). Existing
417+
symbol-only usage remains fully supported.
303418
304419
Returns:
305420
BatchQuotesResponse with .quotes list and .count
306421
"""
307422
from .models import BatchQuotesResponse
308-
if not symbols:
423+
if isinstance(identifiers, str):
424+
identifiers = [identifiers]
425+
if not identifiers:
309426
raise ValueError("At least one symbol is required")
310-
if len(symbols) > 50:
427+
if len(identifiers) > 50:
311428
raise SahmkError("Maximum 50 symbols per batch request")
312-
data = self._request(
313-
"GET", "/quotes/", params={"symbols": ",".join(symbols)}
314-
)
429+
joined = ",".join(str(identifier) for identifier in identifiers)
430+
431+
# Prefer the new backend contract first. If the backend is older and
432+
# rejects `identifiers`, transparently fall back to `symbols`.
433+
try:
434+
data = self._request(
435+
"GET",
436+
"/quotes/",
437+
params={"identifiers": joined},
438+
)
439+
except SahmkError as exc:
440+
if not self._is_legacy_quotes_param_error(exc):
441+
raise
442+
data = self._request("GET", "/quotes/", params={"symbols": joined})
315443
return BatchQuotesResponse.from_dict(data)
316444

317445
# -------------------------------------------------------------------------

0 commit comments

Comments
 (0)