kensho-kfinance 3.2.4__py3-none-any.whl → 4.0.0__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.
Files changed (57) hide show
  1. {kensho_kfinance-3.2.4.dist-info → kensho_kfinance-4.0.0.dist-info}/METADATA +3 -3
  2. {kensho_kfinance-3.2.4.dist-info → kensho_kfinance-4.0.0.dist-info}/RECORD +57 -56
  3. kfinance/CHANGELOG.md +51 -0
  4. kfinance/client/batch_request_handling.py +3 -1
  5. kfinance/client/fetch.py +127 -54
  6. kfinance/client/kfinance.py +38 -39
  7. kfinance/client/meta_classes.py +50 -20
  8. kfinance/client/models/date_and_period_models.py +32 -7
  9. kfinance/client/models/decimal_with_unit.py +14 -2
  10. kfinance/client/models/response_models.py +33 -0
  11. kfinance/client/models/tests/test_decimal_with_unit.py +9 -0
  12. kfinance/client/tests/test_batch_requests.py +5 -4
  13. kfinance/client/tests/test_fetch.py +134 -58
  14. kfinance/client/tests/test_objects.py +207 -145
  15. kfinance/conftest.py +10 -0
  16. kfinance/domains/business_relationships/business_relationship_tools.py +17 -8
  17. kfinance/domains/business_relationships/tests/test_business_relationship_tools.py +18 -16
  18. kfinance/domains/capitalizations/capitalization_models.py +7 -5
  19. kfinance/domains/capitalizations/capitalization_tools.py +38 -20
  20. kfinance/domains/capitalizations/tests/test_capitalization_tools.py +66 -36
  21. kfinance/domains/companies/company_models.py +22 -2
  22. kfinance/domains/companies/company_tools.py +49 -16
  23. kfinance/domains/companies/tests/test_company_tools.py +27 -9
  24. kfinance/domains/competitors/competitor_tools.py +19 -5
  25. kfinance/domains/competitors/tests/test_competitor_tools.py +22 -19
  26. kfinance/domains/cusip_and_isin/cusip_and_isin_tools.py +29 -8
  27. kfinance/domains/cusip_and_isin/tests/test_cusip_and_isin_tools.py +13 -8
  28. kfinance/domains/earnings/earning_tools.py +73 -29
  29. kfinance/domains/earnings/tests/test_earnings_tools.py +52 -43
  30. kfinance/domains/line_items/line_item_models.py +372 -16
  31. kfinance/domains/line_items/line_item_tools.py +198 -46
  32. kfinance/domains/line_items/tests/test_line_item_tools.py +305 -39
  33. kfinance/domains/mergers_and_acquisitions/merger_and_acquisition_models.py +46 -2
  34. kfinance/domains/mergers_and_acquisitions/merger_and_acquisition_tools.py +55 -74
  35. kfinance/domains/mergers_and_acquisitions/tests/test_merger_and_acquisition_tools.py +61 -59
  36. kfinance/domains/prices/price_models.py +7 -6
  37. kfinance/domains/prices/price_tools.py +24 -16
  38. kfinance/domains/prices/tests/test_price_tools.py +47 -39
  39. kfinance/domains/segments/segment_models.py +17 -3
  40. kfinance/domains/segments/segment_tools.py +102 -42
  41. kfinance/domains/segments/tests/test_segment_tools.py +166 -37
  42. kfinance/domains/statements/statement_models.py +17 -3
  43. kfinance/domains/statements/statement_tools.py +130 -46
  44. kfinance/domains/statements/tests/test_statement_tools.py +251 -49
  45. kfinance/integrations/local_mcp/kfinance_mcp.py +1 -1
  46. kfinance/integrations/tests/test_example_notebook.py +57 -16
  47. kfinance/integrations/tool_calling/all_tools.py +5 -1
  48. kfinance/integrations/tool_calling/static_tools/get_n_quarters_ago.py +5 -0
  49. kfinance/integrations/tool_calling/static_tools/tests/test_get_lastest.py +13 -10
  50. kfinance/integrations/tool_calling/static_tools/tests/test_get_n_quarters_ago.py +2 -1
  51. kfinance/integrations/tool_calling/tests/test_tool_calling_models.py +15 -4
  52. kfinance/integrations/tool_calling/tool_calling_models.py +18 -6
  53. kfinance/version.py +2 -2
  54. {kensho_kfinance-3.2.4.dist-info → kensho_kfinance-4.0.0.dist-info}/WHEEL +0 -0
  55. {kensho_kfinance-3.2.4.dist-info → kensho_kfinance-4.0.0.dist-info}/licenses/AUTHORS.md +0 -0
  56. {kensho_kfinance-3.2.4.dist-info → kensho_kfinance-4.0.0.dist-info}/licenses/LICENSE +0 -0
  57. {kensho_kfinance-3.2.4.dist-info → kensho_kfinance-4.0.0.dist-info}/top_level.txt +0 -0
@@ -31,13 +31,21 @@ from kfinance.client.meta_classes import (
31
31
  DelegatedCompanyFunctionsMetaClass,
32
32
  )
33
33
  from kfinance.client.models.date_and_period_models import (
34
+ CurrentPeriod,
35
+ LatestAnnualPeriod,
34
36
  LatestPeriods,
37
+ LatestQuarterlyPeriod,
35
38
  Periodicity,
36
39
  YearAndQuarter,
37
40
  )
38
41
  from kfinance.client.server_thread import ServerThread
39
42
  from kfinance.domains.companies.company_models import IdentificationTriple
40
43
  from kfinance.domains.earnings.earning_models import EarningsCall, TranscriptComponent
44
+ from kfinance.domains.mergers_and_acquisitions.merger_and_acquisition_models import (
45
+ MergerConsideration,
46
+ MergerInfo,
47
+ MergerTimelineElement,
48
+ )
41
49
  from kfinance.domains.prices.price_models import HistoryMetadataResp, PriceHistory
42
50
 
43
51
 
@@ -1266,24 +1274,13 @@ class MergerOrAcquisition:
1266
1274
  self.transaction_id = transaction_id
1267
1275
  self.merger_title = merger_title
1268
1276
  self.closed_date = closed_date
1269
- self._merger_info: dict | None = None
1277
+ self._merger_info: MergerInfo | None = None
1270
1278
 
1271
1279
  @property
1272
- def merger_info(self) -> dict:
1280
+ def merger_info(self) -> MergerInfo:
1273
1281
  """Property for the combined information in the merger."""
1274
1282
  if not self._merger_info:
1275
1283
  self._merger_info = self.kfinance_api_client.fetch_merger_info(self.transaction_id)
1276
- if "timeline" in self._merger_info and self._merger_info["timeline"]:
1277
- timeline = pd.DataFrame(self._merger_info["timeline"])
1278
- timeline["date"] = pd.to_datetime(timeline["date"])
1279
- self._merger_info["timeline"] = timeline
1280
- if (
1281
- "consideration" in self._merger_info
1282
- and self._merger_info["consideration"]
1283
- and "details" in self._merger_info["consideration"]
1284
- ):
1285
- details = pd.DataFrame(self._merger_info["consideration"]["details"])
1286
- self._merger_info["consideration"]["details"] = details
1287
1284
  return self._merger_info
1288
1285
 
1289
1286
  @property
@@ -1292,9 +1289,9 @@ class MergerOrAcquisition:
1292
1289
  return self.merger_title
1293
1290
 
1294
1291
  @property
1295
- def get_timeline(self) -> pd.DataFrame:
1292
+ def get_timeline(self) -> list[MergerTimelineElement]:
1296
1293
  """The timeline of the merger includes every new status, along with the dates of each status change."""
1297
- return self.merger_info["timeline"]
1294
+ return self.merger_info.timeline
1298
1295
 
