pixbr 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.
- pixbr/__init__.py +69 -0
- pixbr/_odata.py +168 -0
- pixbr/aggregate.py +119 -0
- pixbr/api.py +97 -0
- pixbr/client.py +333 -0
- pixbr/utils.py +118 -0
- pixbr-0.1.0.dist-info/METADATA +108 -0
- pixbr-0.1.0.dist-info/RECORD +9 -0
- pixbr-0.1.0.dist-info/WHEEL +4 -0
pixbr/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""pixbr — access the Brazilian Central Bank PIX Open Data API from Python.
|
|
2
|
+
|
|
3
|
+
Two ways to use it:
|
|
4
|
+
|
|
5
|
+
1. A reusable client (recommended for multiple requests)::
|
|
6
|
+
|
|
7
|
+
from pixbr import PixClient
|
|
8
|
+
client = PixClient()
|
|
9
|
+
df = client.transaction_stats("202509", filter="NATUREZA eq 'P2P'")
|
|
10
|
+
|
|
11
|
+
2. Module-level convenience functions mirroring the R package ``pixr``::
|
|
12
|
+
|
|
13
|
+
from pixbr import get_pix_transaction_stats
|
|
14
|
+
df = get_pix_transaction_stats("202509")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .client import ENDPOINTS, PixApiError, PixClient
|
|
20
|
+
from .utils import (
|
|
21
|
+
format_brl,
|
|
22
|
+
pix_columns,
|
|
23
|
+
pix_endpoints,
|
|
24
|
+
year_month_to_date,
|
|
25
|
+
)
|
|
26
|
+
from .api import (
|
|
27
|
+
get_pix_fraud_stats,
|
|
28
|
+
get_pix_fraud_stats_multi,
|
|
29
|
+
get_pix_keys,
|
|
30
|
+
get_pix_keys_by_type,
|
|
31
|
+
get_pix_keys_summary,
|
|
32
|
+
get_pix_summary,
|
|
33
|
+
get_pix_transaction_stats,
|
|
34
|
+
get_pix_transaction_stats_multi,
|
|
35
|
+
get_pix_transactions_by_municipality,
|
|
36
|
+
get_pix_transactions_by_region,
|
|
37
|
+
get_pix_transactions_by_state,
|
|
38
|
+
pix_ping,
|
|
39
|
+
pix_query,
|
|
40
|
+
pix_url,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"PixClient",
|
|
47
|
+
"PixApiError",
|
|
48
|
+
"ENDPOINTS",
|
|
49
|
+
# utils
|
|
50
|
+
"format_brl",
|
|
51
|
+
"pix_columns",
|
|
52
|
+
"pix_endpoints",
|
|
53
|
+
"year_month_to_date",
|
|
54
|
+
# convenience functions
|
|
55
|
+
"get_pix_keys",
|
|
56
|
+
"get_pix_keys_summary",
|
|
57
|
+
"get_pix_keys_by_type",
|
|
58
|
+
"get_pix_transaction_stats",
|
|
59
|
+
"get_pix_transaction_stats_multi",
|
|
60
|
+
"get_pix_summary",
|
|
61
|
+
"get_pix_transactions_by_municipality",
|
|
62
|
+
"get_pix_transactions_by_state",
|
|
63
|
+
"get_pix_transactions_by_region",
|
|
64
|
+
"get_pix_fraud_stats",
|
|
65
|
+
"get_pix_fraud_stats_multi",
|
|
66
|
+
"pix_ping",
|
|
67
|
+
"pix_query",
|
|
68
|
+
"pix_url",
|
|
69
|
+
]
|
pixbr/_odata.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""URL building and parameter helpers for the BCB PIX Open Data (OData) API.
|
|
2
|
+
|
|
3
|
+
The BCB Olinda API uses a non-standard OData syntax where endpoint parameters
|
|
4
|
+
are passed as function arguments in the URL path *and* repeated as named query
|
|
5
|
+
parameters, e.g.::
|
|
6
|
+
|
|
7
|
+
ChavesPix(Data=@Data)?$format=json&@Data='2025-12-01'&$top=10
|
|
8
|
+
|
|
9
|
+
These helpers reproduce the exact URL construction used by the R package
|
|
10
|
+
``pixr`` (which deliberately avoids standard percent-encoding, encoding only
|
|
11
|
+
spaces as ``%20``). They are pure functions so they can be unit-tested without
|
|
12
|
+
hitting the network.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import warnings
|
|
19
|
+
from datetime import date, datetime
|
|
20
|
+
from typing import Mapping, Optional, Sequence, Union
|
|
21
|
+
|
|
22
|
+
BASE_URL = "https://olinda.bcb.gov.br/olinda/servico/Pix_DadosAbertos/versao/v1/odata"
|
|
23
|
+
DEFAULT_TIMEOUT = 120
|
|
24
|
+
|
|
25
|
+
ParamValue = Union[str, int, float]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_url(
|
|
29
|
+
endpoint: str,
|
|
30
|
+
params: Optional[Mapping[str, ParamValue]] = None,
|
|
31
|
+
*,
|
|
32
|
+
fmt: str = "json",
|
|
33
|
+
filter: Optional[str] = None,
|
|
34
|
+
select: Optional[Sequence[str]] = None,
|
|
35
|
+
orderby: Optional[str] = None,
|
|
36
|
+
top: Optional[int] = None,
|
|
37
|
+
skip: Optional[int] = None,
|
|
38
|
+
base_url: str = BASE_URL,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Build a full request URL for a BCB PIX OData endpoint.
|
|
41
|
+
|
|
42
|
+
Mirrors ``pixr::pix_request`` URL construction exactly.
|
|
43
|
+
"""
|
|
44
|
+
# Endpoint path, with function-style parameter declaration.
|
|
45
|
+
if params:
|
|
46
|
+
func_params = ",".join(f"{name}=@{name}" for name in params)
|
|
47
|
+
endpoint_url = f"{base_url}/{endpoint}({func_params})"
|
|
48
|
+
else:
|
|
49
|
+
endpoint_url = f"{base_url}/{endpoint}"
|
|
50
|
+
|
|
51
|
+
query_parts: list[str] = [f"$format={fmt}"]
|
|
52
|
+
|
|
53
|
+
# Endpoint parameters as @Param='value' (quoted for strings).
|
|
54
|
+
if params:
|
|
55
|
+
for name, value in params.items():
|
|
56
|
+
if isinstance(value, str):
|
|
57
|
+
query_parts.append(f"@{name}='{value}'")
|
|
58
|
+
else:
|
|
59
|
+
query_parts.append(f"@{name}={value}")
|
|
60
|
+
|
|
61
|
+
if filter:
|
|
62
|
+
# Collapse whitespace after commas and opening parens, matching pixr.
|
|
63
|
+
filter = re.sub(r",\s+", ",", filter)
|
|
64
|
+
filter = re.sub(r"\(\s+", "(", filter)
|
|
65
|
+
query_parts.append(f"$filter={filter}")
|
|
66
|
+
|
|
67
|
+
if select:
|
|
68
|
+
query_parts.append(f"$select={','.join(select)}")
|
|
69
|
+
|
|
70
|
+
if orderby:
|
|
71
|
+
query_parts.append(f"$orderby={orderby}")
|
|
72
|
+
|
|
73
|
+
if top is not None:
|
|
74
|
+
query_parts.append(f"$top={int(top)}")
|
|
75
|
+
|
|
76
|
+
if skip is not None:
|
|
77
|
+
warnings.warn(
|
|
78
|
+
"Parameter 'skip' is not supported by the BCB PIX API; "
|
|
79
|
+
"pagination with skip is not available. Use 'top' to limit results.",
|
|
80
|
+
stacklevel=2,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
query_string = "&".join(query_parts)
|
|
84
|
+
# The API is picky about encoding: encode only spaces, like pixr.
|
|
85
|
+
query_string = query_string.replace(" ", "%20")
|
|
86
|
+
return f"{endpoint_url}?{query_string}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_year_month(year_month: Union[str, int, date, None]) -> Optional[str]:
|
|
90
|
+
"""Normalize a year-month to ``YYYYMM`` string form."""
|
|
91
|
+
if year_month is None:
|
|
92
|
+
return None
|
|
93
|
+
if isinstance(year_month, (date, datetime)):
|
|
94
|
+
return year_month.strftime("%Y%m")
|
|
95
|
+
s = str(year_month)
|
|
96
|
+
if not re.fullmatch(r"\d{6}", s):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Invalid year_month format: {s!r}. "
|
|
99
|
+
"Expected YYYYMM (e.g. '202312' for December 2023)."
|
|
100
|
+
)
|
|
101
|
+
return s
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_date_param(value: Union[str, date, None]) -> Optional[str]:
|
|
105
|
+
"""Normalize a date to ``YYYY-MM-DD`` string form (for the ChavesPix endpoint)."""
|
|
106
|
+
if value is None:
|
|
107
|
+
return None
|
|
108
|
+
if isinstance(value, (date, datetime)):
|
|
109
|
+
return value.strftime("%Y-%m-%d")
|
|
110
|
+
s = str(value)
|
|
111
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", s):
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Invalid date format: {s!r}. Expected YYYY-MM-DD (e.g. '2025-12-01')."
|
|
114
|
+
)
|
|
115
|
+
return s
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_columns(
|
|
119
|
+
columns: Optional[Sequence[str]], valid_columns: Sequence[str]
|
|
120
|
+
) -> Optional[list[str]]:
|
|
121
|
+
"""Drop unknown column names (with a warning), preserving order."""
|
|
122
|
+
if columns is None:
|
|
123
|
+
return None
|
|
124
|
+
valid_set = set(valid_columns)
|
|
125
|
+
invalid = [c for c in columns if c not in valid_set]
|
|
126
|
+
if invalid:
|
|
127
|
+
warnings.warn(
|
|
128
|
+
f"Unknown column(s) ignored: {invalid}. Valid columns: {list(valid_columns)}",
|
|
129
|
+
stacklevel=2,
|
|
130
|
+
)
|
|
131
|
+
kept = [c for c in columns if c in valid_set]
|
|
132
|
+
return kept or None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def format_orderby(
|
|
136
|
+
orderby: Optional[str], valid_columns: Optional[Sequence[str]] = None
|
|
137
|
+
) -> Optional[str]:
|
|
138
|
+
"""Format an orderby spec into the OData ``"Column asc|desc"`` form.
|
|
139
|
+
|
|
140
|
+
Accepts a leading ``-`` for descending order, or an already-formatted
|
|
141
|
+
``"Column desc"`` string (passed through after validation).
|
|
142
|
+
"""
|
|
143
|
+
if not orderby:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
if orderby.startswith("-"):
|
|
147
|
+
col, direction = orderby[1:], "desc"
|
|
148
|
+
elif " " in orderby:
|
|
149
|
+
# Already "Column asc/desc" form.
|
|
150
|
+
col = orderby.split(" ", 1)[0]
|
|
151
|
+
if valid_columns is not None and col not in valid_columns:
|
|
152
|
+
warnings.warn(
|
|
153
|
+
f"Invalid orderby column {col!r}; orderby will be ignored.",
|
|
154
|
+
stacklevel=2,
|
|
155
|
+
)
|
|
156
|
+
return None
|
|
157
|
+
return orderby
|
|
158
|
+
else:
|
|
159
|
+
col, direction = orderby, "asc"
|
|
160
|
+
|
|
161
|
+
if valid_columns is not None and col not in valid_columns:
|
|
162
|
+
warnings.warn(
|
|
163
|
+
f"Invalid orderby column {col!r}; orderby will be ignored.",
|
|
164
|
+
stacklevel=2,
|
|
165
|
+
)
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
return f"{col} {direction}"
|
pixbr/aggregate.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Convenience aggregations over the raw endpoint data (pandas-based).
|
|
2
|
+
|
|
3
|
+
These mirror the dplyr-based summaries in ``pixr`` (get_pix_summary,
|
|
4
|
+
get_pix_keys_summary, get_pix_transactions_by_state, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import List, Sequence, Union
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from .client import PixClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def keys_summary(client: PixClient, date: str, n_top: int = 20) -> pd.DataFrame:
|
|
17
|
+
"""Total keys by institution, sorted descending, limited to ``n_top``."""
|
|
18
|
+
data = client.keys(date=date)
|
|
19
|
+
if data.empty:
|
|
20
|
+
return data
|
|
21
|
+
agg = (
|
|
22
|
+
data.groupby(["Nome", "ISPB"])
|
|
23
|
+
.agg(
|
|
24
|
+
total_keys=("qtdChaves", "sum"),
|
|
25
|
+
n_key_types=("TipoChave", "nunique"),
|
|
26
|
+
)
|
|
27
|
+
.reset_index()
|
|
28
|
+
)
|
|
29
|
+
pf = data[data["NaturezaUsuario"] == "PF"].groupby(["Nome", "ISPB"])["qtdChaves"].sum()
|
|
30
|
+
pj = data[data["NaturezaUsuario"] == "PJ"].groupby(["Nome", "ISPB"])["qtdChaves"].sum()
|
|
31
|
+
agg["pf_keys"] = agg.set_index(["Nome", "ISPB"]).index.map(pf).fillna(0).to_numpy()
|
|
32
|
+
agg["pj_keys"] = agg.set_index(["Nome", "ISPB"]).index.map(pj).fillna(0).to_numpy()
|
|
33
|
+
return agg.sort_values("total_keys", ascending=False).head(n_top).reset_index(drop=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def keys_by_type(client: PixClient, date: str) -> pd.DataFrame:
|
|
37
|
+
"""Total keys grouped by key type and user nature."""
|
|
38
|
+
data = client.keys(date=date)
|
|
39
|
+
if data.empty:
|
|
40
|
+
return data
|
|
41
|
+
return (
|
|
42
|
+
data.groupby(["TipoChave", "NaturezaUsuario"])
|
|
43
|
+
.agg(total_keys=("qtdChaves", "sum"), n_institutions=("ISPB", "nunique"))
|
|
44
|
+
.reset_index()
|
|
45
|
+
.sort_values("total_keys", ascending=False)
|
|
46
|
+
.reset_index(drop=True)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def transaction_summary(
|
|
51
|
+
client: PixClient, database: str, group_by: Union[str, Sequence[str]] = "NATUREZA"
|
|
52
|
+
) -> pd.DataFrame:
|
|
53
|
+
"""Aggregate transaction statistics by one or more grouping columns."""
|
|
54
|
+
data = client.transaction_stats(database=database)
|
|
55
|
+
if data.empty:
|
|
56
|
+
return data
|
|
57
|
+
keys = [group_by] if isinstance(group_by, str) else list(group_by)
|
|
58
|
+
grouped = data.groupby(keys)
|
|
59
|
+
out = grouped.agg(
|
|
60
|
+
total_value=("VALOR", "sum"),
|
|
61
|
+
total_count=("QUANTIDADE", "sum"),
|
|
62
|
+
n_records=("VALOR", "size"),
|
|
63
|
+
).reset_index()
|
|
64
|
+
out["avg_value"] = out["total_value"] / out["total_count"]
|
|
65
|
+
return out.sort_values("total_value", ascending=False).reset_index(drop=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_MUNI_SUM_COLS = [
|
|
69
|
+
"VL_PagadorPF", "QT_PagadorPF", "VL_PagadorPJ", "QT_PagadorPJ",
|
|
70
|
+
"VL_RecebedorPF", "QT_RecebedorPF", "VL_RecebedorPJ", "QT_RecebedorPJ",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _muni_lower(col: str) -> str:
|
|
75
|
+
return col.lower().replace("pagadorpf", "pagador_pf").replace("pagadorpj", "pagador_pj") \
|
|
76
|
+
.replace("recebedorpf", "recebedor_pf").replace("recebedorpj", "recebedor_pj")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def transactions_by_state(client: PixClient, database: str) -> pd.DataFrame:
|
|
80
|
+
"""Aggregate municipality data to the state level."""
|
|
81
|
+
return _aggregate_geo(
|
|
82
|
+
client, database,
|
|
83
|
+
keys=["AnoMes", "Estado_Ibge", "Estado", "Sigla_Regiao", "Regiao"],
|
|
84
|
+
count_name="n_municipalities",
|
|
85
|
+
count_kind="size",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def transactions_by_region(client: PixClient, database: str) -> pd.DataFrame:
|
|
90
|
+
"""Aggregate municipality data to the region level."""
|
|
91
|
+
data = client.transactions_by_municipality(database=database)
|
|
92
|
+
if data.empty:
|
|
93
|
+
return data
|
|
94
|
+
agg = {c: "sum" for c in _MUNI_SUM_COLS if c in data.columns}
|
|
95
|
+
out = data.groupby(["AnoMes", "Sigla_Regiao", "Regiao"]).agg(
|
|
96
|
+
n_states=("Estado_Ibge", "nunique"),
|
|
97
|
+
n_municipalities=("Estado_Ibge", "size"),
|
|
98
|
+
**{_muni_lower(c): (c, "sum") for c in _MUNI_SUM_COLS if c in data.columns},
|
|
99
|
+
).reset_index()
|
|
100
|
+
sort_col = "vl_pagador_pf" if "vl_pagador_pf" in out.columns else out.columns[-1]
|
|
101
|
+
return out.sort_values(sort_col, ascending=False).reset_index(drop=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _aggregate_geo(
|
|
105
|
+
client: PixClient,
|
|
106
|
+
database: str,
|
|
107
|
+
keys: List[str],
|
|
108
|
+
count_name: str,
|
|
109
|
+
count_kind: str,
|
|
110
|
+
) -> pd.DataFrame:
|
|
111
|
+
data = client.transactions_by_municipality(database=database)
|
|
112
|
+
if data.empty:
|
|
113
|
+
return data
|
|
114
|
+
out = data.groupby(keys).agg(
|
|
115
|
+
**{count_name: ("AnoMes", count_kind)},
|
|
116
|
+
**{_muni_lower(c): (c, "sum") for c in _MUNI_SUM_COLS if c in data.columns},
|
|
117
|
+
).reset_index()
|
|
118
|
+
sort_col = "vl_pagador_pf" if "vl_pagador_pf" in out.columns else out.columns[-1]
|
|
119
|
+
return out.sort_values(sort_col, ascending=False).reset_index(drop=True)
|
pixbr/api.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Module-level convenience functions mirroring the pixr function names.
|
|
2
|
+
|
|
3
|
+
Each call creates a short-lived :class:`~pixbr.client.PixClient`. For repeated
|
|
4
|
+
requests, instantiate a ``PixClient`` once and reuse it (it keeps an HTTP
|
|
5
|
+
connection pool open).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import List, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from . import aggregate
|
|
15
|
+
from .client import PixClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_pix_keys(date: str, *, verbose: bool = True, **kwargs) -> pd.DataFrame:
|
|
19
|
+
with PixClient(verbose=verbose) as c:
|
|
20
|
+
return c.keys(date=date, verbose=verbose, **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_pix_keys_summary(date: str, n_top: int = 20, *, verbose: bool = True) -> pd.DataFrame:
|
|
24
|
+
with PixClient(verbose=verbose) as c:
|
|
25
|
+
return aggregate.keys_summary(c, date=date, n_top=n_top)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_pix_keys_by_type(date: str, *, verbose: bool = True) -> pd.DataFrame:
|
|
29
|
+
with PixClient(verbose=verbose) as c:
|
|
30
|
+
return aggregate.keys_by_type(c, date=date)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_pix_transaction_stats(database: str, *, verbose: bool = True, **kwargs) -> pd.DataFrame:
|
|
34
|
+
with PixClient(verbose=verbose) as c:
|
|
35
|
+
return c.transaction_stats(database=database, verbose=verbose, **kwargs)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_pix_summary(database: str, group_by="NATUREZA", *, verbose: bool = True) -> pd.DataFrame:
|
|
39
|
+
with PixClient(verbose=verbose) as c:
|
|
40
|
+
return aggregate.transaction_summary(c, database=database, group_by=group_by)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_pix_transactions_by_municipality(
|
|
44
|
+
database: str, *, verbose: bool = True, **kwargs
|
|
45
|
+
) -> pd.DataFrame:
|
|
46
|
+
with PixClient(verbose=verbose) as c:
|
|
47
|
+
return c.transactions_by_municipality(database=database, verbose=verbose, **kwargs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_pix_transactions_by_state(database: str, *, verbose: bool = True) -> pd.DataFrame:
|
|
51
|
+
with PixClient(verbose=verbose) as c:
|
|
52
|
+
return aggregate.transactions_by_state(c, database=database)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_pix_transactions_by_region(database: str, *, verbose: bool = True) -> pd.DataFrame:
|
|
56
|
+
with PixClient(verbose=verbose) as c:
|
|
57
|
+
return aggregate.transactions_by_region(c, database=database)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_pix_fraud_stats(database: str, *, verbose: bool = True, **kwargs) -> pd.DataFrame:
|
|
61
|
+
with PixClient(verbose=verbose) as c:
|
|
62
|
+
return c.fraud_stats(database=database, verbose=verbose, **kwargs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_multi(method_name: str, databases: Sequence[str], **kwargs) -> pd.DataFrame:
|
|
66
|
+
frames: List[pd.DataFrame] = []
|
|
67
|
+
with PixClient(verbose=False) as c:
|
|
68
|
+
method = getattr(c, method_name)
|
|
69
|
+
for db in databases:
|
|
70
|
+
try:
|
|
71
|
+
frames.append(method(database=db, verbose=False, **kwargs))
|
|
72
|
+
except Exception: # noqa: BLE001 - keep going on per-month failures
|
|
73
|
+
frames.append(pd.DataFrame())
|
|
74
|
+
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_pix_transaction_stats_multi(databases: Sequence[str], **kwargs) -> pd.DataFrame:
|
|
78
|
+
return _get_multi("transaction_stats", databases, **kwargs)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_pix_fraud_stats_multi(databases: Sequence[str], **kwargs) -> pd.DataFrame:
|
|
82
|
+
return _get_multi("fraud_stats", databases, **kwargs)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def pix_ping() -> pd.DataFrame:
|
|
86
|
+
with PixClient() as c:
|
|
87
|
+
return c.ping()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pix_query(endpoint: str, params: Optional[dict] = None, *, verbose: bool = True, **kwargs) -> pd.DataFrame:
|
|
91
|
+
with PixClient(verbose=verbose) as c:
|
|
92
|
+
return c.query(endpoint, params, verbose=verbose, **kwargs)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def pix_url(endpoint: str, params: Optional[dict] = None, **kwargs) -> str:
|
|
96
|
+
with PixClient(verbose=False) as c:
|
|
97
|
+
return c.build_url(endpoint, params, **kwargs)
|
pixbr/client.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""HTTP client for the BCB PIX Open Data API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Mapping, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from . import _odata
|
|
12
|
+
from ._odata import ParamValue
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("pixbr")
|
|
15
|
+
|
|
16
|
+
# Endpoint metadata: endpoint name -> (param name, param format).
|
|
17
|
+
ENDPOINTS = {
|
|
18
|
+
"ChavesPix": ("Data", "YYYY-MM-DD"),
|
|
19
|
+
"TransacoesPixPorMunicipio": ("DataBase", "YYYYMM"),
|
|
20
|
+
"EstatisticasTransacoesPix": ("Database", "YYYYMM"),
|
|
21
|
+
"EstatisticasFraudesPix": ("Database", "YYYYMM"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_USER_AGENT = "pixbr Python package (https://github.com/StrategicProjects/pixbr)"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PixApiError(RuntimeError):
|
|
28
|
+
"""Raised when the BCB PIX API returns an error or cannot be reached."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PixClient:
|
|
32
|
+
"""Client for the Brazilian Central Bank PIX Open Data API.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
timeout:
|
|
37
|
+
Request timeout in seconds (default 120). The BCB API can be slow for
|
|
38
|
+
large queries, so a generous timeout is recommended.
|
|
39
|
+
max_retries:
|
|
40
|
+
Number of retry attempts on transport errors (default 3).
|
|
41
|
+
verbose:
|
|
42
|
+
If True (default), log progress messages at INFO level.
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> client = PixClient()
|
|
47
|
+
>>> df = client.keys(date="2025-12-01", top=100) # doctest: +SKIP
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
timeout: float = _odata.DEFAULT_TIMEOUT,
|
|
53
|
+
max_retries: int = 3,
|
|
54
|
+
verbose: bool = True,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.timeout = timeout
|
|
57
|
+
self.verbose = verbose
|
|
58
|
+
transport = httpx.HTTPTransport(retries=max_retries)
|
|
59
|
+
self._http = httpx.Client(
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
transport=transport,
|
|
62
|
+
headers={
|
|
63
|
+
"Accept": "application/json;odata.metadata=minimal",
|
|
64
|
+
"User-Agent": _USER_AGENT,
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# -- context manager / lifecycle ------------------------------------
|
|
69
|
+
|
|
70
|
+
def __enter__(self) -> "PixClient":
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def __exit__(self, *exc) -> None:
|
|
74
|
+
self.close()
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
self._http.close()
|
|
78
|
+
|
|
79
|
+
# -- low-level ------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def build_url(
|
|
82
|
+
self,
|
|
83
|
+
endpoint: str,
|
|
84
|
+
params: Optional[Mapping[str, ParamValue]] = None,
|
|
85
|
+
*,
|
|
86
|
+
fmt: str = "json",
|
|
87
|
+
filter: Optional[str] = None,
|
|
88
|
+
select: Optional[Sequence[str]] = None,
|
|
89
|
+
orderby: Optional[str] = None,
|
|
90
|
+
top: Optional[int] = None,
|
|
91
|
+
skip: Optional[int] = None,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Build (without sending) the URL for a request. Useful for debugging."""
|
|
94
|
+
return _odata.build_url(
|
|
95
|
+
endpoint,
|
|
96
|
+
params,
|
|
97
|
+
fmt=fmt,
|
|
98
|
+
filter=filter,
|
|
99
|
+
select=select,
|
|
100
|
+
orderby=orderby,
|
|
101
|
+
top=top,
|
|
102
|
+
skip=skip,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def query(
|
|
106
|
+
self,
|
|
107
|
+
endpoint: str,
|
|
108
|
+
params: Optional[Mapping[str, ParamValue]] = None,
|
|
109
|
+
*,
|
|
110
|
+
filter: Optional[str] = None,
|
|
111
|
+
select: Optional[Sequence[str]] = None,
|
|
112
|
+
orderby: Optional[str] = None,
|
|
113
|
+
top: Optional[int] = None,
|
|
114
|
+
skip: Optional[int] = None,
|
|
115
|
+
verbose: Optional[bool] = None,
|
|
116
|
+
) -> pd.DataFrame:
|
|
117
|
+
"""Low-level call to any PIX endpoint; returns a DataFrame.
|
|
118
|
+
|
|
119
|
+
This is the Python equivalent of ``pixr::pix_query``.
|
|
120
|
+
"""
|
|
121
|
+
url = self.build_url(
|
|
122
|
+
endpoint,
|
|
123
|
+
params,
|
|
124
|
+
filter=filter,
|
|
125
|
+
select=select,
|
|
126
|
+
orderby=orderby,
|
|
127
|
+
top=top,
|
|
128
|
+
skip=skip,
|
|
129
|
+
)
|
|
130
|
+
return self._perform(url, verbose=verbose)
|
|
131
|
+
|
|
132
|
+
def _perform(self, url: str, verbose: Optional[bool] = None) -> pd.DataFrame:
|
|
133
|
+
verbose = self.verbose if verbose is None else verbose
|
|
134
|
+
if verbose:
|
|
135
|
+
logger.info("URL: %s", url)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
resp = self._http.get(url)
|
|
139
|
+
except httpx.HTTPError as exc:
|
|
140
|
+
raise PixApiError(
|
|
141
|
+
f"Connection error to BCB PIX API. Check your internet connection; "
|
|
142
|
+
f"the API may be temporarily unavailable. URL: {url}"
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
if resp.status_code != 200:
|
|
146
|
+
body = resp.text[:500]
|
|
147
|
+
raise PixApiError(
|
|
148
|
+
f"API request failed with status {resp.status_code} "
|
|
149
|
+
f"({resp.reason_phrase}). URL: {resp.url}. Response: {body}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
body = resp.json()
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
raise PixApiError(
|
|
156
|
+
f"Failed to parse JSON response. Raw response: {resp.text[:500]}"
|
|
157
|
+
) from exc
|
|
158
|
+
|
|
159
|
+
# OData responses wrap rows in a top-level 'value' field.
|
|
160
|
+
data = body.get("value", body) if isinstance(body, dict) else body
|
|
161
|
+
|
|
162
|
+
if not data:
|
|
163
|
+
if verbose:
|
|
164
|
+
logger.info("No data returned from API")
|
|
165
|
+
return pd.DataFrame()
|
|
166
|
+
|
|
167
|
+
df = pd.DataFrame(data)
|
|
168
|
+
if verbose:
|
|
169
|
+
logger.info("Retrieved %d record(s)", len(df))
|
|
170
|
+
return df
|
|
171
|
+
|
|
172
|
+
# -- typed endpoint accessors --------------------------------------
|
|
173
|
+
|
|
174
|
+
def keys(
|
|
175
|
+
self,
|
|
176
|
+
date: str,
|
|
177
|
+
*,
|
|
178
|
+
filter: Optional[str] = None,
|
|
179
|
+
columns: Optional[Sequence[str]] = None,
|
|
180
|
+
top: Optional[int] = None,
|
|
181
|
+
skip: Optional[int] = None,
|
|
182
|
+
orderby: Optional[str] = None,
|
|
183
|
+
verbose: Optional[bool] = None,
|
|
184
|
+
) -> pd.DataFrame:
|
|
185
|
+
"""PIX keys stock by participant (ChavesPix endpoint).
|
|
186
|
+
|
|
187
|
+
``date`` is required, in YYYY-MM-DD form. The API returns data for the
|
|
188
|
+
last day of the month containing the given date.
|
|
189
|
+
"""
|
|
190
|
+
valid = ["Data", "ISPB", "Nome", "NaturezaUsuario", "TipoChave", "qtdChaves"]
|
|
191
|
+
columns = _odata.validate_columns(columns, valid)
|
|
192
|
+
df = self.query(
|
|
193
|
+
"ChavesPix",
|
|
194
|
+
{"Data": _odata.parse_date_param(date)},
|
|
195
|
+
filter=filter,
|
|
196
|
+
select=columns,
|
|
197
|
+
orderby=orderby,
|
|
198
|
+
top=top,
|
|
199
|
+
skip=skip,
|
|
200
|
+
verbose=verbose,
|
|
201
|
+
)
|
|
202
|
+
return _coerce_numeric(df, ["qtdChaves"])
|
|
203
|
+
|
|
204
|
+
def transaction_stats(
|
|
205
|
+
self,
|
|
206
|
+
database: str,
|
|
207
|
+
*,
|
|
208
|
+
filter: Optional[str] = None,
|
|
209
|
+
columns: Optional[Sequence[str]] = None,
|
|
210
|
+
top: Optional[int] = None,
|
|
211
|
+
skip: Optional[int] = None,
|
|
212
|
+
orderby: Optional[str] = None,
|
|
213
|
+
verbose: Optional[bool] = None,
|
|
214
|
+
) -> pd.DataFrame:
|
|
215
|
+
"""PIX transaction statistics (EstatisticasTransacoesPix endpoint).
|
|
216
|
+
|
|
217
|
+
``database`` is required, in YYYYMM form.
|
|
218
|
+
"""
|
|
219
|
+
valid = [
|
|
220
|
+
"AnoMes", "PAG_PFPJ", "REC_PFPJ", "PAG_REGIAO", "REC_REGIAO",
|
|
221
|
+
"PAG_IDADE", "REC_IDADE", "FORMAINICIACAO", "NATUREZA",
|
|
222
|
+
"FINALIDADE", "VALOR", "QUANTIDADE",
|
|
223
|
+
]
|
|
224
|
+
columns = _odata.validate_columns(columns, valid)
|
|
225
|
+
df = self.query(
|
|
226
|
+
"EstatisticasTransacoesPix",
|
|
227
|
+
{"Database": _odata.parse_year_month(database)},
|
|
228
|
+
filter=filter,
|
|
229
|
+
select=columns,
|
|
230
|
+
orderby=orderby,
|
|
231
|
+
top=top,
|
|
232
|
+
skip=skip,
|
|
233
|
+
verbose=verbose,
|
|
234
|
+
)
|
|
235
|
+
return _coerce_numeric(df, ["AnoMes", "VALOR", "QUANTIDADE"])
|
|
236
|
+
|
|
237
|
+
def transactions_by_municipality(
|
|
238
|
+
self,
|
|
239
|
+
database: str,
|
|
240
|
+
*,
|
|
241
|
+
filter: Optional[str] = None,
|
|
242
|
+
columns: Optional[Sequence[str]] = None,
|
|
243
|
+
top: Optional[int] = None,
|
|
244
|
+
skip: Optional[int] = None,
|
|
245
|
+
orderby: Optional[str] = None,
|
|
246
|
+
verbose: Optional[bool] = None,
|
|
247
|
+
) -> pd.DataFrame:
|
|
248
|
+
"""PIX transactions by municipality (TransacoesPixPorMunicipio endpoint).
|
|
249
|
+
|
|
250
|
+
``database`` is required, in YYYYMM form. Note this endpoint uses the
|
|
251
|
+
``DataBase`` parameter (capital B).
|
|
252
|
+
"""
|
|
253
|
+
valid = [
|
|
254
|
+
"AnoMes", "Municipio_Ibge", "Municipio", "Estado_Ibge", "Estado",
|
|
255
|
+
"Sigla_Regiao", "Regiao",
|
|
256
|
+
"VL_PagadorPF", "QT_PagadorPF", "VL_PagadorPJ", "QT_PagadorPJ",
|
|
257
|
+
"VL_RecebedorPF", "QT_RecebedorPF", "VL_RecebedorPJ", "QT_RecebedorPJ",
|
|
258
|
+
"QT_PES_PagadorPF", "QT_PES_PagadorPJ",
|
|
259
|
+
"QT_PES_RecebedorPF", "QT_PES_RecebedorPJ",
|
|
260
|
+
]
|
|
261
|
+
columns = _odata.validate_columns(columns, valid)
|
|
262
|
+
df = self.query(
|
|
263
|
+
"TransacoesPixPorMunicipio",
|
|
264
|
+
{"DataBase": _odata.parse_year_month(database)},
|
|
265
|
+
filter=filter,
|
|
266
|
+
select=columns,
|
|
267
|
+
orderby=orderby,
|
|
268
|
+
top=top,
|
|
269
|
+
skip=skip,
|
|
270
|
+
verbose=verbose,
|
|
271
|
+
)
|
|
272
|
+
numeric = [c for c in valid if c not in ("Municipio", "Estado", "Sigla_Regiao", "Regiao")]
|
|
273
|
+
return _coerce_numeric(df, numeric)
|
|
274
|
+
|
|
275
|
+
def fraud_stats(
|
|
276
|
+
self,
|
|
277
|
+
database: str,
|
|
278
|
+
*,
|
|
279
|
+
filter: Optional[str] = None,
|
|
280
|
+
columns: Optional[Sequence[str]] = None,
|
|
281
|
+
top: Optional[int] = None,
|
|
282
|
+
skip: Optional[int] = None,
|
|
283
|
+
orderby: Optional[str] = None,
|
|
284
|
+
verbose: Optional[bool] = None,
|
|
285
|
+
) -> pd.DataFrame:
|
|
286
|
+
"""PIX fraud statistics / MED (EstatisticasFraudesPix endpoint).
|
|
287
|
+
|
|
288
|
+
``database`` is required, in YYYYMM form. The exact schema varies, so
|
|
289
|
+
columns are not validated; numeric-looking columns are coerced.
|
|
290
|
+
"""
|
|
291
|
+
df = self.query(
|
|
292
|
+
"EstatisticasFraudesPix",
|
|
293
|
+
{"Database": _odata.parse_year_month(database)},
|
|
294
|
+
filter=filter,
|
|
295
|
+
select=columns,
|
|
296
|
+
orderby=orderby,
|
|
297
|
+
top=top,
|
|
298
|
+
skip=skip,
|
|
299
|
+
verbose=verbose,
|
|
300
|
+
)
|
|
301
|
+
if not df.empty:
|
|
302
|
+
patterns = ("QT_", "VL_", "QUANTIDADE", "VALOR", "ANOMES")
|
|
303
|
+
numeric = [c for c in df.columns if any(p in c.upper() for p in patterns)]
|
|
304
|
+
df = _coerce_numeric(df, numeric)
|
|
305
|
+
return df
|
|
306
|
+
|
|
307
|
+
def ping(self) -> pd.DataFrame:
|
|
308
|
+
"""Test connectivity to all four endpoints with a single-record request."""
|
|
309
|
+
probes = {
|
|
310
|
+
"ChavesPix": {"Data": "2025-01-01"},
|
|
311
|
+
"TransacoesPixPorMunicipio": {"DataBase": "202501"},
|
|
312
|
+
"EstatisticasTransacoesPix": {"Database": "202501"},
|
|
313
|
+
"EstatisticasFraudesPix": {"Database": "202501"},
|
|
314
|
+
}
|
|
315
|
+
rows = []
|
|
316
|
+
for endpoint, params in probes.items():
|
|
317
|
+
url = self.build_url(endpoint, params, top=1)
|
|
318
|
+
try:
|
|
319
|
+
resp = self._http.get(url)
|
|
320
|
+
status = "OK" if resp.status_code == 200 else f"HTTP {resp.status_code}"
|
|
321
|
+
except httpx.HTTPError as exc:
|
|
322
|
+
status = str(exc)
|
|
323
|
+
rows.append({"endpoint": endpoint, "status": status})
|
|
324
|
+
return pd.DataFrame(rows)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _coerce_numeric(df: pd.DataFrame, columns: Sequence[str]) -> pd.DataFrame:
|
|
328
|
+
if df.empty:
|
|
329
|
+
return df
|
|
330
|
+
for col in columns:
|
|
331
|
+
if col in df.columns:
|
|
332
|
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
333
|
+
return df
|
pixbr/utils.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Standalone utility helpers (no network access)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterable, Union
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from ._odata import parse_year_month # noqa: F401 (re-exported convenience)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pix_endpoints() -> pd.DataFrame:
|
|
13
|
+
"""Return a DataFrame describing the available BCB PIX API endpoints."""
|
|
14
|
+
return pd.DataFrame(
|
|
15
|
+
[
|
|
16
|
+
("ChavesPix", "Data", "YYYY-MM-DD", "keys", "PIX keys stock by participant"),
|
|
17
|
+
("TransacoesPixPorMunicipio", "DataBase", "YYYYMM",
|
|
18
|
+
"transactions_by_municipality", "PIX transactions by municipality"),
|
|
19
|
+
("EstatisticasTransacoesPix", "Database", "YYYYMM",
|
|
20
|
+
"transaction_stats", "Transaction statistics with breakdowns"),
|
|
21
|
+
("EstatisticasFraudesPix", "Database", "YYYYMM",
|
|
22
|
+
"fraud_stats", "Fraud statistics (MED)"),
|
|
23
|
+
],
|
|
24
|
+
columns=["endpoint", "parameter", "param_format", "method", "description"],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_COLUMNS = {
|
|
29
|
+
"keys": [
|
|
30
|
+
("Data", "string", "Reference date (YYYY-MM-DD, last day of month)"),
|
|
31
|
+
("ISPB", "string", "8-digit code identifying the financial institution"),
|
|
32
|
+
("Nome", "string", "Name of the PIX participant (financial institution)"),
|
|
33
|
+
("NaturezaUsuario", "string", "User type: PF (Individual) or PJ (Legal Entity)"),
|
|
34
|
+
("TipoChave", "string", "Key type: CPF, CNPJ, Celular, e-mail, or Aleatória"),
|
|
35
|
+
("qtdChaves", "numeric", "Number of registered keys"),
|
|
36
|
+
],
|
|
37
|
+
"municipality": [
|
|
38
|
+
("AnoMes", "integer", "Reference year-month (YYYYMM)"),
|
|
39
|
+
("Municipio_Ibge", "integer", "IBGE municipality code"),
|
|
40
|
+
("Municipio", "string", "Municipality name"),
|
|
41
|
+
("Estado_Ibge", "integer", "IBGE state code"),
|
|
42
|
+
("Estado", "string", "State name"),
|
|
43
|
+
("Sigla_Regiao", "string", "Region abbreviation (NE, SE, S, CO, N)"),
|
|
44
|
+
("Regiao", "string", "Region name"),
|
|
45
|
+
("VL_PagadorPF", "numeric", "Value paid by individuals (BRL)"),
|
|
46
|
+
("QT_PagadorPF", "numeric", "Count of transactions with individual payers"),
|
|
47
|
+
("VL_PagadorPJ", "numeric", "Value paid by legal entities (BRL)"),
|
|
48
|
+
("QT_PagadorPJ", "numeric", "Count of transactions with legal entity payers"),
|
|
49
|
+
("VL_RecebedorPF", "numeric", "Value received by individuals (BRL)"),
|
|
50
|
+
("QT_RecebedorPF", "numeric", "Count of transactions with individual receivers"),
|
|
51
|
+
("VL_RecebedorPJ", "numeric", "Value received by legal entities (BRL)"),
|
|
52
|
+
("QT_RecebedorPJ", "numeric", "Count of transactions with legal entity receivers"),
|
|
53
|
+
("QT_PES_PagadorPF", "numeric", "Distinct individual payers"),
|
|
54
|
+
("QT_PES_PagadorPJ", "numeric", "Distinct legal entity payers"),
|
|
55
|
+
("QT_PES_RecebedorPF", "numeric", "Distinct individual receivers"),
|
|
56
|
+
("QT_PES_RecebedorPJ", "numeric", "Distinct legal entity receivers"),
|
|
57
|
+
],
|
|
58
|
+
"stats": [
|
|
59
|
+
("AnoMes", "integer", "Reference year-month (YYYYMM)"),
|
|
60
|
+
("PAG_PFPJ", "string", "Payer type: PF or PJ"),
|
|
61
|
+
("REC_PFPJ", "string", "Receiver type: PF or PJ"),
|
|
62
|
+
("PAG_REGIAO", "string", "Payer region"),
|
|
63
|
+
("REC_REGIAO", "string", "Receiver region"),
|
|
64
|
+
("PAG_IDADE", "string", "Payer age group"),
|
|
65
|
+
("REC_IDADE", "string", "Receiver age group"),
|
|
66
|
+
("FORMAINICIACAO", "string", "Initiation method (DICT, QRDN, QRES, MANU, INIC)"),
|
|
67
|
+
("NATUREZA", "string", "Transaction nature (P2P, P2B, B2P, B2B, P2G, G2P)"),
|
|
68
|
+
("FINALIDADE", "string", "Transaction purpose"),
|
|
69
|
+
("VALOR", "numeric", "Total transaction value (BRL)"),
|
|
70
|
+
("QUANTIDADE", "numeric", "Number of transactions"),
|
|
71
|
+
],
|
|
72
|
+
"fraud": [
|
|
73
|
+
("AnoMes", "integer", "Reference year-month (YYYYMM)"),
|
|
74
|
+
("(varies)", "varies", "Fraud statistics columns - query the API for the full schema"),
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def pix_columns(endpoint: str = "keys") -> pd.DataFrame:
|
|
80
|
+
"""Return column metadata for an endpoint.
|
|
81
|
+
|
|
82
|
+
``endpoint`` is one of ``"keys"``, ``"municipality"``, ``"stats"``, ``"fraud"``.
|
|
83
|
+
"""
|
|
84
|
+
if endpoint not in _COLUMNS:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Unknown endpoint {endpoint!r}. Choose one of {list(_COLUMNS)}."
|
|
87
|
+
)
|
|
88
|
+
return pd.DataFrame(_COLUMNS[endpoint], columns=["column", "type", "description"])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def year_month_to_date(year_month: Union[str, Iterable[str]]) -> Union[pd.Timestamp, pd.DatetimeIndex]:
|
|
92
|
+
"""Convert ``YYYYMM`` string(s) to date(s) at the first day of the month."""
|
|
93
|
+
if isinstance(year_month, str):
|
|
94
|
+
parse_year_month(year_month) # validate
|
|
95
|
+
return pd.to_datetime(year_month + "01", format="%Y%m%d")
|
|
96
|
+
values = list(year_month)
|
|
97
|
+
for v in values:
|
|
98
|
+
parse_year_month(v)
|
|
99
|
+
return pd.to_datetime([v + "01" for v in values], format="%Y%m%d")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def format_brl(
|
|
103
|
+
x: Union[float, Iterable[float]],
|
|
104
|
+
prefix: bool = True,
|
|
105
|
+
decimal_mark: str = ",",
|
|
106
|
+
big_mark: str = ".",
|
|
107
|
+
) -> Union[str, list[str]]:
|
|
108
|
+
"""Format number(s) as Brazilian Real currency (e.g. ``R$ 1.234.567,89``)."""
|
|
109
|
+
|
|
110
|
+
def _one(value: float) -> str:
|
|
111
|
+
# Format with US separators first, then swap to BR convention.
|
|
112
|
+
formatted = f"{round(value, 2):,.2f}"
|
|
113
|
+
formatted = formatted.replace(",", "\0").replace(".", decimal_mark).replace("\0", big_mark)
|
|
114
|
+
return f"R$ {formatted}" if prefix else formatted
|
|
115
|
+
|
|
116
|
+
if isinstance(x, (int, float)):
|
|
117
|
+
return _one(float(x))
|
|
118
|
+
return [_one(float(v)) for v in x]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pixbr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Access Brazilian Central Bank PIX Open Data API (pandas-friendly)
|
|
5
|
+
Project-URL: Homepage, https://github.com/StrategicProjects/pixbr
|
|
6
|
+
Project-URL: Issues, https://github.com/StrategicProjects/pixbr/issues
|
|
7
|
+
Author-email: Andre Leite <leite@castlab.org>, Marcos Wasilew <marcos.wasilew@gmail.com>, Hugo Vasconcelos <hugo.vasconcelos@ufpe.br>, Diogo Bezerra <diogo.bezerra@ufpe.br>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: banco central,bcb,brazil,odata,open data,pix
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: pandas>=2.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# pixbr
|
|
24
|
+
|
|
25
|
+
Python client for the **Brazilian Central Bank (BCB) PIX Open Data API**
|
|
26
|
+
([Olinda / OData service](https://olinda.bcb.gov.br/olinda/servico/Pix_DadosAbertos/versao/v1/aplicacao)).
|
|
27
|
+
It hides the BCB's unusual OData URL syntax and returns
|
|
28
|
+
[pandas](https://pandas.pydata.org/) DataFrames.
|
|
29
|
+
|
|
30
|
+
This is the Python counterpart of the R package
|
|
31
|
+
[`pixr`](https://github.com/StrategicProjects/pixr).
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install pixbr # once published
|
|
37
|
+
# or, from source:
|
|
38
|
+
pip install -e ".[dev]"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
Reusable client (recommended for multiple requests):
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pixbr import PixClient
|
|
47
|
+
|
|
48
|
+
client = PixClient()
|
|
49
|
+
|
|
50
|
+
# PIX keys stock by participant (date in YYYY-MM-DD)
|
|
51
|
+
keys = client.keys("2025-12-01", filter="TipoChave eq 'CPF'", top=100)
|
|
52
|
+
|
|
53
|
+
# Transaction statistics (database in YYYYMM)
|
|
54
|
+
stats = client.transaction_stats("202509", filter="NATUREZA eq 'P2P'")
|
|
55
|
+
|
|
56
|
+
# Transactions by municipality
|
|
57
|
+
muni = client.transactions_by_municipality("202512", filter="Sigla_Regiao eq 'NE'")
|
|
58
|
+
|
|
59
|
+
# Fraud statistics (MED)
|
|
60
|
+
fraud = client.fraud_stats("202509", top=100)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Module-level convenience functions mirror the `pixr` names:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from pixbr import get_pix_transaction_stats, get_pix_summary, format_brl
|
|
67
|
+
|
|
68
|
+
df = get_pix_transaction_stats("202509")
|
|
69
|
+
summary = get_pix_summary("202509", group_by="PAG_REGIAO")
|
|
70
|
+
format_brl(1234567.89) # 'R$ 1.234.567,89'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Endpoints
|
|
74
|
+
|
|
75
|
+
| Endpoint | Parameter | `PixClient` method | Convenience function |
|
|
76
|
+
|---|---|---|---|
|
|
77
|
+
| `ChavesPix` | `Data` (YYYY-MM-DD) | `.keys()` | `get_pix_keys()` |
|
|
78
|
+
| `TransacoesPixPorMunicipio` | `DataBase` (YYYYMM) | `.transactions_by_municipality()` | `get_pix_transactions_by_municipality()` |
|
|
79
|
+
| `EstatisticasTransacoesPix` | `Database` (YYYYMM) | `.transaction_stats()` | `get_pix_transaction_stats()` |
|
|
80
|
+
| `EstatisticasFraudesPix` | `Database` (YYYYMM) | `.fraud_stats()` | `get_pix_fraud_stats()` |
|
|
81
|
+
|
|
82
|
+
Use `pix_endpoints()` and `pix_columns("keys"|"municipality"|"stats"|"fraud")`
|
|
83
|
+
to inspect available endpoints and columns.
|
|
84
|
+
|
|
85
|
+
## OData query parameters
|
|
86
|
+
|
|
87
|
+
All endpoint methods accept the common OData parameters:
|
|
88
|
+
|
|
89
|
+
- `filter` — OData filter expression, e.g. `"NATUREZA eq 'P2P' and PAG_REGIAO eq 'SUDESTE'"`
|
|
90
|
+
- `columns` — list of columns to select (unknown columns are dropped with a warning)
|
|
91
|
+
- `orderby` — `"Column"` (asc) or `"Column desc"`
|
|
92
|
+
- `top` — maximum number of records
|
|
93
|
+
|
|
94
|
+
> **Note:** `skip` is **not supported** by the BCB PIX API; passing it emits a
|
|
95
|
+
> warning and is ignored. Use `top` to limit results.
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
- `PixClient(timeout=..., max_retries=..., verbose=...)` configures the HTTP
|
|
100
|
+
session. The default timeout is 120s — the BCB API can be slow for large
|
|
101
|
+
queries.
|
|
102
|
+
- `client.build_url(...)` / `pix_url(...)` return the request URL without
|
|
103
|
+
sending it (handy for debugging).
|
|
104
|
+
- `client.ping()` / `pix_ping()` test connectivity to all four endpoints.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pixbr/__init__.py,sha256=XS7GTe-VsUkIl3AuKv-gP2HIgQ0Vwus3FwRtaQCuWug,1670
|
|
2
|
+
pixbr/_odata.py,sha256=vR8tgwhSnoAOcQLeFvaADdlSjT0XQuEJiZPL_L_68HI,5530
|
|
3
|
+
pixbr/aggregate.py,sha256=i-JPL2qs7paGDT4gXY_qfoOj7Ukq9LoTpVdne5eemag,4457
|
|
4
|
+
pixbr/api.py,sha256=rM4LCHN7gw1UVZFx6hm2b05RzgYzcscDCZrmogawqPE,3605
|
|
5
|
+
pixbr/client.py,sha256=S2O8zQNIkSbMU6NMfDBtrRp92yhxYIPKSRmk2LSbCAs,10891
|
|
6
|
+
pixbr/utils.py,sha256=hxgzCdyZCe2_LixNb-u_FCcegUIqBb6wYEmJa070uQ4,5418
|
|
7
|
+
pixbr-0.1.0.dist-info/METADATA,sha256=oJpsA7GReRG2lH-OA_qVlckYxHy-tirFCVbJIM7esKI,3838
|
|
8
|
+
pixbr-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
pixbr-0.1.0.dist-info/RECORD,,
|