agrobr 0.1.2__py3-none-any.whl → 0.5.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.
agrobr/__init__.py CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
6
6
  __author__ = "Bruno"
7
7
 
8
8
  from agrobr import cepea, conab, ibge
9
+ from agrobr.models import MetaInfo
9
10
 
10
- __all__ = ["cepea", "conab", "ibge", "__version__"]
11
+ __all__ = ["cepea", "conab", "ibge", "MetaInfo", "__version__"]
@@ -0,0 +1,343 @@
1
+ """Benchmark suite para testes de performance do agrobr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import statistics
6
+ import time
7
+ from collections.abc import Callable, Coroutine
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from typing import Any
11
+
12
+ import structlog
13
+
14
+ logger = structlog.get_logger()
15
+
16
+
17
+ @dataclass
18
+ class BenchmarkResult:
19
+ """Resultado de um benchmark."""
20
+
21
+ name: str
22
+ iterations: int
23
+ total_time_ms: float
24
+ mean_time_ms: float
25
+ median_time_ms: float
26
+ min_time_ms: float
27
+ max_time_ms: float
28
+ std_dev_ms: float
29
+ times_ms: list[float] = field(default_factory=list)
30
+ timestamp: datetime = field(default_factory=datetime.now)
31
+ metadata: dict[str, Any] = field(default_factory=dict)
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ """Converte para dicionario."""
35
+ return {
36
+ "name": self.name,
37
+ "iterations": self.iterations,
38
+ "total_time_ms": round(self.total_time_ms, 2),
39
+ "mean_time_ms": round(self.mean_time_ms, 2),
40
+ "median_time_ms": round(self.median_time_ms, 2),
41
+ "min_time_ms": round(self.min_time_ms, 2),
42
+ "max_time_ms": round(self.max_time_ms, 2),
43
+ "std_dev_ms": round(self.std_dev_ms, 2),
44
+ "timestamp": self.timestamp.isoformat(),
45
+ "metadata": self.metadata,
46
+ }
47
+
48
+ def summary(self) -> str:
49
+ """Retorna resumo formatado."""
50
+ return (
51
+ f"{self.name}: "
52
+ f"mean={self.mean_time_ms:.2f}ms, "
53
+ f"median={self.median_time_ms:.2f}ms, "
54
+ f"min={self.min_time_ms:.2f}ms, "
55
+ f"max={self.max_time_ms:.2f}ms "
56
+ f"({self.iterations} iterations)"
57
+ )
58
+
59
+
60
+ @dataclass
61
+ class BenchmarkSuite:
62
+ """Suite de benchmarks."""
63
+
64
+ name: str
65
+ results: list[BenchmarkResult] = field(default_factory=list)
66
+ timestamp: datetime = field(default_factory=datetime.now)
67
+
68
+ def add_result(self, result: BenchmarkResult) -> None:
69
+ """Adiciona resultado."""
70
+ self.results.append(result)
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ """Converte para dicionario."""
74
+ return {
75
+ "name": self.name,
76
+ "timestamp": self.timestamp.isoformat(),
77
+ "results": [r.to_dict() for r in self.results],
78
+ }
79
+
80
+ def summary(self) -> str:
81
+ """Retorna resumo formatado."""
82
+ lines = [f"Benchmark Suite: {self.name}", "=" * 50]
83
+ for result in self.results:
84
+ lines.append(result.summary())
85
+ return "\n".join(lines)
86
+
87
+
88
+ async def benchmark_async(
89
+ name: str,
90
+ func: Callable[..., Coroutine[Any, Any, Any]],
91
+ iterations: int = 10,
92
+ warmup: int = 1,
93
+ **kwargs: Any,
94
+ ) -> BenchmarkResult:
95
+ """
96
+ Executa benchmark de funcao async.
97
+
98
+ Args:
99
+ name: Nome do benchmark
100
+ func: Funcao async a testar
101
+ iterations: Numero de iteracoes
102
+ warmup: Iteracoes de aquecimento
103
+ **kwargs: Argumentos para a funcao
104
+
105
+ Returns:
106
+ BenchmarkResult com estatisticas
107
+ """
108
+ for _ in range(warmup):
109
+ await func(**kwargs)
110
+
111
+ times: list[float] = []
112
+ for _ in range(iterations):
113
+ start = time.perf_counter()
114
+ await func(**kwargs)
115
+ elapsed = (time.perf_counter() - start) * 1000
116
+ times.append(elapsed)
117
+
118
+ return BenchmarkResult(
119
+ name=name,
120
+ iterations=iterations,
121
+ total_time_ms=sum(times),
122
+ mean_time_ms=statistics.mean(times),
123
+ median_time_ms=statistics.median(times),
124
+ min_time_ms=min(times),
125
+ max_time_ms=max(times),
126
+ std_dev_ms=statistics.stdev(times) if len(times) > 1 else 0,
127
+ times_ms=times,
128
+ metadata={"warmup": warmup, "kwargs": str(kwargs)},
129
+ )
130
+
131
+
132
+ def benchmark_sync(
133
+ name: str,
134
+ func: Callable[..., Any],
135
+ iterations: int = 10,
136
+ warmup: int = 1,
137
+ **kwargs: Any,
138
+ ) -> BenchmarkResult:
139
+ """
140
+ Executa benchmark de funcao sincrona.
141
+
142
+ Args:
143
+ name: Nome do benchmark
144
+ func: Funcao a testar
145
+ iterations: Numero de iteracoes
146
+ warmup: Iteracoes de aquecimento
147
+ **kwargs: Argumentos para a funcao
148
+
149
+ Returns:
150
+ BenchmarkResult com estatisticas
151
+ """
152
+ for _ in range(warmup):
153
+ func(**kwargs)
154
+
155
+ times: list[float] = []
156
+ for _ in range(iterations):
157
+ start = time.perf_counter()
158
+ func(**kwargs)
159
+ elapsed = (time.perf_counter() - start) * 1000
160
+ times.append(elapsed)
161
+
162
+ return BenchmarkResult(
163
+ name=name,
164
+ iterations=iterations,
165
+ total_time_ms=sum(times),
166
+ mean_time_ms=statistics.mean(times),
167
+ median_time_ms=statistics.median(times),
168
+ min_time_ms=min(times),
169
+ max_time_ms=max(times),
170
+ std_dev_ms=statistics.stdev(times) if len(times) > 1 else 0,
171
+ times_ms=times,
172
+ metadata={"warmup": warmup, "kwargs": str(kwargs)},
173
+ )
174
+
175
+
176
+ async def run_api_benchmarks(iterations: int = 5) -> BenchmarkSuite:
177
+ """
178
+ Executa benchmarks das APIs principais.
179
+
180
+ Args:
181
+ iterations: Numero de iteracoes por benchmark
182
+
183
+ Returns:
184
+ BenchmarkSuite com resultados
185
+ """
186
+ from agrobr import cepea, conab, ibge
187
+
188
+ suite = BenchmarkSuite(name="agrobr_api_benchmarks")
189
+
190
+ try:
191
+ result = await benchmark_async(
192
+ "cepea.indicador(soja, offline=True)",
193
+ cepea.indicador,
194
+ iterations=iterations,
195
+ produto="soja",
196
+ offline=True,
197
+ )
198
+ suite.add_result(result)
199
+ except Exception as e:
200
+ logger.warning("benchmark_failed", name="cepea.indicador", error=str(e))
201
+
202
+ try:
203
+ result = await benchmark_async(
204
+ "cepea.produtos()",
205
+ cepea.produtos,
206
+ iterations=iterations,
207
+ )
208
+ suite.add_result(result)
209
+ except Exception as e:
210
+ logger.warning("benchmark_failed", name="cepea.produtos", error=str(e))
211
+
212
+ try:
213
+ result = await benchmark_async(
214
+ "conab.produtos()",
215
+ conab.produtos,
216
+ iterations=iterations,
217
+ )
218
+ suite.add_result(result)
219
+ except Exception as e:
220
+ logger.warning("benchmark_failed", name="conab.produtos", error=str(e))
221
+
222
+ try:
223
+ result = await benchmark_async(
224
+ "ibge.produtos_pam()",
225
+ ibge.produtos_pam,
226
+ iterations=iterations,
227
+ )
228
+ suite.add_result(result)
229
+ except Exception as e:
230
+ logger.warning("benchmark_failed", name="ibge.produtos_pam", error=str(e))
231
+
232
+ return suite
233
+
234
+
235
+ def run_contract_benchmarks(iterations: int = 100) -> BenchmarkSuite:
236
+ """
237
+ Executa benchmarks de validacao de contratos.
238
+
239
+ Args:
240
+ iterations: Numero de iteracoes por benchmark
241
+
242
+ Returns:
243
+ BenchmarkSuite com resultados
244
+ """
245
+ import pandas as pd
246
+
247
+ from agrobr.contracts.cepea import CEPEA_INDICADOR_V1
248
+
249
+ suite = BenchmarkSuite(name="contract_validation_benchmarks")
250
+
251
+ df_small = pd.DataFrame(
252
+ {
253
+ "data": pd.date_range("2024-01-01", periods=10),
254
+ "produto": ["soja"] * 10,
255
+ "praca": ["paranagua"] * 10,
256
+ "valor": [150.0] * 10,
257
+ "unidade": ["BRL/sc60kg"] * 10,
258
+ "fonte": ["cepea"] * 10,
259
+ "metodologia": [None] * 10,
260
+ "anomalies": [None] * 10,
261
+ }
262
+ )
263
+
264
+ result = benchmark_sync(
265
+ "contract.validate(10 rows)",
266
+ CEPEA_INDICADOR_V1.validate,
267
+ iterations=iterations,
268
+ df=df_small,
269
+ )
270
+ suite.add_result(result)
271
+
272
+ df_large = pd.DataFrame(
273
+ {
274
+ "data": pd.date_range("2020-01-01", periods=1000),
275
+ "produto": ["soja"] * 1000,
276
+ "praca": ["paranagua"] * 1000,
277
+ "valor": [150.0] * 1000,
278
+ "unidade": ["BRL/sc60kg"] * 1000,
279
+ "fonte": ["cepea"] * 1000,
280
+ "metodologia": [None] * 1000,
281
+ "anomalies": [None] * 1000,
282
+ }
283
+ )
284
+
285
+ result = benchmark_sync(
286
+ "contract.validate(1000 rows)",
287
+ CEPEA_INDICADOR_V1.validate,
288
+ iterations=iterations,
289
+ df=df_large,
290
+ )
291
+ suite.add_result(result)
292
+
293
+ return suite
294
+
295
+
296
+ def run_semantic_benchmarks(iterations: int = 50) -> BenchmarkSuite:
297
+ """
298
+ Executa benchmarks de validacao semantica.
299
+
300
+ Args:
301
+ iterations: Numero de iteracoes por benchmark
302
+
303
+ Returns:
304
+ BenchmarkSuite com resultados
305
+ """
306
+ import pandas as pd
307
+
308
+ from agrobr.validators.semantic import validate_semantic
309
+
310
+ suite = BenchmarkSuite(name="semantic_validation_benchmarks")
311
+
312
+ df = pd.DataFrame(
313
+ {
314
+ "data": pd.date_range("2024-01-01", periods=100),
315
+ "valor": [150.0 + i * 0.5 for i in range(100)],
316
+ "produto": ["soja"] * 100,
317
+ "produtividade": [3500.0] * 100,
318
+ "area_plantada": [1000.0] * 100,
319
+ "area_colhida": [950.0] * 100,
320
+ "safra": ["2024/25"] * 100,
321
+ }
322
+ )
323
+
324
+ result = benchmark_sync(
325
+ "validate_semantic(100 rows)",
326
+ validate_semantic,
327
+ iterations=iterations,
328
+ df=df,
329
+ )
330
+ suite.add_result(result)
331
+
332
+ return suite
333
+
334
+
335
+ __all__ = [
336
+ "BenchmarkResult",
337
+ "BenchmarkSuite",
338
+ "benchmark_async",
339
+ "benchmark_sync",
340
+ "run_api_benchmarks",
341
+ "run_contract_benchmarks",
342
+ "run_semantic_benchmarks",
343
+ ]
agrobr/cache/policies.py CHANGED
@@ -13,7 +13,7 @@ class CachePolicy(NamedTuple):
13
13
  ttl_seconds: int
14
14
  stale_max_seconds: int
15
15
  description: str
16
- smart_expiry: bool = False # Se True, usa horário fixo de expiração
16
+ smart_expiry: bool = False
17
17
 
18
18
 
19
19
  class TTL(Enum):
@@ -30,14 +30,13 @@ class TTL(Enum):
30
30
  DAYS_90 = 90 * 24 * 60 * 60
31
31
 
32
32
 
33
- # Horário de atualização do CEPEA (18:00 Brasília)
34
33
  CEPEA_UPDATE_HOUR = 18
35
34
  CEPEA_UPDATE_MINUTE = 0
36
35
 
37
36
 
38
37
  POLICIES: dict[str, CachePolicy] = {
39
38
  "cepea_diario": CachePolicy(
40
- ttl_seconds=TTL.HOURS_24.value, # Fallback se smart_expiry falhar
39
+ ttl_seconds=TTL.HOURS_24.value,
41
40
  stale_max_seconds=TTL.HOURS_24.value * 2,
42
41
  description="CEPEA indicador diário (expira às 18h)",
43
42
  smart_expiry=True,
@@ -73,7 +72,7 @@ POLICIES: dict[str, CachePolicy] = {
73
72
  smart_expiry=False,
74
73
  ),
75
74
  "noticias_agricolas": CachePolicy(
76
- ttl_seconds=TTL.HOURS_24.value, # Fallback
75
+ ttl_seconds=TTL.HOURS_24.value,
77
76
  stale_max_seconds=TTL.HOURS_24.value * 2,
78
77
  description="Notícias Agrícolas (expira às 18h, mirror CEPEA)",
79
78
  smart_expiry=True,
@@ -129,10 +128,8 @@ def _get_smart_expiry_time() -> datetime:
129
128
  today_expiry = datetime.combine(now.date(), time(CEPEA_UPDATE_HOUR, CEPEA_UPDATE_MINUTE))
130
129
 
131
130
  if now < today_expiry:
132
- # Ainda não chegou às 18h hoje → expira hoje às 18h
133
131
  return today_expiry
134
132
  else:
135
- # Já passou das 18h → expira amanhã às 18h
136
133
  return today_expiry + timedelta(days=1)
137
134
 
138
135
 
@@ -192,11 +189,9 @@ def is_expired(created_at: datetime, source: Fonte | str, endpoint: str | None =
192
189
  policy = get_policy(source, endpoint)
193
190
 
194
191
  if policy.smart_expiry:
195
- # Smart expiry: cache válido se criado após última expiração (18h)
196
192
  last_expiry = _get_last_expiry_time()
197
193
  return created_at < last_expiry
198
194
 
199
- # TTL fixo tradicional
200
195
  expires_at = created_at + timedelta(seconds=policy.ttl_seconds)
201
196
  return datetime.now() > expires_at
202
197
 
agrobr/cepea/api.py CHANGED
@@ -2,18 +2,21 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
6
+ import time
5
7
  from datetime import date, datetime, timedelta
6
8
  from decimal import Decimal
7
- from typing import TYPE_CHECKING, Any
9
+ from typing import TYPE_CHECKING, Any, Literal, overload
8
10
 
9
11
  import pandas as pd
10
12
  import structlog
11
13
 
12
14
  from agrobr import constants
13
15
  from agrobr.cache.duckdb_store import get_store
16
+ from agrobr.cache.policies import calculate_expiry
14
17
  from agrobr.cepea import client
15
18
  from agrobr.cepea.parsers.detector import get_parser_with_fallback
16
- from agrobr.models import Indicador
19
+ from agrobr.models import Indicador, MetaInfo
17
20
  from agrobr.validators.sanity import validate_batch
18
21
 
19
22
  if TYPE_CHECKING:
@@ -21,10 +24,10 @@ if TYPE_CHECKING:
21
24
 
22
25
  logger = structlog.get_logger()
23
26
 
24
- # Janela de dados disponível na fonte (Notícias Agrícolas tem ~10 dias)
25
27
  SOURCE_WINDOW_DAYS = 10
26
28
 
27
29
 
30
+ @overload
28
31
  async def indicador(
29
32
  produto: str,
30
33
  praca: str | None = None,
@@ -35,7 +38,39 @@ async def indicador(
35
38
  validate_sanity: bool = False,
36
39
  force_refresh: bool = False,
37
40
  offline: bool = False,
38
- ) -> pd.DataFrame | pl.DataFrame:
41
+ *,
42
+ return_meta: Literal[False] = False,
43
+ ) -> pd.DataFrame | pl.DataFrame: ...
44
+
45
+
46
+ @overload
47
+ async def indicador(
48
+ produto: str,
49
+ praca: str | None = None,
50
+ inicio: str | date | None = None,
51
+ fim: str | date | None = None,
52
+ _moeda: str = "BRL",
53
+ as_polars: bool = False,
54
+ validate_sanity: bool = False,
55
+ force_refresh: bool = False,
56
+ offline: bool = False,
57
+ *,
58
+ return_meta: Literal[True],
59
+ ) -> tuple[pd.DataFrame | pl.DataFrame, MetaInfo]: ...
60
+
61
+
62
+ async def indicador(
63
+ produto: str,
64
+ praca: str | None = None,
65
+ inicio: str | date | None = None,
66
+ fim: str | date | None = None,
67
+ _moeda: str = "BRL",
68
+ as_polars: bool = False,
69
+ validate_sanity: bool = False,
70
+ force_refresh: bool = False,
71
+ offline: bool = False,
72
+ return_meta: bool = False,
73
+ ) -> pd.DataFrame | pl.DataFrame | tuple[pd.DataFrame | pl.DataFrame, MetaInfo]:
39
74
  """
