Skip to content

Commit 4b44634

Browse files
authored
Merge pull request #3 from nklsla/feature/add-report-and-kpi-batch-request
2 parents 431ebaf + 93f393f commit 4b44634

File tree

5 files changed

+285
-14
lines changed

5 files changed

+285
-14
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,12 @@ Comprehensive documentation is available in the `docs` directory:
114114
- `get_stock_prices()` - Get stock prices for an instrument
115115
- `get_stock_prices_batch()` - Get stock prices for a batch of instrument
116116
- `get_reports()` - Get financial reports for an instrument
117+
- `get_reports_batch()` - Get financial reports for a batch of instrument
117118
- `get_reports_metadata()` - Get metadata for all financial report values
118119
- `get_kpi_metadata()` - Get metadata for all KPIs
119120
- `get_kpi_updated()` - Get last update time for KPIs
120121
- `get_kpi_history()` - Get one KPIs history for an instrument
122+
- `get_kpi_history_batch()` - Get one KPIs history for a batch of instrument
121123
- `get_kpi_summary()` - Get summary of KPIs history for an instrument (Note: Not all kpi's)
122124
- `get_insider_holdings()` - Get insider holdings for instruments
123125
- `get_short_positions()` - Get short positions for all instruments

src/borsdata_client/client.py

Lines changed: 110 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Borsdata API client implementation."""
22

33
from datetime import datetime
4-
from typing import Any, Dict, List, Optional
4+
from typing import Any, Dict, Iterable, List, Optional
55

66
import httpx
77
from tenacity import (
@@ -25,6 +25,7 @@
2525
KpiAllResponse,
2626
KpiCalcUpdatedResponse,
2727
KpiMetadata,
28+
KpisHistoryArrayResp,
2829
KpisSummaryResponse,
2930
KpiSummaryGroup,
3031
Market,
@@ -33,6 +34,8 @@
3334
ReportCalendarListResponse,
3435
ReportMetadata,
3536
ReportMetadataResponse,
37+
ReportsArrayResp,
38+
ReportsCombineResp,
3639
Sector,
3740
SectorsResponse,
3841
ShortsListResponse,
@@ -220,22 +223,26 @@ def get_stock_prices(
220223

221224
def get_stock_prices_batch(
222225
self,
223-
instrument_ids: list[int],
226+
instrument_ids: Iterable[int],
224227
from_date: Optional[datetime] = None,
225228
to_date: Optional[datetime] = None,
226229
) -> List[StockPricesArrayRespList]:
227230
"""Get stock prices for multiple instruments, max 50 instruments per call.
228231
229232
Args:
230-
instrument_ids: List of instrument IDs
233+
instrument_ids: Iterable of instrument IDs
231234
from_date: Start date for price data
232235
to_date: End date for price data
233236
234237
Returns:
235238
List of StockPrice objects
236239
"""
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"
240+
assert isinstance(
241+
instrument_ids, Iterable
242+
), "instrument_ids must be an iterable"
243+
assert (
244+
len(list(instrument_ids)) <= 50
245+
), "Max 50 instrument IDs allowed per request"
239246

240247
params = {"instList": ",".join(map(str, instrument_ids))}
241248

@@ -278,6 +285,56 @@ def get_reports(
278285
)
279286
return [Report(**report) for report in response.get("reports", [])]
280287

288+
def get_reports_batch(
289+
self,
290+
instrument_ids: Iterable[int],
291+
max_year_count: Optional[int] = 10,
292+
max_quarter_r12_count: Optional[int] = 10,
293+
original_currency: bool = False,
294+
) -> List[ReportsCombineResp]:
295+
"""Get financial reports for multiple instruments, max 50 instruments per call.
296+
297+
Args:
298+
instrument_ids: Iterable of instrument IDs
299+
max_year_count: Maximum number of year reports to return, max 20.
300+
max_quarter_r12_count: Maximum number of quarter/R12 reports to return, max 40.
301+
original_currency: Whether to return values in original currency
302+
303+
Returns:
304+
List of Report objects
305+
"""
306+
assert isinstance(
307+
instrument_ids, Iterable
308+
), "instrument_ids must be an iterable"
309+
assert (
310+
len(list(instrument_ids)) <= 50
311+
), "Max 50 instrument IDs allowed per request"
312+
assert max_quarter_r12_count is None or isinstance(
313+
max_quarter_r12_count, int
314+
), "max_quarter_r12_count must be an integer"
315+
assert max_year_count <= 20, "max_year_count must be 20 or less"
316+
assert max_quarter_r12_count <= 40, "max_quarter_r12_count must be 40 or less"
317+
318+
params = {"instList": ",".join(map(str, instrument_ids))}
319+
320+
if max_year_count is not None:
321+
assert (
322+
isinstance(max_year_count, int) and 0 < max_year_count <= 20
323+
), "max_year_count must be a positive integer"
324+
params["maxYearCount"] = str(max_year_count)
325+
if max_quarter_r12_count is not None:
326+
assert (
327+
isinstance(max_quarter_r12_count, int)
328+
and 0 < max_quarter_r12_count <= 40
329+
), "max_quarter_r12_count must be a positive integer"
330+
params["maxQuarterR12Count"] = str(max_quarter_r12_count)
331+
332+
params["original"] = "1" if original_currency else "0"
333+
334+
response = self._get(f"/instruments/reports", params)
335+
response_model = ReportsArrayResp(**response)
336+
return response_model.report_list
337+
281338
def get_reports_metadata(
282339
self,
283340
) -> List[ReportMetadata]:
@@ -341,8 +398,47 @@ def get_kpi_history(
341398
)
342399
return KpiAllResponse(**response)
343400

401+
def get_kpi_history_batch(
402+
self,
403+
instrument_ids: Iterable[int],
404+
kpi_id: int,
405+
report_type: str,
406+
price_type: str = "mean",
407+
max_count: Optional[int] = None,
408+
) -> List[KpiAllResponse]:
409+
"""Get KPI history for multiple instruments, max 50 instruments per call.
410+
411+
Args:
412+
instrument_ids: IDs of the instruments
413+
kpi_id: ID of the KPI
414+
report_type: Type of report ('year', 'r12', 'quarter')
415+
price_type: Type of price calculation
416+
max_count: Maximum number of results to return, 10 by default. report_type 'year' can return max 20, 'r12' and 'quarter' can return max 40.
417+
418+
Returns:
419+
List of KPI responses
420+
"""
421+
422+
assert isinstance(
423+
instrument_ids, Iterable
424+
), "instrument_ids must be an iterable"
425+
assert (
426+
len(list(instrument_ids)) <= 50
427+
), "Max 50 instrument IDs allowed per request"
428+
429+
params = {"instList": ",".join(map(str, instrument_ids))}
430+
if max_count:
431+
params["maxCount"] = str(max_count)
432+
433+
response = self._get(
434+
f"/instruments/kpis/{kpi_id}/{report_type}/{price_type}/history",
435+
params,
436+
)
437+
438+
return KpisHistoryArrayResp(**response)
439+
344440
def get_kpi_summary(
345-
self, instrument_id: str, report_type: str, max_count: Optional[int] = None
441+
self, instrument_id: int, report_type: str, max_count: Optional[int] = None
346442
) -> List[KpiSummaryGroup]:
347443
"""Get all KPI history for an instrument.
348444
@@ -364,7 +460,7 @@ def get_kpi_summary(
364460
return KpisSummaryResponse(**response).kpis or []
365461

366462
def get_insider_holdings(
367-
self, instrument_ids: List[int]
463+
self, instrument_ids: Iterable[int]
368464
) -> List[InsiderListResponse]:
369465
"""Get insider holdings for specified instruments.
370466
@@ -387,7 +483,7 @@ def get_short_positions(self) -> List[ShortsListResponse]:
387483
response = self._get("/holdings/shorts")
388484
return ShortsListResponse(**response).list or []
389485

390-
def get_buybacks(self, instrument_ids: List[int]) -> List[BuybackListResponse]:
486+
def get_buybacks(self, instrument_ids: Iterable[int]) -> List[BuybackListResponse]:
391487
"""Get buyback data for specified instruments.
392488
393489
Args:
@@ -401,12 +497,12 @@ def get_buybacks(self, instrument_ids: List[int]) -> List[BuybackListResponse]:
401497
return BuybackListResponse(**response).list or []
402498

403499
def get_instrument_descriptions(
404-
self, instrument_ids: List[int]
500+
self, instrument_ids: Iterable[int]
405501
) -> List[InstrumentDescriptionListResponse]:
406502
"""Get descriptions for specified instruments.
407503
408504
Args:
409-
instrument_ids: List of instrument IDs to get descriptions for
505+
instrument_ids: Iterable of instrument IDs to get descriptions for
410506
411507
Returns:
412508
List of instrument description responses
@@ -416,12 +512,12 @@ def get_instrument_descriptions(
416512
return InstrumentDescriptionListResponse(**response).list or []
417513

418514
def get_report_calendar(
419-
self, instrument_ids: List[int]
515+
self, instrument_ids: Iterable[int]
420516
) -> List[ReportCalendarListResponse]:
421517
"""Get report calendar for specified instruments.
422518
423519
Args:
424-
instrument_ids: List of instrument IDs to get calendar for
520+
instrument_ids: Iterable of instrument IDs to get calendar for
425521
426522
Returns:
427523
List of report calendar responses
@@ -431,12 +527,12 @@ def get_report_calendar(
431527
return ReportCalendarListResponse(**response).list or []
432528

433529
def get_dividend_calendar(
434-
self, instrument_ids: List[int]
530+
self, instrument_ids: Iterable[int]
435531
) -> List[DividendCalendarListResponse]:
436532
"""Get dividend calendar for specified instruments.
437533
438534
Args:
439-
instrument_ids: List of instrument IDs to get calendar for
535+
instrument_ids: Iterable of instrument IDs to get calendar for
440536
441537
Returns:
442538
List of dividend calendar responses

src/borsdata_client/models.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,40 @@ class KpisSummaryResponse(BaseModel):
101101
kpis: Optional[List[KpiSummaryGroup]]
102102

103103

104+
class KpiHistory(BaseModel):
105+
y: int = Field(description="Year")
106+
p: int = Field(description="Period")
107+
v: Optional[float] = Field(None, description="Value (nullable)")
108+
109+
@property
110+
def year(self) -> int:
111+
return self.y
112+
113+
@property
114+
def period(self) -> int:
115+
return self.p
116+
117+
@property
118+
def value(self) -> Optional[float]:
119+
return self.v
120+
121+
122+
class KpisHistoryComp(BaseModel):
123+
instrument: int
124+
kpi_id: Optional[int] = Field(None, alias="kpiId", description="KPI ID")
125+
error: Optional[str] = Field(None, description="Optional error message")
126+
values: Optional[List[KpiHistory]] = Field(
127+
None, description="List of KPI history values"
128+
)
129+
130+
131+
class KpisHistoryArrayResp(BaseModel):
132+
kpi_id: int = Field(alias="kpiId")
133+
report_time: Optional[str] = Field(None, alias="reportTime")
134+
price_value: Optional[str] = Field(None, alias="priceValue")
135+
kpis_list: Optional[List[KpisHistoryComp]] = Field(None, alias="kpisList")
136+
137+
104138
class Report(BaseModel):
105139
"""Financial report model."""
106140

@@ -155,6 +189,18 @@ class Report(BaseModel):
155189
report_date: Optional[datetime] = Field(None, alias="report_Date")
156190

157191

192+
class ReportsCombineResp(BaseModel):
193+
instrument: int
194+
error: Optional[str] = None
195+
reports_year: Optional[List[Report]] = Field(None, alias="reportsYear")
196+
reports_quarter: Optional[List[Report]] = Field(None, alias="reportsQuarter")
197+
reports_r12: Optional[List[Report]] = Field(None, alias="reportsR12")
198+
199+
200+
class ReportsArrayResp(BaseModel):
201+
report_list: Optional[List[ReportsCombineResp]] = Field(None, alias="reportList")
202+
203+
158204
class ReportMetadata(BaseModel):
159205
report_property: Optional[str] = Field(None, alias="reportPropery")
160206
name_sv: Optional[str] = Field(None, alias="nameSv")

tests/test_endpoints.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
Market,
2525
Report,
2626
ReportCalendarResponse,
27+
ReportsCombineResp,
2728
ShortsResponse,
2829
StockPrice,
2930
StockPriceLastValue,
@@ -395,6 +396,73 @@ def test_get_reports(mock_client):
395396
assert reports[0].earnings_per_share == 2
396397

397398

399+
# Test for get_report_batch
400+
def test_get_report_batch(mock_client):
401+
"""Test the get_report_batch method."""
402+
# Create mock response
403+
rep_data = {
404+
"year": 2020,
405+
"period": 1,
406+
"revenues": 1000,
407+
"gross_Income": 800.0,
408+
"operating_Income": 600.0,
409+
"profit_Before_Tax": 500.0,
410+
"profit_To_Equity_Holders": 400.0,
411+
"earnings_Per_Share": 2.0,
412+
"number_Of_Shares": 200.0,
413+
"dividend": 1.0,
414+
"intangible_Assets": 300.0,
415+
"tangible_Assets": 400.0,
416+
"financial_Assets": 500.0,
417+
"non_Current_Assets": 1200.0,
418+
"cash_And_Equivalents": 200.0,
419+
"current_Assets": 1500.0,
420+
"total_Assets": 3000.0,
421+
"total_Equity": 1800.0,
422+
"non_Current_Liabilities": 700.0,
423+
"current_Liabilities": 500.0,
424+
"total_Liabilities_And_Equity": 3000.0,
425+
"net_Debt": 100.0,
426+
"cash_Flow_From_Operating_Activities": 600.0,
427+
"cash_Flow_From_Investing_Activities": -200.0,
428+
"cash_Flow_From_Financing_Activities": -100.0,
429+
"cash_Flow_For_The_Year": 300.0,
430+
"free_Cash_Flow": 400.0,
431+
"stock_Price_Average": 50.0,
432+
"stock_Price_High": 60.0,
433+
"stock_Price_Low": 40.0,
434+
"report_Start_Date": "2020-01-01T00:00:00",
435+
"report_End_Date": "2020-12-31T00:00:00",
436+
"broken_Fiscal_Year": False,
437+
"currency": "USD",
438+
}
439+
create_mock_response(
440+
"instruments/reports",
441+
{
442+
"reportList": [
443+
{
444+
"instrument": 1,
445+
"error": "No error",
446+
"reportsYear": [rep_data],
447+
"reportsQuarter": [rep_data],
448+
"reportsR12": [rep_data],
449+
}
450+
]
451+
},
452+
)
453+
# Call the method
454+
reports = mock_client.get_reports_batch(instrument_ids=[1])
455+
456+
# Verify the result
457+
assert len(reports) == 1
458+
assert isinstance(reports[0], ReportsCombineResp)
459+
assert isinstance(reports[0].reports_year[0], Report)
460+
assert reports[0].instrument == 1
461+
assert reports[0].reports_year[0].year == 2020
462+
assert reports[0].reports_quarter[0].revenues == 1000
463+
assert reports[0].reports_r12[0].earnings_per_share == 2
464+
465+
398466
# Test for get_kpi_metadata
399467
def test_get_kpi_metadata(mock_client):
400468
"""Test the get_kpi_metadata method."""

0 commit comments

Comments
 (0)