PYield 0.32.0__tar.gz → 0.32.2__tar.gz

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 (52) hide show
  1. {pyield-0.32.0 → pyield-0.32.2}/PKG-INFO +2 -2
  2. pyield-0.32.2/pyield/__about__.py +1 -0
  3. {pyield-0.32.0 → pyield-0.32.2}/pyield/anbima/tpf.py +2 -2
  4. {pyield-0.32.0 → pyield-0.32.2}/pyield/b3/di.py +21 -18
  5. {pyield-0.32.0 → pyield-0.32.2}/pyield/bc/bcdata.py +17 -7
  6. {pyield-0.32.0 → pyield-0.32.2}/pyield/bday.py +59 -34
  7. pyield-0.32.2/pyield/data_cache.py +70 -0
  8. {pyield-0.32.0 → pyield-0.32.2}/pyield/holidays/br_holidays_new.txt +105 -0
  9. pyield-0.32.2/pyield/retry.py +154 -0
  10. {pyield-0.32.0 → pyield-0.32.2}/pyproject.toml +1 -1
  11. pyield-0.32.0/pyield/__about__.py +0 -1
  12. pyield-0.32.0/pyield/data_cache.py +0 -184
  13. pyield-0.32.0/pyield/retry.py +0 -56
  14. {pyield-0.32.0 → pyield-0.32.2}/.gitignore +0 -0
  15. {pyield-0.32.0 → pyield-0.32.2}/LICENSE +0 -0
  16. {pyield-0.32.0 → pyield-0.32.2}/README.md +0 -0
  17. {pyield-0.32.0 → pyield-0.32.2}/pyield/__init__.py +0 -0
  18. {pyield-0.32.0 → pyield-0.32.2}/pyield/anbima/__init__.py +0 -0
  19. {pyield-0.32.0 → pyield-0.32.2}/pyield/anbima/ettj.py +0 -0
  20. {pyield-0.32.0 → pyield-0.32.2}/pyield/anbima/ima.py +0 -0
  21. {pyield-0.32.0 → pyield-0.32.2}/pyield/anbima/imaq.py +0 -0
  22. {pyield-0.32.0 → pyield-0.32.2}/pyield/anbima/ipca.py +0 -0
  23. {pyield-0.32.0 → pyield-0.32.2}/pyield/b3/__init__.py +0 -0
  24. {pyield-0.32.0 → pyield-0.32.2}/pyield/b3/di_over.py +0 -0
  25. {pyield-0.32.0 → pyield-0.32.2}/pyield/b3/futures/__init__.py +0 -0
  26. {pyield-0.32.0 → pyield-0.32.2}/pyield/b3/futures/common.py +0 -0
  27. {pyield-0.32.0 → pyield-0.32.2}/pyield/b3/futures/historical.py +0 -0
  28. {pyield-0.32.0 → pyield-0.32.2}/pyield/b3/futures/intraday.py +0 -0
  29. {pyield-0.32.0 → pyield-0.32.2}/pyield/b3/futures/xml.py +0 -0
  30. {pyield-0.32.0 → pyield-0.32.2}/pyield/bc/__init__.py +0 -0
  31. {pyield-0.32.0 → pyield-0.32.2}/pyield/bc/auction.py +0 -0
  32. {pyield-0.32.0 → pyield-0.32.2}/pyield/bc/ptax.py +0 -0
  33. {pyield-0.32.0 → pyield-0.32.2}/pyield/bc/repo.py +0 -0
  34. {pyield-0.32.0 → pyield-0.32.2}/pyield/bc/trade.py +0 -0
  35. {pyield-0.32.0 → pyield-0.32.2}/pyield/bc/vna.py +0 -0
  36. {pyield-0.32.0 → pyield-0.32.2}/pyield/conftest.py +0 -0
  37. {pyield-0.32.0 → pyield-0.32.2}/pyield/date_converter.py +0 -0
  38. {pyield-0.32.0 → pyield-0.32.2}/pyield/forward.py +0 -0
  39. {pyield-0.32.0 → pyield-0.32.2}/pyield/holidays/__init__.py +0 -0
  40. {pyield-0.32.0 → pyield-0.32.2}/pyield/holidays/br_holidays_old.txt +0 -0
  41. {pyield-0.32.0 → pyield-0.32.2}/pyield/holidays/brholidays.py +0 -0
  42. {pyield-0.32.0 → pyield-0.32.2}/pyield/ibge/__init__.py +0 -0
  43. {pyield-0.32.0 → pyield-0.32.2}/pyield/ibge/ipca.py +0 -0
  44. {pyield-0.32.0 → pyield-0.32.2}/pyield/interpolator.py +0 -0
  45. {pyield-0.32.0 → pyield-0.32.2}/pyield/py.typed +0 -0
  46. {pyield-0.32.0 → pyield-0.32.2}/pyield/tpf/__init__.py +0 -0
  47. {pyield-0.32.0 → pyield-0.32.2}/pyield/tpf/lft.py +0 -0
  48. {pyield-0.32.0 → pyield-0.32.2}/pyield/tpf/ltn.py +0 -0
  49. {pyield-0.32.0 → pyield-0.32.2}/pyield/tpf/ntnb.py +0 -0
  50. {pyield-0.32.0 → pyield-0.32.2}/pyield/tpf/ntnc.py +0 -0
  51. {pyield-0.32.0 → pyield-0.32.2}/pyield/tpf/ntnf.py +0 -0
  52. {pyield-0.32.0 → pyield-0.32.2}/pyield/tpf/tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PYield
3
- Version: 0.32.0
3
+ Version: 0.32.2
4
4
  Summary: A Python library for analysis of fixed income instruments in Brazil
5
5
  Project-URL: Source, https://github.com/crdcj/PYield
6
6
  Author-email: Carlos Carvalho <cr.cj@outlook.com>
@@ -34,7 +34,7 @@ Classifier: Programming Language :: Python :: 3
34
34
  Classifier: Topic :: Office/Business :: Financial :: Investment
