notify-utils 0.0.1__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.
@@ -0,0 +1,108 @@
1
+ """
2
+ Formatadores para exibição de preços e históricos.
3
+ """
4
+
5
+ from typing import List
6
+ from ..models import Price
7
+
8
+
9
+ def format_price(value: float) -> str:
10
+ """
11
+ Formata preço para exibição.
12
+
13
+ Regras:
14
+ - Se >= R$ 1: sem centavos (ex: "R$ 1.299")
15
+ - Se < R$ 1: com centavos (ex: "R$ 0,99")
16
+ - Usa separador de milhar (ponto) e decimal (vírgula) padrão BR
17
+
18
+ Args:
19
+ value: Valor do preço
20
+
21
+ Returns:
22
+ String formatada
23
+
24
+ Examples:
25
+ >>> format_price(1299.90)
26
+ 'R$ 1.299'
27
+ >>> format_price(899.50)
28
+ 'R$ 899'
29
+ >>> format_price(0.99)
30
+ 'R$ 0,99'
31
+ >>> format_price(0.50)
32
+ 'R$ 0,50'
33
+ """
34
+ if value < 1.0:
35
+ # Produtos baratos: mostra centavos
36
+ return f"R$ {value:.2f}".replace('.', ',')
37
+ else:
38
+ # Produtos >= R$ 1: sem centavos, com separador de milhar
39
+ valor_inteiro = int(round(value))
40
+
41
+ # Formata com separador de milhar
42
+ valor_str = f"{valor_inteiro:,}".replace(',', '.')
43
+
44
+ return f"R$ {valor_str}"
45
+
46
+
47
+ def get_unique_prices(prices: List[Price], limit: int = 5) -> List[float]:
48
+ """
49
+ Extrai preços únicos de uma lista, ordenados do menor para o maior.
50
+
51
+ Args:
52
+ prices: Lista de objetos Price
53
+ limit: Quantidade máxima de preços a retornar
54
+
55
+ Returns:
56
+ Lista de valores únicos ordenados (menor para maior)
57
+ """
58
+ if not prices:
59
+ return []
60
+
61
+ # Extrai valores e remove duplicatas usando set
62
+ unique_values = list(set(p.value for p in prices))
63
+
64
+ # Ordena do menor para o maior
65
+ unique_values.sort()
66
+
67
+ # Limita quantidade
68
+ return unique_values[:limit]
69
+
70
+
71
+ def format_price_history(prices: List[Price], limit: int = 5) -> str:
72
+ """
73
+ Formata histórico de preços para exibição.
74
+
75
+ Extrai os últimos N preços ÚNICOS, ordena do menor para maior,
76
+ e formata como string separada por vírgulas.
77
+
78
+ Args:
79
+ prices: Lista de objetos Price
80
+ limit: Quantidade máxima de preços a mostrar (padrão: 5)
81
+
82
+ Returns:
83
+ String formatada (ex: "R$ 899, R$ 999, R$ 1.299")
84
+
85
+ Examples:
86
+ >>> prices = [
87
+ ... Price(value=1399.90, date=datetime.now()),
88
+ ... Price(value=1299.90, date=datetime.now()),
89
+ ... Price(value=1299.90, date=datetime.now()), # duplicata
90
+ ... Price(value=999.90, date=datetime.now()),
91
+ ... Price(value=899.90, date=datetime.now()),
92
+ ... ]
93
+ >>> format_price_history(prices, limit=5)
94
+ 'R$ 899, R$ 999, R$ 1.299, R$ 1.399'
95
+ """
96
+ if not prices:
97
+ return "Sem historico"
98
+
99
+ # Obtém preços únicos ordenados
100
+ unique_values = get_unique_prices(prices, limit)
101
+
102
+ if not unique_values:
103
+ return "Sem historico"
104
+
105
+ # Formata cada preço e junta com vírgula
106
+ formatted_prices = [format_price(value) for value in unique_values]
107
+
108
+ return ", ".join(formatted_prices)
notify_utils/parser.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ Parser para normalizar valores de preços de diferentes formatos.
3
+ """
4
+
5
+ import re
6
+ from typing import Union
7
+
8
+
9
+ def parse_price(price_string: Union[str, float, int]) -> float:
10
+ """
11
+ Converte uma string de preço em formato decimal (float).
12
+
13
+ Suporta múltiplos formatos:
14
+ - "R$ 1.299,90" → 1299.90
15
+ - "1.299,90" → 1299.90
16
+ - "1,299.90" → 1299.90
17
+ - "$1,299.90" → 1299.90
18
+ - "1299.90" → 1299.90
19
+ - 1299.90 → 1299.90
20
+
21
+ Args:
22
+ price_string: String contendo o preço ou número
23
+
24
+ Returns:
25
+ Valor do preço em formato decimal
26
+
27
+ Raises:
28
+ ValueError: Se o formato não for reconhecido ou valor for inválido
29
+ """
30
+ # Se já é número, retorna
31
+ if isinstance(price_string, (int, float)):
32
+ if price_string < 0:
33
+ raise ValueError("Preço não pode ser negativo")
34
+ return float(price_string)
35
+
36
+ if not isinstance(price_string, str):
37
+ raise TypeError(f"Esperado str, int ou float, recebido {type(price_string)}")
38
+
39
+ # Remove espaços em branco
40
+ price_string = price_string.strip()
41
+
42
+ if not price_string:
43
+ raise ValueError("String de preço vazia")
44
+
45
+ # Remove símbolos de moeda e espaços
46
+ # Remove: R$, $, €, £, etc
47
+ cleaned = re.sub(r'[R\$€£¥\s]', '', price_string)
48
+
49
+ if not cleaned:
50
+ raise ValueError("String de preço inválida após limpeza")
51
+
52
+ # Detecta o formato baseado na última ocorrência de separador
53
+ # Se tiver vírgula depois do último ponto, é formato BR (1.299,90)
54
+ # Se tiver ponto depois da última vírgula, é formato US (1,299.90)
55
+
56
+ last_comma = cleaned.rfind(',')
57
+ last_dot = cleaned.rfind('.')
58
+
59
+ try:
60
+ if last_comma > last_dot:
61
+ # Formato brasileiro: 1.299,90
62
+ # Remove pontos (separador de milhar) e troca vírgula por ponto
63
+ cleaned = cleaned.replace('.', '').replace(',', '.')
64
+ else:
65
+ # Formato americano: 1,299.90
66
+ # Remove vírgulas (separador de milhar)
67
+ cleaned = cleaned.replace(',', '')
68
+
69
+ value = float(cleaned)
70
+
71
+ if value < 0:
72
+ raise ValueError("Preço não pode ser negativo")
73
+
74
+ return value
75
+
76
+ except ValueError as e:
77
+ raise ValueError(f"Não foi possível converter '{price_string}' para preço: {e}")
78
+
79
+
80
+ def parse_price_range(price_string: str) -> tuple[float, float]:
81
+ """
82
+ Parseia strings de faixa de preço.
83
+
84
+ Exemplos:
85
+ - "R$ 100 - R$ 200" → (100.0, 200.0)
86
+ - "100-200" → (100.0, 200.0)
87
+
88
+ Args:
89
+ price_string: String contendo a faixa de preço
90
+
91
+ Returns:
92
+ Tupla (preço_mínimo, preço_máximo)
93
+
94
+ Raises:
95
+ ValueError: Se o formato não for reconhecido
96
+ """
97
+ # Separa por hífen, "a", "até", etc
98
+ parts = re.split(r'\s*[-–—]\s*|\s+a\s+|\s+até\s+', price_string, maxsplit=1)
99
+
100
+ if len(parts) != 2:
101
+ raise ValueError(f"Formato de faixa inválido: '{price_string}'")
102
+
103
+ min_price = parse_price(parts[0])
104
+ max_price = parse_price(parts[1])
105
+
106
+ if min_price > max_price:
107
+ raise ValueError(f"Preço mínimo ({min_price}) maior que máximo ({max_price})")
108
+
109
+ return min_price, max_price
@@ -0,0 +1,362 @@
1
+ """
2
+ Análise estatística de histórico de preços.
3
+ """
4
+
5
+ from datetime import datetime, timedelta
6
+ from typing import List, Optional
7
+ from statistics import mean, median as median_builtin, stdev
8
+ from .models import Price, PriceTrend
9
+
10
+
11
+ def days_since_most_recent_price(prices: List[Price]) -> Optional[int]:
12
+ """
13
+ Calcula quantos dias se passaram desde o preço mais recente até hoje.
14
+
15
+ Útil para verificar se o histórico está desatualizado e ajustar
16
+ o período de análise dinamicamente.
17
+
18
+ Args:
19
+ prices: Lista de preços
20
+
21
+ Returns:
22
+ Número de dias desde o preço mais recente (arredondado para cima)
23
+ ou None se a lista estiver vazia
24
+
25
+ Example:
26
+ >>> prices = [Price(100.0, datetime.now() - timedelta(days=33))]
27
+ >>> days_since_most_recent_price(prices)
28
+ 33
29
+ """
30
+ if not prices:
31
+ return None
32
+
33
+ # Encontra o preço mais recente
34
+ most_recent = max(prices, key=lambda p: p.date)
35
+
36
+ # Calcula diferença em dias (arredonda para cima)
37
+ time_diff = datetime.now() - most_recent.date
38
+ days_diff = int(time_diff.total_seconds() / 86400) # 86400 segundos em um dia
39
+
40
+ # Arredonda para cima se houver fração de dia
41
+ if time_diff.total_seconds() % 86400 > 0:
42
+ days_diff += 1
43
+
44
+ return days_diff
45
+
46
+
47
+ def get_recommended_period(prices: List[Price], desired_period: int) -> int:
48
+ """
49
+ Retorna o período recomendado que garante incluir o histórico mais recente.
50
+
51
+ Se o preço mais recente for mais antigo que o período desejado,
52
+ retorna o número de dias necessário para incluí-lo.
53
+
54
+ Args:
55
+ prices: Lista de preços
56
+ desired_period: Período desejado em dias
57
+
58
+ Returns:
59
+ Período ajustado que inclui o histórico mais recente
60
+
61
+ Example:
62
+ >>> # Histórico mais recente tem 33 dias
63
+ >>> prices = [Price(100.0, datetime.now() - timedelta(days=33))]
64
+ >>> get_recommended_period(prices, 30)
65
+ 33 # Ajustado para incluir o histórico
66
+
67
+ >>> # Histórico recente tem 20 dias
68
+ >>> prices = [Price(100.0, datetime.now() - timedelta(days=20))]
69
+ >>> get_recommended_period(prices, 30)
70
+ 30 # Mantém período desejado
71
+ """
72
+ if not prices:
73
+ return desired_period
74
+
75
+ days_since = days_since_most_recent_price(prices)
76
+
77
+ if days_since is None:
78
+ return desired_period
79
+
80
+ # Retorna o maior entre o período desejado e dias desde o mais recente
81
+ return max(desired_period, days_since)
82
+
83
+
84
+ def filter_by_period_range(
85
+ prices: List[Price],
86
+ start_days: int,
87
+ end_days: int
88
+ ) -> List[Price]:
89
+ """
90
+ Filtra preços por um intervalo de dias (ignorando dias muito recentes).
91
+
92
+ Útil para ignorar ruído de dados muito recentes (mesmo dia, próximos dias).
93
+
94
+ Args:
95
+ prices: Lista de preços
96
+ start_days: Começa N dias atrás (ex: 3 = ignora 0, 1, 2 dias)
97
+ end_days: Termina M dias atrás (ex: 30 = até 30 dias atrás)
98
+
99
+ Returns:
100
+ Lista de preços entre [hoje-end_days ... hoje-start_days]
101
+
102
+ Raises:
103
+ ValueError: Se start_days < 0, end_days <= 0, ou start_days >= end_days
104
+
105
+ Example:
106
+ >>> # Ignora preços de 0-2 dias, usa apenas 3-30 dias
107
+ >>> filtered = filter_by_period_range(prices, start_days=3, end_days=30)
108
+ >>> # Retorna preços entre hoje-30d e hoje-3d
109
+ """
110
+ if start_days < 0:
111
+ raise ValueError("start_days deve ser >= 0")
112
+ if end_days <= 0:
113
+ raise ValueError("end_days deve ser > 0")
114
+ if start_days >= end_days:
115
+ raise ValueError("start_days deve ser menor que end_days")
116
+
117
+ now = datetime.now()
118
+ start_date = now - timedelta(days=start_days) # Data mais recente permitida
119
+ end_date = now - timedelta(days=end_days) # Data mais antiga permitida
120
+
121
+ # Filtra preços entre end_date (mais antigo) e start_date (mais recente)
122
+ return [p for p in prices if end_date <= p.date <= start_date]
123
+
124
+
125
+ def filter_by_period(prices: List[Price], days: int) -> List[Price]:
126
+ """
127
+ Filtra preços por período de dias a partir de hoje.
128
+
129
+ Args:
130
+ prices: Lista de preços
131
+ days: Número de dias para filtrar (ex: 7, 30, 90)
132
+
133
+ Returns:
134
+ Lista de preços dentro do período especificado
135
+ """
136
+ if days <= 0:
137
+ raise ValueError("Número de dias deve ser positivo")
138
+
139
+ cutoff_date = datetime.now() - timedelta(days=days)
140
+ return [p for p in prices if p.date >= cutoff_date]
141
+
142
+
143
+ def calculate_mean(prices: List[Price], days: Optional[int] = None) -> Optional[float]:
144
+ """
145
+ Calcula a média de preços.
146
+
147
+ Args:
148
+ prices: Lista de preços
149
+ days: Número de dias para filtrar (opcional, se None considera todos os preços)
150
+
151
+ Returns:
152
+ Média dos preços ou None se lista vazia
153
+
154
+ Raises:
155
+ ValueError: Se days for <= 0
156
+ """
157
+ if not prices:
158
+ return None
159
+
160
+ filtered_prices = filter_by_period(prices, days) if days else prices
161
+
162
+ if not filtered_prices:
163
+ return None
164
+
165
+ values = [p.value for p in filtered_prices]
166
+ return round(mean(values), 2)
167
+
168
+
169
+ def calculate_median(prices: List[Price], days: Optional[int] = None) -> Optional[float]:
170
+ """
171
+ Calcula a mediana de preços.
172
+
173
+ Args:
174
+ prices: Lista de preços
175
+ days: Número de dias para filtrar (opcional, se None considera todos os preços)
176
+
177
+ Returns:
178
+ Mediana dos preços ou None se lista vazia
179
+
180
+ Raises:
181
+ ValueError: Se days for <= 0
182
+ """
183
+ if not prices:
184
+ return None
185
+
186
+ filtered_prices = filter_by_period(prices, days) if days else prices
187
+
188
+ if not filtered_prices:
189
+ return None
190
+
191
+ values = [p.value for p in filtered_prices]
192
+ return round(median_builtin(values), 2)
193
+
194
+
195
+ def get_min_max(
196
+ prices: List[Price],
197
+ days: Optional[int] = None
198
+ ) -> Optional[tuple[float, float]]:
199
+ """
200
+ Retorna o preço mínimo e máximo do período.
201
+
202
+ Args:
203
+ prices: Lista de preços
204
+ days: Número de dias para filtrar (opcional)
205
+
206
+ Returns:
207
+ Tupla (preço_mínimo, preço_máximo) ou None se lista vazia
208
+
209
+ Raises:
210
+ ValueError: Se days for <= 0
211
+ """
212
+ if not prices:
213
+ return None
214
+
215
+ filtered_prices = filter_by_period(prices, days) if days else prices
216
+
217
+ if not filtered_prices:
218
+ return None
219
+
220
+ values = [p.value for p in filtered_prices]
221
+ return round(min(values), 2), round(max(values), 2)
222
+
223
+
224
+ def calculate_price_statistics(
225
+ prices: List[Price],
226
+ days: Optional[int] = None
227
+ ) -> Optional[dict]:
228
+ """
229
+ Calcula estatísticas completas de preços.
230
+
231
+ Args:
232
+ prices: Lista de preços
233
+ days: Número de dias para filtrar (opcional)
234
+
235
+ Returns:
236
+ Dicionário com estatísticas ou None se lista vazia:
237
+ {
238
+ 'mean': float,
239
+ 'median': float,
240
+ 'min': float,
241
+ 'max': float,
242
+ 'count': int,
243
+ 'period_days': int or None
244
+ }
245
+
246
+ Raises:
247
+ ValueError: Se days for <= 0
248
+ """
249
+ if not prices:
250
+ return None
251
+
252
+ filtered_prices = filter_by_period(prices, days) if days else prices
253
+
254
+ if not filtered_prices:
255
+ return None
256
+
257
+ values = [p.value for p in filtered_prices]
258
+
259
+ return {
260
+ 'mean': round(mean(values), 2),
261
+ 'median': round(median_builtin(values), 2),
262
+ 'min': round(min(values), 2),
263
+ 'max': round(max(values), 2),
264
+ 'count': len(values),
265
+ 'period_days': days
266
+ }
267
+
268
+
269
+ def calculate_volatility(prices: List[Price]) -> Optional[float]:
270
+ """
271
+ Calcula a volatilidade (desvio padrão) dos preços.
272
+
273
+ Args:
274
+ prices: Lista de preços
275
+
276
+ Returns:
277
+ Desvio padrão dos preços ou None se lista vazia ou com apenas 1 item
278
+ """
279
+ if not prices or len(prices) < 2:
280
+ return None
281
+
282
+ values = [p.value for p in prices]
283
+ try:
284
+ return round(stdev(values), 2)
285
+ except:
286
+ return None
287
+
288
+
289
+ def calculate_price_trend(
290
+ prices: List[Price],
291
+ days: int = 30,
292
+ stability_threshold: float = 5.0
293
+ ) -> Optional[PriceTrend]:
294
+ """
295
+ Analisa a tendência de preço com informações detalhadas.
296
+
297
+ Compara a média dos últimos N dias com a média do período anterior
298
+ e retorna análise completa incluindo volatilidade e confiança.
299
+
300
+ Args:
301
+ prices: Lista de preços
302
+ days: Período em dias para análise (padrão: 30)
303
+ stability_threshold: % de mudança para considerar estável (padrão: 5.0)
304
+
305
+ Returns:
306
+ PriceTrend com análise detalhada ou None se não houver dados suficientes
307
+ """
308
+ if len(prices) < 2:
309
+ return None
310
+
311
+ # Divide em dois períodos
312
+ recent_prices = filter_by_period(prices, days)
313
+ cutoff_date = datetime.now() - timedelta(days=days)
314
+ older_cutoff = cutoff_date - timedelta(days=days)
315
+ older_prices = [p for p in prices if older_cutoff <= p.date < cutoff_date]
316
+
317
+ if not recent_prices or not older_prices:
318
+ return None
319
+
320
+ # Calcula médias
321
+ recent_mean = mean([p.value for p in recent_prices])
322
+ older_mean = mean([p.value for p in older_prices])
323
+
324
+ # Calcula mudança percentual (positivo = aumento, negativo = queda)
325
+ change_pct = ((recent_mean - older_mean) / older_mean) * 100
326
+
327
+ # Determina direção
328
+ if abs(change_pct) < stability_threshold:
329
+ direction = "stable"
330
+ elif change_pct > 0:
331
+ direction = "increasing"
332
+ else:
333
+ direction = "decreasing"
334
+
335
+ # Calcula volatilidade (de todos os preços dos dois períodos)
336
+ all_period_prices = recent_prices + older_prices
337
+ volatility = calculate_volatility(all_period_prices) or 0.0
338
+
339
+ # Determina se está acelerando (mudança > 10%)
340
+ is_accelerating = abs(change_pct) >= 10.0
341
+
342
+ # Calcula confiança baseado na quantidade de amostras
343
+ total_samples = len(recent_prices) + len(older_prices)
344
+ if total_samples >= 20:
345
+ confidence = "high"
346
+ elif total_samples >= 10:
347
+ confidence = "medium"
348
+ else:
349
+ confidence = "low"
350
+
351
+ return PriceTrend(
352
+ direction=direction,
353
+ change_percentage=round(change_pct, 2),
354
+ recent_avg=round(recent_mean, 2),
355
+ previous_avg=round(older_mean, 2),
356
+ volatility=volatility,
357
+ confidence=confidence,
358
+ samples_recent=len(recent_prices),
359
+ samples_previous=len(older_prices),
360
+ is_accelerating=is_accelerating,
361
+ analysis_period_days=days
362
+ )
@@ -0,0 +1,98 @@
1
+ """
2
+ Validadores para preços e dados relacionados.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Union
7
+
8
+
9
+ def validate_price(value: Union[float, int]) -> bool:
10
+ """
11
+ Valida se um valor de preço é válido.
12
+
13
+ Args:
14
+ value: Valor a ser validado
15
+
16
+ Returns:
17
+ True se válido, False caso contrário
18
+ """
19
+ if not isinstance(value, (int, float)):
20
+ return False
21
+
22
+ return value >= 0
23
+
24
+
25
+ def validate_currency(currency: str) -> bool:
26
+ """
27
+ Valida se um código de moeda é válido (formato básico).
28
+
29
+ Args:
30
+ currency: Código da moeda (ex: "BRL", "USD")
31
+
32
+ Returns:
33
+ True se válido, False caso contrário
34
+ """
35
+ if not isinstance(currency, str):
36
+ return False
37
+
38
+ # Códigos de moeda geralmente têm 3 letras maiúsculas
39
+ return len(currency) == 3 and currency.isupper() and currency.isalpha()
40
+
41
+
42
+ def validate_date(date: datetime) -> bool:
43
+ """
44
+ Valida se uma data é válida e não está no futuro.
45
+
46
+ Args:
47
+ date: Data a ser validada
48
+
49
+ Returns:
50
+ True se válida, False caso contrário
51
+ """
52
+ if not isinstance(date, datetime):
53
+ return False
54
+
55
+ # Não aceita datas futuras
56
+ return date <= datetime.now()
57
+
58
+
59
+ def validate_discount_percentage(percentage: float) -> bool:
60
+ """
61
+ Valida se um percentual de desconto é razoável.
62
+
63
+ Args:
64
+ percentage: Percentual de desconto
65
+
66
+ Returns:
67
+ True se válido (entre -100 e 100), False caso contrário
68
+ """
69
+ if not isinstance(percentage, (int, float)):
70
+ return False
71
+
72
+ # Permite de -100% (aumento de 100%) até 100% (desconto de 100%)
73
+ return -100 <= percentage <= 100
74
+
75
+
76
+ def is_suspicious_discount(
77
+ current_price: float,
78
+ advertised_old_price: float,
79
+ min_percentage: float = 70.0
80
+ ) -> bool:
81
+ """
82
+ Verifica se um desconto é suspeito (muito alto).
83
+
84
+ Descontos acima de 70% geralmente são suspeitos de fraude.
85
+
86
+ Args:
87
+ current_price: Preço atual
88
+ advertised_old_price: Preço "de" anunciado
89
+ min_percentage: Percentual mínimo para considerar suspeito (padrão: 70%)
90
+
91
+ Returns:
92
+ True se suspeito, False caso contrário
93
+ """
94
+ if current_price >= advertised_old_price:
95
+ return False
96
+
97
+ discount_pct = ((advertised_old_price - current_price) / advertised_old_price) * 100
98
+ return discount_pct >= min_percentage