40
75
  Obtém série de indicadores de preço do CEPEA.
41
76
 
@@ -55,17 +90,23 @@ async def indicador(
55
90
  validate_sanity: Se True, valida dados estatisticamente
56
91
  force_refresh: Se True, ignora histórico e busca da fonte
57
92
  offline: Se True, usa apenas histórico local
93
+ return_meta: Se True, retorna tupla (DataFrame, MetaInfo)
58
94
 
59
95
  Returns:
60
- DataFrame com indicadores
96
+ DataFrame com indicadores ou tupla (DataFrame, MetaInfo) se return_meta=True
61
97
  """
62
- # Normaliza datas
98
+ fetch_start = time.perf_counter()
99
+ meta = MetaInfo(
100
+ source="unknown",
101
+ source_url="",
102
+ source_method="unknown",
103
+ fetched_at=datetime.now(),
104
+ )
63
105
  if isinstance(inicio, str):
64
106
  inicio = datetime.strptime(inicio, "%Y-%m-%d").date()
65
107
  if isinstance(fim, str):
66
108
  fim = datetime.strptime(fim, "%Y-%m-%d").date()
67
109
 
68
- # Default: último ano
69
110
  if fim is None:
70
111
  fim = date.today()
71
112
  if inicio is None:
@@ -74,7 +115,9 @@ async def indicador(
74
115
  store = get_store()
75
116
  indicadores: list[Indicador] = []
76
117
 
77
- # 1. Busca no histórico local
118
+ source_url = ""
119
+ parser_version = 1
120
+
78
121
  if not force_refresh:
79
122
  cached_data = store.indicadores_query(
80
123
  produto=produto,
@@ -85,6 +128,11 @@ async def indicador(
85
128
 
86
129
  indicadores = _dicts_to_indicadores(cached_data)
87
130
 
131
+ if indicadores:
132
+ meta.from_cache = True
133
+ meta.source = "cache"
134
+ meta.source_method = "duckdb"
135
+
88
136
  logger.info(
89
137
  "history_query",
90
138
  produto=produto,
@@ -93,54 +141,61 @@ async def indicador(
93
141
  cached_count=len(indicadores),
94
142
  )
95
143
 
96
- # 2. Verifica se precisa buscar dados recentes
97
144
  needs_fetch = False
98
145
  if not offline:
99
146
  if force_refresh:
100
147
  needs_fetch = True
101
148
  else:
102
- # Verifica se faltam dados na janela recente
103
149
  today = date.today()
104
150
  recent_start = today - timedelta(days=SOURCE_WINDOW_DAYS)
105
151
 
106
- # Se o período solicitado inclui dados recentes
107
152
  if fim >= recent_start:
108
153
  existing_dates = {ind.data for ind in indicadores}
109
- # Verifica se tem lacunas nos últimos dias
110
154
  for i in range(min(SOURCE_WINDOW_DAYS, (fim - max(inicio, recent_start)).days + 1)):
111
155
  check_date = fim - timedelta(days=i)
112
- # Pula fins de semana (CEPEA não publica)
113
156
  if check_date.weekday() < 5 and check_date not in existing_dates:
114
157
  needs_fetch = True
115
158
  break
116
159
 
117
- # 3. Busca da fonte se necessário
118
160
  if needs_fetch:
119
161
  logger.info("fetching_from_source", produto=produto)
120
162
 
121
163
  try:
122
- # Tenta buscar - pode vir do CEPEA ou Notícias Agrícolas
164
+ parse_start = time.perf_counter()
123
165
  html = await client.fetch_indicador_page(produto)
166
+ raw_content_size = len(html.encode("utf-8"))
167
+ raw_content_hash = f"sha256:{hashlib.sha256(html.encode('utf-8')).hexdigest()[:16]}"
124
168
 
125
- # Detecta fonte pelo conteúdo do HTML
126
169
  is_noticias_agricolas = "noticiasagricolas" in html.lower() or "cot-fisicas" in html
127
170
 
128
171
  if is_noticias_agricolas:
129
- # Usa parser de Notícias Agrícolas
130
172
  from agrobr.noticias_agricolas.parser import parse_indicador as na_parse
131
173
 
132
174
  new_indicadores = na_parse(html, produto)
175
+ source_url = f"https://www.noticiasagricolas.com.br/cotacoes/{produto}"
176
+ meta.source = "noticias_agricolas"
177
+ meta.source_method = "httpx"
133
178
  logger.info(
134
179
  "parse_success",
135
180
  source="noticias_agricolas",
136
181
  records_count=len(new_indicadores),
137
182
  )
138
183
  else:
139
- # Usa parser CEPEA
140
184
  parser, new_indicadores = await get_parser_with_fallback(html, produto)
185
+ source_url = f"https://www.cepea.esalq.usp.br/br/indicador/{produto}.aspx"
186
+ meta.source = "cepea"
187
+ meta.source_method = "httpx"
188
+ parser_version = parser.version
189
+
190
+ parse_duration_ms = int((time.perf_counter() - parse_start) * 1000)
191
+ meta.parse_duration_ms = parse_duration_ms
192
+ meta.source_url = source_url
193
+ meta.raw_content_hash = raw_content_hash
194
+ meta.raw_content_size = raw_content_size
195
+ meta.parser_version = parser_version
196
+ meta.from_cache = False
141
197
 
142
198
  if new_indicadores:
143
- # 4. Salva novos dados no histórico
144
199
  new_dicts = _indicadores_to_dicts(new_indicadores)
145
200
  saved_count = store.indicadores_upsert(new_dicts)
146
201
 
@@ -151,7 +206,6 @@ async def indicador(
151
206
  saved=saved_count,
152
207
  )
153
208
 
154
- # Merge com dados existentes
155
209
  existing_dates = {ind.data for ind in indicadores}
156
210
  for ind in new_indicadores:
157
211
  if ind.data not in existing_dates:
@@ -163,13 +217,11 @@ async def indicador(
163
217
  produto=produto,
164
218
  error=str(e),
165
219
  )
166
- # Continua com dados do histórico
220
+ meta.validation_warnings.append(f"source_fetch_failed: {e}")
167
221
 
168
- # 5. Validação estatística
169
222
  if validate_sanity and indicadores:
170
223
  indicadores, anomalies = await validate_batch(indicadores)
171
224
 
172
- # 6. Filtra por período e praça
173
225
  indicadores = [ind for ind in indicadores if inicio <= ind.data <= fim]
174
226
 
175
227
  if praca:
@@ -177,17 +229,27 @@ async def indicador(
177
229
  ind for ind in indicadores if ind.praca and ind.praca.lower() == praca.lower()
178
230
  ]
179
231
 
180
- # 7. Converte para DataFrame
181
232
  df = _to_dataframe(indicadores)
182
233
 
234
+ meta.fetch_duration_ms = int((time.perf_counter() - fetch_start) * 1000)
235
+ meta.records_count = len(df)
236
+ meta.columns = df.columns.tolist() if not df.empty else []
237
+ meta.cache_key = f"cepea:{produto}:{praca or 'all'}"
238
+ meta.cache_expires_at = calculate_expiry(constants.Fonte.CEPEA)
239
+
183
240
  if as_polars:
184
241
  try:
185
242
  import polars as pl
186
243
 
187
- return pl.from_pandas(df)
244
+ result_df = pl.from_pandas(df)
245
+ if return_meta:
246
+ return result_df, meta
247
+ return result_df
188
248
  except ImportError:
189
249
  logger.warning("polars_not_installed", fallback="pandas")
190
250
 
251
+ if return_meta:
252
+ return df, meta
191
253
  return df
192
254
 
193
255
 
@@ -270,7 +332,6 @@ async def ultimo(produto: str, praca: str | None = None, offline: bool = False)
270
332
  store = get_store()
271
333
  indicadores: list[Indicador] = []
272
334
 
273
- # Busca no histórico (últimos 30 dias)
274
335
  fim = date.today()
275
336
  inicio = fim - timedelta(days=30)
276
337
 
@@ -284,7 +345,6 @@ async def ultimo(produto: str, praca: str | None = None, offline: bool = False)
284
345
  if cached_data:
285
346
  indicadores = _dicts_to_indicadores(cached_data)
286
347
 
287
- # Se não tem dados recentes ou não está offline, busca da fonte
288
348
  if not offline:
289
349
  has_recent = any(ind.data >= fim - timedelta(days=3) for ind in indicadores)
290
350
 
@@ -292,7 +352,6 @@ async def ultimo(produto: str, praca: str | None = None, offline: bool = False)
292
352
  try:
293
353
  html = await client.fetch_indicador_page(produto)
294
354
 
295
- # Detecta fonte pelo conteúdo do HTML
296
355
  is_noticias_agricolas = "noticiasagricolas" in html.lower() or "cot-fisicas" in html
297
356
 
298
357
  if is_noticias_agricolas:
@@ -303,11 +362,9 @@ async def ultimo(produto: str, praca: str | None = None, offline: bool = False)
303
362
  parser, new_indicadores = await get_parser_with_fallback(html, produto)
304
363
 
305
364
  if new_indicadores:
306
- # Salva no histórico
307
365
  new_dicts = _indicadores_to_dicts(new_indicadores)
308
366
  store.indicadores_upsert(new_dicts)
309
367
 
310
- # Merge
311
368
  existing_dates = {ind.data for ind in indicadores}
312
369
  for ind in new_indicadores:
313
370
  if ind.data not in existing_dates: