PYield 0.40.2__tar.gz → 0.40.4__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 (59) hide show
  1. {pyield-0.40.2 → pyield-0.40.4}/PKG-INFO +1 -1
  2. pyield-0.40.4/pyield/__about__.py +1 -0
  3. {pyield-0.40.2 → pyield-0.40.4}/pyield/anbima/tpf.py +4 -5
  4. {pyield-0.40.2 → pyield-0.40.4}/pyield/b3/di1.py +25 -28
  5. {pyield-0.40.2 → pyield-0.40.4}/pyield/data_cache.py +0 -3
  6. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/auctions.py +26 -37
  7. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/ntnb.py +113 -46
  8. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/ntnc.py +7 -2
  9. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/ntnf.py +24 -7
  10. pyield-0.40.2/pyield/__about__.py +0 -1
  11. {pyield-0.40.2 → pyield-0.40.4}/.gitignore +0 -0
  12. {pyield-0.40.2 → pyield-0.40.4}/LICENSE +0 -0
  13. {pyield-0.40.2 → pyield-0.40.4}/README.md +0 -0
  14. {pyield-0.40.2 → pyield-0.40.4}/pyield/__init__.py +0 -0
  15. {pyield-0.40.2 → pyield-0.40.4}/pyield/anbima/__init__.py +0 -0
  16. {pyield-0.40.2 → pyield-0.40.4}/pyield/anbima/difusao.py +0 -0
  17. {pyield-0.40.2 → pyield-0.40.4}/pyield/anbima/ettj_intraday.py +0 -0
  18. {pyield-0.40.2 → pyield-0.40.4}/pyield/anbima/ettj_last.py +0 -0
  19. {pyield-0.40.2 → pyield-0.40.4}/pyield/anbima/ima.py +0 -0
  20. {pyield-0.40.2 → pyield-0.40.4}/pyield/anbima/imaq.py +0 -0
  21. {pyield-0.40.2 → pyield-0.40.4}/pyield/b3/__init__.py +0 -0
  22. {pyield-0.40.2 → pyield-0.40.4}/pyield/b3/di_over.py +0 -0
  23. {pyield-0.40.2 → pyield-0.40.4}/pyield/b3/futures/__init__.py +0 -0
  24. {pyield-0.40.2 → pyield-0.40.4}/pyield/b3/futures/common.py +0 -0
  25. {pyield-0.40.2 → pyield-0.40.4}/pyield/b3/futures/historical.py +0 -0
  26. {pyield-0.40.2 → pyield-0.40.4}/pyield/b3/futures/intraday.py +0 -0
  27. {pyield-0.40.2 → pyield-0.40.4}/pyield/b3/futures/xml.py +0 -0
  28. {pyield-0.40.2 → pyield-0.40.4}/pyield/bc/__init__.py +0 -0
  29. {pyield-0.40.2 → pyield-0.40.4}/pyield/bc/auction.py +0 -0
  30. {pyield-0.40.2 → pyield-0.40.4}/pyield/bc/ptax_api.py +0 -0
  31. {pyield-0.40.2 → pyield-0.40.4}/pyield/bc/rates.py +0 -0
  32. {pyield-0.40.2 → pyield-0.40.4}/pyield/bc/repo.py +0 -0
  33. {pyield-0.40.2 → pyield-0.40.4}/pyield/bc/trades_intraday.py +0 -0
  34. {pyield-0.40.2 → pyield-0.40.4}/pyield/bc/trades_monthly.py +0 -0
  35. {pyield-0.40.2 → pyield-0.40.4}/pyield/bc/vna.py +0 -0
  36. {pyield-0.40.2 → pyield-0.40.4}/pyield/bday/__init__.py +0 -0
  37. {pyield-0.40.2 → pyield-0.40.4}/pyield/bday/core.py +0 -0
  38. {pyield-0.40.2 → pyield-0.40.4}/pyield/bday/holidays/__init__.py +0 -0
  39. {pyield-0.40.2 → pyield-0.40.4}/pyield/bday/holidays/br_holidays_new.txt +0 -0
  40. {pyield-0.40.2 → pyield-0.40.4}/pyield/bday/holidays/br_holidays_old.txt +0 -0
  41. {pyield-0.40.2 → pyield-0.40.4}/pyield/bday/holidays/brholidays.py +0 -0
  42. {pyield-0.40.2 → pyield-0.40.4}/pyield/config.py +0 -0
  43. {pyield-0.40.2 → pyield-0.40.4}/pyield/conftest.py +0 -0
  44. {pyield-0.40.2 → pyield-0.40.4}/pyield/converters.py +0 -0
  45. {pyield-0.40.2 → pyield-0.40.4}/pyield/fwd.py +0 -0
  46. {pyield-0.40.2 → pyield-0.40.4}/pyield/interpolator.py +0 -0
  47. {pyield-0.40.2 → pyield-0.40.4}/pyield/ipca/__init__.py +0 -0
  48. {pyield-0.40.2 → pyield-0.40.4}/pyield/ipca/historical.py +0 -0
  49. {pyield-0.40.2 → pyield-0.40.4}/pyield/ipca/projected.py +0 -0
  50. {pyield-0.40.2 → pyield-0.40.4}/pyield/py.typed +0 -0
  51. {pyield-0.40.2 → pyield-0.40.4}/pyield/retry.py +0 -0
  52. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/__init__.py +0 -0
  53. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/benchmark.py +0 -0
  54. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/lft.py +0 -0
  55. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/ltn.py +0 -0
  56. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/pre.py +0 -0
  57. {pyield-0.40.2 → pyield-0.40.4}/pyield/tn/tools.py +0 -0
  58. {pyield-0.40.2 → pyield-0.40.4}/pyield/types.py +0 -0
  59. {pyield-0.40.2 → pyield-0.40.4}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PYield
