bmv-cli 0.1.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.
- bmv_cli/__init__.py +1 -0
- bmv_cli/api/__init__.py +0 -0
- bmv_cli/api/client.py +39 -0
- bmv_cli/commands/__init__.py +0 -0
- bmv_cli/commands/credits.py +14 -0
- bmv_cli/commands/currencies.py +27 -0
- bmv_cli/commands/dividend.py +102 -0
- bmv_cli/commands/hello.py +8 -0
- bmv_cli/commands/price.py +32 -0
- bmv_cli/commands/search.py +49 -0
- bmv_cli/commands/tests/__init__.py +0 -0
- bmv_cli/commands/tests/conftest.py +9 -0
- bmv_cli/commands/tests/fixtures/APIResponses.py +134 -0
- bmv_cli/commands/tests/fixtures/__init__.py +17 -0
- bmv_cli/commands/tests/test_currencies.py +51 -0
- bmv_cli/commands/tests/test_hello.py +12 -0
- bmv_cli/commands/tests/test_price.py +100 -0
- bmv_cli/main.py +20 -0
- bmv_cli-0.1.0.dist-info/METADATA +119 -0
- bmv_cli-0.1.0.dist-info/RECORD +22 -0
- bmv_cli-0.1.0.dist-info/WHEEL +4 -0
- bmv_cli-0.1.0.dist-info/entry_points.txt +3 -0
bmv_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""BMV CLI - Mexican stock market data via DataBursatil API."""
|
bmv_cli/api/__init__.py
ADDED
|
File without changes
|
bmv_cli/api/client.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from os import getenv
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
load_dotenv()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_base_url(endpoint: str, withExchange: bool) -> str:
|
|
10
|
+
return f"https://api.databursatil.com/v2/{endpoint}?token={getenv('BMV_API_TOKEN')}" + (
|
|
11
|
+
"&bolsa=bmv,biva" if withExchange else ""
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def request_prices(symbols: list[str]):
|
|
16
|
+
response = requests.get(
|
|
17
|
+
get_base_url("cotizaciones", withExchange=True)
|
|
18
|
+
+ "&concepto=u,p,a,x,n,c,m,v,o,i"
|
|
19
|
+
+ "&emisora_serie="
|
|
20
|
+
+ ",".join(symbols)
|
|
21
|
+
)
|
|
22
|
+
return response.json()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def request_currencies():
|
|
26
|
+
response = requests.get(get_base_url("divisas", withExchange=False))
|
|
27
|
+
return response.json()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def request_credits():
|
|
31
|
+
response = requests.get(get_base_url("creditos", withExchange=False))
|
|
32
|
+
return response.json()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def request_emisoras(letra: str, mercado: str = "local"):
|
|
36
|
+
response = requests.get(
|
|
37
|
+
get_base_url("emisoras", withExchange=False) + f"&letra={letra}&mercado={mercado}"
|
|
38
|
+
)
|
|
39
|
+
return response.json()
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
|
|
4
|
+
from bmv_cli.api.client import request_credits
|
|
5
|
+
|
|
6
|
+
app = typer.Typer()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@app.command()
|
|
10
|
+
def credits():
|
|
11
|
+
"""Check remaining API credits"""
|
|
12
|
+
response = request_credits()
|
|
13
|
+
console = Console()
|
|
14
|
+
console.print(f"[bold green]Remaining credits:[/] {response['disponibles']}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
from bmv_cli.api.client import request_currencies
|
|
6
|
+
|
|
7
|
+
app = typer.Typer()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command()
|
|
11
|
+
def currencies():
|
|
12
|
+
response = request_currencies()
|
|
13
|
+
table = Table(
|
|
14
|
+
"Currency",
|
|
15
|
+
"Last Value",
|
|
16
|
+
"Change %",
|
|
17
|
+
"Change",
|
|
18
|
+
)
|
|
19
|
+
date = response.get("t")
|
|
20
|
+
response.pop("t")
|
|
21
|
+
for currency, data in response.items():
|
|
22
|
+
# pp(data)
|
|
23
|
+
table.add_row(currency, *[str(value) for value in data.values()])
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
console.print(table)
|
|
27
|
+
console.print(f"Datetime: {date}")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from bmv_cli.api.client import request_emisoras
|
|
8
|
+
|
|
9
|
+
app = typer.Typer()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_emitter_name(symbol: str) -> str:
|
|
13
|
+
"""Extract emitter name from symbol+series (e.g., FMTY14 -> FMTY, GFNORTEO -> GFNORTE, WALMEX* -> WALMEX)"""
|
|
14
|
+
symbol = symbol.upper()
|
|
15
|
+
|
|
16
|
+
# If ends with *, strip it
|
|
17
|
+
if symbol.endswith("*"):
|
|
18
|
+
return symbol[:-1]
|
|
19
|
+
|
|
20
|
+
# If ends with digits, strip them
|
|
21
|
+
match = re.match(r"^([A-Z]+)\d+$", symbol)
|
|
22
|
+
if match:
|
|
23
|
+
return match.group(1)
|
|
24
|
+
|
|
25
|
+
# If ends with a single letter, try stripping it (risky but common pattern)
|
|
26
|
+
# Only do this if the remaining part is at least 3 characters
|
|
27
|
+
if len(symbol) > 3 and symbol[-1].isalpha():
|
|
28
|
+
return symbol[:-1]
|
|
29
|
+
|
|
30
|
+
return symbol
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def dividend(
|
|
35
|
+
symbol: str = typer.Argument(..., help="Stock symbol (e.g., FMTY, FMTY14, WALMEX*)"),
|
|
36
|
+
):
|
|
37
|
+
"""Get dividend information for a symbol"""
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
# Try the symbol as-is first
|
|
41
|
+
response = request_emisoras(symbol)
|
|
42
|
+
|
|
43
|
+
# If API returns an error, try extracting just the emitter name
|
|
44
|
+
if "Error" in response:
|
|
45
|
+
emitter_name = extract_emitter_name(symbol)
|
|
46
|
+
if emitter_name != symbol:
|
|
47
|
+
console.print(f"[yellow]Trying with emitter name: {emitter_name}[/yellow]")
|
|
48
|
+
response = request_emisoras(emitter_name)
|
|
49
|
+
|
|
50
|
+
# Still an error?
|
|
51
|
+
if "Error" in response:
|
|
52
|
+
console.print(f"[red]Error: {response['Error']}[/red]")
|
|
53
|
+
raise typer.Exit(code=1)
|
|
54
|
+
|
|
55
|
+
# Response structure: {SYMBOL: {SERIE: {data...}}}
|
|
56
|
+
found = False
|
|
57
|
+
for emitter_key, series_data in response.items():
|
|
58
|
+
if not isinstance(series_data, dict):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
for serie, data in series_data.items():
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
dividendos = data.get("dividendos", {})
|
|
66
|
+
found = True
|
|
67
|
+
|
|
68
|
+
if not dividendos:
|
|
69
|
+
console.print(
|
|
70
|
+
f"[yellow]No dividend information available for {emitter_key}{serie}[/yellow]"
|
|
71
|
+
)
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
console.print(f"\n[bold]Dividend Info for {emitter_key}{serie}:[/]")
|
|
75
|
+
|
|
76
|
+
# Recent dividend
|
|
77
|
+
if "reciente" in dividendos:
|
|
78
|
+
console.print("\n[bold green]Most Recent Dividend(s):[/]")
|
|
79
|
+
for date, info in dividendos["reciente"].items():
|
|
80
|
+
console.print(f" Date: {date}")
|
|
81
|
+
console.print(f" Ex-coupon Date: {info.get('fechaexcupon', 'N/A')}")
|
|
82
|
+
console.print(f" Amount: {info.get('pago', 'N/A')} {info.get('divisa', '')}")
|
|
83
|
+
console.print(f" Type: {info.get('tipo', 'N/A')}")
|
|
84
|
+
console.print()
|
|
85
|
+
|
|
86
|
+
# Historical dividends
|
|
87
|
+
if "historico" in dividendos:
|
|
88
|
+
console.print("\n[bold blue]Dividend History:[/]")
|
|
89
|
+
table = Table("Date", "Amount", "Type")
|
|
90
|
+
|
|
91
|
+
for date, info in dividendos["historico"].items():
|
|
92
|
+
table.add_row(
|
|
93
|
+
date,
|
|
94
|
+
str(info.get("pago", "N/A")),
|
|
95
|
+
info.get("tipo", "N/A"),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
console.print(table)
|
|
99
|
+
|
|
100
|
+
if not found:
|
|
101
|
+
console.print(f"[red]No data found for symbol: {symbol}[/red]")
|
|
102
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
from bmv_cli.api.client import request_prices
|
|
6
|
+
|
|
7
|
+
app = typer.Typer()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command()
|
|
11
|
+
def price(symbols: list[str]):
|
|
12
|
+
response = request_prices(symbols)
|
|
13
|
+
table = Table(
|
|
14
|
+
"simbolo",
|
|
15
|
+
"ultimo",
|
|
16
|
+
"ppp",
|
|
17
|
+
"anterior",
|
|
18
|
+
"maximo",
|
|
19
|
+
"minimo",
|
|
20
|
+
"cambioporciento",
|
|
21
|
+
"cambiopesos",
|
|
22
|
+
"volumenoperado",
|
|
23
|
+
"operaciones",
|
|
24
|
+
"importeacumulado",
|
|
25
|
+
"fecha",
|
|
26
|
+
)
|
|
27
|
+
for symbol, data in response.items():
|
|
28
|
+
row = data["bmv"] if data["bmv"]["f"] > data["biva"]["f"] else data["biva"]
|
|
29
|
+
table.add_row(symbol, *[str(value) for value in row.values()])
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
console.print(table)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
from bmv_cli.api.client import request_emisoras
|
|
6
|
+
|
|
7
|
+
app = typer.Typer()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command()
|
|
11
|
+
def search(
|
|
12
|
+
text: str = typer.Argument(..., help="Search text (letter or company name)"),
|
|
13
|
+
market: str = typer.Option(
|
|
14
|
+
"local",
|
|
15
|
+
"--market",
|
|
16
|
+
"-m",
|
|
17
|
+
help="Market type: local or global",
|
|
18
|
+
case_sensitive=False,
|
|
19
|
+
),
|
|
20
|
+
):
|
|
21
|
+
"""Search for stock symbols"""
|
|
22
|
+
response = request_emisoras(text, market)
|
|
23
|
+
table = Table(
|
|
24
|
+
"Symbol",
|
|
25
|
+
"Status",
|
|
26
|
+
"Company Name",
|
|
27
|
+
"Exchange",
|
|
28
|
+
"Type",
|
|
29
|
+
"Shares Outstanding",
|
|
30
|
+
"ISIN",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
# Response structure: {SYMBOL: {SERIE: {data...}}}
|
|
36
|
+
for symbol, series_data in response.items():
|
|
37
|
+
for serie, data in series_data.items():
|
|
38
|
+
if isinstance(data, dict):
|
|
39
|
+
table.add_row(
|
|
40
|
+
f"{symbol}{serie}",
|
|
41
|
+
data.get("estatus", "N/A"),
|
|
42
|
+
data.get("razon_social", "N/A"),
|
|
43
|
+
data.get("bolsa", "N/A"),
|
|
44
|
+
data.get("tipo_valor_descripcion", "N/A"),
|
|
45
|
+
str(data.get("acciones_en_circulacion", "N/A")),
|
|
46
|
+
data.get("isin", "N/A"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
console.print(table)
|
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Test fixtures for API responses - DataBursatil v2 API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def prices_response():
|
|
5
|
+
"""Mock response from /v2/cotizaciones endpoint."""
|
|
6
|
+
return {
|
|
7
|
+
"GFNORTEO": {
|
|
8
|
+
"bmv": {
|
|
9
|
+
"u": 213.5,
|
|
10
|
+
"p": 213.46,
|
|
11
|
+
"a": 207.91,
|
|
12
|
+
"x": 214.69,
|
|
13
|
+
"n": 206.89,
|
|
14
|
+
"c": 2.67,
|
|
15
|
+
"m": 5.55,
|
|
16
|
+
"v": 8081217.0,
|
|
17
|
+
"o": 25245.0,
|
|
18
|
+
"i": 1710147325.48,
|
|
19
|
+
"f": "2026-10-23 14:00:00",
|
|
20
|
+
},
|
|
21
|
+
"biva": {
|
|
22
|
+
"u": 213.5,
|
|
23
|
+
"p": 213.46,
|
|
24
|
+
"a": 207.91,
|
|
25
|
+
"x": 214.43,
|
|
26
|
+
"n": 207.49,
|
|
27
|
+
"c": 2.68,
|
|
28
|
+
"m": 5.59,
|
|
29
|
+
"v": 1190223.0,
|
|
30
|
+
"o": 6307.0,
|
|
31
|
+
"i": 252457560.01,
|
|
32
|
+
"f": "2026-10-23 14:05:00",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
"WALMEX*": {
|
|
36
|
+
"bmv": {
|
|
37
|
+
"u": 50.73,
|
|
38
|
+
"p": 50.77,
|
|
39
|
+
"a": 50.34,
|
|
40
|
+
"x": 50.94,
|
|
41
|
+
"n": 50.01,
|
|
42
|
+
"c": 0.85,
|
|
43
|
+
"m": 0.43,
|
|
44
|
+
"v": 8904568.0,
|
|
45
|
+
"o": 18587.0,
|
|
46
|
+
"i": 451685201.71,
|
|
47
|
+
"f": "2026-10-23 14:00:00",
|
|
48
|
+
},
|
|
49
|
+
"biva": {
|
|
50
|
+
"u": 50.88,
|
|
51
|
+
"p": 50.77,
|
|
52
|
+
"a": 50.34,
|
|
53
|
+
"x": 50.94,
|
|
54
|
+
"n": 50.12,
|
|
55
|
+
"c": 1.07,
|
|
56
|
+
"m": 0.53,
|
|
57
|
+
"v": 1714520.0,
|
|
58
|
+
"o": 6643.0,
|
|
59
|
+
"i": 86940490.91,
|
|
60
|
+
"f": "2026-10-23 14:05:00",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def currencies_response():
|
|
67
|
+
"""Mock response from /v2/divisas endpoint."""
|
|
68
|
+
return {
|
|
69
|
+
"AUDUSD": {"u": 0.6903, "c": -0.1, "m": -0.0007},
|
|
70
|
+
"CADMXN": {"u": 12.3493, "c": 0.16, "m": 0.0196},
|
|
71
|
+
"CHFMXN": {"u": 21.6778, "c": 0.3, "m": 0.0659},
|
|
72
|
+
"EURMXN": {"u": 19.9913, "c": 0.42, "m": 0.0833},
|
|
73
|
+
"EURUSD": {"u": 1.1408, "c": 0.34, "m": 0.0039},
|
|
74
|
+
"GBPMXN": {"u": 23.1671, "c": 0.29, "m": 0.0671},
|
|
75
|
+
"GBPUSD": {"u": 1.3224, "c": 0.24, "m": 0.0031},
|
|
76
|
+
"USDCAD": {"u": 1.4181, "c": -0.14, "m": -0.002},
|
|
77
|
+
"USDJPY": {"u": 161.55, "c": -0.15, "m": -0.24},
|
|
78
|
+
"USDMXN": {"u": 17.5147, "c": 0.03, "m": 0.0047},
|
|
79
|
+
"t": "2026-06-26 04:01:00",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def credits_response():
|
|
84
|
+
"""Mock response from /v2/creditos endpoint."""
|
|
85
|
+
return {"disponibles": 249421}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def emisoras_response():
|
|
89
|
+
"""Mock response from /v2/emisoras endpoint."""
|
|
90
|
+
return {
|
|
91
|
+
"WALMEX": {
|
|
92
|
+
"*": {
|
|
93
|
+
"razon_social": "WAL - MART DE MEXICO, S.A.B. DE C.V.",
|
|
94
|
+
"isin": "MX01WA000038",
|
|
95
|
+
"bolsa": "BMV",
|
|
96
|
+
"tipo_valor_descripcion": "ACCIONES DE EMPRESAS COMERCIALES, INDUSTRIALES Y DE SERVICIOS",
|
|
97
|
+
"tipo_valor_id": "1",
|
|
98
|
+
"estatus": "ACTIVA",
|
|
99
|
+
"acciones_en_circulacion": 17461402631,
|
|
100
|
+
"rango_historicos": "2010-01-05 a 2026-10-23",
|
|
101
|
+
"rango_financieros": "1T_2017, 1T_2018, 1T_2019, 1T_2020, 1T_2021, 1T_2022, 1T_2023, 1T_2024, 1T_2025, 1T_2026",
|
|
102
|
+
"dividendos": {
|
|
103
|
+
"reciente": {
|
|
104
|
+
"2025-12-17": {
|
|
105
|
+
"pago": 0.85,
|
|
106
|
+
"tipo": "DIVIDENDO EN EFECTIVO",
|
|
107
|
+
"divisa": "MXN",
|
|
108
|
+
"fechaexcupon": "2025-12-16",
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"historico": {
|
|
112
|
+
"2021-11-24": {"pago": 0.81, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
113
|
+
"2021-12-28": {"pago": 0.82, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
114
|
+
"2022-11-22": {"pago": 0.85, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
115
|
+
"2022-12-27": {"pago": 0.86, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
116
|
+
"2023-04-11": {"pago": 0.75, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
117
|
+
"2023-11-29": {"pago": 0.97, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
118
|
+
"2023-12-06": {"pago": 0.97, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
119
|
+
"2024-11-12": {"pago": 0.59, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
120
|
+
"2024-12-10": {"pago": 1.09, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
121
|
+
"2025-11-19": {"pago": 0.84, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
122
|
+
"2025-12-17": {"pago": 0.85, "tipo": "DIVIDENDO EN EFECTIVO"},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def emisoras_error_response():
|
|
131
|
+
"""Mock error response from /v2/emisoras endpoint."""
|
|
132
|
+
return {
|
|
133
|
+
"Error": "La busqueda no arrojo ningun resultado. ¿Dudas? Revisa la documentacion: https://databursatil.com/docs.html"
|
|
134
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Test fixtures package."""
|
|
2
|
+
|
|
3
|
+
from .APIResponses import (
|
|
4
|
+
credits_response,
|
|
5
|
+
currencies_response,
|
|
6
|
+
emisoras_error_response,
|
|
7
|
+
emisoras_response,
|
|
8
|
+
prices_response,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"credits_response",
|
|
13
|
+
"currencies_response",
|
|
14
|
+
"emisoras_error_response",
|
|
15
|
+
"emisoras_response",
|
|
16
|
+
"prices_response",
|
|
17
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from typer.testing import CliRunner
|
|
3
|
+
|
|
4
|
+
from bmv_cli.commands.currencies import app
|
|
5
|
+
from bmv_cli.commands.tests.fixtures import currencies_response
|
|
6
|
+
|
|
7
|
+
runner = CliRunner()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def mock_currencies_api(requests_mock):
|
|
12
|
+
"""Mock the divisas API endpoint."""
|
|
13
|
+
response = currencies_response()
|
|
14
|
+
requests_mock.get(
|
|
15
|
+
"https://api.databursatil.com/v2/divisas",
|
|
16
|
+
json=response,
|
|
17
|
+
)
|
|
18
|
+
return response
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_currencies_command(requests_mock, mock_currencies_api):
|
|
22
|
+
"""Test currencies command with mocked API."""
|
|
23
|
+
result = runner.invoke(app, [], env={"COLUMNS": "200"})
|
|
24
|
+
|
|
25
|
+
assert result.exit_code == 0
|
|
26
|
+
# Check that some currencies appear in output
|
|
27
|
+
assert "USDMXN" in result.stdout
|
|
28
|
+
assert "EURUSD" in result.stdout
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_currencies_table_data(requests_mock, mock_currencies_api):
|
|
32
|
+
"""Verify table output contains expected currency data."""
|
|
33
|
+
result = runner.invoke(app, [], env={"COLUMNS": "200"})
|
|
34
|
+
|
|
35
|
+
assert result.exit_code == 0
|
|
36
|
+
# Check for table headers
|
|
37
|
+
assert "Currency" in result.stdout
|
|
38
|
+
assert "Last Value" in result.stdout
|
|
39
|
+
# Check for specific currency values from the fixture
|
|
40
|
+
assert "17.5147" in result.stdout # USDMXN rate
|
|
41
|
+
assert "1.1408" in result.stdout # EURUSD rate
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_currencies_date_display(requests_mock, mock_currencies_api):
|
|
45
|
+
"""Verify date display."""
|
|
46
|
+
result = runner.invoke(app, [], env={"COLUMNS": "200"})
|
|
47
|
+
|
|
48
|
+
assert result.exit_code == 0
|
|
49
|
+
# Check that the datetime is displayed
|
|
50
|
+
assert "Datetime:" in result.stdout
|
|
51
|
+
assert "2026-06-26 04:01:00" in result.stdout
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typer.testing import CliRunner
|
|
2
|
+
|
|
3
|
+
from bmv_cli.commands.hello import app
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_hello_command():
|
|
7
|
+
"""Test hello command output and exit code."""
|
|
8
|
+
runner = CliRunner()
|
|
9
|
+
result = runner.invoke(app, [])
|
|
10
|
+
|
|
11
|
+
assert result.exit_code == 0
|
|
12
|
+
assert "Hello world" in result.stdout
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from typer.testing import CliRunner
|
|
3
|
+
|
|
4
|
+
from bmv_cli.commands.price import app
|
|
5
|
+
from bmv_cli.commands.tests.fixtures import prices_response
|
|
6
|
+
|
|
7
|
+
runner = CliRunner()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def mock_prices_api(requests_mock):
|
|
12
|
+
"""Mock the cotizaciones API endpoint."""
|
|
13
|
+
response = prices_response()
|
|
14
|
+
requests_mock.get(
|
|
15
|
+
"https://api.databursatil.com/v2/cotizaciones",
|
|
16
|
+
json=response,
|
|
17
|
+
)
|
|
18
|
+
return response
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_single_symbol_price(requests_mock, mock_prices_api):
|
|
22
|
+
"""Test single symbol price lookup with mocked API."""
|
|
23
|
+
result = runner.invoke(app, ["GFNORTEO"], env={"COLUMNS": "200"})
|
|
24
|
+
|
|
25
|
+
assert result.exit_code == 0
|
|
26
|
+
assert "GFNORTEO" in result.stdout
|
|
27
|
+
# Should show BIVA data (more recent timestamp) - ultimo price 213.5
|
|
28
|
+
assert "213.5" in result.stdout
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_multiple_symbols_price(requests_mock, mock_prices_api):
|
|
32
|
+
"""Test multiple symbols price lookup."""
|
|
33
|
+
result = runner.invoke(app, ["GFNORTEO", "WALMEX*"], env={"COLUMNS": "200"})
|
|
34
|
+
|
|
35
|
+
assert result.exit_code == 0
|
|
36
|
+
assert "GFNORTEO" in result.stdout
|
|
37
|
+
assert "WALMEX*" in result.stdout
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_price_table_columns(requests_mock, mock_prices_api):
|
|
41
|
+
"""Verify table output contains expected columns and data."""
|
|
42
|
+
result = runner.invoke(app, ["GFNORTEO"], env={"COLUMNS": "200"})
|
|
43
|
+
|
|
44
|
+
assert result.exit_code == 0
|
|
45
|
+
# Check for table headers (full names with wide terminal)
|
|
46
|
+
assert "simbolo" in result.stdout
|
|
47
|
+
assert "ultimo" in result.stdout
|
|
48
|
+
assert "fecha" in result.stdout
|
|
49
|
+
# Check for data values from the BIVA response (more recent)
|
|
50
|
+
assert "213.5" in result.stdout
|
|
51
|
+
assert "2.68" in result.stdout
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_bmv_vs_biva_selection(requests_mock):
|
|
55
|
+
"""Test BMV vs BIVA selection logic (higher 'f' value wins)."""
|
|
56
|
+
# Create a response where BIVA has a more recent timestamp
|
|
57
|
+
response = {
|
|
58
|
+
"TESTSYMBOL": {
|
|
59
|
+
"bmv": {
|
|
60
|
+
"u": 100.0,
|
|
61
|
+
"p": 99.5,
|
|
62
|
+
"a": 98.0,
|
|
63
|
+
"x": 101.0,
|
|
64
|
+
"n": 97.0,
|
|
65
|
+
"c": 2.0,
|
|
66
|
+
"m": 2.0,
|
|
67
|
+
"v": 1000.0,
|
|
68
|
+
"o": 10.0,
|
|
69
|
+
"i": 100000.0,
|
|
70
|
+
"f": "2026-10-23 14:00:00",
|
|
71
|
+
},
|
|
72
|
+
"biva": {
|
|
73
|
+
"u": 105.0,
|
|
74
|
+
"p": 104.5,
|
|
75
|
+
"a": 103.0,
|
|
76
|
+
"x": 106.0,
|
|
77
|
+
"n": 102.0,
|
|
78
|
+
"c": 3.0,
|
|
79
|
+
"m": 3.0,
|
|
80
|
+
"v": 2000.0,
|
|
81
|
+
"o": 20.0,
|
|
82
|
+
"i": 200000.0,
|
|
83
|
+
"f": "2026-10-23 14:05:00", # More recent
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
requests_mock.get(
|
|
89
|
+
"https://api.databursatil.com/v2/cotizaciones",
|
|
90
|
+
json=response,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
result = runner.invoke(app, ["TESTSYMBOL"], env={"COLUMNS": "200"})
|
|
94
|
+
|
|
95
|
+
assert result.exit_code == 0
|
|
96
|
+
# Should show BIVA data (105.0 ultimo) not BMV (100.0)
|
|
97
|
+
assert "105.0" in result.stdout
|
|
98
|
+
# BMV's ultimo (100.0) should NOT appear since we use BIVA
|
|
99
|
+
# But 100.0 could appear as part of other values, so check for the row pattern
|
|
100
|
+
assert "TESTSYMBOL" in result.stdout
|
bmv_cli/main.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from bmv_cli.commands.credits import app as credits
|
|
4
|
+
from bmv_cli.commands.currencies import app as currencies
|
|
5
|
+
from bmv_cli.commands.dividend import app as dividend
|
|
6
|
+
from bmv_cli.commands.hello import app as hello
|
|
7
|
+
from bmv_cli.commands.price import app as price
|
|
8
|
+
from bmv_cli.commands.search import app as search
|
|
9
|
+
|
|
10
|
+
app = typer.Typer()
|
|
11
|
+
app.add_typer(hello)
|
|
12
|
+
app.add_typer(price)
|
|
13
|
+
app.add_typer(currencies)
|
|
14
|
+
app.add_typer(credits)
|
|
15
|
+
app.add_typer(search)
|
|
16
|
+
app.add_typer(dividend)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
app()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bmv-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for Mexican stock market (BMV/BIVA) data via DataBursatil API
|
|
5
|
+
Author: Axel Berrelleza
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
14
|
+
Classifier: Topic :: Terminals
|
|
15
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
16
|
+
Requires-Dist: requests>=2.32.5
|
|
17
|
+
Requires-Dist: rich>=14.2.0
|
|
18
|
+
Requires-Dist: typer>=0.20.0
|
|
19
|
+
Requires-Dist: pytest>=8.3.5 ; extra == 'dev'
|
|
20
|
+
Requires-Dist: requests-mock>=1.12.1 ; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.9.0 ; extra == 'dev'
|
|
22
|
+
Requires-Dist: mypy>=1.15.0 ; extra == 'dev'
|
|
23
|
+
Requires-Dist: types-requests>=2.32.0.20241016 ; extra == 'dev'
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Project-URL: Homepage, https://github.com/AxelBerrelleza/bmv-cli
|
|
26
|
+
Project-URL: Issues, https://github.com/AxelBerrelleza/bmv-cli/issues
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# bmv-cli
|
|
31
|
+
|
|
32
|
+
A command-line interface for Mexican stock market (BMV/BIVA) data via the [DataBursatil](https://www.databursatil.com) API v2.
|
|
33
|
+
|
|
34
|
+
Get real-time quotes, currency rates, API credits, search for symbols, and dividend information — all from your terminal.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
### uv tool (recommended)
|
|
39
|
+
```bash
|
|
40
|
+
uv tool install bmv-cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### pipx
|
|
44
|
+
```bash
|
|
45
|
+
pipx install bmv-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### pip
|
|
49
|
+
```bash
|
|
50
|
+
pip install bmv-cli
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Setup
|
|
54
|
+
|
|
55
|
+
You need a DataBursatil API token. Get one at <https://www.databursatil.com>.
|
|
56
|
+
|
|
57
|
+
Set the token as an environment variable:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export BMV_API_TOKEN="your-token-here"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
For local development, you can also create a `.env` file in the project root:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
cp .env.sample .env
|
|
67
|
+
# Edit .env and add your BMV_API_TOKEN
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
All commands require the `BMV_API_TOKEN` environment variable.
|
|
73
|
+
|
|
74
|
+
### Get real-time prices
|
|
75
|
+
```bash
|
|
76
|
+
bmv price GFNORTEO WALMEX*
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Get currency exchange rates
|
|
80
|
+
```bash
|
|
81
|
+
bmv currencies
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Check remaining API credits
|
|
85
|
+
```bash
|
|
86
|
+
bmv credits
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Search for stock symbols
|
|
90
|
+
```bash
|
|
91
|
+
bmv search WALMEX
|
|
92
|
+
bmv search GFNORTE --market global
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Get dividend information
|
|
96
|
+
```bash
|
|
97
|
+
bmv dividend FMTY14
|
|
98
|
+
bmv dividend WALMEX*
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Clone and setup
|
|
105
|
+
git clone https://github.com/AxelBerrelleza/bmv-cli.git
|
|
106
|
+
cd bmv-cli
|
|
107
|
+
uv sync --extra dev
|
|
108
|
+
|
|
109
|
+
# Run quality gates
|
|
110
|
+
uv run ruff format --check .
|
|
111
|
+
uv run ruff check .
|
|
112
|
+
uv run mypy src/bmv_cli/api src/bmv_cli/commands
|
|
113
|
+
uv run pytest
|
|
114
|
+
|
|
115
|
+
# Run the CLI during development
|
|
116
|
+
uv run bmv --help
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
See [AGENTS.md](AGENTS.md) for detailed development instructions.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
bmv_cli/__init__.py,sha256=a5FCIEVEu-KVO1kfxUjM-h5QEvKqDioHemcPZfhTCls,64
|
|
2
|
+
bmv_cli/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
bmv_cli/api/client.py,sha256=5ilaYSXMY_ODwmYhvg7JhKWHmY0o3nibB3FAg9dvDz0,1021
|
|
4
|
+
bmv_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
bmv_cli/commands/credits.py,sha256=XN0nlGlCPuGi5oCsz8y1R0YyNHHgzTkgRAYPohYClrM,324
|
|
6
|
+
bmv_cli/commands/currencies.py,sha256=lfDZXAVrKWCmiNV7HRFvLVVUpOnaDVd4K0DZe5W5VoE,600
|
|
7
|
+
bmv_cli/commands/dividend.py,sha256=86cVv834o3SA0pLOvR415KVDKzqj56V9qYdOf1ffCzk,3410
|
|
8
|
+
bmv_cli/commands/hello.py,sha256=w-LWb67zIzAFejYupeTGPAIHq6oaETlkCBV6SkaDVc0,89
|
|
9
|
+
bmv_cli/commands/price.py,sha256=WqlLGvYhArX0W46DO7rmo6YwwPnWgXvYzpC6fgifQ3I,754
|
|
10
|
+
bmv_cli/commands/search.py,sha256=_djAfIXk_VeOgUUOMUUQGb9_ROZp_-Ufi5hUXqFZ1qQ,1328
|
|
11
|
+
bmv_cli/commands/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
bmv_cli/commands/tests/conftest.py,sha256=TdkdP3BwxyZKGmfpR8KVxqddsh19ojaEEVfB0589l3A,176
|
|
13
|
+
bmv_cli/commands/tests/fixtures/APIResponses.py,sha256=TPgAY8t-DmTHs-W-DWUTauGMEWFszLTrYOeO_TPP7QU,4910
|
|
14
|
+
bmv_cli/commands/tests/fixtures/__init__.py,sha256=WGCSdLdwBIr4nT0IZ86ZtTRid8qOClAMIT3VsCaR4gw,325
|
|
15
|
+
bmv_cli/commands/tests/test_currencies.py,sha256=BZ-HKeW2MvKYFi9XFfT8pRxmZltLsVUR4NyMPKw9SgA,1606
|
|
16
|
+
bmv_cli/commands/tests/test_hello.py,sha256=-U1ICTfW-zFJwX9jJXp6HQ_iXGvtkD2UbWV9TqOAuRY,292
|
|
17
|
+
bmv_cli/commands/tests/test_price.py,sha256=RUzHzWRYOlqDTbm2SNvHCRe-CIu8AsOPrLBVq6kN_Cc,3122
|
|
18
|
+
bmv_cli/main.py,sha256=FL8brjsTrUHEPa97LAnRAgtMxhXdbCfbzh2B6tjmn0E,521
|
|
19
|
+
bmv_cli-0.1.0.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
|
|
20
|
+
bmv_cli-0.1.0.dist-info/entry_points.txt,sha256=NJ0fDHrtZ3eG6WYKwuyqwN1O8R9s-eQFCAxThMvQSig,42
|
|
21
|
+
bmv_cli-0.1.0.dist-info/METADATA,sha256=7ZHp-XktMfUdHeHbMbDQ3jwScJxqybQecu-GIUxClr0,2751
|
|
22
|
+
bmv_cli-0.1.0.dist-info/RECORD,,
|