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.
- notify_utils/__init__.py +90 -0
- notify_utils/discount.py +335 -0
- notify_utils/models.py +532 -0
- notify_utils/notifiers/__init__.py +13 -0
- notify_utils/notifiers/discord_notifier.py +127 -0
- notify_utils/notifiers/formatters.py +108 -0
- notify_utils/parser.py +109 -0
- notify_utils/statistics.py +362 -0
- notify_utils/validators.py +98 -0
- notify_utils-0.0.1.dist-info/METADATA +133 -0
- notify_utils-0.0.1.dist-info/RECORD +14 -0
- notify_utils-0.0.1.dist-info/WHEEL +5 -0
- notify_utils-0.0.1.dist-info/licenses/LICENSE +21 -0
- notify_utils-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|