3
- Version: 0.40.2
3
+ Version: 0.40.4
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>
@@ -0,0 +1 @@
1
+ __version__ = "0.40.4"
@@ -211,15 +211,14 @@ def _add_dv01(df_input: pl.DataFrame) -> pl.DataFrame:
211
211
  return df
212
212
 
213
213
 
214
- def _add_di_rate(df: pl.DataFrame) -> pl.DataFrame:
214
+ def _add_di_rate(df: pl.DataFrame, ref_date: dt.date) -> pl.DataFrame:
215
215
  """Add the DI rate column to the DataFrame."""
216
- reference_date = df["ReferenceDate"].item(0)
217
216
  di_rates = di1.interpolate_rates(
218
- dates=reference_date,
217
+ dates=ref_date,
219
218
  expirations=df["MaturityDate"],
220
219
  extrapolate=True,
221
220
  )
222
- df = df.with_columns(di_rates.alias("DIRate"))
221
+ df = df.with_columns(DIRate=di_rates)
223
222
  return df
224
223
 
225
224
 
@@ -298,7 +297,7 @@ def _fetch_tpf_data(date: dt.date) -> pl.DataFrame:
298
297
  df = _process_raw_df(df)
299
298
  df = _add_duration(df)
300
299
  df = _add_dv01(df)
301
- df = _add_di_rate(df)
300
+ df = _add_di_rate(df, date)
302
301
  df = _custom_sort_and_order(df)
303
302
 
304
303
  return df
@@ -5,7 +5,6 @@ import polars as pl
5
5
 
6
6
  import pyield.converters as cv
7
7
  from pyield import b3, bday, interpolator
8
- from pyield.config import TIMEZONE_BZ
9
8
  from pyield.data_cache import get_cached_dataset
10
9
  from pyield.types import DateArray, DateScalar, has_null_args
11
10
 
@@ -13,42 +12,40 @@ logger = logging.getLogger(__name__)
13
12
 
14
13
 
15
14
  def _load_with_intraday(dates: list[dt.date]) -> pl.DataFrame:
16
- """Busca dados de DI, incluindo dados intraday para o dia corrente se necessário."""
15
+ """Busca dados de DI, incluindo dados intraday para datas ausentes no cache."""
17
16
  # 1. Busca inicial no cache com as datas solicitadas pelo usuário.
18
17
  df_cached = get_cached_dataset("di1").filter(pl.col("TradeDate").is_in(dates))
19
18
 
20
- today = dt.datetime.now(TIMEZONE_BZ).date()
21
- last_bday = bday.last_business_day()
19
+ # 2. Identifica datas solicitadas que não estão no cache
20
+ cached_dates = set(df_cached["TradeDate"].unique().to_list())
21
+ missing_dates = [d for d in dates if d not in cached_dates]
22
22
 
