kensho-kfinance 2.0.1__py3-none-any.whl → 2.2.2__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.

Potentially problematic release.


This version of kensho-kfinance might be problematic. Click here for more details.

Files changed (40) hide show
  1. {kensho_kfinance-2.0.1.dist-info → kensho_kfinance-2.2.2.dist-info}/METADATA +9 -1
  2. kensho_kfinance-2.2.2.dist-info/RECORD +42 -0
  3. {kensho_kfinance-2.0.1.dist-info → kensho_kfinance-2.2.2.dist-info}/WHEEL +1 -1
  4. kfinance/CHANGELOG.md +21 -0
  5. kfinance/batch_request_handling.py +32 -27
  6. kfinance/constants.py +23 -7
  7. kfinance/fetch.py +106 -40
  8. kfinance/kfinance.py +164 -89
  9. kfinance/meta_classes.py +118 -9
  10. kfinance/tests/conftest.py +32 -0
  11. kfinance/tests/test_batch_requests.py +46 -8
  12. kfinance/tests/test_client.py +54 -0
  13. kfinance/tests/test_example_notebook.py +194 -0
  14. kfinance/tests/test_fetch.py +31 -2
  15. kfinance/tests/test_group_objects.py +32 -0
  16. kfinance/tests/test_objects.py +40 -0
  17. kfinance/tests/test_tools.py +13 -61
  18. kfinance/tool_calling/__init__.py +2 -6
  19. kfinance/tool_calling/get_business_relationship_from_identifier.py +2 -1
  20. kfinance/tool_calling/get_capitalization_from_identifier.py +2 -1
  21. kfinance/tool_calling/get_cusip_from_ticker.py +2 -0
  22. kfinance/tool_calling/get_earnings_call_datetimes_from_identifier.py +2 -0
  23. kfinance/tool_calling/get_financial_line_item_from_identifier.py +2 -1
  24. kfinance/tool_calling/get_financial_statement_from_identifier.py +2 -1
  25. kfinance/tool_calling/get_history_metadata_from_identifier.py +2 -1
  26. kfinance/tool_calling/get_info_from_identifier.py +3 -1
  27. kfinance/tool_calling/get_isin_from_ticker.py +2 -0
  28. kfinance/tool_calling/get_latest.py +2 -1
  29. kfinance/tool_calling/get_n_quarters_ago.py +2 -1
  30. kfinance/tool_calling/get_prices_from_identifier.py +2 -1
  31. kfinance/tool_calling/resolve_identifier.py +18 -0
  32. kfinance/tool_calling/shared_models.py +2 -0
  33. kfinance/version.py +2 -2
  34. kensho_kfinance-2.0.1.dist-info/RECORD +0 -40
  35. kfinance/tool_calling/get_company_id_from_identifier.py +0 -14
  36. kfinance/tool_calling/get_security_id_from_identifier.py +0 -14
  37. kfinance/tool_calling/get_trading_item_id_from_identifier.py +0 -14
  38. {kensho_kfinance-2.0.1.dist-info → kensho_kfinance-2.2.2.dist-info}/licenses/AUTHORS.md +0 -0
  39. {kensho_kfinance-2.0.1.dist-info → kensho_kfinance-2.2.2.dist-info}/licenses/LICENSE +0 -0
  40. {kensho_kfinance-2.0.1.dist-info → kensho_kfinance-2.2.2.dist-info}/top_level.txt +0 -0
