Skip to content

Commit dfdea1d

Browse files
committed
v0.6.2: Market index scoping and delay metadata support
Add index-aware market endpoint support across SDK and CLI with NOMUC alias normalization, explicit INVALID_INDEX handling, and typed exposure of index/is_delayed. This aligns the client with Apr 9 API behavior while preserving backward compatibility when index is omitted. Made-with: Cursor
1 parent 1021374 commit dfdea1d

12 files changed

Lines changed: 330 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ 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.6.2] — 2026-04-12
8+
9+
### Added
10+
11+
- Market endpoints now accept optional `index` in SDK methods and CLI (`--index`) for `TASI`/`NOMU`, with `NOMUC` alias normalized to `NOMU`
12+
- New `SahmkInvalidIndexError` for clear handling of invalid market index usage (`INVALID_INDEX`)
13+
- Market typed response models now expose top-level `index` and `is_delayed` fields
14+
15+
### Changed
16+
17+
- Market method docs/examples updated to reflect index scoping and delayed-vs-realtime response metadata
18+
- Test coverage expanded for index query propagation, alias normalization, and invalid-index failures
19+
720
## [0.6.1] — 2026-04-02
821

922
### Fixed

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ A lightweight Python client for the [SAHMK Developer API](https://sahmk.sa/devel
77
- **Real-time quotes** — live prices for 350+ Tadawul stocks
88
- **Batch quotes** — up to 50 stocks in a single request
99
- **Historical data** — daily/weekly/monthly OHLCV with custom date ranges
10-
- **Market overview**TASI index, gainers, losers, volume/value leaders, sectors
10+
- **Market overview** — index-scoped market data (TASI/NOMU), gainers, losers, volume/value leaders, sectors
1111
- **Company info** — fundamentals, technicals, valuation, analyst consensus (by plan)
1212
- **Financials** — income statements, balance sheets, cash flow
1313
- **Dividends** — history, yield, upcoming payments
@@ -43,6 +43,10 @@ print(f"{quote['name_en']}: {quote['price']} SAR ({quote['change_percent']}%)")
4343
market = client.market_summary()
4444
print(f"TASI: {market['index_value']} ({market['index_change_percent']}%)")
4545

46+
# Scope market endpoints by index (NOMUC alias is accepted)
47+
nomu = client.gainers(limit=5, index="NOMUC")
48+
print(f"Index: {nomu.index}, delayed: {nomu.is_delayed}")
49+
4650
# Batch quotes (Starter+ plan)
4751
result = client.quotes(["2222", "1120", "4191"])
4852
for q in result["quotes"]:
@@ -57,6 +61,7 @@ The package also installs a CLI for instant testing:
5761
export SAHMK_API_KEY="your_api_key"
5862
sahmk quote 2222
5963
sahmk market gainers --limit 5
64+
sahmk market summary --index NOMU
6065
sahmk historical 2222 --from 2026-01-01 --to 2026-01-28
6166
sahmk company 2222
6267
sahmk financials 2222
@@ -93,6 +98,25 @@ Access the original API response dict via `.raw`:
9398
raw_dict = quote.raw
9499
```
95100

101+
## Market Index Scoping
102+
103+
Market endpoints support optional `index` scoping:
104+
105+
- Accepted values: `TASI`, `NOMU`
106+
- Alias: `NOMUC` (normalized to `NOMU`)
107+
- Omitted `index` remains backward compatible (server defaults to `TASI`)
108+
109+
```python
110+
summary = client.market_summary(index="NOMUC")
111+
print(summary.index) # NOMU
112+
print(summary.is_delayed) # True/False by plan entitlement
113+
```
114+
115+
```bash
116+
sahmk market summary --index NOMU
117+
sahmk market gainers --limit 10 --index NOMUC
118+
```
119+
96120
## Retries and Rate Limits
97121

98122
The client automatically retries transient failures (429 rate-limit and 5xx server errors) with exponential backoff:

examples/market_summary.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,22 @@
1717
client = SahmkClient(API_KEY)
1818

1919
# Market overview
20-
summary = client.market_summary()
20+
summary = client.market_summary(index="TASI")
2121
print("=== Market Summary ===")
22+
print(f"Index: {summary.get('index', 'N/A')}")
2223
print(f"TASI: {summary.get('index_value', 'N/A')}")
2324
print(f"Change: {summary.get('index_change', 'N/A')} ({summary.get('index_change_percent', 'N/A')}%)")
2425
print(f"Volume: {summary.get('total_volume', 'N/A')}")
26+
print(f"Delayed: {summary.get('is_delayed', 'N/A')}")
2527
print(f"Mood: {summary.get('market_mood', 'N/A')}")
2628
print()
2729

2830
# Top gainers
2931
print("=== Top Gainers ===")
30-
result = client.gainers(limit=5)
32+
result = client.gainers(limit=5, index="NOMUC")
3133
for stock in result["gainers"]:
3234
print(f" {stock['symbol']} {stock.get('name_en', '')}: +{stock.get('change_percent', 'N/A')}%")
35+
print(f"Index: {result.get('index', 'N/A')} | Delayed: {result.get('is_delayed', 'N/A')}")
3336
print()
3437

3538
# Top losers

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "sahmk"
7-
version = "0.6.1"
8-
description = "Lightweight Python client for the SAHMK Developer API."
7+
version = "0.6.2"
8+
description = "Python SDK for SAHMK API with typed models, CLI, and market index scoping."
99
readme = "README.md"
1010
requires-python = ">=3.9"
1111
license = "MIT"

sahmk/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
from .client import SahmkClient, SahmkError, SahmkRateLimitError
1+
from .client import (
2+
SahmkClient,
3+
SahmkError,
4+
SahmkRateLimitError,
5+
SahmkInvalidIndexError,
6+
)
27
from .models import (
38
Quote,
49
BatchQuote,
@@ -26,11 +31,12 @@
2631
Liquidity,
2732
)
2833

29-
__version__ = "0.6.1"
34+
__version__ = "0.6.2"
3035
__all__ = [
3136
"SahmkClient",
3237
"SahmkError",
3338
"SahmkRateLimitError",
39+
"SahmkInvalidIndexError",
3440
"Quote",
3541
"BatchQuote",
3642
"BatchQuotesResponse",

sahmk/cli.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ def _build_parser():
6767
type=int,
6868
help="Optional limit for gainers/losers/volume/value.",
6969
)
70+
market_parser.add_argument(
71+
"--index",
72+
help='Optional market index: "TASI" or "NOMU" (alias "NOMUC" accepted).',
73+
)
7074
_compact_arg(market_parser)
7175

7276
historical_parser = subparsers.add_parser(
@@ -212,17 +216,17 @@ def main(argv=None):
212216
result = client.quotes(symbols)
213217
elif args.command == "market":
214218
if args.view == "summary":
215-
result = client.market_summary()
219+
result = client.market_summary(index=args.index)
216220
elif args.view == "gainers":
217-
result = client.gainers(limit=args.limit)
221+
result = client.gainers(limit=args.limit, index=args.index)
218222
elif args.view == "losers":
219-
result = client.losers(limit=args.limit)
223+
result = client.losers(limit=args.limit, index=args.index)
220224
elif args.view == "volume":
221-
result = client.volume_leaders(limit=args.limit)
225+
result = client.volume_leaders(limit=args.limit, index=args.index)
222226
elif args.view == "value":
223-
result = client.value_leaders(limit=args.limit)
227+
result = client.value_leaders(limit=args.limit, index=args.index)
224228
else:
225-
result = client.sectors()
229+
result = client.sectors(index=args.index)
226230
elif args.command == "historical":
227231
result = client.historical(
228232
args.symbol,

sahmk/client.py

Lines changed: 94 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
logger = logging.getLogger("sahmk")
1818

1919
_RETRIABLE_STATUS_CODES = frozenset({500, 502, 503, 504})
20+
_MARKET_INDEX_ALIASES = {"NOMUC": "NOMU"}
21+
_VALID_MARKET_INDEXES = frozenset({"TASI", "NOMU"})
2022

2123

2224
class SahmkError(Exception):
@@ -61,6 +63,18 @@ def __init__(
6163
self.rate_reset = rate_reset
6264

6365

66+
class SahmkInvalidIndexError(SahmkError):
67+
"""Raised when an invalid market index is supplied or returned by the API."""
68+
69+
def __init__(self, message, response=None):
70+
super().__init__(
71+
message,
72+
status_code=400,
73+
error_code="INVALID_INDEX",
74+
response=response,
75+
)
76+
77+
6478
class SahmkClient:
6579
"""
6680
SAHMK Developer API client.
@@ -217,6 +231,11 @@ def _build_api_error(response):
217231
except (ValueError, KeyError):
218232
code = "UNKNOWN"
219233
message = response.text
234+
if response.status_code == 400 and code == "INVALID_INDEX":
235+
return SahmkInvalidIndexError(
236+
f"Invalid market index: {message}",
237+
response=response,
238+
)
220239
return SahmkError(
221240
f"API error {response.status_code}: {message}",
222241
status_code=response.status_code,
@@ -234,6 +253,29 @@ def _rate_limit_wait(self, response, attempt):
234253
pass
235254
return self.backoff_factor * (2 ** attempt)
236255

256+
@staticmethod
257+
def _normalize_market_index(index):
258+
"""Normalize/validate market index query parameter."""
259+
if index is None:
260+
return None
261+
normalized = str(index).strip().upper()
262+
normalized = _MARKET_INDEX_ALIASES.get(normalized, normalized)
263+
if normalized not in _VALID_MARKET_INDEXES:
264+
raise SahmkInvalidIndexError(
265+
"Index must be one of: TASI, NOMU (NOMUC is accepted as NOMU)."
266+
)
267+
return normalized
268+
269+
def _market_params(self, limit=None, index=None):
270+
"""Build validated query params for market endpoints."""
271+
params = {}
272+
if limit is not None:
273+
params["limit"] = limit
274+
normalized_index = self._normalize_market_index(index)
275+
if normalized_index is not None:
276+
params["index"] = normalized_index
277+
return params or None
278+
237279
# -------------------------------------------------------------------------
238280
# Quotes
239281
# -------------------------------------------------------------------------
@@ -304,94 +346,122 @@ def historical(self, symbol, from_date=None, to_date=None, interval=None):
304346
# Market
305347
# -------------------------------------------------------------------------
306348

307-
def market_summary(self):
349+
def market_summary(self, index=None):
308350
"""
309351
Get market overview (TASI index, change, volume, market_mood).
310352
353+
Args:
354+
index: Optional market index ("TASI" or "NOMU"). "NOMUC" alias
355+
is accepted and normalized to "NOMU".
356+
311357
Returns:
312358
MarketSummary object
313359
"""
314360
from .models import MarketSummary
315-
data = self._request("GET", "/market/summary/")
361+
data = self._request(
362+
"GET",
363+
"/market/summary/",
364+
params=self._market_params(index=index),
365+
)
316366
return MarketSummary.from_dict(data)
317367

318-
def gainers(self, limit=None):
368+
def gainers(self, limit=None, index=None):
319369
"""
320370
Get top gaining stocks.
321371
322372
Args:
323373
limit: Number of results (default: 10, max: 50)
374+
index: Optional market index ("TASI" or "NOMU"). "NOMUC" alias
375+
is accepted and normalized to "NOMU".
324376
325377
Returns:
326378
MarketMoversResponse with .stocks list
327379
"""
328380
from .models import MarketMoversResponse
329-
params = {}
330-
if limit is not None:
331-
params["limit"] = limit
332-
data = self._request("GET", "/market/gainers/", params=params or None)
381+
data = self._request(
382+
"GET",
383+
"/market/gainers/",
384+
params=self._market_params(limit=limit, index=index),
385+
)
333386
return MarketMoversResponse.from_dict(data, list_key="gainers")
334387

335-
def losers(self, limit=None):
388+
def losers(self, limit=None, index=None):
336389
"""
337390
Get top losing stocks.
338391
339392
Args:
340393
limit: Number of results (default: 10, max: 50)
394+
index: Optional market index ("TASI" or "NOMU"). "NOMUC" alias
395+
is accepted and normalized to "NOMU".
341396
342397
Returns:
343398
MarketMoversResponse with .stocks list
344399
"""
345400
from .models import MarketMoversResponse
346-
params = {}
347-
if limit is not None:
348-
params["limit"] = limit
349-
data = self._request("GET", "/market/losers/", params=params or None)
401+
data = self._request(
402+
"GET",
403+
"/market/losers/",
404+
params=self._market_params(limit=limit, index=index),
405+
)
350406
return MarketMoversResponse.from_dict(data, list_key="losers")
351407

352-
def volume_leaders(self, limit=None):
408+
def volume_leaders(self, limit=None, index=None):
353409
"""
354410
Get stocks with highest trading volume.
355411
356412
Args:
357413
limit: Number of results (default: 10, max: 50)
414+
index: Optional market index ("TASI" or "NOMU"). "NOMUC" alias
415+
is accepted and normalized to "NOMU".
358416
359417
Returns:
360418
MarketMoversResponse with .stocks list
361419
"""
362420
from .models import MarketMoversResponse
363-
params = {}
364-
if limit is not None:
365-
params["limit"] = limit
366-
data = self._request("GET", "/market/volume/", params=params or None)
421+
data = self._request(
422+
"GET",
423+
"/market/volume/",
424+
params=self._market_params(limit=limit, index=index),
425+
)
367426
return MarketMoversResponse.from_dict(data, list_key="stocks")
368427

369-
def value_leaders(self, limit=None):
428+
def value_leaders(self, limit=None, index=None):
370429
"""
371430
Get stocks with highest trading value (SAR).
372431
373432
Args:
374433
limit: Number of results (default: 10, max: 50)
434+
index: Optional market index ("TASI" or "NOMU"). "NOMUC" alias
435+
is accepted and normalized to "NOMU".
375436
376437
Returns:
377438
MarketMoversResponse with .stocks list
378439
"""
379440
from .models import MarketMoversResponse
380-
params = {}
381-
if limit is not None:
382-
params["limit"] = limit
383-
data = self._request("GET", "/market/value/", params=params or None)
441+
data = self._request(
442+
"GET",
443+
"/market/value/",
444+
params=self._market_params(limit=limit, index=index),
445+
)
384446
return MarketMoversResponse.from_dict(data, list_key="stocks")
385447

386-
def sectors(self):
448+
def sectors(self, index=None):
387449
"""
388450
Get sector performance.
389451
452+
Args:
453+
index: Optional market index ("TASI" or "NOMU"). "NOMUC" alias
454+
is accepted and normalized to "NOMU".
455+
390456
Returns:
391457
SectorsResponse with .sectors list
392458
"""
393459
from .models import SectorsResponse
394-
data = self._request("GET", "/market/sectors/")
460+
data = self._request(
461+
"GET",
462+
"/market/sectors/",
463+
params=self._market_params(index=index),
464+
)
395465
return SectorsResponse.from_dict(data)
396466

397467
# -------------------------------------------------------------------------

0 commit comments

Comments
 (0)