23
- # 2. Lógica para buscar dados intraday.
24
- # Isso é necessário quando o usuário solicita os dados do dia corrente
25
- # e eles ainda não foram persistidos no cache (processo noturno).
26
- # Condição 1: O dia de hoje foi solicitado.
27
- has_today = today in dates
28
- # Condição 2: E ainda não está no cache.
29
- is_today_not_in_cache = df_cached.filter(pl.col("TradeDate") == today).is_empty()
30
- # Condição 3: E hoje é o último dia útil.
31
- is_today_last_bday = today == last_bday
23
+ # 3. Para cada data faltante, tenta buscar dados via API
24
+ dfs_to_concat = [df_cached] if not df_cached.is_empty() else []
32
25
 
33
- if has_today and is_today_not_in_cache and is_today_last_bday:
26
+ for ref_date in missing_dates:
34
27
  try:
35
- df_intraday = b3.futures(contract_code="DI1", date=today)
36
- if "SettlementPrice" not in df_intraday.columns or df_intraday.is_empty():
28
+ df_missing = b3.futures(contract_code="DI1", date=ref_date)
29
+
30
+ # Só adiciona se tiver SettlementPrice
31
+ if "SettlementPrice" in df_missing.columns:
32
+ df_missing = df_missing.drop("DaysToExp", strict=False)
33
+ dfs_to_concat.append(df_missing)
34
+ else:
37
35
  logger.warning(
38
- f"Ainda sem dados de ajustes intraday para {today}."
39
- " Retornando apenas dados do cache."
36
+ f"Dados para {ref_date} não contêm 'SettlementPrice'. "
37
+ "Pulando esta data."
40
38
  )
41
- df_intraday = df_intraday.drop("DaysToExp", strict=False)
42
-
43
- return df_cached
44
- if df_cached.is_empty():
45
- return df_intraday
46
- return pl.concat([df_cached, df_intraday], how="diagonal")
47
39
  except Exception as e:
48
- logger.error(f"Falha ao buscar dados intraday para {today}: {e}")
49
-
50
- # 3. Se a lógica intraday não for acionada ou falhar, retorna apenas dados do cache.
51
- return df_cached
40
+ logger.error(f"Falha ao buscar dados para {ref_date}: {e}")
41
+
42
+ # 4. Retorna concatenação de todos os DataFrames disponíveis
43
+ if len(dfs_to_concat) == 0:
44
+ return pl.DataFrame() # Retorna DataFrame vazio se nada foi encontrado
45
+ elif len(dfs_to_concat) == 1:
46
+ return dfs_to_concat[0]
47
+ else:
48
+ return pl.concat(dfs_to_concat, how="diagonal")
52
49
 
53
50
 
54
51
  def _get_data(dates: DateScalar | DateArray) -> pl.DataFrame:
@@ -72,9 +72,6 @@ def _get_dataset_with_ttl(dataset_id: str, date_key: str) -> pl.DataFrame:
72
72
  raise
73
73
 
74
74
 
75
- # --- API Pública ---
76
-
77
-
78
75
  def get_cached_dataset(dataset_id: DatasetId) -> pl.DataFrame:
79
76
  """
80
77
  Obtém um dataset configurado pelo seu ID, garantindo que o cache
@@ -130,13 +130,7 @@ def _transform_raw_data(raw_data: list[dict]) -> pl.DataFrame:
130
130
  .rename(COLUMN_MAP)
131
131
  .with_columns(
132
132
  # Conversão de datas
133
- cs.starts_with("data_")
134
- .exclude("data_liquidacao_2v")
135
- .str.strptime(pl.Date, "%d/%m/%Y"),
136
- # Tratamento da data de 2ª volta que tem formato diferente
137
- pl.col("data_liquidacao_2v").str.strptime(
138
- pl.Date, "%Y-%m-%dT%H:%M:%S%.f%#z", strict=False
139
- ),
133
+ cs.starts_with("data_").str.strptime(pl.Date, "%d/%m/%Y"),
140
134
  # Cálculos de totais
141
135
  pl.sum_horizontal("quantidade_ofertada_1v", "quantidade_ofertada_2v").alias(
142
136
  "quantidade_ofertada_total"
@@ -192,28 +186,25 @@ def _transform_raw_data(raw_data: list[dict]) -> pl.DataFrame:
192
186
  cs.starts_with("financeiro_ofertado").round(2),
193
187
  (cs.starts_with("taxa") / 100).round(7), # Percentual -> decimal
194
188
  )
195
- .with_columns(
196
- # Aplicar a lógica condicional em um segundo passo para evitar
197
- # conflito de nomes duplicados dentro do MESMO with_columns.
198
- [
199
- pl.when(pl.col("quantidade_aceita_1v") == 0)
200
- .then(None)
201
- .otherwise(pl.col(col))
202
- .alias(col)
203
- for col in [
204
- "pu_minimo",
205
- "pu_medio",
206
- "tipo_pu_medio",
207
- "taxa_media",
208
- "taxa_maxima",
209
- ]
210
- ]
211
- )
189
+ )
190
+ ajustar_cols = [
191
+ "pu_minimo",
192
+ "pu_medio",
193
+ "tipo_pu_medio",
194
+ "taxa_media",
195
+ "taxa_maxima",
196
+ ]
197
+ # Se a quantidade aceita for zero, ajustar colunas específicas para None
198
+ df = df.with_columns(
199
+ pl.when(pl.col("quantidade_aceita_1v") == 0)
200
+ .then(None)
201
+ .otherwise(pl.col(ajustar_cols))
202
+ .name.keep()
212
203
  )
213
204
 
214
205
  # Cálculo de dias úteis (requer acesso a colunas já convertidas)
215
206
  dias_uteis = bday.count(df["data_liquidacao_1v"], df["data_vencimento"])
216
- df = df.with_columns(pl.Series(name="dias_uteis", values=dias_uteis))
207
+ df = df.with_columns(dias_uteis.alias("dias_uteis"))
217
208
  return df.sort(["data_1v", "titulo", "data_vencimento"])
218
209
 
219
210
 
@@ -244,13 +235,11 @@ def _add_duration(df: pl.DataFrame) -> pl.DataFrame:
244
235
 
245
236
  df = df.with_columns(
246
237
  pl.struct(
247
- [
248
- "titulo",
249
- "data_liquidacao_1v",
250
- "data_vencimento",
251
- "taxa_media",
252
- "dias_uteis",
253
- ]
238
+ "titulo",
239
+ "data_liquidacao_1v",
240
+ "data_vencimento",
241
+ "taxa_media",
242
+ "dias_uteis",
254
243
  )
255
244
  .map_elements(calculate_duration_per_row, return_dtype=pl.Float64)
256
245
  .alias("duration")
@@ -279,9 +268,9 @@ def _add_dv01(df: pl.DataFrame) -> pl.DataFrame:
279
268
 
280
269
  df = df.with_columns(
281
270
  # 2. Criar as colunas DV01 multiplicando a expressão base pelas quantidades.
282
- (dv01_unit_expr * pl.col("quantidade_aceita_1v")).alias("dv01_1v"),
283
- (dv01_unit_expr * pl.col("quantidade_aceita_2v")).alias("dv01_2v"),
284
- (dv01_unit_expr * pl.col("quantidade_aceita_total")).alias("dv01_total"),
271
+ dv01_1v=dv01_unit_expr * pl.col("quantidade_aceita_1v"),
272
+ dv01_2v=dv01_unit_expr * pl.col("quantidade_aceita_2v"),
273
+ dv01_total=dv01_unit_expr * pl.col("quantidade_aceita_total"),
285
274
  ).with_columns(cs.starts_with("dv01").round(2))
286
275
 
287
276
  return df
@@ -301,7 +290,7 @@ def _fetch_ptax_data(auction_date: dt.date) -> pl.DataFrame:
301
290
  return pl.DataFrame()
302
291
 
303
292
  return (
304
- df.select(["Date", "MidRate"])
293
+ df.select("Date", "MidRate")
305
294
  .rename({"Date": "data_ref", "MidRate": "ptax"})
306
295
  .sort("data_ref")
307
296
  )
@@ -346,7 +335,7 @@ def auction(auction_date: DateScalar) -> pl.DataFrame:
346
335
  {...},
347
336
  {
348
337
  "quantidade_bcb": 0,
349
- "liquidacao_segunda_volta": "2025-10-01T00:00:00.000Z",
338
+ "liquidacao_segunda_volta": "30/09/2025",
350
339
  "oferta_segunda_volta": 37499,
351
340
  "data_leilao": "30/09/2025",
352
341
  "oferta": 150000,
@@ -1,4 +1,5 @@
1
1
  import datetime as dt
2
+ import logging
2
3
 
3
4
  import pandas as pd
4
5
  import polars as pl
@@ -20,6 +21,8 @@ COUPON_MONTHS = {2, 5, 8, 11}
20
21
  COUPON_PMT = 2.956301
21
22
  FINAL_PMT = 102.956301
22
23
 
24
+ logger = logging.getLogger(__name__)
25
+
23
26
 
24
27
  def data(date: DateScalar) -> pl.DataFrame:
25
28
  """