kfinance/kfinance.py CHANGED
@@ -22,6 +22,7 @@ from .batch_request_handling import add_methods_of_singular_class_to_iterable_cl
22
22
  from .constants import (
23
23
  HistoryMetadata,
24
24
  IdentificationTriple,
25
+ IndustryClassification,
25
26
  LatestPeriods,
26
27
  Periodicity,
27
28
  YearAndQuarter,
@@ -233,10 +234,9 @@ class Company(CompanyFunctionsMetaClass):
233
234
  primary_security_id = self.kfinance_api_client.fetch_primary_security(self.company_id)[
234
235
  "primary_security"
235
236
  ]
236
- self.primary_security = Security(
237
+ return Security(
237
238
  kfinance_api_client=self.kfinance_api_client, security_id=primary_security_id
238
239
  )
239
- return self.primary_security
240
240
 
241
241
  @cached_property
242
242
  def securities(self) -> Securities:
@@ -246,10 +246,7 @@ class Company(CompanyFunctionsMetaClass):
246
246
  :rtype: Securities
247
247
  """
248
248
  security_ids = self.kfinance_api_client.fetch_securities(self.company_id)["securities"]
249
- self.securities = Securities(
250
- kfinance_api_client=self.kfinance_api_client, security_ids=security_ids
251
- )
252
- return self.securities
249
+ return Securities(kfinance_api_client=self.kfinance_api_client, security_ids=security_ids)
253
250
 
254
251
  @cached_property
255
252
  def latest_earnings_call(self) -> None:
@@ -265,11 +262,10 @@ class Company(CompanyFunctionsMetaClass):
265
262
  def info(self) -> dict:
266
263
  """Get the company info
267
264
 
268
- :return: a dict with containing: name, status, type, simple industry, number of employees, founding date, webpage, address, city, zip code, state, country, & iso_country
265
+ :return: a dict with containing: name, status, type, simple industry, number of employees (if available), founding date, webpage, address, city, zip code, state, country, & iso_country
269
266
  :rtype: dict
270
267
  """
271
- self.info = self.kfinance_api_client.fetch_info(self.company_id)
272
- return self.info
268
+ return self.kfinance_api_client.fetch_info(self.company_id)
273
269
 
274
270
  @property
275
271
  def name(self) -> str:
@@ -308,11 +304,11 @@ class Company(CompanyFunctionsMetaClass):
308
304
  return self.info["simple_industry"]
309
305
 
310
306
  @property
311
- def number_of_employees(self) -> str:
312
- """Get the number of employees the company has
307
+ def number_of_employees(self) -> str | None:
308
+ """Get the number of employees the company has (if available)
313
309
 
314
310
  :return: how many employees the company has
315
- :rtype: str
311
+ :rtype: str | None
316
312
  """
317
313
  return self.info["number_of_employees"]
318
314
 
@@ -539,6 +535,44 @@ class Ticker(DelegatedCompanyFunctionsMetaClass):
539
535
  "Neither an identifier nor an identification triple (company id, security id, & trading item id) were passed in"
540
536
  )
541
537
 
538
+ @property
539
+ def id_triple(self) -> IdentificationTriple:
540
+ """Returns a unique identification triple for the Ticker object.
541
+
542
+ :return: an identification triple consisting of company_id, security_id, and trading_item_id
543
+ :rtype: IdentificationTriple
544
+ """
545
+
546
+ if self._company_id is None or self._security_id is None or self._trading_item_id is None:
547
+ if self._identifier is None:
548
+ raise RuntimeError(
549
+ "Fetching the id triple of a Ticker requires an identifier "
550
+ "(ticker, CUSIP, or ISIN)."
551
+ )
552
+ id_triple = self.kfinance_api_client.fetch_id_triple(
553
+ identifier=self._identifier, exchange_code=self.exchange_code
554
+ )
555
+ self._company_id = id_triple["company_id"]
556
+ self._security_id = id_triple["security_id"]
557
+ self._trading_item_id = id_triple["trading_item_id"]
558
+ assert self._company_id
559
+ assert self._security_id
560
+ assert self._trading_item_id
561
+
562
+ return IdentificationTriple(
563
+ company_id=self._company_id,
564
+ security_id=self._security_id,
565
+ trading_item_id=self._trading_item_id,
566
+ )
567
+
568
+ def __hash__(self) -> int:
569
+ return hash(self.id_triple)
570
+
571
+ def __eq__(self, other: Any) -> bool:
572
+ if not isinstance(other, Ticker):
573
+ return False
574
+ return self.id_triple == other.id_triple
575
+
542
576
  def __str__(self) -> str:
543
577
  """String representation for the ticker object"""
544
578
  str_attributes = []
@@ -557,79 +591,32 @@ class Ticker(DelegatedCompanyFunctionsMetaClass):
557
591
 
558
592
  return f"{type(self).__module__}.{type(self).__qualname__} of {', '.join(str_attributes)}"
559
593
 
560
- def set_identification_triple(self) -> None:
561
- """Get & set company_id, security_id, & trading_item_id for ticker with an exchange"""
562
- if self._identifier is None:
563
- raise RuntimeError(
564
- "Ticker.set_identification_triple was called with a identifier set to None"
565
- )
566
- else:
567
- id_triple = self.kfinance_api_client.fetch_id_triple(
568
- self._identifier, self.exchange_code
569
- )
570
- self.company_id = id_triple["company_id"]
571
- self.security_id = id_triple["security_id"]
572
- self.trading_item_id = id_triple["trading_item_id"]
573
-
574
- def set_company_id(self) -> int:
575
- """Set the company id for the object
576
-
577
- :return: the CIQ company id
578
- :rtype: int
579
- """
580
- self.set_identification_triple()
581
- return self.company_id
582
-
583
- def set_security_id(self) -> int:
584
- """Set the security id for the object
585
-
586
- :return: the CIQ security id
587
- :rtype: int
588
- """
589
- self.set_identification_triple()
590
- return self.security_id
591
-
592
- def set_trading_item_id(self) -> int:
593
- """Set the trading item id for the object
594
-
595
- :return: the CIQ trading item id
596
- :rtype: int
597
- """
598
- self.set_identification_triple()
599
- return self.trading_item_id
600
-
601
- @cached_property
594
+ @property
602
595
  def company_id(self) -> int:
603
596
  """Get the company id for the object
604
597
 
605
598
  :return: the CIQ company id
606
599
  :rtype: int
607
600
  """
608
- if self._company_id:
609
- return self._company_id
610
- return self.set_company_id()
601
+ return self.id_triple.company_id
611
602
 
612
- @cached_property
603
+ @property
613
604
  def security_id(self) -> int:
614
605
  """Get the CIQ security id for the object
615
606
 
616
607
  :return: the CIQ security id
617
608
  :rtype: int
618
609
  """
619
- if self._security_id:
620
- return self._security_id
621
- return self.set_security_id()
610
+ return self.id_triple.security_id
622
611
 
623
- @cached_property
612
+ @property
624
613
  def trading_item_id(self) -> int:
625
614
  """Get the CIQ trading item id for the object
626
615
 
627
616
  :return: the CIQ trading item id
628
617
  :rtype: int
629
618
  """
630
- if self._trading_item_id:
631
- return self._trading_item_id
632
- return self.set_trading_item_id()
619
+ return self.id_triple.trading_item_id
633
620
 
634
621
  @cached_property
635
622
  def primary_security(self) -> Security:
@@ -698,7 +685,7 @@ class Ticker(DelegatedCompanyFunctionsMetaClass):
698
685
  def info(self) -> dict:
699
686
  """Get the company info for the ticker
700
687
 
701
- :return: a dict with containing: name, status, type, simple industry, number of employees, founding date, webpage, address, city, zip code, state, country, & iso_country
688
+ :return: a dict with containing: name, status, type, simple industry, number of employees (if available), founding date, webpage, address, city, zip code, state, country, & iso_country
702
689
  :rtype: dict
703
690
  """
704
691
  return self.company.info
@@ -740,11 +727,11 @@ class Ticker(DelegatedCompanyFunctionsMetaClass):
740
727
  return self.company.simple_industry
741
728
 
742
729
  @property
743
- def number_of_employees(self) -> str:
744
- """Get the number of employees the company has
730
+ def number_of_employees(self) -> str | None:
731
+ """Get the number of employees the company has (if available)
745
732
 
746
733
  :return: how many employees the company has
747
- :rtype: str
734
+ :rtype: str | None
748
735
  """
749
736
  return self.company.number_of_employees
750
737
 
@@ -991,13 +978,33 @@ class Tickers(set):
991
978
  super().__init__(
992
979
  Ticker(
993
980
  kfinance_api_client=kfinance_api_client,
994
- company_id=id_triple["company_id"],
995
- security_id=id_triple["security_id"],
996
- trading_item_id=id_triple["trading_item_id"],
981
+ company_id=id_triple.company_id,
982
+ security_id=id_triple.security_id,
983
+ trading_item_id=id_triple.trading_item_id,
997
984
  )
998
985
  for id_triple in id_triples
999
986
  )
1000
987
 
988
+ def intersection(self, *s: Iterable[Any]) -> Tickers:
989
+ """Returns the intersection of Tickers objects"""
990
+ for obj in s:
991
+ if not isinstance(obj, Tickers):
992
+ raise ValueError("Can only intersect Tickers object with other Tickers object.")
993
+
994
+ self_triples = {t.id_triple for t in self}
995
+ set_triples = []
996
+
997
+ for ticker_set in s:
998
+ set_triples.append({t.id_triple for t in ticker_set})
999
+ common_triples = self_triples.intersection(*set_triples)
1000
+
1001
+ return Tickers(kfinance_api_client=self.kfinance_api_client, id_triples=common_triples)
1002
+
1003
+ def __and__(self, other: Any) -> "Tickers":
1004
+ if not isinstance(other, Tickers):
1005
+ raise ValueError("Can only combine Tickers objects with other Tickers objects.")
1006
+ return self.intersection(other)
1007
+
1001
1008
  def companies(self) -> Companies:
1002
1009
  """Build a group of company objects from the group of tickers
1003
1010
 
@@ -1126,7 +1133,16 @@ class Client:
1126
1133
  if self._tools is None:
1127
1134
  from kfinance.tool_calling import ALL_TOOLS
1128
1135
 
1129
- self._tools = [t(kfinance_client=self) for t in ALL_TOOLS] # type: ignore[call-arg]
1136
+ self._tools = []
1137
+ # Add tool to _tools if the user has permissions to use it.
1138
+ for tool_cls in ALL_TOOLS:
1139
+ tool = tool_cls(kfinance_client=self) # type: ignore[call-arg]
1140
+ if (
1141
+ tool.required_permission is None
1142
+ or tool.required_permission in self.kfinance_api_client.user_permissions
1143
+ ):
1144
+ self._tools.append(tool)
1145
+
1130
1146
  return self._tools
1131
1147
 
1132
1148
  @property
@@ -1212,31 +1228,90 @@ class Client:
1212
1228
  state_iso_code: Optional[str] = None,
1213
1229
  simple_industry: Optional[str] = None,
1214
1230
  exchange_code: Optional[str] = None,
1231
+ sic: Optional[str] = None,
1232
+ naics: Optional[str] = None,
1233
+ nace: Optional[str] = None,
1234
+ anzsic: Optional[str] = None,
1235
+ spcapiqetf: Optional[str] = None,
1236
+ spratings: Optional[str] = None,
1237
+ gics: Optional[str] = None,
1215
1238
  ) -> Tickers:
1216
- """Generate tickers object representing the collection of Tickers that meet all the supplied parameters
1239
+ """Generate a Tickers object representing the collection of Tickers that meet all the supplied parameters.
1217
1240
 
1218
- One of country_iso_code, simple_industry, or exchange_code must be supplied. A parameter set to None is not used to filter on
1241
+ One of country_iso_code, simple_industry, or exchange_code must be supplied, or one of sic, naics, nace, anzsic, spcapiqetf, spratings, or gics.
1219
1242
 
1220
- :param country_iso_code: The ISO 3166-1 Alpha-2 or Alpha-3 code that represent the primary country the firm is based in. It default None
1243
+ :param country_iso_code: The ISO 3166-1 Alpha-2 or Alpha-3 code that represent the primary country the firm is based in. It defaults to None
1221
1244
  :type country_iso_code: str, optional
1222
- :param state_iso_code: The ISO 3166-2 Alpha-2 code that represents the primary subdivision of the country the firm the based in. Not all ISO 3166-2 codes are supported as S&P doesn't maintain the full list but a feature request for the full list is submitted to S&P product. Requires country_iso_code also to have a value other then None. It default None
1245
+ :param state_iso_code: The ISO 3166-2 Alpha-2 code that represents the primary subdivision of the country the firm the based in. Not all ISO 3166-2 codes are supported as S&P doesn't maintain the full list but a feature request for the full list is submitted to S&P product. Requires country_iso_code also to have a value other then None. It defaults to None
1223
1246
  :type state_iso_code: str, optional