35
35
  Requires-Python: >=3.11
36
36
  Requires-Dist: numpy>=2.0.0
37
- Requires-Dist: pandas[html]>=2.0.0
37
+ Requires-Dist: pandas[html,parquet]>=2.0.0
38
38
  Requires-Dist: requests>=2.31.0
39
39
  Requires-Dist: tenacity>=9.0.0
40
40
  Description-Content-Type: text/markdown
@@ -0,0 +1 @@
1
+ __version__ = "0.32.2"
@@ -5,7 +5,7 @@ from urllib.error import HTTPError
5
5
  import pandas as pd
6
6
 
7
7
  from pyield import bday
8
- from pyield.data_cache import get_tpf_dataset
8
+ from pyield.data_cache import get_cached_dataset
9
9
  from pyield.date_converter import DateScalar, convert_input_dates
10
10
  from pyield.retry import default_retry
11
11
 
@@ -154,7 +154,7 @@ def tpf_data(
154
154
  - Price: The price (PU) of the bond.
155
155
  """
156
156
 
157
- df = get_tpf_dataset()
157
+ df = get_cached_dataset("TPF")
158
158
  date = convert_input_dates(date)
159
159
  df = df.query("ReferenceDate == @date").reset_index(drop=True)
160
160
 
@@ -7,7 +7,7 @@ import pandas as pd
7
7
  import pyield.date_converter as dc
8
8
  import pyield.forward as fw
9
9
  from pyield import b3, bday, interpolator
10
- from pyield.data_cache import get_di_dataset, get_tpf_dataset
10
+ from pyield.data_cache import get_cached_dataset
11
11
  from pyield.date_converter import DateArray, DateScalar
12
12
 
13
13
  logger = logging.getLogger(__name__)
@@ -78,7 +78,7 @@ class DIFutures:
78
78
  Returns a sorted series of unique trade dates available in the DI dataset.
79
79
  """
80
80
  return (
81
- get_di_dataset()
81
+ get_cached_dataset("DI")
82
82
  .drop_duplicates(subset=["TradeDate"])["TradeDate"]
83
83
  .sort_values(ascending=True)
84
84
  .reset_index(drop=True)
@@ -110,21 +110,25 @@ class DIFutures:
110
110
 
111
111
  def _update_df(self) -> pd.DataFrame:
112
112
  """Retrieve DI contract DataFrame for the initialized trade date."""
113
- if self._date is None:
113
+ if self.date is None:
114
114
  logger.info("No date specified. Returning empty DataFrame.")
115
115
  return pd.DataFrame()
116
116
  # Return an empty DataFrame if the trade date is a holiday
117
- if not bday.is_business_day(self._date):
117
+ if not bday.is_business_day(self.date):
118
118
  logger.warning("Specified date is not a business day.")
119
119
  logger.warning("Returning empty DataFrame.")
120
120
  return pd.DataFrame()
121
121
 
122
122
  # Get historical data
123
- df = get_di_dataset().query("TradeDate == @self._date").reset_index(drop=True)
123
+ df = (
124
+ get_cached_dataset("DI")
125
+ .query("TradeDate == @self.date")
126
+ .reset_index(drop=True)
127
+ )
124
128
 
125
129
  if df.empty:
126
130
  logger.info("No historical data found. Trying real-time data.")
127
- df = b3.futures(contract_code="DI1", date=self._date)
131
+ df = b3.futures(contract_code="DI1", date=self.date)
128
132
 
129
133
  if df.empty:
130
134
  logger.warning("No DI Futures data found for the specified date.")
@@ -134,13 +138,11 @@ class DIFutures:
134
138
  if "DaysToExpiration" in df.columns:
135
139
  df.drop(columns=["DaysToExpiration"], inplace=True)
136
140
 
137
- if self._pre_filter:
141
+ if self.pre_filter:
138
142
  df_pre = (
139
- get_tpf_dataset()
143
+ get_cached_dataset("TPF")
140
144
  .query("BondType in ['LTN', 'NTN-F']")
141
- .query("ReferenceDate == @self._date")[
142
- ["ReferenceDate", "MaturityDate"]
143
- ]
145
+ .query("ReferenceDate == @self.date")[["ReferenceDate", "MaturityDate"]]
144
146
  .drop_duplicates()
145
147
  .reset_index(drop=True)
146
148
  )
@@ -155,12 +157,12 @@ class DIFutures:
155
157
 
156
158
  df = df.merge(df_pre, how="inner")
157
159
 
158
- if self._month_start:
160
+ if self.month_start:
159
161
  df["ExpirationDate"] = (
160
162
  df["ExpirationDate"].dt.to_period("M").dt.to_timestamp()
161
163
  )
162
164
 
163
- if not self._all_columns:
165
+ if not self.all_columns:
164
166
  cols = [
165
167
  "TradeDate",
166
168
  "TickerSymbol",
@@ -273,7 +275,7 @@ class DIFutures:
273
275
 
274
276
  # Load DI rates dataset filtered by the provided reference dates
275
277
  dfr = (
276
- get_di_dataset()
278
+ get_cached_dataset("DI")
277
279
  .query("TradeDate in @dfi['tdate'].unique()")
278
280
  .reset_index(drop=True)
279
281
  )
@@ -316,8 +318,10 @@ class DIFutures:
316
318
  ) -> float:
317
319
  """Retrieve the DI rate for a specified expiration date."""
318
320
  expiration = dc.convert_input_dates(expiration)
321
+ if not self.date:
322
+ return float("NaN")
319
323
 
320
- if self._month_start:
324
+ if self.month_start:
321
325
  # Force the expiration date to be the start of the month
322
326
  expiration = expiration.to_period("M").to_timestamp()
323
327
  else:
@@ -349,11 +353,11 @@ class DIFutures:
349
353
  known_rates=df["SettlementRate"],
350
354
  extrapolate=extrapolate,
351
355
  )
352
- bd = bday.count(self._date, expiration)
356
+ bd = bday.count(self.date, expiration)
353
357
  return ff_interp(bd)
354
358
 
355
359
  @property
356
- def date(self) -> pd.Timestamp:
360
+ def date(self) -> pd.Timestamp | None:
357
361
  """
358
362
  The trade date to retrieve the DI contract data.
359
363
 
@@ -363,7 +367,6 @@ class DIFutures:
363
367
  Returns:
364
368
  pd.Timestamp: The trade date set for this instance.
365
369
  """
366
-
367
370
  return self._date
368
371
 
369
372
  @date.setter
@@ -19,6 +19,7 @@ from enum import Enum
19
19
  from urllib.error import HTTPError
20
20
 
21
21
  import pandas as pd
22
+ import requests
22
23
 
23
24
  from pyield.date_converter import DateScalar, convert_input_dates
24
25
  from pyield.retry import default_retry
@@ -63,7 +64,7 @@ def _build_download_url(
63
64
  end_str = end.strftime("%d/%m/%Y")
64
65
 
65
66
  api_url = BASE_URL
66
- api_url += f"{serie.value}/dados?formato=csv"
67
+ api_url += f"{serie.value}/dados?formato=json"
67
68
  api_url += f"&dataInicial={start_str}&dataFinal={end_str}"
68
69
 
69
70
  return api_url
@@ -91,16 +92,29 @@ def _fetch_data_from_url(
91
92
  api_url = _build_download_url(serie, start, end)
92
93
 
93
94
  try:
94
- df = pd.read_csv(api_url, sep=";", decimal=",", dtype_backend="numpy_nullable")
95
+ response = requests.get(api_url, timeout=30)
96
+ response.raise_for_status() # Raise exception for 4XX/5XX status codes
97
+ data = response.json()
98
+
99
+ if not data:
100
+ logger.warning(f"No data available for the requested period: {api_url}")
101
+ return pd.DataFrame()
102
+
103
+ # Convert JSON to DataFrame
104
+ df = pd.DataFrame(data)
105
+
95
106
  if df.empty or "valor" not in df.columns:
96
107
  logger.warning(f"No data available for the requested period: {api_url}")
97
108
  return pd.DataFrame()
98
109
 
99
110
  # Process the dataframe
111
+ df = pd.DataFrame(data, dtype="string")
100
112
  df = df.rename(columns={"data": "Date", "valor": "Value"})
113
+
101
114
  df["Date"] = pd.to_datetime(df["Date"], format="%d/%m/%Y")
102
115
  # Value is in percentage format, so divide by 100
103
- df["Value"] /= 100
116
+ df["Value"] = (df["Value"].astype("Float64")) / 100
117
+
104
118
  return df
105
119
  except HTTPError as e:
106
120
  if e.code == 404: # noqa
@@ -142,10 +156,6 @@ def selic_over_series(
142
156
 
143
157
  Examples:
144
158
  >>> from pyield import bc
145
- >>> bc.selic_over_series("31-05-2024", "31-05-2024")
146
- Date Value
147
- 0 2024-05-31 0.104
148
-
149
159
  >>> # No data on 26-01-2025 (sunday). Rate changed due to Copom meeting.
150
160
  >>> bc.selic_over_series("26-01-2025") # Returns all data since 26-01-2025
151
161
  Date Value
@@ -8,7 +8,6 @@ import pyield.date_converter as dc
8
8
  import pyield.holidays as hl
9
9
  from pyield.date_converter import DateArray, DateScalar
10
10
 
11
- # Timezone for Brazil
12
11
  TIMEZONE_BZ = ZoneInfo("America/Sao_Paulo")
13
12
 
14
13
  IntegerScalar = int | np.integer
@@ -62,7 +61,12 @@ def offset(
62
61
 
63
62
  Args:
64
63
  dates (DateScalar | DateArray): The date(s) to offset. Can be a scalar date type
65
- or a collection of dates. The holiday list is determined based on this date.
64
+ or a collection of dates. **Transition Handling:** Due to a change in
65
+ Brazilian national holidays effective from 2023-12-26 (`TRANSITION_DATE`),
66
+ this function automatically selects the appropriate holiday list
67
+ (old or new) based on **each individual date** in the `dates` input.
68
+ Dates before 2023-12-26 use the old list for their offset calculation,
69
+ while dates on or after 2023-12-26 use the new list.
66
70
  offset (int | Series | np.ndarray | list[int] | tuple[int], optional):
67
71
  The number of business days to offset the dates. Positive for future dates,
68
72
  negative for past dates. Zero will return the same date if it's a business
@@ -142,37 +146,40 @@ def offset(
142
146
 
143
147
  if isinstance(dates_pd, pd.Series):
144
148
  # Divide the input in order to apply the correct holiday list
145
- dates1 = dates_pd[dates_pd < br_holidays.TRANSITION_DATE]
146
- dates2 = dates_pd[dates_pd >= br_holidays.TRANSITION_DATE]
147
- dates3 = dates_pd[dates_pd.isna()]
149
+ old_dates = dates_pd[dates_pd < br_holidays.TRANSITION_DATE]
150
+ new_dates = dates_pd[dates_pd >= br_holidays.TRANSITION_DATE]
151
+ # nat = NaT = Not a Time = missing value
152
+ nat_dates = dates_pd[dates_pd.isna()]
148
153
 
149
- offsetted_dates1 = np.busday_offset(
150
- dc.to_numpy_date_type(dates1),
154
+ offsetted_old_dates = np.busday_offset(
155
+ dc.to_numpy_date_type(old_dates),
151
156
  offsets=offset,
152
157
  roll=roll,
153
158
  holidays=OLD_HOLIDAYS_ARRAY,
154
159
  )
155
160
 
156
- offsetted_dates2 = np.busday_offset(
157
- dc.to_numpy_date_type(dates2),
161
+ offsetted_new_dates = np.busday_offset(
162
+ dc.to_numpy_date_type(new_dates),
158
163
  offsets=offset,
159
164
  roll=roll,
160
165
  holidays=NEW_HOLIDAYS_ARRAY,
161
166
  )
162
167
 
163
168
  # Convert from numpy.datetime64 to pandas.Timestamp
164
- offsetted_dates1 = pd.to_datetime(offsetted_dates1)
165
- offsetted_dates2 = pd.to_datetime(offsetted_dates2)
169
+ offsetted_old_dates = pd.to_datetime(offsetted_old_dates)
170
+ offsetted_new_dates = pd.to_datetime(offsetted_new_dates)
166
171
 
167
172
  # 'pd.to_datetime' does not necessarily return datetime64[ns] Series
168
- offsetted_dates1 = pd.Series(offsetted_dates1).astype("datetime64[ns]")
169
- offsetted_dates2 = pd.Series(offsetted_dates2).astype("datetime64[ns]")
173
+ offsetted_old_dates = pd.Series(offsetted_old_dates).astype("datetime64[ns]")
174
+ offsetted_new_dates = pd.Series(offsetted_new_dates).astype("datetime64[ns]")
170
175
 
171
176
  # Use old index to rejoin the results
172
- offsetted_dates1.index = dates1.index
173
- offsetted_dates2.index = dates2.index
177
+ offsetted_old_dates.index = old_dates.index
178
+ offsetted_new_dates.index = new_dates.index
174
179
 
175
- offsetted_dates = pd.concat([offsetted_dates1, offsetted_dates2, dates3])
180
+ offsetted_dates = pd.concat(
181
+ [offsetted_old_dates, offsetted_new_dates, nat_dates]
182
+ )
176
183
 
177
184
  # Reorder the result to match the original input order
178
185
  return offsetted_dates.sort_index()
@@ -224,10 +231,13 @@ def count(
224
231
  applies to the calculation.
225
232
 
226
233
  Args:
227
- start (DateScalar | DateArray): The start date(s) for counting. The holiday list
228
- is selected based on this date.
229
- end (DateScalar | DateArray): The end date(s) for counting, which
230
- is excluded from the count themselves.
234
+ start (DateScalar | DateArray): The start date(s) for counting (inclusive).
235
+ **Transition Handling:** The holiday list used for the *entire* counting
236
+ period between `start` and `end` is determined solely by the `start` date's
237
+ relation to the holiday transition date (2023-12-26). If `start` is before
238
+ this date, the old holiday list is used for the whole count, even if `end`
239
+ is after it. If `start` is on or after this date, new holiday list is used.
240
+ end (DateScalar | DateArray): The end date(s) for counting (exclusive).
231
241
 
232
242
  Returns:
233
243
  int | pd.Series: Returns an integer if `start` and `end` are single dates,
@@ -235,20 +245,19 @@ def count(
235
245
 
236
246
  Notes:
237
247
  - This function is a wrapper around `numpy.busday_count`, adapted to work
238
- directly with various Pandas and Numpy date formats.
248
+ directly with various Pandas and Numpy date formats.
239
249
  - It supports flexible date inputs, including single dates, lists, Series, and
240
- more, for both `start` and `end` parameters.
250
+ more, for both `start` and `end` parameters.
241
251
  - The return type depends on the input types: single dates return an int, while
242
- arrays of dates return a pd.Series with the count for each date range.
252
+ arrays of dates return a pd.Series with the count for each date range.
243
253
  - The `start` date determines the holiday list, ensuring consistency with the
244
- applicable calendar at the time.
254
+ applicable calendar at the time.
245
255
  - See `numpy.busday_count` documentation for more details on how holidays are
246
- handled and how business day counts are calculated:
247
- https://numpy.org/doc/stable/reference/generated/numpy.busday_count.html.
256
+ handled and how business day counts are calculated:
257
+ https://numpy.org/doc/stable/reference/generated/numpy.busday_count.html.
248
258
 
249
259
  Examples:
250
260
  >>> from pyield import bday
251
-
252
261
  >>> bday.count("15-12-2023", "01-01-2024")
253
262
  10
254
263
 
@@ -334,7 +343,7 @@ def generate(
334
343
  start: DateScalar | None = None,
335
344
  end: DateScalar | None = None,
336
345
  inclusive: Literal["both", "neither", "left", "right"] = "both",
337
- holiday_option: Literal["old", "new", "infer"] = "infer",
346
+ holiday_option: Literal["old", "new", "infer"] = "new",
338
347
  ) -> pd.Series:
339
348
  """
340
349
  Generates a Series of business days between a `start` and `end` date, considering
@@ -350,9 +359,14 @@ def generate(
350
359
  Determines which of the start and end dates are included in the result.
351
360
  Valid options are 'both', 'neither', 'left', 'right'. Defaults to 'both'.
352
361
  holiday_option (Literal["old", "new", "infer"], optional):
353
- Specifies the list of holidays to consider. 'old' or 'new' refer to
354
- predefined lists, 'infer' selects the list based on the most recent date in
355
- the range. Defaults to "infer".
362
+ Specifies the list of holidays to consider. Defaults to "new".
363
+ - **'old'**: Uses the holiday list effective *before* the transition date
364
+ of 2023-12-26.
365
+ - **'new'**: Uses the holiday list effective *on and after* the transition
366
+ date of 2023-12-26.
367
+ - **'infer'**: Automatically selects the holiday list ('old' or 'new') based
368
+ on the `start` date relative to the transition date (2023-12-26). If `start`
369
+ is before the transition, 'old' is used; otherwise, 'new' is used.
356
370
 
357
371
  Returns:
358
372
  pd.Series: A Series representing a range of business days between the specified
@@ -384,7 +398,7 @@ def generate(
384
398
  else:
385
399
  end_pd = pd.Timestamp.today()
386
400
 
387
- holidays_list = br_holidays.get_holiday_series(
401
+ applicable_holidays = br_holidays.get_holiday_series(
388
402
  dates=start_pd, holiday_option=holiday_option
389
403
  ).to_list()
390
404
 
@@ -394,8 +408,9 @@ def generate(
394
408
  end=end_pd,
395
409
  freq="C",
396
410
  inclusive=inclusive,
397
- holidays=holidays_list,
411
+ holidays=applicable_holidays,
398
412
  )
413
+
399
414
  return pd.Series(result_dti.values)
400
415
 
401
416
 
@@ -413,9 +428,14 @@ def is_business_day(date: DateScalar) -> bool:
413
428
  >>> from pyield import bday
414
429
  >>> bday.is_business_day("25-12-2023") # Christmas
415
430
  False
431
+
432
+ Notes:
433
+ - This function correctly identifies business days according to the applicable
434
+ Brazilian holiday list (before or after the 2023-12-26 transition), based
435
+ on the input `date`.
416
436
  """
417
437
  date_pd = dc.convert_input_dates(date)
418
- shifted_date = offset(date_pd, 0) # Shift the date if it is not a bus. day
438
+ shifted_date = offset(date_pd, 0) # Shift the date if it is not a business day
419
439
  return date_pd == shifted_date
420
440
 
421
441
 
@@ -428,6 +448,11 @@ def last_business_day() -> pd.Timestamp:
428
448
  Returns:
429
449
  pd.Timestamp: The last business day in Brazil.
430
450
 
451
+ Notes:
452
+ - The determination of the last business day considers the correct Brazilian
453
+ holiday list (before or after the 2023-12-26 transition) applicable to
454
+ the current date.
455
+
431
456
  """
432
457
  # Get the current date in Brazil without timezone information
433
458
  bz_today = pd.Timestamp.now(TIMEZONE_BZ).normalize().tz_localize(None)
@@ -0,0 +1,70 @@
1
+ import functools
2
+ import logging
3
+ from datetime import datetime
4
+ from zoneinfo import ZoneInfo
5
+
6
+ import pandas as pd
7
+
8
+ # --- Configurações Centralizadas ---
9
+ TIMEZONE_BZ = ZoneInfo("America/Sao_Paulo")
10
+
11
+ GIT_URL = "https://raw.githubusercontent.com/crdcj/pyield-data/main"
12
+ # Diretório base onde os arquivos parquet estão localizados
13
+ BASE_DATA_URL = f"{GIT_URL}/data"
14
+
15
+ # Dicionário global para configurações dos datasets usando 'filename'
16
+ DATASET_CONFIGS = {
17
+ "DI": {
18
+ "filename": "b3_di.parquet", # Apenas o nome do arquivo
19
+ "date_column": "TradeDate",
20
+ "name": "Futuro de DI (B3)",
21
+ },
22
+ "TPF": {
23
+ "filename": "anbima_tpf.parquet", # Apenas o nome do arquivo
24
+ "date_column": "ReferenceDate",
25
+ "name": "TPF (ANBIMA)",
26
+ },
27
+ # --- Adicionar futuros datasets aqui ---
28
+ # "NOVODATASET": {
29
+ # "filename": "novo_dataset.parquet",
30
+ # "date_column": "DataReferencia",
31
+ # "name": "Novo Dataset Exemplo"
32
+ # },
33
+ }
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ def _get_today_date_key():
39
+ """Retorna a data atual como string no formato YYYY-MM-DD."""
40
+ return datetime.now(TIMEZONE_BZ).strftime("%Y-%m-%d")
41
+
42
+
43
+ @functools.lru_cache(maxsize=len(DATASET_CONFIGS))
44
+ def _get_dataset_with_ttl(dataset_id: str, date_key: str) -> pd.DataFrame:
45
+ """Carrega o dataset a partir do ID e da chave de data. Cache expira diariamente."""
46
+ if dataset_id not in DATASET_CONFIGS:
47
+ raise ValueError(f"Dataset com ID '{dataset_id}' não encontrado.")
48
+
49
+ config = DATASET_CONFIGS[dataset_id]
50
+ filename = config["filename"]
51
+ date_column = config["date_column"]
52
+ dataset_name = config.get("name", dataset_id)
53
+
54
+ # Constrói a URL completa
55
+ full_url = f"{BASE_DATA_URL}/{filename}"
56
+
57
+ try:
58
+ df = pd.read_parquet(full_url)
59
+ last_date = df[date_column].max().date()
60
+ logger.info(f"Dataset {dataset_name} carregado. Última data: {last_date}")
61
+ return df.copy()
62
+
63
+ except Exception:
64
+ logger.exception(f"Erro ao carregar dataset {dataset_name} ({dataset_id})")
65
+ raise # Re-raise a exceção para que o chamador saiba que falhou
66
+
67
+
68
+ def get_cached_dataset(dataset_id: str) -> pd.DataFrame:
69
+ """Obtém um dataset configurado pelo seu ID. O cache expira diariamente."""
70
+ return _get_dataset_with_ttl(dataset_id, _get_today_date_key())
@@ -3,6 +3,111 @@
3
3
  # Direct link to the ANBIMA file: https://www.anbima.com.br/feriados/arqs/feriados_nacionais.xls
4
4
  # In december 2023, a new holiday was added: "Dia Nacional de Zumbi"
5
5
  # Note: dates until 22-12-2023 (inclusive) must use "old_br_holidays.txt" list
6
+ 01/01/1990
7
+ 26/02/1990
8
+ 27/02/1990
9
+ 01/05/1990
10
+ 14/06/1990
11
+ 07/09/1990
12
+ 12/10/1990
13
+ 02/11/1990
14
+ 15/11/1990
15
+ 25/12/1990
16
+ 01/01/1991
17
+ 11/02/1991
18
+ 12/02/1991
19
+ 29/03/1991
20
+ 01/05/1991
21
+ 30/05/1991
22
+ 15/11/1991
23
+ 25/12/1991
24
+ 01/01/1992
25
+ 02/03/1992
26
+ 03/03/1992
27
+ 17/04/1992
28
+ 21/04/1992
29
+ 01/05/1992
30
+ 18/06/1992
31
+ 07/09/1992
32
+ 12/10/1992
33
+ 02/11/1992
34
+ 25/12/1992
35
+ 01/01/1993
36
+ 22/02/1993
37
+ 23/02/1993
38
+ 09/04/1993
39
+ 21/04/1993
40
+ 10/06/1993
41
+ 07/09/1993
42
+ 12/10/1993
43
+ 02/11/1993
44
+ 15/11/1993
45
+ 14/02/1994
46
+ 15/02/1994
47
+ 01/04/1994
48
+ 21/04/1994
49
+ 02/06/1994
50
+ 07/09/1994
51
+ 12/10/1994
52
+ 02/11/1994
53
+ 15/11/1994
54
+ 27/02/1995
55
+ 28/02/1995
56
+ 14/04/1995
57
+ 21/04/1995
58
+ 01/05/1995
59
+ 15/06/1995
60
+ 07/09/1995
61
+ 12/10/1995
62
+ 02/11/1995
63
+ 15/11/1995
64
+ 25/12/1995
65
+ 01/01/1996
66
+ 19/02/1996
67
+ 20/02/1996
68
+ 05/04/1996
69
+ 01/05/1996
70
+ 06/06/1996
71
+ 15/11/1996
72
+ 25/12/1996
73
+ 01/01/1997
74
+ 10/02/1997
75
+ 11/02/1997
76
+ 28/03/1997
77
+ 21/04/1997
78
+ 01/05/1997
79
+ 29/05/1997
80
+ 25/12/1997
81
+ 01/01/1998
82
+ 23/02/1998
83
+ 24/02/1998
84
+ 10/04/1998
85
+ 21/04/1998
86
+ 01/05/1998
87
+ 11/06/1998
88
+ 07/09/1998
89
+ 12/10/1998
90
+ 02/11/1998
91
+ 25/12/1998
92
+ 01/01/1999
93
+ 15/02/1999
94
+ 16/02/1999
95
+ 02/04/1999
96
+ 21/04/1999
97
+ 03/06/1999
98
+ 07/09/1999
99
+ 12/10/1999
100
+ 02/11/1999
101
+ 15/11/1999
102
+ 06/03/2000
103
+ 07/03/2000
104
+ 01/05/2000
105
+ 22/06/2000
106
+ 07/09/2000
107
+ 12/10/2000
108
+ 02/11/2000
109
+ 15/11/2000
110
+ 25/12/2000
6
111
  01/01/2001
7
112
  26/02/2001
8
113
  27/02/2001
@@ -0,0 +1,154 @@
1
+ """
2
+ Módulo de retry configurável para chamadas de API e processamento de dados.
3
+
4
+ Este módulo fornece um decorador `tenacity.retry` pré-configurado (`default_retry`)
5
+ para lidar com erros transitórios comuns ao interagir com APIs web e ao
6
+ processar os dados resultantes (ex: parsing de CSV/JSON).
7
+ """
8
+
9
+ import logging
10
+ from urllib.error import HTTPError, URLError
11
+
12
+ # Dependências de terceiros
13
+ import pandas as pd
14
+ import tenacity
15
+
16
+ # Importar tipos específicos necessários
17
+ from tenacity import RetryCallState
18
+
19
+ # --- Configuração do Logger ---
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # --- Constantes ---
23
+ DEFAULT_TIMEOUT = (2, 10)
24
+ RETRYABLE_NETWORK_ERRORS = (URLError,)
25
+ RETRYABLE_PARSING_ERRORS = (pd.errors.EmptyDataError, pd.errors.ParserError)
26
+
27
+
28
+ # --- Funções Auxiliares para Tenacity ---
29
+
30
+
31
+ def _log_before_sleep(retry_state: RetryCallState):
32
+ """
33
+ Loga uma mensagem ANTES de o Tenacity entrar em espera (sleep) entre tentativas.
34
+
35
+ Simplificada para confiar no contrato do Tenacity e usar asserções
36
+ para verificar o estado esperado.
37
+ """
38
+ # 1. Verificar o resultado (outcome)
39
+ outcome = retry_state.outcome
40
+ assert outcome is not None, "before_sleep chamada sem 'outcome'"
41
+ # Adicionado: Garantir que realmente falhou antes de logar como falha
42
+ assert outcome.failed, "before_sleep chamada para 'outcome' bem-sucedido"
43
+
44
+ exception = outcome.exception
45
+ # Adicionado: Garantir que a exceção existe quando falhou
46
+ assert exception is not None, "before_sleep chamada sem 'exception' no outcome"
47
+
48
+ # 2. Verificar a próxima ação (next_action)
49
+ next_action = retry_state.next_action
50
+ assert next_action is not None, "before_sleep chamada sem 'next_action'"
51
+
52
+ # 3. Acessar a duração do sono (sleep)
53
+ # Confiamos que 'next_action' terá '.sleep' neste contexto, pois é antes de dormir.
54
+ sleep_duration = next_action.sleep
55
+
56
+ # Log principal
57
+ logger.warning(
58
+ f"Tentativa {retry_state.attempt_number} falhou com "
59
+ f"{type(exception).__name__}: {exception}. Tentando novamente em "
60
+ f"{sleep_duration:.2f} segundos..."
61
+ )
62
+
63
+
64
+ def should_retry_exception(retry_state: RetryCallState) -> bool:
65
+ """Determina se uma exceção capturada justifica uma nova tentativa."""
66
+ if not retry_state.outcome or not retry_state.outcome.failed:
67
+ return False
68
+
69
+ # Usar o atributo .exception
70
+ exception = retry_state.outcome.exception
71
+ assert exception is not None, (
72
+ "should_retry_exception processando um outcome sem exceção?"
73
+ )
74
+
75
+ attempt_number = retry_state.attempt_number
76
+ url = getattr(exception, "url", "N/A")
77
+
78
+ retry_decision = False
79
+ log_message = ""
80
+ log_level = logging.ERROR
81
+
82
+ match exception:
83
+ case HTTPError(code=404):
84
+ log_message = (
85
+ f"Tentativa {attempt_number}: Recurso não encontrado (404) em {url}. "
86
+ f"Não tentará novamente."
87
+ )
88
+ retry_decision = False
89
+ log_level = logging.INFO
90
+
91
+ case HTTPError(code=504) if attempt_number == 1:
92
+ log_message = (
93
+ f"Tentativa {attempt_number}: Gateway Timeout (504) da API em {url}. "
94
+ f"Tentará novamente uma vez."
95
+ )
96
+ retry_decision = True
97
+ log_level = logging.WARNING
98
+
99
+ case HTTPError(code=504):
100
+ log_message = (
101
+ f"Tentativa {attempt_number}: Gateway Timeout (504) persistiu em {url}."
102
+ f"API parece indisponível. Desistindo."
103
+ )
104
+ retry_decision = False
105
+ log_level = logging.ERROR
106
+
107
+ case HTTPError() as http_err:
108
+ log_message = (
109
+ f"Tentativa {attempt_number}: Erro HTTP {http_err.code} encontrado "
110
+ f"para {url}. Pode ser transitório. Tentando novamente."
111
+ )
112
+ retry_decision = True
113
+ log_level = logging.WARNING
114
+
115
+ case URLError():
116
+ log_message = (
117
+ f"Tentativa {attempt_number}: Erro de rede recuperável "
118
+ f"({type(exception).__name__}). Tentando novamente."
119
+ )
120
+ retry_decision = True
121
+ log_level = logging.WARNING
122
+
123
+ case pd.errors.EmptyDataError() | pd.errors.ParserError():
124
+ log_message = (
125
+ f"Tentativa {attempt_number}: Erro de parsing "
126
+ f"({type(exception).__name__}). Tentando novamente."
127
+ )
128
+ retry_decision = True
129
+ log_level = logging.WARNING
130
+
131
+ case _:
132
+ log_message_exc = (
133
+ f"Tentativa {attempt_number}: Erro não recuperável/inesperado "
134
+ f"({type(exception).__name__}). Desistindo."
135
+ )
136
+ retry_decision = False
137
+ log_level = logging.ERROR
138
+ logger.exception(log_message_exc)
139
+ log_message = None
140
+
141
+ if log_message:
142
+ logger.log(log_level, log_message)
143
+
144
+ return retry_decision
145
+
146
+
147
+ # --- Decorador Tenacity Principal ---
148
+ default_retry = tenacity.retry(
149
+ retry=should_retry_exception,
150
+ wait=tenacity.wait_exponential(multiplier=1, min=1, max=10),
151
+ stop=(tenacity.stop_after_attempt(3) | tenacity.stop_after_delay(15)),
152
+ before_sleep=_log_before_sleep,
153
+ reraise=True,
154
+ )
@@ -14,7 +14,7 @@ classifiers = [
14
14
  "Topic :: Office/Business :: Financial :: Investment",
15
15
  ]
16
16
  dependencies = [
17
- "pandas[html]>=2.0.0",
17
+ "pandas[html, parquet]>=2.0.0",
18
18
  "numpy>=2.0.0",
19
19
  "requests>=2.31.0",
20
20
  "tenacity>=9.0.0",
@@ -1 +0,0 @@
1
- __version__ = "0.32.0"
@@ -1,184 +0,0 @@
1
- """
2
- Módulo para carregamento e cache de dados financeiros.
3
- Fornece acesso a dados DI e TPF com atualizações automáticas.
4
- """
5
-
6
- import logging
7
- from functools import wraps
8
- from typing import Any, Dict
9
- from zoneinfo import ZoneInfo
10
-
11
- import pandas as pd
12
- import requests
13
-
14
- from pyield import bday
15
-
16
- # Configuração de logging
17
- logger = logging.getLogger(__name__)
18
-
19
- # Configurações
20
- TIMEZONE_BZ = ZoneInfo("America/Sao_Paulo")
21
-
22
- GIT_URL = "https://raw.githubusercontent.com/crdcj/pyield-data/main"
23
- DI_URL = f"{GIT_URL}/data/b3_di.pkl.gz"
24
- TPF_URL = f"{GIT_URL}/data/anbima_tpf.pkl.gz"
25
-
26
- # Configuração de datasets
27
- DATASETS_CONFIG = {
28
- "di": {"url": f"{DI_URL}", "date_column": "TradeDate"},
29
- "tpf": {"url": f"{TPF_URL}", "date_column": "ReferenceDate"},
30
- }
31
-
32
-
33
- def singleton(cls):
34
- """Decorator para implementar o padrão Singleton."""
35
- instances = {}
36
-
37
- @wraps(cls)
38
- def get_instance(*args, **kwargs):
39
- if cls not in instances:
40
- instances[cls] = cls(*args, **kwargs)
41
- return instances[cls]
42
-
43
- return get_instance
44
-
45
-
46
- @singleton
47
- class DataCache:
48
- """
49
- Classe para gerenciar o cache de datasets financeiros.
50
- Implementa o padrão Singleton para garantir uma única instância.
51
- """
52
-
53
- def __init__(self):
54
- """Inicializa o cache de dados vazio."""
55
- self._datasets: Dict[str, Dict[str, Any]] = {}
56
-
57
- # Inicializar configurações de datasets
58
- for key, config in DATASETS_CONFIG.items():
59
- self._datasets[key] = {
60
- "url": config["url"],
61
- "date_column": config["date_column"],
62
- "df": None,
63
- "last_date": None,
64
- }
65
-
66
- def _should_update_dataset(self, key: str) -> bool:
67
- """
68
- Verifica se o dataset precisa ser atualizado.
69
-
70
- Args:
71
- key: Identificador do dataset
72
-
73
- Returns:
74
- True se o dataset precisar ser atualizado, False caso contrário
75
- """
76
- data_info = self._datasets[key]
77
- last_date_in_dataset = data_info["last_date"]
78
-
79
- # Se nunca foi carregado, deve atualizar
80
- if last_date_in_dataset is None:
81
- return True
82
-
83
- bz_last_bday = bday.last_business_day().date()
84
- n_bdays = bday.count(last_date_in_dataset, bz_last_bday)
85
-
86
- # Atualizar se houver mais de 1 dia útil de diferença
87
- return n_bdays > 1 # noqa
88
-
89
- def _load_dataset(self, key: str) -> None:
90
- """
91
- Carrega ou atualiza um dataset.
92
-
93
- Args:
94
- key: Identificador do dataset
95
-
96
- Raises:
97
- requests.RequestException: Se ocorrer erro ao fazer download dos dados
98
- ValueError: Se o formato dos dados for inválido
99
- """
100
- dataset_info = self._datasets[key]
101
-
102
- try:
103
- logger.info(f"Carregando dataset {key} de {dataset_info['url']}")
104
- df = pd.read_pickle(dataset_info["url"])
105
-
106
- # Validar se o DataFrame tem a coluna de data esperada
107
- date_column = dataset_info["date_column"]
108
- if date_column not in df.columns:
109
- raise ValueError(
110
- f"Coluna de data '{date_column}' não encontrada no dataset '{key}'"
111
- )
112
-
113
- dataset_info["df"] = df
114
- dataset_info["last_date"] = df[date_column].max().date()
115
-
116
- logger.info(
117
- f"Dataset {key} atualizado. Última data: {dataset_info['last_date']}"
118
- )
119
-
120
- except requests.RequestException as e:
121
- logger.error(f"Erro ao fazer download do dataset {key}: {str(e)}")
122
- raise
123
- except Exception as e:
124
- logger.error(f"Erro ao processar dataset {key}: {str(e)}")
125
- raise
126
-
127
- def get_dataframe(self, key: str) -> pd.DataFrame:
128
- """
129
- Obtém um DataFrame, carregando-o ou atualizando-o se necessário.
130
-
131
- Args:
132
- key: Identificador do dataset
133
-
134
- Returns:
135
- DataFrame com os dados solicitados
136
-
137
- Raises:
138
- ValueError: Se o dataset não estiver configurado
139
- Exception: Se ocorrer erro ao carregar os dados
140
- """
141
- dataset_info = self._datasets.get(key)
142
-
143
- if dataset_info is None:
144
- logger.error(f"Dataset '{key}' não está configurado")
145
- raise ValueError(f"Dataset '{key}' não está configurado")
146
-
147
- try:
148
- if dataset_info["df"] is None or self._should_update_dataset(key):
149
- self._load_dataset(key)
150
-
151
- return dataset_info["df"].copy()
152
- except Exception as e:
153
- logger.error(f"Erro ao obter dataset {key}: {str(e)}")
154
- raise e
155
-
156
-
157
- # Instância única da classe para uso interno da biblioteca
158
- _data_cache = DataCache()
159
-
160
-
161
- def get_di_dataset() -> pd.DataFrame:
162
- """
163
- Obtém o dataset de DI (Depósito Interfinanceiro).
164
-
165
- Returns:
166
- DataFrame com os dados de DI
167
-
168
- Raises:
169
- Exception: Se ocorrer erro ao carregar os dados
170
- """
171
- return _data_cache.get_dataframe("di")
172
-
173
-
174
- def get_tpf_dataset() -> pd.DataFrame:
175
- """
176
- Obtém o dataset de TPF (Títulos Públicos Federais).
177
-
178
- Returns:
179
- DataFrame com os dados de TPF
180
-
181
- Raises:
182
- Exception: Se ocorrer erro ao carregar os dados
183
- """
184
- return _data_cache.get_dataframe("tpf")
@@ -1,56 +0,0 @@
1
- import logging
2
- import time
3
- from urllib.error import HTTPError, URLError
4
-
5
- import pandas as pd
6
- import tenacity
7
-
8
- logger = logging.getLogger(__name__)
9
-
10
- # Default timeout values for HTTP requests (connect_timeout, read_timeout)
11
- DEFAULT_TIMEOUT = (2, 10)
12
-
13
-
14
- def retry_if_recoverable(exception):
15
- """
16
- Determine if the exception is worth retrying.
17
- """
18
- if isinstance(exception, HTTPError):
19
- # Don't retry for 404 errors
20
- if exception.code == 404: # noqa
21
- logger.info("Resource not found (404), not retrying.")
22
- return False
23
-
24
- # Don't retry for 504 Gateway Timeout after first attempt
25
- # These often indicate longer-term issues with API
26
- if exception.code == 504: # noqa
27
- logger.warning(
28
- "Gateway Timeout (504) from API - likely service degradation."
29
- )
30
- # Check if we've already retried for this 504
31
- retry_state = getattr(retry_if_recoverable, "retry_for_504", 0)
32
- if retry_state >= 1:
33
- logger.error("Multiple 504 errors - BC API appears to be unavailable.")
34
- return False
35
- # Allow just one retry for 504 with a longer wait
36
- retry_if_recoverable.retry_for_504 = retry_state + 1
37
- time.sleep(2) # Immediate wait before retry
38
- return True
39
-
40
- # Reset the 504 counter for other types of errors
41
- retry_if_recoverable.retry_for_504 = 0
42
-
43
- # Retry for other HTTP errors, URLErrors, and parsing errors
44
- return isinstance(
45
- exception,
46
- (HTTPError, URLError, pd.errors.EmptyDataError, pd.errors.ParserError),
47
- )
48
-
49
-
50
- # Custom retry configuration that handles parsing errors
51
- default_retry = tenacity.retry(
52
- retry=tenacity.retry_if_exception(retry_if_recoverable),
53
- stop=(tenacity.stop_after_attempt(3) | tenacity.stop_after_delay(15)),
54
- wait=tenacity.wait_exponential(multiplier=0.5, min=0.5, max=2),
55
- before_sleep=tenacity.before_sleep_log(logger, logging.WARNING),
56
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes