Argentina's securities regulator, the Comision Nacional de Valores (CNV), keeps a public register of virtual asset service providers. Locally they are called PSAVs (Proveedores de Servicios de Activos Virtuales). The register is the only public record of who is allowed to offer crypto services in the country, and from 1 November 2026 it also tells you who owes the CNV a monthly report.
This post walks through a small Python script that reads the register, and the traps I hit on the way. All numbers below come from the file I downloaded on 3 November 2026 (Asia/Shanghai time). The register changes, so re-run the script before you quote anything.
Where the data lives
The CNV's PSAV page links to an Excel export:
https://www.cnv.gov.ar/SitioWeb/Reportes/ListadoRegistrosPsav
No key and no login. The server names the file with a timestamp (mine was listado_Registros_PSAV_02112026231045.xlsx), so save it under a fixed name:
curl -sL -o psav.xlsx "https://www.cnv.gov.ar/SitioWeb/Reportes/ListadoRegistrosPsav"
The script
import re
from collections import Counter
from urllib.parse import urlparse
import openpyxl
wb = openpyxl.load_workbook("psav.xlsx") # NOT read_only=True, see trap 1
def rows(sheet):
ws = wb[sheet]
it = ws.iter_rows(values_only=True)
next(it) # row 1: title banner
header = [h for h in next(it) if h] # row 2: the real header
for r in it:
if any(r):
yield dict(zip(header, r[:len(header)]))
firms = list(rows("Juridicas")) # legal persons
people = list(rows("Humanas")) # natural persons
print("legal persons:", len(firms), "| natural persons:", len(people))
nums = [f["Nro. Matricula"] for f in firms]
print("registration numbers:", min(nums), "to", max(nums), "| unique:", len(set(nums)))
by_year = Counter(f["Fecha Alta"].year for f in firms)
print("by year:", dict(sorted(by_year.items())))
latest = max(firms, key=lambda f: f["Fecha Alta"])
print("latest entry:", latest["Fecha Alta"].date(), "no.", latest["Nro. Matricula"])
def activities(f):
return [a.strip().rstrip(".") for a in str(f["Actividad"]).split(" / ") if a.strip()]
acts = Counter(a for f in firms for a in activities(f))
for name, n in acts.most_common():
print(f"{n:>3} {name[:60]}")
print("activities per firm:", dict(sorted(Counter(len(activities(f)) for f in firms).items())))
def host(cell):
s = re.sub(r"^(https?://)+", "", str(cell or "").strip(), flags=re.I)
h = urlparse("https://" + s).hostname or ""
return h.lower() if "." in h else None # "En construccion" -> None
hosts = [host(f["Sitio Web"]) for f in firms]
print("no usable website:", hosts.count(None))
print(".ar website:", sum(1 for h in hosts if h and h.endswith(".ar")), "of", len(firms))
Output on 3 November 2026:
legal persons: 81 | natural persons: 0
registration numbers: 2 to 174 | unique: 81
by year: {2023: 1, 2024: 47, 2025: 21, 2026: 12}
latest entry: 2026-10-22 no. 174
74 Intercambio entre activos virtuales y monedas de curso legal
72 Transferencia de activos virtuales
67 Intercambio entre una (1) o mas formas de activos virtuales
57 Custodia y/o administracion de activos virtuales o instrumen
37 Participacion y provision de servicios financieros relaciona
activities per firm: {1: 8, 2: 8, 3: 11, 4: 20, 5: 34}
no usable website: 6
.ar website: 74 of 81
(Accents stripped in this listing; the file has them.)
Five traps in the file
1. read_only=True silently drops most rows. openpyxl's read-only mode trusts the sheet's stored dimension tag, and in this export it is wrong. In read-only mode the "Juridicas" sheet reports max_row 3. Loaded normally, it has 83 rows: a title, a header and 81 firms. If your count is suspiciously tiny, this is why.
2. The header is on row 2, not row 1. Row 1 is a centred title ("Listado de Registros PSAV - Personas Juridicas"). Using row 1 as a header gives you one named column and a pile of Nones. The two sheets also have different columns: "Humanas" has no Nombre Comercial. Read the header per sheet.
3. Registration numbers are not a count. Nro. Matricula runs from 2 to 174, but there are 81 rows. The file does not say why numbers are missing, so do not use the highest number as "how many firms have registered". Count rows.
4. Activities are one string. Each firm's Actividad cell joins its activities with " / ". In this export the fifth activity always comes last and carries the cell's closing full stop ("...venta de un activo virtual."). Split on " / ", strip, and drop the trailing dot, so each label matches the same activity written anywhere else, including in the legal text. The five labels correspond to the five activities in the legal definition of a PSAV.
5. The website column is free text. 41 entries have no scheme (www.example.com.ar), one has the scheme twice (https://https://...), and six are placeholders like "En construccion" or "En proceso". A naive urlparse(cell).netloc returns an empty string for most of them, and my first .ar count came out at 39 instead of 74.
Why this matters now: the monthly clock
On 14 October 2026 the CNV adopted General Resolution 1172/2026, which rewrites the PSAV chapter of its rules. Under the new Article 40(b), registered PSAVs must file, within 15 calendar days after the end of each month, their number of clients, monthly traded volume in US dollars, their ten most-traded virtual assets and the assets they hold in custody. The filing uses form PSAV_014, and the resolution says this monthly information applies from 1 November 2026. A registered PSAV that has not started operating must say so on form PSAV_015 instead. VASP Tracker covered the details in its news item on the monthly reporting duty.
That makes the register the denominator for compliance. 81 legal persons on the list means 81 firms that either file monthly or declare that they have not started. 57 of them list custody as an activity, so the custody block of the report is not a niche case.
Two more details from the resolution are useful if you build tools on top of the register:
-
The legend. Registered PSAVs must show "Inscripto bajo el N... en el Registro de PSAV de la CNV (Republica Argentina)" on their website, app and social media. The number in that legend is the
Nro. Matriculain the file, so a platform's claim can be checked against the register by number, not by fuzzy name matching. - What stays private. Custody wallet addresses reported to the CNV are explicitly "not public". Do not expect the export to grow a wallet column.
A simple check you can run
def check_claim(number: int, name_fragment: str) -> str:
match = next((f for f in firms if f["Nro. Matricula"] == number), None)
if match is None:
return f"No. {number} is not on the register"
names = f'{match["Razon Social"]} {match.get("Nombre Comercial") or ""}'.lower()
if name_fragment.lower() in names:
return f'OK: {match["Razon Social"]}, registered {match["Fecha Alta"].date()}'
return f'No. {number} belongs to {match["Razon Social"]}, not "{name_fragment}"'
Run it against a fresh download every time. A number that was valid last month can disappear.
If you would rather not maintain a parser, VASP Tracker's Argentina register page follows the same CNV file.
This is not legal advice. The CNV register and the text of General Resolution 1172/2026 on InfoLEG are the official sources; check them before relying on any number here.
VASP Tracker is an independent tracker. It is not an official source and is not affiliated with any regulator.
Daniel Bradley, VASP Tracker
Top comments (1)
Two things here generalise beyond this one register.
The dimension trap is the one I would headline. read_only=True trusts the sheet's stored dimension record, and exporters — government report generators, spreadsheet web exports, some Java writers — routinely leave it stale, so max_row reports a number the file does not actually contain. Recomputing from iter_rows is the right fix, and it deserves a comment in the script because the failure is silent: you get a short list, not an error.
Second, for a register that becomes legally load-bearing on 1 November, the diff between snapshots matters more than any single snapshot. Firms tend to get re-listed with a changed status rather than deleted, so a count like 81 can stay flat while the composition moves underneath it. Keeping the dated xlsx and diffing on the register id rather than the name (names get reformatted with and without accents) is what makes this monitorable month over month.
And the caveat you already wrote — re-run before quoting — is the line most open-data posts skip. Registered firms is not the same set as operating firms; pairing the register with the sanction and warning records is what turns a directory into something usable as a risk signal.