1224
- :param simple_industry: The S&P CIQ Simple Industry defined in ciqSimpleIndustry in XPF. It default None
1247
+ :param simple_industry: The S&P CIQ Simple Industry defined in ciqSimpleIndustry in XPF. It defaults to None
1225
1248
  :type simple_industry: str, optional
1226
- :param exchange_code: The exchange code for the primary equity listing exchange of the firm. It default None
1249
+ :param exchange_code: The exchange code for the primary equity listing exchange of the firm. It defaults to None
1227
1250
  :type exchange_code: str, optional
1228
- :return: A tickers object that is the group of Ticker objects meeting all the supplied parameters
1251
+ :param sic: The SIC industry code. It defaults to None
1252
+ :type sic: str, optional
1253
+ :param naics: The NAICS industry code. It defaults to None
1254
+ :type naics: str, optional
1255
+ :param nace: The NACE industry code. It defaults to None
1256
+ :type nace: str, optional
1257
+ :param anzsic: The ANZSIC industry code. It defaults to None
1258
+ :type anzsic: str, optional
1259
+ :param spcapiqetf: The S&P CapitalIQ ETF industry code. It defaults to None
1260
+ :type spcapiqetf: str, optional
1261
+ :param spratings: The S&P Ratings industry code. It defaults to None
1262
+ :type spratings: str, optional
1263
+ :param gics: The GICS code. It defaults to None
1264
+ :type gics: str, optional
1265
+ :return: A Tickers object that is the intersection of Ticker objects meeting all the supplied parameters.
1229
1266
  :rtype: Tickers