1299
1296
  @property
1300
1297
  def get_participants(self) -> dict:
@@ -1308,8 +1305,8 @@ class MergerOrAcquisition:
1308
1305
  transaction_id=self.transaction_id,
1309
1306
  company=Company(
1310
1307
  kfinance_api_client=self.kfinance_api_client,
1311
- company_id=self.merger_info["participants"]["target"]["company_id"],
1312
- company_name=self.merger_info["participants"]["target"]["company_name"],
1308
+ company_id=self.merger_info.participants.target.company_id,
1309
+ company_name=self.merger_info.participants.target.company_name,
1313
1310
  ),
1314
1311
  ),
1315
1312
  "buyers": [
@@ -1318,11 +1315,11 @@ class MergerOrAcquisition:
1318
1315
  transaction_id=self.transaction_id,
1319
1316
  company=Company(
1320
1317
  kfinance_api_client=self.kfinance_api_client,
1321
- company_id=company["company_id"],
1322
- company_name=company["company_name"],
1318
+ company_id=company.company_id,
1319
+ company_name=company.company_name,
1323
1320
  ),
1324
1321
  )
1325
- for company in self.merger_info["participants"]["buyers"]
1322
+ for company in self.merger_info.participants.buyers
1326
1323
  ],
1327
1324
  "sellers": [
1328
1325
  ParticipantInMerger(
@@ -1330,16 +1327,16 @@ class MergerOrAcquisition:
1330
1327
  transaction_id=self.transaction_id,
1331
1328
  company=Company(
1332
1329
  kfinance_api_client=self.kfinance_api_client,
1333
- company_id=company["company_id"],
1334
- company_name=company["company_name"],
1330
+ company_id=company.company_id,
1331
+ company_name=company.company_name,
1335
1332
  ),
1336
1333
  )
1337
- for company in self.merger_info["participants"]["sellers"]
1334
+ for company in self.merger_info.participants.sellers
1338
1335
  ],
1339
1336
  }
1340
1337
 
1341
1338
  @property
1342
- def get_consideration(self) -> dict:
1339
+ def get_consideration(self) -> MergerConsideration:
1343
1340
  """A merger's consideration is the assets exchanged for the target company.
1344
1341
 
1345
1342
  Properties in the consideration include:
@@ -1354,7 +1351,7 @@ class MergerOrAcquisition:
1354
1351
  - The number of shares in the target company.
1355
1352
  - The current gross total of the consideration detail.
1356
1353
  """
1357
- return self.merger_info["consideration"]
1354
+ return self.merger_info.consideration
1358
1355
 
1359
1356
 
1360
1357
  @add_methods_of_singular_class_to_iterable_class(Company)
@@ -1903,16 +1900,18 @@ class Client:
1903
1900
  most_recent_year_annual = current_year - 1
1904
1901
 
1905
1902
  current_month = datetime_now.month
1906
- latest: LatestPeriods = {
1907
- "annual": {"latest_year": most_recent_year_annual},
1908
- "quarterly": {"latest_quarter": most_recent_qtr, "latest_year": most_recent_year_qtrly},
1909
- "now": {
1910
- "current_year": current_year,
1911
- "current_quarter": current_qtr,
1912
- "current_month": current_month,
1913
- "current_date": datetime_now.date().isoformat(),
1914
- },
1915
- }
1903
+ latest = LatestPeriods(
1904
+ annual=LatestAnnualPeriod(latest_year=most_recent_year_annual),
1905
+ quarterly=LatestQuarterlyPeriod(
1906
+ latest_quarter=most_recent_qtr, latest_year=most_recent_year_qtrly
1907
+ ),
1908
+ now=CurrentPeriod(
1909
+ current_year=current_year,
1910
+ current_quarter=current_qtr,
1911
+ current_month=current_month,
1912
+ current_date=datetime_now.date(),
1913
+ ),
1914
+ )
1916
1915
  return latest
