Skip to content

Commit 431ebaf

Browse files
authored
Merge pull request #2 from nklsla/feature/add-price-array-call
2 parents 2d22b38 + 5c10196 commit 431ebaf

File tree

10 files changed

+268
-72
lines changed

10 files changed

+268
-72
lines changed

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Borsdata API Client
2+
23
### [NOT AFFILIATED WITH BÖRSDATA]
4+
35
### [THIS IS A THIRD PARTY LIBRARY]
46

57
This is a modern Python client for the Borsdata API, featuring:
@@ -11,7 +13,7 @@ This is a modern Python client for the Borsdata API, featuring:
1113
- Context manager support
1214
- Intuitive API design
1315

14-
For the official documentation check out:
16+
For the official documentation check out:
1517
[https://github.com/Borsdata-Sweden/API]
1618

1719
## Installation
@@ -106,19 +108,23 @@ Comprehensive documentation is available in the `docs` directory:
106108
- `get_branches()` - Get all branches/industries
107109
- `get_countries()` - Get all countries
108110
- `get_markets()` - Get all markets
111+
- `get_sectors()` - Get all sectors
109112
- `get_instruments()` - Get all Nordic instruments
110113
- `get_global_instruments()` - Get all global instruments (Pro+ subscription required)
111114
- `get_stock_prices()` - Get stock prices for an instrument
115+
- `get_stock_prices_batch()` - Get stock prices for a batch of instrument
112116
- `get_reports()` - Get financial reports for an instrument
117+
- `get_reports_metadata()` - Get metadata for all financial report values
113118
- `get_kpi_metadata()` - Get metadata for all KPIs
119+
- `get_kpi_updated()` - Get last update time for KPIs
120+
- `get_kpi_history()` - Get one KPIs history for an instrument
121+
- `get_kpi_summary()` - Get summary of KPIs history for an instrument (Note: Not all kpi's)
114122
- `get_insider_holdings()` - Get insider holdings for instruments
115123
- `get_short_positions()` - Get short positions for all instruments
116124
- `get_buybacks()` - Get buybacks for instruments
117125
- `get_instrument_descriptions()` - Get descriptions for instruments
118126
- `get_report_calendar()` - Get report calendar for instruments
119127
- `get_dividend_calendar()` - Get dividend calendar for instruments
120-
- `get_kpi_history()` - Get KPI history for an instrument
121-
- `get_kpi_updated()` - Get last update time for KPIs
122128
- `get_last_stock_prices()` - Get last stock prices for all instruments
123129
- `get_last_global_stock_prices()` - Get last stock prices for all global instruments
124130
- `get_stock_prices_by_date()` - Get stock prices for all instruments on a specific date
@@ -140,6 +146,8 @@ except BorsdataClientError as e:
140146
print(f"API request failed: {e}")
141147
```
142148

149+
Retries five times by default if reaching the APIs rate limit (100 requests / 10 seconds).
150+
143151
## Data Models
144152

145153
All API responses are validated and converted to Pydantic models. See the [Models Reference](docs/api/models.md) for details.
@@ -151,6 +159,7 @@ All API responses are validated and converted to Pydantic models. See the [Model
151159
- httpx>=0.25.2
152160
- python-dateutil>=2.8.2
153161
- typing-extensions>=4.8.0
162+
- tenacity>=9.0.0
154163

155164
## License
156165

docs/examples/advanced_usage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ from datetime import datetime
1111

1212
api_key = os.environ.get("BORSDATA_API_KEY")
1313

14-
with BorsdataClient(api_key) as client:
14+
with BorsdataClient(api_key, retry=True, max_retries=5) as client:
1515
# Get all instruments
1616
instruments = client.get_instruments()
1717

requirements-dev.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ seaborn>=0.13.0
55
pytest>=7.4.0
66
pytest-cov>=4.1.0
77
pytest-mock>=3.11.1
8-
httpx>=0.25.2
8+
httpx>=0.25.2
9+
tenacity>=9.0.0

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ httpx>=0.25.2
33
python-dateutil>=2.8.2
44
typing-extensions>=4.8.0
55
python-dotenv>=1.0.0
6+
tenacity>=9.0.0

src/borsdata_client/client.py

Lines changed: 129 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
from typing import Any, Dict, List, Optional
55

66
import httpx
7+
from tenacity import (
8+
Retrying,
9+
retry_if_exception,
10+
stop_after_attempt,
11+
wait_random_exponential,
12+
)
713

814
from .models import (
915
Branch,
@@ -33,6 +39,8 @@
3339
StockPrice,
3440
StockPriceLastResponse,
3541
StockPriceLastValue,
42+
StockPricesArrayResp,
43+
StockPricesArrayRespList,
3644
StockPricesResponse,
3745
StockSplit,
3846
StockSplitResponse,
@@ -51,14 +59,34 @@ class BorsdataClient:
5159

5260
BASE_URL = "https://apiservice.borsdata.se/v1"
5361

54-
def __init__(self, api_key: str):
62+
def __init__(self, api_key: str, retry: bool = True, max_retries: int = 5):
5563
"""Initialize the Borsdata API client.
5664
5765
Args:
5866
api_key: Your Borsdata API authentication key
67+
retry: Whether to enable retry on rate limit errors
68+
max_retries: Maximum number of retries for rate limit errors
5969
"""
6070
self.api_key = api_key
6171
self._client = httpx.Client(timeout=30.0)
72+
self.retry = retry
73+
self.retryer = None
74+
75+
def is_retryable_exception(exception):
76+
"""Check if the exception is retryable."""
77+
if isinstance(exception, httpx.HTTPStatusError):
78+
# Retry for 429 Too Many Requests
79+
if exception.response.status_code == 429:
80+
print("Rate limit exceeded. Retrying...")
81+
return True
82+
return False
83+
84+
self.retryer = Retrying(
85+
wait=wait_random_exponential(multiplier=1, min=1, max=20),
86+
stop=stop_after_attempt(max_retries),
87+
reraise=True,
88+
retry=retry_if_exception(is_retryable_exception),
89+
)
6290

6391
def _get(
6492
self, endpoint: str, params: Optional[Dict[str, Any]] = None
@@ -79,10 +107,23 @@ def _get(
79107
params = {}
80108
params["authKey"] = self.api_key
81109

82-
try:
83-
response = self._client.get(f"{self.BASE_URL}{endpoint}", params=params)
84-
response.raise_for_status() # This will raise an HTTPError for 4XX/5XX responses
110+
def _get_wrapper(api_endpoint=endpoint, params=params):
111+
response = self._client.get(api_endpoint, params=params)
112+
response.raise_for_status() # This raises HTTPStatusError for 4xx/5xx codes
85113
return response.json()
114+
115+
try:
116+
if self.retry:
117+
return self.retryer(
118+
_get_wrapper,
119+
api_endpoint=f"{self.BASE_URL}{endpoint}",
120+
params=params,
121+
)
122+
else:
123+
return _get_wrapper(
124+
api_endpoint=f"{self.BASE_URL}{endpoint}", params=params
125+
)
126+
86127
except httpx.HTTPStatusError as e:
87128
error_msg = str(e)
88129
status_code = e.response.status_code
@@ -177,6 +218,38 @@ def get_stock_prices(
177218
# Convert each stock price dict to a StockPrice object
178219
return [StockPrice(**price) for price in response_model.stockPricesList]
179220

221+
def get_stock_prices_batch(
222+
self,
223+
instrument_ids: list[int],
224+
from_date: Optional[datetime] = None,
225+
to_date: Optional[datetime] = None,
226+
) -> List[StockPricesArrayRespList]:
227+
"""Get stock prices for multiple instruments, max 50 instruments per call.
228+
229+
Args:
230+
instrument_ids: List of instrument IDs
231+
from_date: Start date for price data
232+
to_date: End date for price data
233+
234+
Returns:
235+
List of StockPrice objects
236+
"""
237+
assert isinstance(instrument_ids, list), "instrument_ids must be a list"
238+
assert len(instrument_ids) <= 50, "Max 50 instrument IDs allowed per request"
239+
240+
params = {"instList": ",".join(map(str, instrument_ids))}
241+
242+
if from_date:
243+
params["from"] = from_date.strftime("%Y-%m-%d")
244+
if to_date:
245+
params["to"] = to_date.strftime("%Y-%m-%d")
246+
247+
response = self._get("/instruments/stockprices", params)
248+
response_model = StockPricesArrayResp(**response)
249+
250+
# Return list of instrument stock prices
251+
return response_model.stockPricesArrayList
252+
180253
def get_reports(
181254
self,
182255
instrument_id: int,
@@ -238,6 +311,58 @@ def get_kpi_updated(self) -> datetime:
238311
response = self._get("/instruments/kpis/updated")
239312
return KpiCalcUpdatedResponse(**response).kpis_calc_updated
240313

314+
def get_kpi_history(
315+
self,
316+
instrument_id: str,
317+
kpi_id: int,
318+
report_type: str,
319+
price_type: str = "mean",
320+
max_count: Optional[int] = None,
321+
) -> List[KpiAllResponse]:
322+
"""Get KPI history for an instrument.
323+
324+
Args:
325+
instrument_id: ID of the instrument
326+
kpi_id: ID of the KPI
327+
report_type: Type of report ('year', 'r12', 'quarter')
328+
price_type: Type of price calculation
329+
max_count: Maximum number of results to return
330+
331+
Returns:
332+
List of KPI responses
333+
"""
334+
params = {}
335+
if max_count:
336+
params["maxCount"] = str(max_count)
337+
338+
response = self._get(
339+
f"/instruments/{instrument_id}/kpis/{kpi_id}/{report_type}/{price_type}/history",
340+
params,
341+
)
342+
return KpiAllResponse(**response)
343+
344+
def get_kpi_summary(
345+
self, instrument_id: str, report_type: str, max_count: Optional[int] = None
346+
) -> List[KpiSummaryGroup]:
347+
"""Get all KPI history for an instrument.
348+
349+
Args:
350+
instrument_id: ID of the instrument
351+
report_type: Type of report ('year', 'r12', 'quarter')
352+
max_count: Maximum number of results to return
353+
354+
Returns:
355+
List of all KPI responses
356+
"""
357+
params = {}
358+
if max_count:
359+
params["maxCount"] = str(max_count)
360+
361+
response = self._get(
362+
f"/instruments/{instrument_id}/kpis/{report_type}/summary", params
363+
)
364+
return KpisSummaryResponse(**response).kpis or []
365+
241366
def get_insider_holdings(
242367
self, instrument_ids: List[int]
243368
) -> List[InsiderListResponse]:
@@ -320,58 +445,6 @@ def get_dividend_calendar(
320445
response = self._get("/instruments/dividend/calendar", params)
321446
return DividendCalendarListResponse(**response).list or []
322447

323-
def get_kpi_history(
324-
self,
325-
instrument_id: str,
326-
kpi_id: int,
327-
report_type: str,
328-
price_type: str = "mean",
329-
max_count: Optional[int] = None,
330-
) -> List[KpiAllResponse]:
331-
"""Get KPI history for an instrument.
332-
333-
Args:
334-
instrument_id: ID of the instrument
335-
kpi_id: ID of the KPI
336-
report_type: Type of report ('year', 'r12', 'quarter')
337-
price_type: Type of price calculation
338-
max_count: Maximum number of results to return
339-
340-
Returns:
341-
List of KPI responses
342-
"""
343-
params = {}
344-
if max_count:
345-
params["maxCount"] = str(max_count)
346-
347-
response = self._get(
348-
f"/instruments/{instrument_id}/kpis/{kpi_id}/{report_type}/{price_type}/history",
349-
params,
350-
)
351-
return KpiAllResponse(**response)
352-
353-
def get_kpi_summary(
354-
self, instrument_id: str, report_type: str, max_count: Optional[int] = None
355-
) -> List[KpiSummaryGroup]:
356-
"""Get all KPI history for an instrument.
357-
358-
Args:
359-
instrument_id: ID of the instrument
360-
report_type: Type of report ('year', 'r12', 'quarter')
361-
max_count: Maximum number of results to return
362-
363-
Returns:
364-
List of all KPI responses
365-
"""
366-
params = {}
367-
if max_count:
368-
params["maxCount"] = str(max_count)
369-
370-
response = self._get(
371-
f"/instruments/{instrument_id}/kpis/{report_type}/summary", params
372-
)
373-
return KpisSummaryResponse(**response).kpis or []
374-
375448
def get_last_stock_prices(self) -> List[StockPriceLastValue]:
376449
"""Get last stock prices for all instruments.
377450

src/borsdata_client/models.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,20 @@ class Instrument(BaseModel):
5858

5959

6060
class StockPrice(BaseModel):
61-
"""Stock price data model."""
61+
"""Single stock price entry."""
6262

63-
d: str = Field(description="Date string in format YYYY-MM-DD")
64-
h: float = Field(description="High price")
65-
l: float = Field(description="Low price")
66-
c: float = Field(description="Closing price")
67-
o: float = Field(description="Opening price")
68-
v: int = Field(description="Volume")
63+
d: Optional[str] = Field(None, description="Date string in format YYYY-MM-DD")
64+
h: Optional[float] = Field(None, description="Highest price")
65+
l: Optional[float] = Field(None, description="Lowest price")
66+
c: float = Field(..., description="Closing price")
67+
o: Optional[float] = Field(None, description="Opening price")
68+
v: Optional[int] = Field(None, description="Total volume")
6969

70-
def get_date(self) -> datetime:
70+
def get_date(self) -> Optional[datetime]:
7171
"""Convert the date string to a datetime object."""
72-
return datetime.strptime(self.d, "%Y-%m-%d")
72+
if isinstance(self.d, str):
73+
return datetime.strptime(self.d, "%Y-%m-%d")
74+
return None
7375

7476

7577
class KpiMetadata(BaseModel):
@@ -204,6 +206,24 @@ class StockPricesResponse(BaseModel):
204206
stockPricesList: List[Dict[str, Any]] # The actual field name from the API
205207

206208

209+
class StockPricesArrayRespList(BaseModel):
210+
"""Stock prices list response for an instrument."""
211+
212+
instrument: int = Field(..., description="Instrument ID")
213+
error: Optional[str] = Field(None, description="Error message, if any")
214+
stockPricesList: Optional[List[StockPrice]] = Field(
215+
None, description="List of stock prices"
216+
)
217+
218+
219+
class StockPricesArrayResp(BaseModel):
220+
"""Top-level response for stock prices array."""
221+
222+
stockPricesArrayList: Optional[List[StockPricesArrayRespList]] = Field(
223+
None, description="List of stock prices per instrument"
224+
)
225+
226+
207227
class InsiderRow(BaseModel):
208228
"""Model for insider trading data."""
209229

@@ -370,10 +390,10 @@ class StockPriceLastValue(BaseModel):
370390

371391
i: int = Field(description="Instrument Id")
372392
d: str = Field(description="Date string in format YYYY-MM-DD")
393+
o: float = Field(description="Opening price")
373394
h: float = Field(description="High price")
374395
l: float = Field(description="Low price")
375396
c: float = Field(description="Closing price")
376-
o: float = Field(description="Opening price")
377397
v: Optional[int] = Field(None, description="Volume")
378398

379399

0 commit comments

Comments
 (0)