1230
1267
  """
1231
- return Tickers(
1232
- kfinance_api_client=self.kfinance_api_client,
1233
- id_triples=self.kfinance_api_client.fetch_ticker_combined(
1234
- country_iso_code=country_iso_code,
1235
- state_iso_code=state_iso_code,
1236
- simple_industry=simple_industry,
1237
- exchange_code=exchange_code,
1238
- )["tickers"],
1239
- )
1268
+ # Create a list to accumulate the fetched ticker sets
1269
+ ticker_sets: list[Tickers] = []
1270
+
1271
+ # Map the parameters to the industry_dict, pass the values in as the key.
1272
+ industry_dict = {
1273
+ "sic": sic,
1274
+ "naics": naics,
1275
+ "nace": nace,
1276
+ "anzsic": anzsic,
1277
+ "spcapiqetf": spcapiqetf,
1278
+ "spratings": spratings,
1279
+ "gics": gics,
1280
+ }
1281
+
1282
+ if any(
1283
+ parameter is not None
1284
+ for parameter in [country_iso_code, state_iso_code, simple_industry, exchange_code]
1285
+ ):
1286
+ ticker_sets.append(
1287
+ Tickers(
1288
+ kfinance_api_client=self.kfinance_api_client,
1289
+ id_triples=self.kfinance_api_client.fetch_ticker_combined(
1290
+ country_iso_code=country_iso_code,
1291
+ state_iso_code=state_iso_code,
1292
+ simple_industry=simple_industry,
1293
+ exchange_code=exchange_code,
1294
+ ),
1295
+ )
1296
+ )
1297
+
1298
+ for key, value in industry_dict.items():
1299
+ if value is not None:
1300
+ ticker_sets.append(
1301
+ Tickers(
1302
+ kfinance_api_client=self.kfinance_api_client,
1303
+ id_triples=self.kfinance_api_client.fetch_ticker_from_industry_code(
1304
+ industry_code=value,
1305
+ industry_classification=IndustryClassification(key),
1306
+ ),
1307
+ )
1308
+ )
1309
+
1310
+ if not ticker_sets:
1311
+ return Tickers(kfinance_api_client=self.kfinance_api_client, id_triples=set())
1312
+
1313
+ common_ticker_elements = Tickers.intersection(*ticker_sets)
1314
+ return common_ticker_elements
1240
1315
 
1241
1316
  def company(self, company_id: int) -> Company:
1242
1317
  """Generate the Company object from company_id
kfinance/meta_classes.py CHANGED
@@ -1,12 +1,13 @@
1
1
  from datetime import datetime
2
- from functools import cache, cached_property
2
+ from functools import cached_property
3
3
  import logging
4
4
  from typing import TYPE_CHECKING, Any, Callable, Literal, Optional
5
5
 
6
+ from cachetools import LRUCache, cached
6
7
  import numpy as np
7
8
  import pandas as pd
8
9
 
9
- from .constants import LINE_ITEMS, BusinessRelationshipType, PeriodType
10
+ from .constants import LINE_ITEMS, BusinessRelationshipType, PeriodType, SegmentType
10
11
  from .fetch import KFinanceApiClient
11
12
 
12
13
 
@@ -46,7 +47,7 @@ class CompanyFunctionsMetaClass:
46
47
  if end_quarter and not (1 <= end_quarter <= 4):
47
48
  raise ValueError("end_qtr is out of range 1 to 4")
48
49
 
