mtcli-volume 2.3.0.dev1__tar.gz → 2.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mtcli_volume-2.3.0.dev1 → mtcli_volume-2.4.0}/PKG-INFO +3 -2
- mtcli_volume-2.4.0/mtcli_volume/cli.py +142 -0
- {mtcli_volume-2.3.0.dev1 → mtcli_volume-2.4.0}/mtcli_volume/conf.py +4 -5
- mtcli_volume-2.4.0/mtcli_volume/controller.py +90 -0
- mtcli_volume-2.4.0/mtcli_volume/model.py +161 -0
- {mtcli_volume-2.3.0.dev1 → mtcli_volume-2.4.0}/mtcli_volume/plugin.py +1 -1
- mtcli_volume-2.4.0/mtcli_volume/view.py +42 -0
- {mtcli_volume-2.3.0.dev1 → mtcli_volume-2.4.0}/pyproject.toml +1 -1
- mtcli_volume-2.3.0.dev1/mtcli_volume/controllers/volume_controller.py +0 -83
- mtcli_volume-2.3.0.dev1/mtcli_volume/models/volume_model.py +0 -129
- mtcli_volume-2.3.0.dev1/mtcli_volume/views/volume_view.py +0 -69
- mtcli_volume-2.3.0.dev1/mtcli_volume/volume.py +0 -97
- {mtcli_volume-2.3.0.dev1 → mtcli_volume-2.4.0}/LICENSE +0 -0
- {mtcli_volume-2.3.0.dev1 → mtcli_volume-2.4.0}/README.md +0 -0
- {mtcli_volume-2.3.0.dev1 → mtcli_volume-2.4.0}/mtcli_volume/__init__.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: mtcli-volume
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Plugin mtcli para exibir o volume profile
|
|
5
|
+
License-File: LICENSE
|
|
5
6
|
Author: Valmir França da Silva
|
|
6
7
|
Author-email: vfranca3@gmail.com
|
|
7
8
|
Requires-Python: >=3.10,<3.14.0
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .conf import (
|
|
6
|
+
FIM,
|
|
7
|
+
INICIO,
|
|
8
|
+
LIMIT,
|
|
9
|
+
PERIOD,
|
|
10
|
+
RANGE,
|
|
11
|
+
SYMBOL,
|
|
12
|
+
TIMEZONE,
|
|
13
|
+
VOLUME,
|
|
14
|
+
)
|
|
15
|
+
from .controller import calcular_volume_profile
|
|
16
|
+
from .view import exibir_volume_profile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command(
|
|
20
|
+
help=(
|
|
21
|
+
"Exibe o Volume Profile distribuindo o volume dos candles "
|
|
22
|
+
"uniformemente entre as faixas de preço entre LOW e HIGH."
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
@click.version_option(package_name="mtcli-volume")
|
|
26
|
+
@click.option(
|
|
27
|
+
"--symbol",
|
|
28
|
+
"-s",
|
|
29
|
+
default=SYMBOL,
|
|
30
|
+
show_default=True,
|
|
31
|
+
help="Símbolo do ativo.",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--period",
|
|
35
|
+
"-p",
|
|
36
|
+
default=PERIOD,
|
|
37
|
+
show_default=True,
|
|
38
|
+
help="Timeframe utilizado no cálculo.",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--limit",
|
|
42
|
+
"-l",
|
|
43
|
+
default=LIMIT,
|
|
44
|
+
show_default=True,
|
|
45
|
+
help="Quantidade de candles usados no cálculo.",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--range",
|
|
49
|
+
"-r",
|
|
50
|
+
type=float,
|
|
51
|
+
default=RANGE,
|
|
52
|
+
show_default=True,
|
|
53
|
+
help="Tamanho da faixa de preço do Volume Profile.",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--volume",
|
|
57
|
+
"-v",
|
|
58
|
+
type=click.Choice(["tick", "real"], case_sensitive=False),
|
|
59
|
+
default=VOLUME,
|
|
60
|
+
show_default=True,
|
|
61
|
+
help="Tipo de volume utilizado no cálculo.",
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"--inicio",
|
|
65
|
+
"-i",
|
|
66
|
+
default=INICIO,
|
|
67
|
+
show_default=True,
|
|
68
|
+
help="Data/hora inicial (YYYY-MM-DD HH:MM).",
|
|
69
|
+
)
|
|
70
|
+
@click.option(
|
|
71
|
+
"--fim",
|
|
72
|
+
"-f",
|
|
73
|
+
default=FIM,
|
|
74
|
+
show_default=True,
|
|
75
|
+
help="Data/hora final (YYYY-MM-DD HH:MM).",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--timezone",
|
|
79
|
+
"-tz",
|
|
80
|
+
type=str,
|
|
81
|
+
default=TIMEZONE,
|
|
82
|
+
show_default=True,
|
|
83
|
+
help="Fuso horário para exibição das datas.",
|
|
84
|
+
)
|
|
85
|
+
@click.option(
|
|
86
|
+
"--hvn-criterio",
|
|
87
|
+
type=click.Choice(["media", "percentil"], case_sensitive=False),
|
|
88
|
+
default="percentil",
|
|
89
|
+
show_default=True,
|
|
90
|
+
help="Critério para identificação de HVNs e LVNs.",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--verbose",
|
|
94
|
+
"-vv",
|
|
95
|
+
is_flag=True,
|
|
96
|
+
help="Exibe informações detalhadas da análise.",
|
|
97
|
+
)
|
|
98
|
+
def volume(
|
|
99
|
+
symbol,
|
|
100
|
+
period,
|
|
101
|
+
limit,
|
|
102
|
+
range,
|
|
103
|
+
volume,
|
|
104
|
+
inicio,
|
|
105
|
+
fim,
|
|
106
|
+
timezone,
|
|
107
|
+
hvn_criterio,
|
|
108
|
+
verbose,
|
|
109
|
+
):
|
|
110
|
+
"""
|
|
111
|
+
Exibe o Volume Profile agrupando volumes por faixa de preço.
|
|
112
|
+
|
|
113
|
+
O volume de cada candle é distribuído igualmente entre todas
|
|
114
|
+
as faixas de preço tocadas entre o LOW e o HIGH do candle,
|
|
115
|
+
resultando em uma representação mais precisa da distribuição
|
|
116
|
+
de volume.
|
|
117
|
+
"""
|
|
118
|
+
inicio_dt = (
|
|
119
|
+
datetime.strptime(inicio, "%Y-%m-%d %H:%M") if inicio else None
|
|
120
|
+
)
|
|
121
|
+
fim_dt = datetime.strptime(fim, "%Y-%m-%d %H:%M") if fim else None
|
|
122
|
+
|
|
123
|
+
profile, stats, info = calcular_volume_profile(
|
|
124
|
+
symbol=symbol,
|
|
125
|
+
period=period,
|
|
126
|
+
limit=limit,
|
|
127
|
+
step=range,
|
|
128
|
+
volume=volume,
|
|
129
|
+
inicio=inicio_dt,
|
|
130
|
+
fim=fim_dt,
|
|
131
|
+
verbose=verbose,
|
|
132
|
+
timezone_str=timezone,
|
|
133
|
+
criterio_hvn=hvn_criterio.lower(),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
exibir_volume_profile(
|
|
137
|
+
profile=profile,
|
|
138
|
+
stats=stats,
|
|
139
|
+
symbol=symbol,
|
|
140
|
+
info=info,
|
|
141
|
+
verbose=verbose,
|
|
142
|
+
)
|
|
@@ -5,13 +5,12 @@ from mtcli.conf import config
|
|
|
5
5
|
SYMBOL = os.getenv("SYMBOL", config["DEFAULT"].get("symbol", fallback="WIN$N"))
|
|
6
6
|
DIGITOS = int(os.getenv("DIGITOS", config["DEFAULT"].getint("digitos", fallback=0)))
|
|
7
7
|
PERIOD = os.getenv("PERIOD", config["DEFAULT"].get("period", fallback="M1"))
|
|
8
|
-
|
|
9
|
-
os.getenv("PERIODOS", config["DEFAULT"].getint("periodos", fallback=566))
|
|
10
|
-
)
|
|
8
|
+
LIMIT = int(os.getenv("LIMIT", config["DEFAULT"].getint("limit", fallback=566)))
|
|
11
9
|
RANGE = float(os.getenv("RANGE", config["DEFAULT"].getfloat("range", fallback=100)))
|
|
12
10
|
VOLUME = os.getenv("VOLUME", config["DEFAULT"].get("volume", fallback="tick"))
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
INICIO = os.getenv("INICIO", config["DEFAULT"].get("inicio", fallback=""))
|
|
12
|
+
FIM = os.getenv("FIM", config["DEFAULT"].get("fim", fallback=""))
|
|
15
13
|
TIMEZONE = os.getenv(
|
|
16
14
|
"TIMEZONE", config["DEFAULT"].get("timezone", fallback="America/Sao_Paulo")
|
|
17
15
|
)
|
|
16
|
+
FORMAT = os.getenv("FORMAT", config["DEFAULT"].get("format", fallback="k"))
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
import zoneinfo
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from mtcli.logger import setup_logger
|
|
6
|
+
from .model import (
|
|
7
|
+
calcular_profile,
|
|
8
|
+
calcular_estatisticas,
|
|
9
|
+
obter_rates,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
log = setup_logger()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def calcular_volume_profile(
|
|
16
|
+
symbol,
|
|
17
|
+
period,
|
|
18
|
+
limit,
|
|
19
|
+
step,
|
|
20
|
+
volume,
|
|
21
|
+
inicio=None,
|
|
22
|
+
fim=None,
|
|
23
|
+
verbose=False,
|
|
24
|
+
timezone_str="America/Sao_Paulo",
|
|
25
|
+
criterio_hvn="media",
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Controla o fluxo de cálculo do Volume Profile.
|
|
29
|
+
|
|
30
|
+
- Obtém candles via MT5
|
|
31
|
+
- Calcula Volume Profile por faixa High–Low
|
|
32
|
+
- Calcula estatísticas (POC, VA, HVN, LVN)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
volume = volume.lower().strip()
|
|
36
|
+
if volume not in ("tick", "real"):
|
|
37
|
+
raise ValueError("Tipo de volume inválido. Use 'tick' ou 'real'.")
|
|
38
|
+
|
|
39
|
+
rates = obter_rates(symbol, period, limit, inicio, fim)
|
|
40
|
+
|
|
41
|
+
# ✅ Correção crítica: validação correta para numpy.ndarray
|
|
42
|
+
if rates is None or not isinstance(rates, np.ndarray) or len(rates) == 0:
|
|
43
|
+
log.error("Nenhum dado retornado para o cálculo do Volume Profile.")
|
|
44
|
+
return {}, {}, {}
|
|
45
|
+
|
|
46
|
+
profile = calcular_profile(
|
|
47
|
+
rates=rates,
|
|
48
|
+
step=step,
|
|
49
|
+
volume_tipo=volume,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
stats = calcular_estatisticas(
|
|
53
|
+
profile=profile,
|
|
54
|
+
criterio=criterio_hvn,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
info = {}
|
|
58
|
+
try:
|
|
59
|
+
fuso = zoneinfo.ZoneInfo(timezone_str)
|
|
60
|
+
except Exception:
|
|
61
|
+
log.warning(
|
|
62
|
+
f"Fuso horário '{timezone_str}' inválido. Usando UTC−3 (Brasília)."
|
|
63
|
+
)
|
|
64
|
+
fuso = timezone(timedelta(hours=-3))
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
inicio_real = (
|
|
68
|
+
datetime.utcfromtimestamp(float(rates[0]["time"]))
|
|
69
|
+
.astimezone(fuso)
|
|
70
|
+
.strftime("%Y-%m-%d %H:%M:%S")
|
|
71
|
+
)
|
|
72
|
+
fim_real = (
|
|
73
|
+
datetime.utcfromtimestamp(float(rates[-1]["time"]))
|
|
74
|
+
.astimezone(fuso)
|
|
75
|
+
.strftime("%Y-%m-%d %H:%M:%S")
|
|
76
|
+
)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
log.error(f"Erro ao converter datas: {e}")
|
|
79
|
+
inicio_real = fim_real = "?"
|
|
80
|
+
|
|
81
|
+
info = {
|
|
82
|
+
"symbol": symbol,
|
|
83
|
+
"period": period,
|
|
84
|
+
"candles": len(rates),
|
|
85
|
+
"inicio": inicio_real,
|
|
86
|
+
"fim": fim_real,
|
|
87
|
+
"timezone": timezone_str,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return profile, stats, info
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import MetaTrader5 as mt5
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from mtcli.logger import setup_logger
|
|
9
|
+
from mtcli.mt5_context import mt5_conexao
|
|
10
|
+
from mtcli_volume.conf import DIGITOS
|
|
11
|
+
|
|
12
|
+
log = setup_logger()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def obter_rates(
|
|
16
|
+
symbol: str,
|
|
17
|
+
period: str,
|
|
18
|
+
bars: int,
|
|
19
|
+
data_inicio: datetime = None,
|
|
20
|
+
data_fim: datetime = None,
|
|
21
|
+
):
|
|
22
|
+
"""Obtém candles históricos do MetaTrader 5."""
|
|
23
|
+
with mt5_conexao():
|
|
24
|
+
tf = getattr(mt5, f"TIMEFRAME_{period.upper()}", None)
|
|
25
|
+
if tf is None:
|
|
26
|
+
log.error(f"Timeframe inválido: {period}")
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
if not mt5.symbol_select(symbol, True):
|
|
30
|
+
log.error(f"Erro ao selecionar símbolo {symbol}")
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
if data_inicio and data_fim:
|
|
35
|
+
rates = mt5.copy_rates_range(symbol, tf, data_inicio, data_fim)
|
|
36
|
+
else:
|
|
37
|
+
rates = mt5.copy_rates_from_pos(symbol, tf, 0, bars)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
log.error(f"Erro ao obter dados históricos: {e}")
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
if rates is None or len(rates) == 0:
|
|
43
|
+
log.error("Nenhum dado retornado.")
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
return rates
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def calcular_profile(
|
|
50
|
+
rates,
|
|
51
|
+
step: float,
|
|
52
|
+
volume_tipo: str = "tick",
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Calcula o Volume Profile distribuindo o volume do candle
|
|
56
|
+
uniformemente entre as faixas de preço entre LOW e HIGH.
|
|
57
|
+
"""
|
|
58
|
+
profile = defaultdict(float)
|
|
59
|
+
|
|
60
|
+
for r in rates:
|
|
61
|
+
if isinstance(r, Mapping):
|
|
62
|
+
low = r["low"]
|
|
63
|
+
high = r["high"]
|
|
64
|
+
tick_volume = r["tick_volume"]
|
|
65
|
+
real_volume = r.get("real_volume", tick_volume)
|
|
66
|
+
|
|
67
|
+
elif isinstance(r, np.void):
|
|
68
|
+
low = float(r["low"])
|
|
69
|
+
high = float(r["high"])
|
|
70
|
+
tick_volume = int(r["tick_volume"])
|
|
71
|
+
real_volume = (
|
|
72
|
+
int(r["real_volume"]) if "real_volume" in r.dtype.names else tick_volume
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
else:
|
|
76
|
+
raise TypeError(f"Formato de rate desconhecido: {type(r)}")
|
|
77
|
+
|
|
78
|
+
volume = tick_volume if volume_tipo == "tick" else real_volume
|
|
79
|
+
|
|
80
|
+
if high < low:
|
|
81
|
+
low, high = high, low
|
|
82
|
+
|
|
83
|
+
faixa_inicio = round(low // step * step, DIGITOS)
|
|
84
|
+
faixa_fim = round(high // step * step, DIGITOS)
|
|
85
|
+
|
|
86
|
+
faixas = []
|
|
87
|
+
p = faixa_inicio
|
|
88
|
+
while p <= faixa_fim:
|
|
89
|
+
faixas.append(round(p, DIGITOS))
|
|
90
|
+
p += step
|
|
91
|
+
|
|
92
|
+
if not faixas:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
vol_por_faixa = volume / len(faixas)
|
|
96
|
+
|
|
97
|
+
for f in faixas:
|
|
98
|
+
profile[f] += vol_por_faixa
|
|
99
|
+
|
|
100
|
+
return dict(profile)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def calcular_estatisticas(
|
|
104
|
+
profile: dict,
|
|
105
|
+
hvn_multiplicador: float = 1.5,
|
|
106
|
+
lvn_multiplicador: float = 0.5,
|
|
107
|
+
percentil_hvn: int = 80,
|
|
108
|
+
percentil_lvn: int = 20,
|
|
109
|
+
criterio: str = "media",
|
|
110
|
+
):
|
|
111
|
+
"""
|
|
112
|
+
Calcula POC, Área de Valor (70%) e HVNs/LVNs com critérios profissionais.
|
|
113
|
+
"""
|
|
114
|
+
if not profile:
|
|
115
|
+
return {"poc": None, "area_valor": (None, None), "hvns": [], "lvns": []}
|
|
116
|
+
|
|
117
|
+
volumes = np.array(list(profile.values()))
|
|
118
|
+
faixas = np.array(list(profile.keys()))
|
|
119
|
+
|
|
120
|
+
idx_sorted = np.argsort(volumes)[::-1]
|
|
121
|
+
volumes_ord = volumes[idx_sorted]
|
|
122
|
+
faixas_ord = faixas[idx_sorted]
|
|
123
|
+
|
|
124
|
+
poc = faixas_ord[0]
|
|
125
|
+
|
|
126
|
+
total = volumes.sum()
|
|
127
|
+
acumulado = 0
|
|
128
|
+
area = []
|
|
129
|
+
for f, v in zip(faixas_ord, volumes_ord):
|
|
130
|
+
acumulado += v
|
|
131
|
+
area.append(f)
|
|
132
|
+
if acumulado >= total * 0.7:
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
area_valor = (min(area), max(area))
|
|
136
|
+
|
|
137
|
+
media = volumes.mean()
|
|
138
|
+
|
|
139
|
+
if criterio == "media":
|
|
140
|
+
hvns = sorted(
|
|
141
|
+
f for f, v in profile.items() if v >= media * hvn_multiplicador
|
|
142
|
+
)
|
|
143
|
+
lvns = sorted(
|
|
144
|
+
f for f, v in profile.items() if v <= media * lvn_multiplicador
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
elif criterio == "percentil":
|
|
148
|
+
p_hvn = np.percentile(volumes, percentil_hvn)
|
|
149
|
+
p_lvn = np.percentile(volumes, percentil_lvn)
|
|
150
|
+
hvns = sorted(f for f, v in profile.items() if v >= p_hvn)
|
|
151
|
+
lvns = sorted(f for f, v in profile.items() if v <= p_lvn)
|
|
152
|
+
|
|
153
|
+
else:
|
|
154
|
+
hvns, lvns = [], []
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"poc": poc,
|
|
158
|
+
"area_valor": area_valor,
|
|
159
|
+
"hvns": hvns,
|
|
160
|
+
"lvns": lvns,
|
|
161
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from .conf import DIGITOS as D
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def formatar_numero(valor: float) -> str:
|
|
6
|
+
return f"{valor:,.{D}f}".replace(",", ".")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def exibir_volume_profile(profile, stats, symbol, info=None, verbose=False):
|
|
10
|
+
if not profile:
|
|
11
|
+
click.echo("Nenhum dado disponível.")
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
max_vol = max(profile.values())
|
|
15
|
+
|
|
16
|
+
if verbose and info:
|
|
17
|
+
click.echo("\nInformações da Análise")
|
|
18
|
+
for k, v in info.items():
|
|
19
|
+
click.echo(f"{k.capitalize():15}: {v}")
|
|
20
|
+
|
|
21
|
+
click.echo(f"\nVolume Profile — {symbol}\n")
|
|
22
|
+
click.echo("Preço | Volume | Distribuição")
|
|
23
|
+
click.echo("-" * 55)
|
|
24
|
+
|
|
25
|
+
for preco in sorted(profile.keys(), reverse=True):
|
|
26
|
+
vol = profile[preco]
|
|
27
|
+
perc = vol / max_vol * 100
|
|
28
|
+
click.echo(
|
|
29
|
+
f"{preco:>15.{D}f} | {formatar_numero(vol):>12} | {perc:5.0f}%"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# click.echo("\nEstatísticas")
|
|
33
|
+
click.echo(f"POC {stats['poc']:.{D}f}")
|
|
34
|
+
click.echo(
|
|
35
|
+
f"VA {stats['area_valor'][0]:.{D}f} → {stats['area_valor'][1]:.{D}f}"
|
|
36
|
+
)
|
|
37
|
+
click.echo(
|
|
38
|
+
f"HVNs {', '.join(f'{x:.{D}f}' for x in stats['hvns']) or 'Nenhum'}"
|
|
39
|
+
)
|
|
40
|
+
click.echo(
|
|
41
|
+
f"LVNs {', '.join(f'{x:.{D}f}' for x in stats['lvns']) or 'Nenhum'}"
|
|
42
|
+
)
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
from datetime import datetime, timedelta, timezone
|
|
2
|
-
import zoneinfo # Python 3.9+
|
|
3
|
-
|
|
4
|
-
import numpy as np
|
|
5
|
-
|
|
6
|
-
from mtcli.logger import setup_logger
|
|
7
|
-
from mtcli_volume.models.volume_model import (
|
|
8
|
-
calcular_estatisticas,
|
|
9
|
-
calcular_profile,
|
|
10
|
-
obter_rates,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
log = setup_logger()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def calcular_volume_profile(
|
|
17
|
-
symbol,
|
|
18
|
-
period,
|
|
19
|
-
bars,
|
|
20
|
-
step,
|
|
21
|
-
volume,
|
|
22
|
-
data_inicio=None,
|
|
23
|
-
data_fim=None,
|
|
24
|
-
verbose=False,
|
|
25
|
-
timezone_str="America/Sao_Paulo",
|
|
26
|
-
):
|
|
27
|
-
"""Controla o fluxo de cálculo do volume profile, com suporte a timezone configurável."""
|
|
28
|
-
volume = volume.lower().strip()
|
|
29
|
-
if volume not in ["tick", "real"]:
|
|
30
|
-
log.error(f"Tipo de volume inválido: {volume}. Use 'tick' ou 'real'.")
|
|
31
|
-
raise ValueError(f"Tipo de volume inválido: {volume}. Use 'tick' ou 'real'.")
|
|
32
|
-
|
|
33
|
-
rates = obter_rates(symbol, period, bars, data_inicio, data_fim)
|
|
34
|
-
|
|
35
|
-
if rates is None or len(rates) == 0:
|
|
36
|
-
log.error("Falha ao obter dados de preços para cálculo do volume profile.")
|
|
37
|
-
return {}, {}, {}
|
|
38
|
-
|
|
39
|
-
profile = calcular_profile(rates, step, volume)
|
|
40
|
-
stats = calcular_estatisticas(profile)
|
|
41
|
-
|
|
42
|
-
# Captura de informações de contexto
|
|
43
|
-
info = {}
|
|
44
|
-
if isinstance(rates, np.ndarray) and len(rates) > 0:
|
|
45
|
-
primeiro = rates[0]
|
|
46
|
-
ultimo = rates[-1]
|
|
47
|
-
if "time" in rates.dtype.names:
|
|
48
|
-
try:
|
|
49
|
-
# Define fuso horário desejado
|
|
50
|
-
try:
|
|
51
|
-
fuso = zoneinfo.ZoneInfo(timezone_str)
|
|
52
|
-
except Exception:
|
|
53
|
-
log.warning(
|
|
54
|
-
f"Fuso horário '{timezone_str}' inválido. Usando UTC−3 (Brasília)."
|
|
55
|
-
)
|
|
56
|
-
fuso = timezone(timedelta(hours=-3))
|
|
57
|
-
|
|
58
|
-
inicio_real = (
|
|
59
|
-
datetime.utcfromtimestamp(float(primeiro["time"]))
|
|
60
|
-
.astimezone(fuso)
|
|
61
|
-
.strftime("%Y-%m-%d %H:%M:%S")
|
|
62
|
-
)
|
|
63
|
-
fim_real = (
|
|
64
|
-
datetime.utcfromtimestamp(float(ultimo["time"]))
|
|
65
|
-
.astimezone(fuso)
|
|
66
|
-
.strftime("%Y-%m-%d %H:%M:%S")
|
|
67
|
-
)
|
|
68
|
-
except Exception as e:
|
|
69
|
-
log.error(f"Erro ao converter timezone: {e}")
|
|
70
|
-
inicio_real = fim_real = "?"
|
|
71
|
-
else:
|
|
72
|
-
inicio_real = fim_real = "?"
|
|
73
|
-
|
|
74
|
-
info = {
|
|
75
|
-
"symbol": symbol,
|
|
76
|
-
"period": period,
|
|
77
|
-
"candles": len(rates),
|
|
78
|
-
"inicio": inicio_real,
|
|
79
|
-
"fim": fim_real,
|
|
80
|
-
"timezone": timezone_str,
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return profile, stats, info
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
from collections import defaultdict
|
|
2
|
-
from collections.abc import Mapping
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
|
|
5
|
-
import MetaTrader5 as mt5
|
|
6
|
-
import numpy as np
|
|
7
|
-
|
|
8
|
-
from mtcli.logger import setup_logger
|
|
9
|
-
from mtcli.mt5_context import mt5_conexao
|
|
10
|
-
from mtcli_volume.conf import DIGITOS
|
|
11
|
-
|
|
12
|
-
log = setup_logger()
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def obter_rates(
|
|
16
|
-
symbol: str,
|
|
17
|
-
period: str,
|
|
18
|
-
bars: int,
|
|
19
|
-
data_inicio: datetime = None,
|
|
20
|
-
data_fim: datetime = None,
|
|
21
|
-
):
|
|
22
|
-
"""Obtém dados históricos via MetaTrader 5, podendo filtrar por intervalo de tempo."""
|
|
23
|
-
with mt5_conexao():
|
|
24
|
-
tf = getattr(mt5, f"TIMEFRAME_{period.upper()}", None)
|
|
25
|
-
if tf is None:
|
|
26
|
-
log.error(f"Timeframe inválido: {period}")
|
|
27
|
-
return None
|
|
28
|
-
|
|
29
|
-
if not mt5.symbol_select(symbol, True):
|
|
30
|
-
log.error(f"Erro ao selecionar símbolo {symbol}")
|
|
31
|
-
return None
|
|
32
|
-
|
|
33
|
-
try:
|
|
34
|
-
if data_inicio and data_fim:
|
|
35
|
-
log.debug(
|
|
36
|
-
f"Obtendo candles de {symbol} entre {data_inicio} e {data_fim}"
|
|
37
|
-
)
|
|
38
|
-
rates = mt5.copy_rates_range(symbol, tf, data_inicio, data_fim)
|
|
39
|
-
else:
|
|
40
|
-
log.debug(f"Obtendo {bars} candles de {symbol} a partir da posição 0")
|
|
41
|
-
rates = mt5.copy_rates_from_pos(symbol, tf, 0, bars)
|
|
42
|
-
except Exception as e:
|
|
43
|
-
log.error(f"Erro ao obter dados históricos: {e}")
|
|
44
|
-
return None
|
|
45
|
-
|
|
46
|
-
# ✅ Correção para arrays NumPy
|
|
47
|
-
if rates is None or len(rates) == 0:
|
|
48
|
-
log.error("Nenhum dado retornado.")
|
|
49
|
-
return None
|
|
50
|
-
|
|
51
|
-
return rates
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def calcular_profile(
|
|
55
|
-
rates: list[dict | tuple | object],
|
|
56
|
-
step: float,
|
|
57
|
-
volume: str = "tick",
|
|
58
|
-
) -> dict[float, float]:
|
|
59
|
-
"""Calcula o volume total por faixa de preço, suportando dicionários, numpy.void, objetos ou tuplas."""
|
|
60
|
-
profile = defaultdict(int)
|
|
61
|
-
|
|
62
|
-
for r in rates:
|
|
63
|
-
# --- 1️⃣ Dict comum ---
|
|
64
|
-
if isinstance(r, Mapping):
|
|
65
|
-
preco = r["close"]
|
|
66
|
-
tick_volume = r["tick_volume"]
|
|
67
|
-
real_volume = r.get("real_volume", tick_volume)
|
|
68
|
-
|
|
69
|
-
# --- 2️⃣ numpy.void (estrutura MT5) ---
|
|
70
|
-
elif isinstance(r, np.void):
|
|
71
|
-
preco = float(r["close"])
|
|
72
|
-
tick_volume = int(r["tick_volume"])
|
|
73
|
-
# real_volume pode não existir dependendo da corretora
|
|
74
|
-
real_volume = (
|
|
75
|
-
int(r["real_volume"]) if "real_volume" in r.dtype.names else tick_volume
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
# --- 3️⃣ Objeto com atributos (ex: namedtuple) ---
|
|
79
|
-
elif hasattr(r, "close"):
|
|
80
|
-
preco = r.close
|
|
81
|
-
tick_volume = r.tick_volume
|
|
82
|
-
real_volume = getattr(r, "real_volume", tick_volume)
|
|
83
|
-
|
|
84
|
-
# --- 4️⃣ Tupla/lista ---
|
|
85
|
-
elif isinstance(r, (tuple, list)) and len(r) >= 6:
|
|
86
|
-
preco = r[4]
|
|
87
|
-
tick_volume = r[5]
|
|
88
|
-
real_volume = r[6] if len(r) > 6 else tick_volume
|
|
89
|
-
|
|
90
|
-
else:
|
|
91
|
-
raise TypeError(f"Formato de rate desconhecido: {type(r)}")
|
|
92
|
-
|
|
93
|
-
faixa = round(round(preco / step) * step, DIGITOS)
|
|
94
|
-
profile[faixa] += tick_volume if volume == "tick" else real_volume
|
|
95
|
-
|
|
96
|
-
return dict(profile)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def calcular_estatisticas(profile):
|
|
100
|
-
"""Calcula POC, área de valor (70%), HVNs e LVNs."""
|
|
101
|
-
if not profile:
|
|
102
|
-
return {"poc": None, "area_valor": (None, None), "hvns": [], "lvns": []}
|
|
103
|
-
|
|
104
|
-
# Ordena as faixas de preço por volume (desc)
|
|
105
|
-
volumes_ordenados = sorted(profile.items(), key=lambda x: x[1], reverse=True)
|
|
106
|
-
poc = volumes_ordenados[0][0]
|
|
107
|
-
|
|
108
|
-
# Cálculo da área de valor (70%)
|
|
109
|
-
total_volume = sum(profile.values())
|
|
110
|
-
acumulado = 0
|
|
111
|
-
faixas_area_valor = []
|
|
112
|
-
for faixa, vol in volumes_ordenados:
|
|
113
|
-
acumulado += vol
|
|
114
|
-
faixas_area_valor.append(faixa)
|
|
115
|
-
if acumulado >= total_volume * 0.7:
|
|
116
|
-
break
|
|
117
|
-
area_valor = (min(faixas_area_valor), max(faixas_area_valor))
|
|
118
|
-
|
|
119
|
-
# HVNs e LVNs
|
|
120
|
-
media = total_volume / len(profile)
|
|
121
|
-
hvns = sorted([faixa for faixa, vol in profile.items() if vol >= media * 1.5])
|
|
122
|
-
lvns = sorted([faixa for faixa, vol in profile.items() if vol <= media * 0.5])
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
"poc": poc,
|
|
126
|
-
"area_valor": area_valor,
|
|
127
|
-
"hvns": hvns,
|
|
128
|
-
"lvns": lvns,
|
|
129
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import click
|
|
2
|
-
|
|
3
|
-
from mtcli_volume.conf import DIGITOS
|
|
4
|
-
|
|
5
|
-
BARRA_CHAR = "#"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def exibir_volume_profile(profile, stats, symbol, info=None, verbose=False):
|
|
9
|
-
"""Exibe o volume profile no terminal de forma acessível e organizada."""
|
|
10
|
-
if not profile:
|
|
11
|
-
click.echo(f"Nenhum dado disponível para {symbol}")
|
|
12
|
-
return
|
|
13
|
-
|
|
14
|
-
# ──────────────────────────────────────────────
|
|
15
|
-
# BLOCO VERBOSO: exibe detalhes da análise
|
|
16
|
-
# ──────────────────────────────────────────────
|
|
17
|
-
if verbose and info:
|
|
18
|
-
click.echo("\n=== Informações da Análise ===")
|
|
19
|
-
linhas = [
|
|
20
|
-
("Símbolo", info.get("symbol", "?")),
|
|
21
|
-
("Timeframe", info.get("period", "?").upper()),
|
|
22
|
-
("Candles analisados", str(info.get("candles", "?"))),
|
|
23
|
-
(
|
|
24
|
-
"Período analisado",
|
|
25
|
-
f"{info.get('inicio', '?')} → {info.get('fim', '?')}",
|
|
26
|
-
),
|
|
27
|
-
("Fuso horário", info.get("timezone", "Desconhecido")),
|
|
28
|
-
]
|
|
29
|
-
largura_esq = max(len(t[0]) for t in linhas) + 2
|
|
30
|
-
for chave, valor in linhas:
|
|
31
|
-
click.echo(f"{chave:<{largura_esq}}: {valor}")
|
|
32
|
-
click.echo("=" * (largura_esq + 30))
|
|
33
|
-
|
|
34
|
-
# ──────────────────────────────────────────────
|
|
35
|
-
# BLOCO PRINCIPAL: Volume Profile
|
|
36
|
-
# ──────────────────────────────────────────────
|
|
37
|
-
dados_ordenados = sorted(profile.items(), reverse=True)
|
|
38
|
-
click.echo(f"\nVolume Profile — {symbol}\n")
|
|
39
|
-
|
|
40
|
-
max_vol = max(profile.values())
|
|
41
|
-
largura_preco = max(len(f"{p:.{DIGITOS}f}") for p in profile.keys())
|
|
42
|
-
|
|
43
|
-
# Cabeçalho
|
|
44
|
-
click.echo(f"{'Preço':>{largura_preco}} | Volume | Distribuição")
|
|
45
|
-
click.echo("-" * (largura_preco + 32))
|
|
46
|
-
|
|
47
|
-
# Corpo da tabela
|
|
48
|
-
for preco, vol in dados_ordenados:
|
|
49
|
-
barra_len = int(vol / max_vol * 50)
|
|
50
|
-
barra = BARRA_CHAR * barra_len
|
|
51
|
-
click.echo(f"{preco:>{largura_preco}.{DIGITOS}f} | {vol:>6} | {barra}")
|
|
52
|
-
|
|
53
|
-
# ──────────────────────────────────────────────
|
|
54
|
-
# BLOCO FINAL: Estatísticas
|
|
55
|
-
# ──────────────────────────────────────────────
|
|
56
|
-
click.echo("\n=== Estatísticas ===")
|
|
57
|
-
if stats.get("poc") is not None:
|
|
58
|
-
click.echo(f"POC {stats['poc']:.{DIGITOS}f}")
|
|
59
|
-
click.echo(
|
|
60
|
-
f"VA {stats['area_valor'][0]:.{DIGITOS}f} a {stats['area_valor'][1]:.{DIGITOS}f}"
|
|
61
|
-
)
|
|
62
|
-
click.echo(
|
|
63
|
-
f"HVNs {', '.join(map(lambda x: f'{x:.{DIGITOS}f}', stats['hvns'])) or 'Nenhum'}"
|
|
64
|
-
)
|
|
65
|
-
click.echo(
|
|
66
|
-
f"LVNs {', '.join(map(lambda x: f'{x:.{DIGITOS}f}', stats['lvns'])) or 'Nenhum'}"
|
|
67
|
-
)
|
|
68
|
-
else:
|
|
69
|
-
click.echo("Estatísticas indisponíveis (dados insuficientes).")
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
|
|
3
|
-
import click
|
|
4
|
-
|
|
5
|
-
from mtcli_volume.conf import (
|
|
6
|
-
FROM,
|
|
7
|
-
PERIOD,
|
|
8
|
-
PERIODOS,
|
|
9
|
-
RANGE,
|
|
10
|
-
SYMBOL,
|
|
11
|
-
TIMEZONE,
|
|
12
|
-
TO,
|
|
13
|
-
VOLUME,
|
|
14
|
-
)
|
|
15
|
-
from mtcli_volume.controllers.volume_controller import calcular_volume_profile
|
|
16
|
-
from mtcli_volume.views.volume_view import exibir_volume_profile
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@click.command(
|
|
20
|
-
help="Exibe o Volume Profile, agrupando volumes por faixa de preço no histórico recente."
|
|
21
|
-
)
|
|
22
|
-
@click.version_option(package_name="mtcli-volume")
|
|
23
|
-
@click.option(
|
|
24
|
-
"--symbol", "-s", default=SYMBOL, show_default=True, help="Símbolo do ativo."
|
|
25
|
-
)
|
|
26
|
-
@click.option(
|
|
27
|
-
"--period",
|
|
28
|
-
"-p",
|
|
29
|
-
default=PERIOD,
|
|
30
|
-
show_default=True,
|
|
31
|
-
help="Período do volume.",
|
|
32
|
-
)
|
|
33
|
-
@click.option(
|
|
34
|
-
"--periodos",
|
|
35
|
-
"-po",
|
|
36
|
-
"bars",
|
|
37
|
-
default=PERIODOS,
|
|
38
|
-
show_default=True,
|
|
39
|
-
help="Quantidade de períodos.",
|
|
40
|
-
)
|
|
41
|
-
@click.option(
|
|
42
|
-
"--range",
|
|
43
|
-
"-r",
|
|
44
|
-
"step",
|
|
45
|
-
type=float,
|
|
46
|
-
default=RANGE,
|
|
47
|
-
show_default=True,
|
|
48
|
-
help="Tamanho do agrupamento de preços.",
|
|
49
|
-
)
|
|
50
|
-
@click.option(
|
|
51
|
-
"--volume",
|
|
52
|
-
"-v",
|
|
53
|
-
default=VOLUME,
|
|
54
|
-
show_default=True,
|
|
55
|
-
help="Tipo de volume (tick ou real).",
|
|
56
|
-
)
|
|
57
|
-
@click.option(
|
|
58
|
-
"--from",
|
|
59
|
-
"data_inicio",
|
|
60
|
-
type=str,
|
|
61
|
-
default=FROM,
|
|
62
|
-
show_default=True,
|
|
63
|
-
help="Data/hora inicial (YYYY-MM-DD HH:MM).",
|
|
64
|
-
)
|
|
65
|
-
@click.option(
|
|
66
|
-
"--to",
|
|
67
|
-
"data_fim",
|
|
68
|
-
type=str,
|
|
69
|
-
default=TO,
|
|
70
|
-
show_default=True,
|
|
71
|
-
help="Data/hora final (YYYY-MM-DD HH:MM).",
|
|
72
|
-
)
|
|
73
|
-
@click.option(
|
|
74
|
-
"--verbose",
|
|
75
|
-
"-vv",
|
|
76
|
-
is_flag=True,
|
|
77
|
-
help="Mostra informações detalhadas sobre a análise.",
|
|
78
|
-
)
|
|
79
|
-
@click.option(
|
|
80
|
-
"--timezone",
|
|
81
|
-
"-tz",
|
|
82
|
-
type=str,
|
|
83
|
-
default=TIMEZONE,
|
|
84
|
-
show_default=True,
|
|
85
|
-
help="Fuso horário para exibição das datas (ex: 'UTC', 'America/Sao_Paulo').",
|
|
86
|
-
)
|
|
87
|
-
def volume(
|
|
88
|
-
symbol, period, bars, step, volume, data_inicio, data_fim, verbose, timezone
|
|
89
|
-
):
|
|
90
|
-
"""Exibe o Volume Profile agrupando volumes por faixa de preço."""
|
|
91
|
-
inicio = datetime.strptime(data_inicio, "%Y-%m-%d %H:%M") if data_inicio else None
|
|
92
|
-
fim = datetime.strptime(data_fim, "%Y-%m-%d %H:%M") if data_fim else None
|
|
93
|
-
|
|
94
|
-
profile, stats, info = calcular_volume_profile(
|
|
95
|
-
symbol, period, bars, step, volume, inicio, fim, verbose, timezone
|
|
96
|
-
)
|
|
97
|
-
exibir_volume_profile(profile, stats, symbol, info, verbose)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|