DEV Community

VASP Tracker
VASP Tracker

Posted on

Reading Argentina's crypto register in Python: 81 firms, a lying dimension tag and a new monthly clock

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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. Matricula in 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}"'
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
deeprade profile image
DeepRade •

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.