1917
1916
 
1918
1917
  @staticmethod
@@ -1932,9 +1931,9 @@ class Client:
1932
1931
  year_n_quarters_ago = total_quarters_completed_n_quarters_ago // 4
1933
1932
  quarter_n_quarters_ago = total_quarters_completed_n_quarters_ago % 4 + 1
1934
1933
 
1935
- year_quarter_n_quarters_ago: YearAndQuarter = {
1936
- "year": year_n_quarters_ago,
1937
- "quarter": quarter_n_quarters_ago,
1938
- }
1934
+ year_quarter_n_quarters_ago = YearAndQuarter(
1935
+ year=year_n_quarters_ago,
1936
+ quarter=quarter_n_quarters_ago,
1937
+ )
1939
1938
 
1940
1939
  return year_quarter_n_quarters_ago
@@ -85,22 +85,34 @@ class CompanyFunctionsMetaClass:
85
85
  except ValueError:
86
86
  return pd.DataFrame()
87
87
 
88
- return (
89
- pd.DataFrame(
90
- self.kfinance_api_client.fetch_statement(
91
- company_id=self.company_id,
92
- statement_type=statement_type,
93
- period_type=period_type,
94
- start_year=start_year,
95
- end_year=end_year,
96
- start_quarter=start_quarter,
97
- end_quarter=end_quarter,
98
- ).model_dump(mode="json")["statements"]
99
- )
100
- .apply(pd.to_numeric)
101
- .replace(np.nan, None)
88
+ statement_response = self.kfinance_api_client.fetch_statement(
89
+ company_ids=[self.company_id],
90
+ statement_type=statement_type,
91
+ period_type=period_type,
92
+ start_year=start_year,
93
+ end_year=end_year,
94
+ start_quarter=start_quarter,
95
+ end_quarter=end_quarter,
102
96
  )
103
97
 
98
+ if not statement_response.results:
99
+ return pd.DataFrame()
100
+
101
+ # Get the first (and only) result
102
+ statement_resp = list(statement_response.results.values())[0]
103
+ periods = statement_resp.model_dump(mode="json")["periods"]
104
+
105
+ # Extract statements data from each period
106
+ statements_data = {}
107
+ for period_key, period_data in periods.items():
108
+ period_statements = {}
109
+ for statement in period_data["statements"]:
110
+ for line_item in statement["line_items"]:
111
+ period_statements[line_item["name"]] = line_item["value"]
112
+ statements_data[period_key] = period_statements
113
+
114
+ return pd.DataFrame(statements_data).apply(pd.to_numeric).replace(np.nan, None)
115
+
104
116
  def income_statement(
105
117
  self,
106
118
  period_type: Optional[PeriodType] = None,
@@ -212,8 +224,8 @@ class CompanyFunctionsMetaClass:
212
224
  except ValueError:
213
225
  return pd.DataFrame()
214
226
 
215
- line_item_response = self.kfinance_api_client.fetch_line_item(
216
- company_id=self.company_id,
227
+ response = self.kfinance_api_client.fetch_line_item(
228
+ company_ids=[self.company_id],
217
229
  line_item=line_item,
218
230
  period_type=period_type,
219
231
  start_year=start_year,
@@ -221,8 +233,19 @@ class CompanyFunctionsMetaClass:
221
233
  start_quarter=start_quarter,
222
234
  end_quarter=end_quarter,
223
235
  )
236
+
237
+ if not response.results:
238
+ return pd.DataFrame()
239
+
240
+ # Get the first (and only) result
241
+ line_item_response = list(response.results.values())[0]
242
+
243
+ line_item_data = {}
244
+ for period_key, period_data in line_item_response.periods.items():
245
+ line_item_data[period_key] = period_data.line_item.value
246
+
224
247
  return (
225
- pd.DataFrame({"line_item": line_item_response.line_item})
248
+ pd.DataFrame({"line_item": line_item_data})
226
249
  .transpose()
227
250
  .apply(pd.to_numeric)
228
251
  .replace(np.nan, None)
@@ -351,15 +374,22 @@ class CompanyFunctionsMetaClass:
351
374
  except ValueError:
352
375
  return {}
353
376
 
354
- return self.kfinance_api_client.fetch_segments(
355
- company_id=self.company_id,
377
+ segments_response = self.kfinance_api_client.fetch_segments(
378
+ company_ids=[self.company_id],
356
379
  segment_type=segment_type,
357
380
  period_type=period_type,
358
381
  start_year=start_year,
359
382
  end_year=end_year,
360
383
  start_quarter=start_quarter,
361
384
  end_quarter=end_quarter,
362
- ).model_dump(mode="json")["segments"]
385
+ )
386
+
387
+ if not segments_response.results:
388
+ return {}
389
+
390
+ # Get the first (and only) result
391
+ segments_resp = list(segments_response.results.values())[0]
392
+ return segments_resp.model_dump(mode="json")["periods"]
363
393
 
364
394
  def business_segments(
365
395
  self,
@@ -1,8 +1,26 @@
1
- from typing import TypedDict
1
+ from datetime import date
2
+ from typing import Annotated
2
3
 
4
+ from pydantic import BaseModel, Field
3
5
  from strenum import StrEnum
4
6
 
5
7
 
8
+ # Constrained integer types for period counts
9
+ NumPeriods = Annotated[
10
+ int,
11
+ Field(ge=1, le=99, description="The number of periods to retrieve data for (1-99)"),
12
+ ]
13
+
14
+ NumPeriodsBack = Annotated[
15
+ int,
16
+ Field(
17
+ ge=0,
18
+ le=99,
19
+ description="The end period of the data range expressed as number of periods back relative to the present period (0-99)",
20
+ ),
21
+ ]
22
+
23
+
6
24
  class PeriodType(StrEnum):
7
25
  """The period type"""
8
26
 
@@ -21,28 +39,35 @@ class Periodicity(StrEnum):
21
39
  year = "year"
22
40
 
23
41
 
24
- class YearAndQuarter(TypedDict):
42
+ class EndpointType(StrEnum):
43
+ """The type of API endpoint to use for financial data retrieval"""
44
+
45
+ absolute = "absolute"
46
+ relative = "relative"
47
+
48
+
49
+ class YearAndQuarter(BaseModel):
25
50
  year: int
26
51
  quarter: int
27
52
 
28
53
 
29
- class LatestAnnualPeriod(TypedDict):
54
+ class LatestAnnualPeriod(BaseModel):
30
55
  latest_year: int
31
56
 
32
57
 
33
- class LatestQuarterlyPeriod(TypedDict):
58
+ class LatestQuarterlyPeriod(BaseModel):
34
59
  latest_quarter: int
35
60
  latest_year: int
36
61
 
37
62
 
38
- class CurrentPeriod(TypedDict):
63
+ class CurrentPeriod(BaseModel):
39
64
  current_year: int
40
65
  current_quarter: int
41
66
  current_month: int
42
- current_date: str
67
+ current_date: date
43
68
 
44
69
 
45
- class LatestPeriods(TypedDict):
70
+ class LatestPeriods(BaseModel):
46
71
  annual: LatestAnnualPeriod
47
72
  quarterly: LatestQuarterlyPeriod
48
73
  now: CurrentPeriod
@@ -2,7 +2,7 @@ from copy import deepcopy
2
2
  from decimal import Decimal
3
3
  from typing import Any
4
4
 
5
- from pydantic import BaseModel, Field, model_validator
5
+ from pydantic import BaseModel, Field, field_validator, model_validator
6
6
  from typing_extensions import Self
7
7
 
8
8
  from kfinance.client.models.currency_models import ISO_CODE_TO_CURRENCY
@@ -20,11 +20,23 @@ class DecimalWithUnit(BaseModel):
20
20
  existing subclass like `Money` or `Shares` or create a new one.
21
21
  """
22
22
 
23
- value: Decimal
23
+ value: Decimal = Field(allow_inf_nan=True)
24
24
  unit: str
25
25
  # exclude conventional_decimals from serialization
26
26
  conventional_decimals: int = Field(exclude=True)
27
27
 
28
+ @field_validator("value", mode="before")
29
+ @classmethod
30
+ def convert_none_to_nan(cls, v: Any) -> Any:
31
+ """Convert None values to NaN.
32
+
33
+ Price data can include None for open prices.
34
+ https://kfinance.kensho.com/api/v1/pricing/37284793/2003-01-01/2024-12-31/month/adjusted
35
+ """
36
+ if v is None:
37
+ return Decimal("NaN")
38
+ return v
39
+
28
40
  @model_validator(mode="after")
29
41
  def quantize_value(self) -> Self:
30
42
  """Quantize the value at the end of the deserialization.
@@ -0,0 +1,33 @@
1
+ from typing import Any, Callable, Dict, Generic, TypeAlias, TypeVar
2
+
3
+ from pydantic import BaseModel, Field, model_serializer
4
+
5
+
6
+ T = TypeVar("T", bound=BaseModel)
7
+
8
+ Source: TypeAlias = dict[str, str]
9
+
10
+
11
+ class RespWithErrors(BaseModel):
12
+ """A response with an `errors` field.
13
+
14
+ - `errors` is always the last field in the response.
15
+ - `errors` is only included if there is at least one error.
16
+ """
17
+
18
+ errors: dict[str, str] = Field(default_factory=dict)
19
+
20
+ @model_serializer(mode="wrap")
21
+ def serialize_model(self, handler: Callable) -> Dict[str, Any]:
22
+ """Make `errors` the last response field and only include if there is at least one error."""
23
+ data = handler(self)
24
+ errors = data.pop("errors")
25
+ if errors:
26
+ data["errors"] = errors
27
+ return data
28
+
29
+
30
+ class PostResponse(RespWithErrors, Generic[T]):
31
+ """Generic response class that wraps results and errors from API calls."""
32
+
33
+ results: dict[str, T]
@@ -32,6 +32,15 @@ class TestDecimalWithUnit:
32
32
  )
33
33
  assert dwu.value == expected_value
34
34
 
35
+ @pytest.mark.parametrize("value", [None, "NaN"])
36
+ def test_null_nan_allowed(self, value: str | None):
37
+ """
38
+ WHEN a DecimalWithUnit gets deserialized with None or "NaN"
39
+ THEN the deserialized value is Decimal("NaN")
40
+ """
41
+ dwu = DecimalWithUnit.model_validate(dict(value=value, conventional_decimals=1, unit="foo"))
42
+ assert dwu.value.is_nan()
43
+
35
44
 
36
45
  class TestMoney:
37
46
  @pytest.mark.parametrize("currency, expected_conventional_decimals", [("USD", 2), ("BIF", 0)])
@@ -212,11 +212,12 @@ class TestTradingItem(TestCase):
212
212
  )
213
213
  m.get("https://kfinance.kensho.com/api/v1/info/1002", status_code=400)
214
214
 
215
- with self.assertRaises(requests.exceptions.HTTPError) as e:
216
- companies = Companies(self.kfinance_api_client, [1001, 1002])
217
- _ = companies.city
215
+ companies = Companies(self.kfinance_api_client, [1001, 1002])
216
+ result = companies.city
217
+ id_based_result = self.company_object_keys_as_company_id(result)
218
218
 
219
- self.assertEqual(e.exception.response.status_code, 400)
219
+ expected_id_based_result = {1001: "Mock City A", 1002: None}
220
+ self.assertDictEqual(id_based_result, expected_id_based_result)
220
221
 
221
222
  @requests_mock.Mocker()
222
223
  def test_batch_request_500(self, m):