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 +3 -2
- agrobr/benchmark/__init__.py +343 -0
- agrobr/cache/policies.py +3 -8
- agrobr/cepea/api.py +87 -30
- agrobr/cepea/client.py +0 -7
- agrobr/cli.py +141 -5
- agrobr/conab/api.py +72 -6
- agrobr/config.py +137 -0
- agrobr/constants.py +1 -2
- agrobr/contracts/__init__.py +186 -0
- agrobr/contracts/cepea.py +80 -0
- agrobr/contracts/conab.py +181 -0
- agrobr/contracts/ibge.py +146 -0
- agrobr/export.py +251 -0
- agrobr/health/__init__.py +10 -0
- agrobr/health/doctor.py +321 -0
- agrobr/http/browser.py +0 -9
- agrobr/ibge/api.py +104 -25
- agrobr/ibge/client.py +5 -20
- agrobr/models.py +100 -1
- agrobr/noticias_agricolas/client.py +0 -7
- agrobr/noticias_agricolas/parser.py +0 -17
- agrobr/plugins/__init__.py +205 -0
- agrobr/quality.py +319 -0
- agrobr/sla.py +249 -0
- agrobr/snapshots.py +321 -0
- agrobr/stability.py +148 -0
- agrobr/validators/semantic.py +447 -0
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/METADATA +12 -12
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/RECORD +33 -19
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/WHEEL +0 -0
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/entry_points.txt +0 -0
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/licenses/LICENSE +0 -0
agrobr/__init__.py
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__version__ = "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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|