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
