mtcli-volume 1.6.2__tar.gz → 2.0.0.dev0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: mtcli-volume
3
- Version: 1.6.2
3
+ Version: 2.0.0.dev0
4
4
  Summary: Plugin mtcli para exibir o volume profile
5
5
  Author: Valmir França da Silva
6
6
  Author-email: vfranca3@gmail.com
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3.10
10
10
  Classifier: Programming Language :: Python :: 3.11
11
11
  Classifier: Programming Language :: Python :: 3.12
12
12
  Classifier: Programming Language :: Python :: 3.13
13
- Requires-Dist: mtcli (>=1.18.1)
13
+ Requires-Dist: mtcli (>=3.2.0)
14
14
  Project-URL: Documentation, https://mtcli-volume.readthedocs.io
15
15
  Project-URL: Homepage, https://github.com/vfranca/mtcli-volume
16
16
  Project-URL: Repository, https://github.com/vfranca/mtcli-volume
@@ -0,0 +1,35 @@
1
+ import click
2
+ from mtcli_volume.controllers.volume_controller import calcular_volume_profile
3
+ from mtcli_volume.views.volume_view import exibir_volume_profile
4
+ from mtcli_volume.conf import SYMBOL, PERIOD, BARS, STEP, VOLUME
5
+
6
+
7
+ @click.command(
8
+ "volume",
9
+ help="Exibe o Volume Profile, agrupando volumes por faixa de preço no histórico recente."
10
+ )
11
+ @click.version_option(package_name="mtcli-volume")
12
+ @click.option("--symbol", "-s", default=SYMBOL, help="Símbolo do ativo (default WIN$N).")
13
+ @click.option("--period", "-p", default=PERIOD, help="Timeframe (ex: M1, M5, H1).")
14
+ @click.option("--bars", "-b", default=BARS, help="Número de barras (default 566).")
15
+ @click.option(
16
+ "--step", "-e", type=float, default=STEP,
17
+ help="Tamanho do agrupamento de preços (default 100)."
18
+ )
19
+ @click.option(
20
+ "--volume", "-v", default=VOLUME,
21
+ help="Tipo de volume (tick ou real), default tick."
22
+ )
23
+ @click.option("--exporta-csv", "-csv", is_flag=True, help="Exportar resultados para CSV.")
24
+ @click.option(
25
+ "--sem-histograma", "-sh", is_flag=True,
26
+ help="Oculta o histograma textual de volume."
27
+ )
28
+ def volume(symbol, period, bars, step, volume, exporta_csv, sem_histograma):
29
+ """Exibe o Volume Profile agrupando volumes por faixa de preço."""
30
+ profile, stats = calcular_volume_profile(symbol, period, bars, step, volume)
31
+ exibir_volume_profile(profile, stats, symbol, exporta_csv, sem_histograma)
32
+
33
+
34
+ if __name__ == "__main__":
35
+ volume()
@@ -1,9 +1,10 @@
1
- import os
2
- from mtcli.conf import config
3
-
4
-
5
- SYMBOL = os.getenv("SYMBOL", config["DEFAULT"].get("symbol", fallback="WIN$N"))
6
- DIGITOS = int(os.getenv("DIGITOS", config["DEFAULT"].getint("digitos", fallback=0)))
7
- PERIODS = int(os.getenv("PERIODS", config["DEFAULT"].getint("periods", fallback=566)))
8
- STEP = float(os.getenv("STEP", config["DEFAULT"].getfloat("step", fallback=100)))
9
- VOLUME = os.getenv("VOLUME", config["DEFAULT"].get("volume", fallback="tick"))
1
+ import os
2
+ from mtcli.conf import config
3
+
4
+
5
+ SYMBOL = os.getenv("SYMBOL", config["DEFAULT"].get("symbol", fallback="WIN$N"))
6
+ DIGITOS = int(os.getenv("DIGITOS", config["DEFAULT"].getint("digitos", fallback=0)))
7
+ PERIOD = os.getenv("PERIOD", config["DEFAULT"].get("period", fallback="M1"))
8
+ BARS = int(os.getenv("BARS", config["DEFAULT"].getint("bars", fallback=566)))
9
+ STEP = float(os.getenv("STEP", config["DEFAULT"].getfloat("step", fallback=100)))
10
+ VOLUME = os.getenv("VOLUME", config["DEFAULT"].get("volume", fallback="tick"))
@@ -0,0 +1,22 @@
1
+ from mtcli.logger import setup_logger
2
+ from mtcli_volume.models.volume_model import obter_rates, calcular_profile, calcular_estatisticas
3
+
4
+ log = setup_logger()
5
+
6
+
7
+ def calcular_volume_profile(symbol, period, bars, step, volume):
8
+ """Controla o fluxo de cálculo do volume profile."""
9
+ volume = volume.lower().strip()
10
+ if volume not in ["tick", "real"]:
11
+ log.error(f"Tipo de volume inválido: {volume}. Use 'tick' ou 'real'.")
12
+ raise ValueError(f"Tipo de volume inválido: {volume}. Use 'tick' ou 'real'.")
13
+
14
+ rates = obter_rates(symbol, period, bars)
15
+ if not rates:
16
+ log.error("Falha ao obter dados de preços para cálculo do volume profile.")
17
+ return {}, {}
18
+
19
+ profile = calcular_profile(rates, step, volume)
20
+ stats = calcular_estatisticas(profile)
21
+
22
+ return profile, stats
@@ -0,0 +1,96 @@
1
+ from collections import defaultdict
2
+ import MetaTrader5 as mt5
3
+ from mtcli.mt5_context import mt5_conexao
4
+ from mtcli.logger import setup_logger
5
+ from mtcli.models.rates_model import RatesModel
6
+ from mtcli_volume.conf import DIGITOS
7
+
8
+ log = setup_logger()
9
+
10
+
11
+ def obter_rates(symbol, period, bars):
12
+ """Obtém os dados históricos de preços via MetaTrader 5."""
13
+ with mt5_conexao():
14
+ tf = getattr(mt5, f"TIMEFRAME_{period.upper()}", None)
15
+ if tf is None:
16
+ log.error(f"Timeframe inválido: {period}")
17
+ return
18
+
19
+ if not mt5.symbol_select(symbol, True):
20
+ log.error(f"Erro ao selecionar símbolo {symbol}")
21
+ return
22
+
23
+ rates = RatesModel(symbol, period, bars).get_data()
24
+
25
+ if not rates:
26
+ log.error("Erro: não foi possível obter os dados históricos.")
27
+ return
28
+
29
+ return rates
30
+
31
+
32
+ def calcular_profile(rates, step, volume="tick"):
33
+ """Calcula o volume total por faixa de preço, suportando dicionários, objetos ou tuplas."""
34
+ from collections.abc import Mapping
35
+
36
+ profile = defaultdict(int)
37
+
38
+ for r in rates:
39
+ # Detecta se r é dict, objeto com atributos ou tupla
40
+ if isinstance(r, Mapping): # dict
41
+ preco = r["close"]
42
+ tick_volume = r["tick_volume"]
43
+ real_volume = r.get("real_volume", tick_volume)
44
+ elif hasattr(r, "close"): # objeto com atributos
45
+ preco = r.close
46
+ tick_volume = r.tick_volume
47
+ real_volume = getattr(r, "real_volume", tick_volume)
48
+ elif isinstance(r, (tuple, list)) and len(r) >= 6: # tupla com campos esperados
49
+ preco = r[4] # índice típico de 'close' em rates do MT5
50
+ tick_volume = r[5]
51
+ real_volume = r[6] if len(r) > 6 else tick_volume
52
+ else:
53
+ raise TypeError(f"Formato de rate desconhecido: {type(r)}")
54
+
55
+ faixa = round(round(preco / step) * step, DIGITOS)
56
+ profile[faixa] += tick_volume if volume == "tick" else real_volume
57
+
58
+ return dict(profile)
59
+
60
+ def calcular_estatisticas(profile):
61
+ """Calcula POC, área de valor (70%), HVNs e LVNs."""
62
+ if profile is None or len(profile) == 0:
63
+ return {
64
+ "poc": None,
65
+ "area_valor": (None, None),
66
+ "hvns": [],
67
+ "lvns": [],
68
+ }
69
+
70
+ # Ordena faixas de preço pelo volume em ordem decrescente
71
+ volumes_ordenados = sorted(profile.items(), key=lambda x: x[1], reverse=True)
72
+ poc = volumes_ordenados[0][0]
73
+
74
+ # Área de valor (70% do volume)
75
+ total_volume = sum(profile.values())
76
+ acumulado = 0
77
+ faixas_area_valor = []
78
+ for faixa, vol in volumes_ordenados:
79
+ acumulado += vol
80
+ faixas_area_valor.append(faixa)
81
+ if acumulado >= total_volume * 0.7:
82
+ break
83
+ area_valor = (min(faixas_area_valor), max(faixas_area_valor))
84
+
85
+ # HVNs (High Volume Nodes) e LVNs (Low Volume Nodes)
86
+ media = total_volume / len(profile)
87
+ hvns = sorted([faixa for faixa, vol in profile.items() if vol >= media * 1.5])
88
+ lvns = sorted([faixa for faixa, vol in profile.items() if vol <= media * 0.5])
89
+
90
+ return {
91
+ "poc": poc,
92
+ "area_valor": area_valor,
93
+ "hvns": hvns,
94
+ "lvns": lvns,
95
+ }
96
+
@@ -0,0 +1,5 @@
1
+ from mtcli_volume.commands.volume_cli import volume
2
+
3
+
4
+ def register(cli):
5
+ cli.add_command(volume, name="volume")
@@ -0,0 +1,50 @@
1
+ import click
2
+ import csv
3
+ from datetime import datetime
4
+ from mtcli.logger import setup_logger
5
+ from mtcli_volume.conf import DIGITOS
6
+
7
+ log = setup_logger()
8
+ BARRA_CHAR = "#" # Pode mudar para "■" ou "=" se UTF-8 for garantido
9
+
10
+
11
+ def exibir_volume_profile(profile, stats, symbol, exporta_csv=False, sem_histograma=False):
12
+ """Exibe o volume profile no terminal ou exporta para CSV."""
13
+ if not profile:
14
+ click.echo(f"Nenhum dado disponível para {symbol}.")
15
+ return
16
+
17
+ dados_ordenados = sorted(profile.items(), reverse=True)
18
+
19
+ if exporta_csv:
20
+ try:
21
+ data_str = datetime.now().strftime("%Y%m%d_%H%M")
22
+ nome_arquivo = f"volume_profile_{symbol}_{data_str}.csv"
23
+ with open(nome_arquivo, mode="w", newline="", encoding="utf-8") as f:
24
+ writer = csv.writer(f)
25
+ writer.writerow(["Faixa de Preço", "Volume"])
26
+ writer.writerows(dados_ordenados)
27
+ click.echo(f"Exportado para {nome_arquivo}")
28
+ log.info(f"Exportado para {nome_arquivo}")
29
+ except Exception as e:
30
+ log.error(f"Erro ao exportar CSV: {e}")
31
+ click.echo(f"Erro ao exportar CSV: {e}")
32
+ return
33
+
34
+ # Exibição textual
35
+ click.echo(f"\nVolume Profile {symbol}\n")
36
+ max_vol = max(profile.values())
37
+ for preco, vol in dados_ordenados:
38
+ barra = "" if sem_histograma else BARRA_CHAR * (vol // max(1, max_vol // 50))
39
+ click.echo(f"{preco:>8.{DIGITOS}f} {vol:>6} {barra}")
40
+
41
+ # Estatísticas
42
+ if stats.get("poc") is not None:
43
+ click.echo(f"\nPOC (Preço de Maior Volume): {stats['poc']:.{DIGITOS}f}")
44
+ click.echo(
45
+ f"Área de Valor: {stats['area_valor'][0]:.{DIGITOS}f} a {stats['area_valor'][1]:.{DIGITOS}f}"
46
+ )
47
+ click.echo(f"HVNs: {stats['hvns']}")
48
+ click.echo(f"LVNs: {stats['lvns']}")
49
+ else:
50
+ click.echo("\nEstatísticas indisponíveis (dados insuficientes).")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mtcli-volume"
3
- version = "1.6.2"
3
+ version = "2.0.0.dev0"
4
4
  description = "Plugin mtcli para exibir o volume profile"
5
5
  authors = [
6
6
  {name = "Valmir França da Silva",email = "vfranca3@gmail.com"}
@@ -8,7 +8,7 @@ authors = [
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.10,<3.14.0"
10
10
  dependencies = [
11
- "mtcli>=1.18.1"
11
+ "mtcli>=3.2.0"
12
12
  ]
13
13
 
14
14
  [project.urls]
@@ -18,7 +18,7 @@ repository = "https://github.com/vfranca/mtcli-volume"
18
18
  issues = "https://github.com/vfranca/mtcli-volume/issues"
19
19
 
20
20
  [project.entry-points."mtcli.plugins"]
21
- mtcli-volume = "mtcli_volume.plugin:volume"
21
+ mtcli_volume = "mtcli_volume.plugin:register"
22
22
 
23
23
  [build-system]
24
24
  requires = ["poetry-core>=2.0.0,<3.0.0"]
@@ -28,6 +28,7 @@ build-backend = "poetry.core.masonry.api"
28
28
  pytest = "^8.4.2"
29
29
  pytest-cov = "^6.2.1"
30
30
  black = "^25.1.0"
31
+ pytest-mock = "^3.15.1"
31
32
 
32
33
  [tool.poetry.group.docs.dependencies]
33
34
  mkdocs = "^1.6.1"
@@ -1,96 +0,0 @@
1
- """Plugin para exibir o Volume Profile do ativo."""
2
-
3
- import click
4
- import MetaTrader5 as mt5
5
- import csv
6
- from datetime import datetime
7
- from mtcli.conecta import conectar, shutdown
8
- from mtcli.logger import setup_logger
9
- from .conf import DIGITOS, SYMBOL, STEP, PERIODS, VOLUME
10
- from .volume import calcular_volume_profile, calcular_estatisticas
11
-
12
- log = setup_logger()
13
- BARRA_CHAR = "#" # Pode mudar para "|", "=" ou "■" se UTF-8 estiver garantido
14
-
15
-
16
- @click.command("volume", help="Exibe o Volume Profile, agrupando volumes por faixa de preço no histórico recente.")
17
- @click.version_option(package_name="mtcli-volume")
18
- @click.option(
19
- "--symbol", "-s", default=SYMBOL, help="Símbolo do ativo (default WIN$N)."
20
- )
21
- @click.option(
22
- "--periods",
23
- "-p",
24
- default=PERIODS,
25
- help="Número de candles de 1 minuto (default 566).",
26
- )
27
- @click.option(
28
- "--step",
29
- "-e",
30
- type=float,
31
- default=STEP,
32
- help="Tamanho do agrupamento de preços (default 100).",
33
- )
34
- @click.option(
35
- "--volume",
36
- "-v",
37
- default=VOLUME,
38
- help="Tipo de volume (tick ou real), default tick.",
39
- )
40
- @click.option("--exporta-csv", "-csv", is_flag=True, help="Exportar para CSV.")
41
- @click.option(
42
- "--sem-histograma",
43
- "-sh",
44
- is_flag=True,
45
- help="Oculta o histograma textual de volume.",
46
- )
47
- def volume(symbol, periods, step, volume, exporta_csv, sem_histograma):
48
- """Exibe o Volume Profile agrupando volumes por faixa de preço."""
49
-
50
- if volume not in ["tick", "real"]:
51
- msg = f"Tipo de volume inválido: {volume}. Use 'tick' ou 'real'."
52
- click.echo(msg)
53
- log.error(msg)
54
- return
55
-
56
- conectar()
57
- rates = mt5.copy_rates_from_pos(symbol, mt5.TIMEFRAME_M1, 0, periods)
58
-
59
- if rates is None or len(rates) == 0:
60
- msg = "Não foi possível obter os dados"
61
- click.echo(msg)
62
- log.error(msg)
63
- shutdown()
64
- return
65
-
66
- profile = calcular_volume_profile(rates, step, volume)
67
- stats = calcular_estatisticas(profile)
68
-
69
- dados_ordenados = sorted(profile.items(), reverse=True)
70
-
71
- if exporta_csv:
72
- data_str = datetime.now().strftime("%Y%m%d_%H%M")
73
- nome_arquivo = f"volume_profile_{symbol}_{data_str}.csv"
74
- with open(nome_arquivo, mode="w", newline="", encoding="utf-8") as f:
75
- writer = csv.writer(f)
76
- writer.writerow(["Faixa de Preço", "Volume"])
77
- writer.writerows(dados_ordenados)
78
- click.echo(f"Exportado para {nome_arquivo}")
79
- log.info(f"Exportado para {nome_arquivo}")
80
- else:
81
- click.echo(f"\nVolume Profile {symbol}\n")
82
- max_vol = max(profile.values())
83
- for preco, vol in dados_ordenados:
84
- barra = (
85
- "" if sem_histograma else BARRA_CHAR * (vol // max(1, max_vol // 50))
86
- )
87
- click.echo(f"{preco:>8.{DIGITOS}f} | {vol:>6} {barra}")
88
- # Estatísticas
89
- click.echo(f"\nPOC (Preço de Maior Volume): {stats['poc']:.{DIGITOS}f}")
90
- click.echo(
91
- f"Área de Valor: {stats['area_valor'][0]:.{DIGITOS}f} a {stats['area_valor'][1]:.{DIGITOS}f}"
92
- )
93
- click.echo(f"HVNs: {stats['hvns']}")
94
- click.echo(f"LVNs: {stats['lvns']}")
95
-
96
- shutdown()
@@ -1,55 +0,0 @@
1
- """Modelo do volume profile."""
2
-
3
- from collections import defaultdict
4
- from .conf import DIGITOS
5
-
6
-
7
- def calcular_volume_profile(rates, step, volume="tick"):
8
- """Calcula o volume por faixa de preço."""
9
- profile = defaultdict(int)
10
- for r in rates:
11
- preco = r["close"]
12
- faixa = round(round(preco / step) * step, DIGITOS)
13
- profile[faixa] += r["tick_volume"] if volume == "tick" else r["real_volume"]
14
- return dict(profile)
15
-
16
-
17
- def calcular_estatisticas(profile):
18
- """Calcula POC, área de valor, HVNs e LVNs."""
19
- if not profile:
20
- return {
21
- "poc": None,
22
- "area_valor": (None, None),
23
- "hvns": [],
24
- "lvns": [],
25
- }
26
-
27
- total_volume = sum(profile.values())
28
- dados = sorted(profile.items())
29
- volumes_ordenados = sorted(dados, key=lambda x: x[1], reverse=True)
30
-
31
- # POC: faixa com maior volume
32
- poc = volumes_ordenados[0][0]
33
-
34
- # Área de valor (70% do volume total)
35
- acumulado = 0
36
- area_valor = []
37
- for faixa, vol in volumes_ordenados:
38
- acumulado += vol
39
- area_valor.append(faixa)
40
- if acumulado / total_volume >= 0.7:
41
- break
42
- area_valor_min = min(area_valor)
43
- area_valor_max = max(area_valor)
44
-
45
- # HVNs e LVNs
46
- media = total_volume / len(profile)
47
- hvns = [faixa for faixa, vol in profile.items() if vol >= media * 1.5]
48
- lvns = [faixa for faixa, vol in profile.items() if vol <= media * 0.5]
49
-
50
- return {
51
- "poc": poc,
52
- "area_valor": (area_valor_min, area_valor_max),
53
- "hvns": sorted(hvns),
54
- "lvns": sorted(lvns),
55
- }
File without changes