@@ -152,7 +155,8 @@ def payment_dates(
152
155
  maturity (DateScalar): The maturity date.
153
156
 
154
157
  Returns:
155
- pl.Series: Series of coupon dates within the specified range.
158
+ pl.Series: Series of coupon dates within the specified range. Returns an empty
159
+ Series if the maturity date is before or equal to the settlement date.
156
160
 
157
161
  Examples:
158
162
  >>> from pyield import ntnb
@@ -171,7 +175,7 @@ def payment_dates(
171
175
  maturity = cv.convert_dates(maturity)
172
176
 
173
177
  if maturity <= settlement:
174
- raise ValueError("Maturity date must be after the settlement date.")
178
+ return pl.Series(dtype=pl.Date)
175
179
 
176
180
  coupon_date = maturity
177
181
  coupon_dates = []
@@ -198,6 +202,7 @@ def cash_flows(
198
202
 
199
203
  Returns:
200
204
  pl.DataFrame: DataFrame with columns "PaymentDate" and "CashFlow".
205
+ Returns empty DataFrame if settlement >= maturity.
201
206
 
202
207
  Returned columns:
203
208
  - PaymentDate: The payment date of the cash flow
@@ -226,6 +231,10 @@ def cash_flows(
226
231
  # Get the coupon dates between the settlement and maturity dates
227
232
  p_dates = payment_dates(settlement, maturity)
228
233
 
234
+ # Return empty DataFrame if no payment dates (settlement >= maturity)
235
+ if p_dates.is_empty():
236
+ return pl.DataFrame(schema={"PaymentDate": pl.Date, "CashFlow": pl.Float64})
237
+
229
238
  df = pl.DataFrame({"PaymentDate": p_dates}).with_columns(
230
239
  pl.when(pl.col("PaymentDate") == maturity)
231
240
  .then(FINAL_PMT)
@@ -251,7 +260,8 @@ def quotation(
251
260
  the cash flows, which is the yield to maturity (YTM) of the NTN-B.
252
261
 
253
262
  Returns:
254
- float: The NTN-B quotation truncated to 4 decimal places.
263
+ float: The NTN-B quotation truncated to 4 decimal places. Returns float('nan')
264
+ if the calculation cannot be performed due to invalid inputs.
255
265
 
256
266
  References:
257
267
  - https://www.anbima.com.br/data/files/A0/02/CC/70/8FEFC8104606BDC8B82BA2A8/Metodologias%20ANBIMA%20de%20Precificacao%20Titulos%20Publicos.pdf
@@ -275,6 +285,9 @@ def quotation(
275
285
  maturity = cv.convert_dates(maturity)
276
286
 
277
287
  cf_df = cash_flows(settlement, maturity)
288
+ if cf_df.is_empty():
289
+ return float("nan")
290
+
278
291
  cf_dates = cf_df["PaymentDate"]
279
292
  cf_values = cf_df["CashFlow"]
280
293
 
@@ -322,6 +335,98 @@ def price(
322
335
  return tl.truncate(vna * quotation / 100, 6)
323
336
 
324
337
 
338
+ def _validate_spot_rate_inputs(
339
+ settlement: DateScalar,
340
+ maturities: DateArray,
341
+ rates: FloatArray,
342
+ ) -> tuple[dt.date, pl.Series, pl.Series]:
343
+ # Process and validate the input data
344
+ settlement = cv.convert_dates(settlement)
345
+ maturities = cv.convert_dates(maturities)
346
+
347
+ # Structural validation: ensure maturities and rates have the same length
348
+ if len(maturities) != len(rates):
349
+ raise ValueError(
350
+ f"Maturities and rates must have the same length. "
351
+ f"Got {len(maturities)} maturities and {len(rates)} rates."
352
+ )
353
+
354
+ # Create base dataframe and filter out invalid data
355
+ df_clean = pl.DataFrame(
356
+ data={"maturities": maturities, "rates": rates},
357
+ schema={"maturities": pl.Date, "rates": pl.Float64},
358
+ ).filter(pl.col("maturities") > settlement)
359
+
360
+ # Warn about filtered maturities
361
+ filtered_count = len(maturities) - df_clean.height
362
+ if filtered_count > 0:
363
+ logger.warning(
364
+ "Maturities on or before settlement were ignored: "
365
+ f"{filtered_count} entries removed."
366
+ )
367
+
368
+ return settlement, df_clean["maturities"], df_clean["rates"]
369
+
370
+
371
+ def _create_bootstrap_dataframe(
372
+ settlement: dt.date,
373
+ rates: pl.Series,
374
+ maturities: pl.Series,
375
+ ) -> pl.DataFrame:
376
+ """Cria o DataFrame base para o bootstrap."""
377
+ # Create the interpolator to calculate the YTM rates for intermediate dates
378
+ flat_fwd = ip.Interpolator(
379
+ method="flat_forward",
380
+ known_bdays=bday.count(settlement, maturities),
381
+ known_rates=rates,
382
+ )
383
+
384
+ # Generate coupon dates up to the longest maturity date
385
+ all_coupon_dates = _generate_all_coupon_dates(settlement, maturities.max())
386
+ bdays_to_mat = bday.count(settlement, all_coupon_dates)
387
+
388
+ df = (
389
+ pl.DataFrame({"MaturityDate": all_coupon_dates, "BDToMat": bdays_to_mat})
390
+ .with_columns(
391
+ BYears=pl.col("BDToMat") / 252,
392
+ YTM=pl.col("BDToMat").map_elements(flat_fwd, return_dtype=pl.Float64),
393
+ Coupon=COUPON_PMT,
394
+ SpotRate=pl.lit(None, dtype=pl.Float64),
395
+ )
396
+ .sort("MaturityDate")
397
+ )
398
+
399
+ return df
400
+
401
+
402
+ def _update_spot_rate(
403
+ df: pl.DataFrame, maturity: dt.date, spot_rate: float
404
+ ) -> pl.DataFrame:
405
+ """Helper function to update the spot rate inside the bootstrap loop."""
406
+ return df.with_columns(
407
+ pl.when(pl.col("MaturityDate") == maturity)
408
+ .then(spot_rate)
409
+ .otherwise(pl.col("SpotRate"))
410
+ .alias("SpotRate")
411
+ )
412
+
413
+
414
+ def _calculate_coupons_present_value(
415
+ df: pl.DataFrame,
416
+ settlement: dt.date,
417
+ maturity: dt.date,
418
+ ) -> float:
419
+ """Calcula o valor presente dos cupons anteriores à maturidade."""
420
+ prev_cf_dates = payment_dates(settlement, maturity).to_list()[:-1]
421
+ df_temp = df.filter(pl.col("MaturityDate").is_in(prev_cf_dates))
422
+
423
+ return tl.calculate_present_value(
424
+ df_temp["Coupon"],
425
+ df_temp["SpotRate"],
426
+ df_temp["BYears"],
427
+ )
428
+
429
+
325
430
  def spot_rates(
326
431
  settlement: DateScalar,
327
432
  maturities: DateArray,
@@ -389,42 +494,12 @@ def spot_rates(
389
494
  """
390
495
  if has_null_args(settlement, maturities, rates):
391
496
  return pl.DataFrame()
392
- # Process and validate the input data
393
- settlement = cv.convert_dates(settlement)
394
- maturities = cv.convert_dates(maturities)
395
497
 
396
- # Create the interpolator to calculate the YTM rates for intermediate dates
397
- flat_fwd = ip.Interpolator(
398
- method="flat_forward",
399
- known_bdays=bday.count(settlement, maturities),
400
- known_rates=rates,
498
+ settlement, maturities, rates = _validate_spot_rate_inputs(
499
+ settlement, maturities, rates
401
500
  )
402
501
 
403
- # Generate coupon dates up to the longest maturity date
404
- all_coupon_dates = _generate_all_coupon_dates(settlement, maturities.max())
405
- bdays_to_mat = bday.count(settlement, all_coupon_dates)
406
-
407
- df = (
408
- pl.DataFrame({"MaturityDate": all_coupon_dates, "BDToMat": bdays_to_mat})
409
- .with_columns(
410
- BYears=pl.col("BDToMat") / 252,
411
- YTM=pl.col("BDToMat").map_elements(flat_fwd, return_dtype=pl.Float64),
412
- Coupon=COUPON_PMT,
413
- SpotRate=pl.lit(None, dtype=pl.Float64),
414
- )
415
- .sort("MaturityDate")
416
- )
417
-
418
- def _update_spot_rate(
419
- df: pl.DataFrame, maturity: dt.date, spot_rate: float
420
- ) -> pl.DataFrame:
421
- """Helper function to update the spot rate inside the bootstrap loop."""
422
- return df.with_columns(
423
- pl.when(pl.col("MaturityDate") == maturity)
424
- .then(spot_rate)
425
- .otherwise(pl.col("SpotRate"))
426
- .alias("SpotRate")
427
- )
502
+ df = _create_bootstrap_dataframe(settlement, rates, maturities)
428
503
 
429
504
  # Bootstrap method to calculate spot rates iteratively
430
505
  df_dicts = df.to_dicts()
@@ -438,18 +513,10 @@ def spot_rates(
438
513
  df = _update_spot_rate(df, maturity, spot_rate)
439
514
  continue
440
515
 
441
- # Get results for all previous cash flows already calculated
442
- prev_cf_dates = payment_dates(settlement, maturity).to_list()[:-1]
443
- df_temp = df.filter(pl.col("MaturityDate").is_in(prev_cf_dates))
444
-
445
516
  # Calculate the spot rate for the current maturity
446
- present_value = tl.calculate_present_value(
447
- df_temp["Coupon"],
448
- df_temp["SpotRate"],
449
- df_temp["BYears"],
450
- )
517
+ coupons_pv = _calculate_coupons_present_value(df, settlement, maturity)
451
518
  bond_price = quotation(settlement, maturity, row["YTM"])
452
- price_factor = FINAL_PMT / (bond_price - present_value)
519
+ price_factor = FINAL_PMT / (bond_price - coupons_pv)
453
520
  spot_rate = price_factor ** (1 / row["BYears"]) - 1
454
521
 
455
522
  df = _update_spot_rate(df, maturity, spot_rate)
@@ -81,7 +81,8 @@ def payment_dates(
81
81
  maturity (DateScalar): The maturity date.
82
82
 
83
83
  Returns:
84
- pl.Series: Series of coupon dates within the specified range.
84
+ pl.Series: Series of coupon dates within the specified range. Returns an
85
+ empty Series if the maturity date is before the settlement date.
85
86
 
86
87
  Examples:
87
88
  >>> from pyield import ntnc
@@ -110,7 +111,7 @@ def payment_dates(
110
111
 
111
112
  # Check if maturity date is after the start date
112
113
  if maturity < settlement:
113
- raise ValueError("Maturity date must be after the start date.")
114
+ return pl.Series(dtype=pl.Date)
114
115
 
115
116
  # Initialize loop variables
116
117
  coupon_date = maturity
@@ -176,6 +177,10 @@ def cash_flows(
176
177
  # Get the coupon dates between the settlement and maturity dates
177
178
  pay_dates = payment_dates(settlement, maturity)
178
179
 
180
+ # Return empty DataFrame if no payment dates (settlement >= maturity)
181
+ if pay_dates.is_empty():
182
+ return pl.DataFrame(schema={"PaymentDate": pl.Date, "CashFlow": pl.Float64})
183
+
179
184
  # Get the right coupon payment and final payment values
180
185
  coupon_pmt = _get_coupon_pmt(maturity)
181
186
  final_pmt = _get_final_pmt(maturity)
@@ -100,7 +100,8 @@ def payment_dates(
100
100
 
101
101
  Returns:
102
102
  pl.Series: A Series containing the coupon dates between the settlement
103
- (exclusive) and maturity (inclusive) dates.
103
+ (exclusive) and maturity (inclusive) dates. Returns an empty Series if
104
+ the maturity date is before or equal to the settlement date.
104
105
 
105
106
  Examples:
106
107
  >>> from pyield import ntnf
@@ -124,7 +125,7 @@ def payment_dates(
124
125
 
125
126
  # Check if maturity date is after the start date
126
127
  if maturity <= settlement:
127
- raise ValueError("Maturity date must be after the settlement date.")
128
+ return pl.Series(dtype=pl.Date)
128
129
 
129
130
  # Initialize loop variables
130
131
  coupon_date = maturity
@@ -185,6 +186,10 @@ def cash_flows(
185
186
  # Get the payment dates between the settlement and maturity dates
186
187
  pay_dates = payment_dates(settlement, maturity)
187
188
 
189
+ # Return empty DataFrame if no payment dates (settlement >= maturity)
190
+ if pay_dates.is_empty():
191
+ return pl.DataFrame(schema={"PaymentDate": pl.Date, "CashFlow": pl.Float64})
192
+
188
193
  # Set the cash flow at maturity to FINAL_PMT and the others to COUPON_PMT
189
194
  df = pl.DataFrame(data={"PaymentDate": pay_dates}).with_columns(
190
195
  pl.when(pl.col("PaymentDate") == maturity)
@@ -230,7 +235,11 @@ def price(
230
235
  """
231
236
  if has_null_args(settlement, maturity, rate):
232
237
  return float("nan")
238
+
233
239
  cf_df = cash_flows(settlement, maturity)
240
+ if cf_df.is_empty():
241
+ return float("nan")
242
+
234
243
  cf_values = cf_df["CashFlow"]
235
244
  bdays = bday.count(settlement, cf_df["PaymentDate"])
236
245
  byears = tools.truncate(bdays / 252, 14)
@@ -553,7 +562,7 @@ def premium( # noqa
553
562
 
554
563
  Returns:
555
564
  float: The premium of the NTN-F bond over the DI curve, expressed as a
556
- factor.
565
+ factor. If calculation fails, returns NaN.
557
566
 
558
567
  Examples:
559
568
  >>> # Obs: only some of the DI rates will be used in the example.
@@ -584,6 +593,9 @@ def premium( # noqa
584
593
  di_rates = pl.Series(di_rates)
585
594
 
586
595
  df_cf = cash_flows(settlement, ntnf_maturity, adj_payment_dates=True)
596
+ if df_cf.is_empty():
597
+ return float("nan")
598
+
587
599
  ff_interpolator = ip.Interpolator(
588
600
  "flat_forward",
589
601
  bday.count(settlement, di_expirations),
@@ -647,11 +659,10 @@ def di_net_spread( # noqa
647
659
  ntnf_rate (float): The yield to maturity (YTM) of the bond.
648
660
  di_rates (FloatArray): A Series of DI rates.
649
661
  di_expirations (DateArray): A list or Series of DI expiration dates.
650
- initial_guess (float, optional): An initial guess for the spread. Defaults to
651
- None. A good initial guess is the DI gross spread for the bond.
652
662
 
653
663
  Returns:
654
664
  float: The net DI spread in decimal format (e.g., 0.0012 for 12 bps).
665
+ If calculation fails, returns NaN.
655
666
 
656
667
  Examples:
657
668
  # Obs: only some of the DI rates will be used in the example.
@@ -680,7 +691,7 @@ def di_net_spread( # noqa
680
691
 
681
692
  # 2. Validação dos inputs de DI
682
693
  if len(di_rates) != len(di_expirations):
683
- raise ValueError("di_rates and di_expirations must have the same length.")
694
+ return float("nan")
684
695
 
685
696
  # 3. Criação do interpolador
686
697
  ff_interpolator = ip.Interpolator(
@@ -691,6 +702,8 @@ def di_net_spread( # noqa
691
702
 
692
703
  # 4. Geração dos fluxos de caixa do NTN-F
693
704
  df = cash_flows(settlement, ntnf_maturity)
705
+ if df.is_empty():
706
+ return float("nan")
694
707
 
695
708
  bdays_to_payment = bday.count(settlement, df["PaymentDate"])
696
709
  byears_to_payment = bdays_to_payment / 252
@@ -732,7 +745,8 @@ def duration(
732
745
  rate (float): The yield to maturity (YTM) used to discount the cash flows.
733
746
 
734
747
  Returns:
735
- float: The Macaulay duration in business business years.
748
+ float: The Macaulay duration in business business years. Returns NaN if
749
+ calculation is not possible due to invalid inputs.
736
750
 
737
751
  Examples:
738
752
  >>> from pyield import ntnf
@@ -746,6 +760,9 @@ def duration(
746
760
  maturity = cv.convert_dates(maturity)
747
761
 
748
762
  df = cash_flows(settlement, maturity)
763
+ if df.is_empty():
764
+ return float("nan")
765
+
749
766
  byears = bday.count(settlement, df["PaymentDate"]) / 252
750
767
  dcf = df["CashFlow"] / (1 + rate) ** byears
751
768
  duration = (dcf * byears).sum() / dcf.sum()
@@ -1 +0,0 @@
1
- __version__ = "0.40.2"
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
File without changes
File without changes
File without changes