49
- @cache
50
+ @cached(cache=LRUCache(maxsize=100))
50
51
  def statement(
51
52
  self,
52
53
  statement_type: str,
@@ -85,7 +86,7 @@ class CompanyFunctionsMetaClass:
85
86
 
86
87
  def income_statement(
87
88
  self,
88
- period_type: Optional[str] = None,
89
+ period_type: Optional[PeriodType] = None,
89
90
  start_year: Optional[int] = None,
90
91
  end_year: Optional[int] = None,
91
92
  start_quarter: Optional[int] = None,
@@ -103,7 +104,7 @@ class CompanyFunctionsMetaClass:
103
104
 
104
105
  def income_stmt(
105
106
  self,
106
- period_type: Optional[str] = None,
107
+ period_type: Optional[PeriodType] = None,
107
108
  start_year: Optional[int] = None,
108
109
  end_year: Optional[int] = None,
109
110
  start_quarter: Optional[int] = None,
@@ -121,7 +122,7 @@ class CompanyFunctionsMetaClass:
121
122
 
122
123
  def balance_sheet(
123
124
  self,
124
- period_type: Optional[str] = None,
125
+ period_type: Optional[PeriodType] = None,
125
126
  start_year: Optional[int] = None,
126
127
  end_year: Optional[int] = None,
127
128
  start_quarter: Optional[int] = None,
@@ -139,7 +140,7 @@ class CompanyFunctionsMetaClass:
139
140
 
140
141
  def cash_flow(
141
142
  self,
142
- period_type: Optional[str] = None,
143
+ period_type: Optional[PeriodType] = None,
143
144
  start_year: Optional[int] = None,
144
145
  end_year: Optional[int] = None,
145
146
  start_quarter: Optional[int] = None,
@@ -157,7 +158,7 @@ class CompanyFunctionsMetaClass:
157
158
 
158
159
  def cashflow(
159
160
  self,
160
- period_type: Optional[str] = None,
161
+ period_type: Optional[PeriodType] = None,
161
162
  start_year: Optional[int] = None,
162
163
  end_year: Optional[int] = None,
163
164
  start_quarter: Optional[int] = None,
@@ -173,7 +174,7 @@ class CompanyFunctionsMetaClass:
173
174
  end_quarter=end_quarter,
174
175
  )
175
176
 
176
- @cache
177
+ @cached(cache=LRUCache(maxsize=100))
177
178
  def line_item(
178
179
  self,
179
180
  line_item: str,
@@ -303,6 +304,114 @@ class CompanyFunctionsMetaClass:
303
304
  )
304
305
  return df.set_index("date")[[column_to_extract]].apply(pd.to_numeric).replace(np.nan, None)
305
306
 
307
+ def _segments(
308
+ self,
309
+ segment_type: SegmentType,
310
+ period_type: Optional[PeriodType] = None,
311
+ start_year: Optional[int] = None,
312
+ end_year: Optional[int] = None,
313
+ start_quarter: Optional[int] = None,
314
+ end_quarter: Optional[int] = None,
315
+ ) -> pd.DataFrame:
316
+ """Get the company's segments"""
317
+ try:
318
+ self.validate_inputs(
319
+ start_year=start_year,
320
+ end_year=end_year,
321
+ start_quarter=start_quarter,
322
+ end_quarter=end_quarter,
323
+ )
324
+ except ValueError:
325
+ return pd.DataFrame()
326
+
327
+ results = self.kfinance_api_client.fetch_segments(
328
+ company_id=self.company_id,
329
+ segment_type=segment_type,
330
+ period_type=period_type,
331
+ start_year=start_year,
332
+ end_year=end_year,
333
+ start_quarter=start_quarter,
334
+ end_quarter=end_quarter,
335
+ )["segments"]
336
+
337
+ period_name = (
338
+ "Year" if (period_type == PeriodType.annual or period_type is None) else "Period"
339
+ )
340
+
341
+ # flatten the nested dictionary and return as a DataFrame
342
+ rows = []
343
+ for period, segments in results.items():
344
+ for segment_name, line_items in segments.items():
345
+ for line_item, value in line_items.items():
346
+ rows.append([period, segment_name, line_item, value])
347
+ return pd.DataFrame(
348
+ rows, columns=[period_name, "Segment Name", "Line Item", "Value"]
349
+ ).replace(np.nan, None)
350
+
351
+ def business_segments(
352
+ self,
353
+ period_type: Optional[PeriodType] = None,
354
+ start_year: Optional[int] = None,
355
+ end_year: Optional[int] = None,
356
+ start_quarter: Optional[int] = None,
357
+ end_quarter: Optional[int] = None,
358
+ ) -> pd.DataFrame:
359
+ """Retrieves the templated line of business segments for a given period_type, start_year, start_quarter, end_year and end_quarter.
360
+
361
+ :param period_type: The period_type requested for. Can be “annual”, “quarterly”, "ytd". Defaults to “annual” when start_quarter and end_quarter are None.
362
+ :type start_year: PeriodType, optional
363
+ :param start_year: The starting calendar year, defaults to None
364
+ :type start_year: int, optional
365
+ :param end_year: The ending calendar year, defaults to None
366
+ :type end_year: int, optional
367
+ :param start_quarter: The starting calendar quarter, defaults to None
368
+ :type start_quarter: int, optional
369
+ :param end_quarter: The ending calendar quarter, defaults to None
370
+ :type end_quarter: int, optional
371
+ :return: A DataFrame with `Year`/`Period`, `Segment Name`, `Line Item` and `Value` column.
372
+ :rtype: pd.DataFrame
373
+ """
374
+ return self._segments(
375
+ segment_type=SegmentType.business,
376
+ period_type=period_type,
377
+ start_year=start_year,
378
+ end_year=end_year,
379
+ start_quarter=start_quarter,
380
+ end_quarter=end_quarter,
381
+ )
382
+
383
+ def geographic_segments(
384
+ self,
385
+ period_type: Optional[PeriodType] = None,
386
+ start_year: Optional[int] = None,
387
+ end_year: Optional[int] = None,
388
+ start_quarter: Optional[int] = None,
389
+ end_quarter: Optional[int] = None,
390
+ ) -> pd.DataFrame:
391
+ """Retrieves the templated geographic segments for a given company for a given period_type, start_year, start_quarter, end_year and end_quarter.
392
+
393
+ :param period_type: The period_type requested for. Can be “annual”, “quarterly”, "ytd". Defaults to “annual” when start_quarter and end_quarter are None.
394
+ :type start_year: PeriodType, optional
395
+ :param start_year: The starting calendar year, defaults to None
396
+ :type start_year: int, optional
397
+ :param end_year: The ending calendar year, defaults to None
398
+ :type end_year: int, optional
399
+ :param start_quarter: The starting calendar quarter, defaults to None
400
+ :type start_quarter: int, optional
401
+ :param end_quarter: The ending calendar quarter, defaults to None
402
+ :type end_quarter: int, optional
403
+ :return: A DataFrame with `Year`/`Period`, `Segment Name`, `Line Item` and `Value` column.
404
+ :rtype: pd.DataFrame
405
+ """
406
+ return self._segments(
407
+ segment_type=SegmentType.geographic,
408
+ period_type=period_type,
409
+ start_year=start_year,
410
+ end_year=end_year,
411
+ start_quarter=start_quarter,
412
+ end_quarter=end_quarter,
413
+ )
414
+
306
415
 
307
416
  for line_item in LINE_ITEMS:
308
417
  line_item_name = line_item["name"]
@@ -0,0 +1,32 @@
1
+ from datetime import datetime
2
+
3
+ import pytest
4
+ from requests_mock import Mocker
5
+
6
+ from kfinance.kfinance import Client
7
+
8
+
9
+ SPGI_COMPANY_ID = 21719
10
+ SPGI_SECURITY_ID = 2629107
11
+ SPGI_TRADING_ITEM_ID = 2629108
12
+
13
+
14
+ @pytest.fixture
15
+ def mock_client(requests_mock: Mocker) -> Client:
16
+ """Create a KFinanceApiClient with a mock response for the SPGI id triple."""
17
+
18
+ client = Client(refresh_token="foo")
19
+ # Set access token so that the client doesn't try to fetch it.
20
+ client.kfinance_api_client._access_token = "foo" # noqa: SLF001
21
+ client.kfinance_api_client._access_token_expiry = datetime(2100, 1, 1).timestamp() # noqa: SLF001
22
+
23
+ # Create a mock for the SPGI id triple.
24
+ requests_mock.get(
25
+ url="https://kfinance.kensho.com/api/v1/id/SPGI",
26
+ json={
27
+ "trading_item_id": SPGI_TRADING_ITEM_ID,
28
+ "security_id": SPGI_SECURITY_ID,
29
+ "company_id": SPGI_COMPANY_ID,
30
+ },
31
+ )
32
+ return client
@@ -1,7 +1,8 @@
1
1
  from concurrent.futures import ThreadPoolExecutor
2
+ import time
2
3
  from typing import Any, Dict
3
4
  from unittest import TestCase
4
- from unittest.mock import patch
5
+ from unittest.mock import PropertyMock, patch
5
6
 
6
7
  import numpy as np
7
8
  import pandas as pd
@@ -9,6 +10,7 @@ import pytest
9
10
  import requests
10
11
  import requests_mock
11
12
 
13
+ from kfinance.batch_request_handling import MAX_WORKERS_CAP
12
14
  from kfinance.fetch import KFinanceApiClient
13
15
  from kfinance.kfinance import Companies, Company, Ticker, TradingItems
14
16
 
@@ -34,8 +36,13 @@ class TestTradingItem(TestCase):
34
36
  def test_batch_request_property(self, m):
35
37
  """GIVEN a kfinance group object like Companies
36
38
  WHEN we batch request a property for each object in the group
37
- THEN the batch request completes successfully and we get back a mapping of
38
- company objects to the corresponding values."""
39
+ THEN the batch request completes successfully, and we get back a mapping of
40
+ company objects to the corresponding values.
41
+
42
+ Note: This test also checks that multiple tasks can be submitted. In the
43
+ first implementation, we used the threadpool context manager, which shuts down
44
+ the threadpool on __exit__ and prevented further tasks from getting submitted.
45
+ """
39
46
 
40
47
  m.get(
41
48
  "https://kfinance.kensho.com/api/v1/info/1001",
@@ -52,12 +59,13 @@ class TestTradingItem(TestCase):
52
59
  },
53
60
  )
54
61
 
55
- companies = Companies(self.kfinance_api_client, [1001, 1002])
56
- result = companies.city
57
- id_based_result = self.company_object_keys_as_company_id(result)
62
+ for _ in range(3):
63
+ companies = Companies(self.kfinance_api_client, [1001, 1002])
64
+ result = companies.city
65
+ id_based_result = self.company_object_keys_as_company_id(result)
58
66
 
59
- expected_id_based_result = {1001: "Mock City A", 1002: "Mock City B"}
60
- self.assertDictEqual(id_based_result, expected_id_based_result)
67
+ expected_id_based_result = {1001: "Mock City A", 1002: "Mock City B"}
68
+ self.assertDictEqual(id_based_result, expected_id_based_result)
61
69
 
62
70
  @requests_mock.Mocker()
63
71
  def test_batch_request_cached_properties(self, m):
@@ -254,3 +262,33 @@ class TestTradingItem(TestCase):
254
262
 
255
263
  expected_id_based_result = {1001: "Mock City A"}
256
264
  self.assertDictEqual(id_based_result, expected_id_based_result)
265
+
266
+ @patch.object(Company, "info", new_callable=PropertyMock)
267
+ def test_batch_requests_processed_in_parallel(self, mock_value: PropertyMock):
268
+ """
269
+ WHEN a batch request gets processed
270
+ THEN the requests are handled in parallel not sequentially.
271
+ """
272
+
273
+ sleep_duration = 0.05
274
+
275
+ def mock_info_with_sleep() -> dict[str, str]:
276
+ """Mock an info call with a short sleep"""
277
+ time.sleep(sleep_duration)
278
+ return {"city": "Cambridge"}
279
+
280
+ mock_value.side_effect = mock_info_with_sleep
281
+
282
+ # Create tasks up to the MAX_WORKERS_CAP (max number of parallel tasks)
283
+ companies = Companies(
284
+ self.kfinance_api_client_with_thread_pool, [i for i in range(MAX_WORKERS_CAP)]
285
+ )
286
+
287
+ start = time.perf_counter()
288
+ result = companies.city
289
+ end = time.perf_counter()
290
+ assert len(result) == MAX_WORKERS_CAP
291
+ # Check that the requests run faster than sequential.
292
+ # In practice, the requests should take barely more than the `sleep_duration` but timing
293
+ # based tests can be flaky, especially in CI.
294
+ assert end - start < MAX_WORKERS_CAP * sleep_duration