Lección 6.1.2 · 70 min
Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
Antes de puntuar un texto hay que conseguirlo, saber cuándo se publicó y quitarle lo que sobra: etiquetas HTML, tablas, copias de agencia y cuentas automatizadas. Esta lección recorre las fuentes de texto financiero, ejecuta un cliente de EDGAR contra un servidor local que imita su API, parsea documentos tipo SEC y CNMV, hechos XBRL, transcripciones y publicaciones de redes, y mide contra la verdad cuántas copias y bots se detectan.
- Módulo
- 6.1 · NLP financiero clásico y moderno
- Duración
- 70 min
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
El mapa de las fuentes de texto
La lección 6.1.1 convirtió texto en puntuaciones. Antes hay un trabajo menos vistoso que decide la calidad de todo lo demás: conseguir el texto, fecharlo con precisión y limpiarlo. Las fuentes habituales en research financiero:
| Fuente | Formato | Hora | Acceso |
|---|---|---|---|
| Noticias de agencias | Texto con titular, a menudo XML o JSON del proveedor | Al segundo, si el proveedor la conserva | Licencia de pago (lección 1.3.4) |
| 8-K (hechos materiales, resultados) | HTML o texto, con anexos (Exhibit 99.1) | Hora de aceptación en EDGAR | Público, API sin clave |
| 10-K y 10-Q (informes anual y trimestral) | HTML con XBRL en línea | Hora de aceptación | Público, API sin clave |
| Información privilegiada y OIR de la CNMV | PDF o texto, con hora de registro | Hora de registro | Público, web y RSS |
| Transcripciones de conference calls | Turnos de palabra con hablante y rol | La de la llamada | Proveedores de pago; audio en la web del emisor |
| Redes sociales | Publicaciones cortas con cashtags ($TICKER) | Al segundo | API de cada plataforma, con sus términos |
Fuente Mermaid
flowchart TB F[Fuente] --> D[Descarga] D --> P[Parseo] P --> L[Limpieza] L --> X[Tabla con hora] X --> S[Puntuación 6.1.1]
El curso trae una versión sintética de cada fuente en los datos del nivel 6, con documentos inventados que siguen la
forma de los reales: 4.020 documentos regulatorios tipo SEC y tipo CNMV, 13.067 hechos XBRL, 840 llamadas y 61.697
publicaciones de redes, además de las noticias de la lección anterior. Todos dicen en su texto que son sintéticos. Las
descargas reales necesitan red y van en bloques skip; donde aporta, un servidor local imita la API y el código del
cliente se ejecuta de verdad.
EDGAR: el historial de envíos de cada empresa
La SEC publica en data.sec.gov, sin autenticación ni claves, el historial de envíos de cada entidad en
/submissions/CIK##########.json (el CIK con diez dígitos y ceros a la izquierda)1Nota 1 · SEC, «EDGAR Application Programming Interfaces» (https://www.sec.gov/search-filings/edgar-application-programming-interfaces,
consultada el 2026-10-23): historial en data.sec.gov/submissions/CIK##########.json, «at least one year's of
filing or to 1,000 (whichever is more)», ficheros adicionales para los anteriores, y «these APIs do not require any
authentication or API keys to access».. La lección 1.5.1 usó la otra
mitad de la API, los hechos XBRL de companyfacts; aquí interesa el índice de documentos, que dice qué se presentó,
cuándo y dónde está:
Fuente Mermaid
flowchart TB S[submissions JSON] --> R[filings.recent] R --> A[accessionNumber] R --> T[acceptanceDateTime] R --> P[primaryDocument] A --> U[URL del documento] P --> U
filings.recent está en formato columnar (una lista por campo): accessionNumber, filingDate, reportDate,
acceptanceDateTime, form, items (los del 8-K), primaryDocument y otros. Contiene al menos un año o los 1.000
envíos más recientes; los anteriores van en ficheros adicionales que enumera filings.files. El documento se descarga
de los archivos de EDGAR en /Archives/edgar/data/<CIK>/<número de registro sin guiones>/<documento>, la estructura de
directorios que describe la propia SEC2Nota 2 · SEC, «Accessing EDGAR Data» (https://www.sec.gov/os/accessing-edgar-data, consultada el 2026-10-23): «Current
max request rate: 10 requests/second», cabecera User-Agent declarada, rutas
/Archives/edgar/data/<CIK>/<número de registro>/… e índices diarios y completos desde 1994..
La política de acceso justo de la SEC tiene dos reglas que afectan al código2: un máximo actual de 10
peticiones por segundo, y una cabecera User-Agent que identifique a quien accede (su ejemplo: «Sample Company Name
AdminContact@<sample company domain>.com»). Para cumplirla sin publicar datos personales, el curso usa un
marcador con un dominio reservado para ejemplos (example.com, RFC 2606): en un uso real va el nombre de la
organización y un buzón de contacto de rol (no el correo de una persona), leído de la configuración y no escrito en
el código que se comparte.
import json
import os
import time
import urllib.request
# Organización y buzón de rol, desde la configuración (lección 0.2.4)
UA = os.environ.get("EDGAR_USER_AGENT",
"Curso PPT [email protected]")
def get(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
datos = r.read()
time.sleep(0.15) # muy por debajo de 10 peticiones por segundo
return datos
cik = 320193
sub = json.loads(get("https://data.sec.gov/submissions/"
f"CIK{cik:010d}.json"))
reciente = sub["filings"]["recent"]
i = reciente["form"].index("10-K")
acc = reciente["accessionNumber"][i].replace("-", "")
doc = reciente["primaryDocument"][i]
html = get(f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc}/{doc}")Para ejecutar ese mismo cliente sin red, la celda siguiente levanta un servidor HTTP local que sirve, con la forma de
submissions, los documentos sintéticos de una empresa del curso. Como el real, rechaza las peticiones sin
User-Agent (con un 403); para simular la saturación, responde 429 (Too Many Requests) a una de cada tres
descargas de documentos, siempre la misma, para que la salida no dependa del reloj:
import json
import re
import threading
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import numpy as np
import pandas as pd
docs = pd.read_parquet("filings6_documentos.parquet")
xbrl = pd.read_parquet("filings6_xbrl.parquet")
CIK = xbrl.merge(docs[["id_doc", "emisor"]], on="id_doc").query(
"emisor == 'Luklux Apparel Corp.'")["cik"].iloc[0]
mios = docs[docs["emisor"] == "Luklux Apparel Corp."].sort_values("ts")
mios = mios.assign(
acc=[f"{CIK}-{t:%y}-{n:06d}" for n, t in
enumerate(mios["ts"], start=1)],
archivo=[f"{i.lower()}.htm" for i in mios["id_doc"]])
def submissions():
r = mios.iloc[::-1] # lo más reciente primero, como la API
ny = r["ts"].dt.tz_convert("America/New_York")
return {"cik": CIK, "name": "Luklux Apparel Corp.",
"filings": {"recent": {
"accessionNumber": r["acc"].tolist(),
"acceptanceDateTime": ny.dt.strftime(
"%Y-%m-%dT%H:%M:%S").tolist(),
"form": r["tipo"].tolist(),
"items": r["items"].tolist(),
"primaryDocument": r["archivo"].tolist()}}}
class EdgarLocal(BaseHTTPRequestHandler):
peticiones = 0
def do_GET(self):
if not self.headers.get("User-Agent", "").strip():
return self.responder(403, b"Declare your User-Agent")
if self.path.startswith("/Archives/"):
EdgarLocal.peticiones += 1
if EdgarLocal.peticiones % 3 == 0: # saturación simulada
return self.responder(429, b"Too Many Requests")
if self.path == f"/submissions/CIK{CIK}.json":
cuerpo = json.dumps(submissions()).encode()
return self.responder(200, cuerpo)
fila = mios[mios["archivo"] == self.path.rsplit("/", 1)[-1]]
if len(fila):
return self.responder(200, fila["texto"].iloc[0].encode())
self.responder(404, b"Not Found")
def responder(self, codigo, cuerpo):
self.send_response(codigo)
self.end_headers()
self.wfile.write(cuerpo)
def log_message(self, *args):
pass
servidor = ThreadingHTTPServer(("127.0.0.1", 0), EdgarLocal)
threading.Thread(target=servidor.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{servidor.server_port}"
print(len(mios), "documentos de Luklux en el servidor local")62 documentos de Luklux en el servidor localEl cliente es el del bloque real con tres cambios: la URL base, un reintento con espera creciente cuando el servidor devuelve 429 y un error claro cuando falta la cabecera:
from collections import Counter
UA = "Curso PPT [email protected]"
reintentos = Counter()
def get(url, ua=UA, intentos=4):
cab = {"User-Agent": ua} if ua else {}
for n in range(intentos):
try:
req = urllib.request.Request(url, headers=cab)
with urllib.request.urlopen(req, timeout=10) as r:
return r.read()
except urllib.error.HTTPError as e:
if e.code != 429 or n == intentos - 1:
raise
reintentos[e.code] += 1
time.sleep(0.05 * 2 ** n) # espera creciente
try:
get(f"{BASE}/submissions/CIK{CIK}.json", ua=None)
except urllib.error.HTTPError as e:
print("sin User-Agent:", e.code, e.read().decode())
sub = json.loads(get(f"{BASE}/submissions/CIK{CIK}.json"))
rec = pd.DataFrame(sub["filings"]["recent"])
print(rec["form"].value_counts().to_dict())
rec.head(3){'8-K': 31, '10-Q': 21, '10-K': 7, '10-Q/A': 3}
accessionNumber acceptanceDateTime form items primaryDocument
0 9900000059-25-000062 2025-11-12T16:09:51 10-Q d003973.htm
1 9900000059-25-000061 2025-11-03T07:34:37 8-K 2.02 d003934.htm
2 9900000059-25-000060 2025-07-30T16:57:37 10-Q d003768.htmCon la tabla de envíos, descargar todos los 10-K de la empresa es un bucle. Cuando el servidor responde 429, el cliente espera y reintenta (tres veces en siete descargas); en el real, la espera fija entre peticiones evita llegar ahí:
diez_k = rec[rec["form"] == "10-K"]
textos = {}
for acc, archivo in zip(diez_k["accessionNumber"],
diez_k["primaryDocument"]):
url = (f"{BASE}/Archives/edgar/data/{int(CIK)}/"
f"{acc.replace('-', '')}/{archivo}")
textos[acc] = get(url).decode()
print(len(textos), "10-K descargados;",
sum(len(t) for t in textos.values()), "caracteres;",
"reintentos tras un 429:", reintentos[429])7 10-K descargados; 17485 caracteres; reintentos tras un 429: 3Hora de aceptación y fecha de presentación
acceptanceDateTime es el instante en que EDGAR aceptó el envío; filingDate, la fecha en que se considera
presentado. No siempre coinciden: según la regla 13 de la Regulation S-T, lo que se envía después de las 17:30 de la
costa este se considera presentado el siguiente día hábil (con excepciones hasta las 22:00 para algunos formularios,
como los 3, 4 y 5)3Nota 3 · 17 CFR 232.13(a)(2) (https://www.law.cornell.edu/cfr/text/17/232.13): lo enviado después de las 17:30 de la
costa este «shall be deemed filed as of the next business day»; las excepciones hasta las 22:00, en (a)(3) y (a)(4).. Los 10-K y 10-Q sintéticos se presentan tras el cierre de la bolsa, entre las 16:00 y las
18:30 de Nueva York:
from pandas.tseries.holiday import USFederalHolidayCalendar
festivos = USFederalHolidayCalendar().holidays("2018", "2026")
periodicos = docs[docs["tipo"].str.match(r"10-[KQ]")].copy()
ny = periodicos["ts"].dt.tz_convert("America/New_York")
dia = ny.dt.tz_localize(None).dt.normalize()
tarde = ny.dt.hour * 60 + ny.dt.minute >= 17 * 60 + 30
siguiente = np.busday_offset(dia.to_numpy().astype("datetime64[D]"),
1, roll="forward",
holidays=festivos.to_numpy()
.astype("datetime64[D]"))
periodicos["filing_date"] = dia.where(~tarde, pd.to_datetime(siguiente))
print(f"aceptados después de las 17:30: {tarde.mean():.1%}")
periodicos.loc[tarde, ["tipo", "ts", "filing_date"]].head(3)aceptados después de las 17:30: 28.9%
tipo ts filing_date
1 10-Q/A 2019-01-02 22:58:24+00:00 2019-01-03
3 10-K/A 2019-01-08 23:06:37+00:00 2019-01-09
5 10-Q/A 2019-01-24 22:42:21+00:00 2019-01-25Un 29 % de los informes periódicos (10-K, 10-Q y sus enmiendas) lleva una fecha de presentación posterior a su aceptación. Para un estudio de
eventos (lección 6.1.3) eso es un retraso de un día, no una anticipación: quien use filingDate llega tarde, pero no
usa información futura. El error peligroso es el contrario: fechar con reportDate (el fin del periodo) o con la
fecha en que una base de datos incorporó el documento sin saber cuál es.
Búsqueda de texto completo
La web de EDGAR tiene una búsqueda de texto completo de los envíos electrónicos desde 20014Nota 4 · SEC, «EDGAR Full Text Search FAQ» (https://www.sec.gov/edgar/search/efts-faq.html, consultada el 2026-10-23):
búsqueda en el texto de los envíos electrónicos desde 2001. La página no documenta el servicio JSON que usa.. La página la usa
llamando a un servicio JSON (efts.sec.gov/LATEST/search-index) que la SEC no documenta como API: se puede
consultar con la misma política de acceso, sabiendo que puede cambiar sin aviso. Para un proceso estable, lo
documentado es la API de envíos y los índices diarios y trimestrales de EDGAR.
import urllib.parse
q = urllib.parse.urlencode({"q": '"material weakness"',
"forms": "8-K",
"startdt": "2024-01-01",
"enddt": "2024-12-31"})
r = json.loads(get(f"https://efts.sec.gov/LATEST/search-index?{q}"))CNMV: información privilegiada y otra información relevante
Los emisores españoles comunican a la CNMV lo que puede afectar a su cotización. Hasta el 8 de febrero de 2020 lo hacían como hechos relevantes; desde entonces, en dos registros separados: información privilegiada (la del artículo 17 del Reglamento de abuso de mercado, el MAR) y otra información relevante (OIR). La CNMV conserva el registro de hechos relevantes «hasta 08/02/2020» junto a los dos nuevos, y hoy la difusión la regulan los artículos 226 y 227 de la Ley 6/2023 del Mercado de Valores5Nota 5 · CNMV, registros oficiales de entidades emisoras: «Hechos relevantes de emisores de valores hasta 08/02/2020», «Información privilegiada» y «Otra información relevante» (https://www.cnmv.es/portal/otra-informacion-relevante/consulta-oir?lang=es); Ley 6/2023, de 17 de marzo, de los Mercados de Valores y de los Servicios de Inversión, artículos 226 y 227 (https://www.boe.es/buscar/act.php?id=BOE-A-2023-7053). Consultados el 2026-10-23.. Para un histórico, hay que unir las dos series.
La CNMV ofrece consulta web de cada registro, avisos por correo y canales RSS 2.0 de información privilegiada y de OIR6Nota 6 · CNMV, canales RSS (https://www.cnmv.es/portal/gpage?id=RSS, consultado el 2026-10-23): información privilegiada, otra información relevante, notas de prensa y otros contenidos.; no hemos encontrado una API documentada. Leer un RSS es una petición y un parseo de XML:
import xml.etree.ElementTree as ET
url = ("https://www.cnmv.es/portal/Otra-Informacion-Relevante/"
"RSS.asmx/GetNoticiasCNMV")
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
canal = ET.fromstring(r.read())
items = [{c.tag: c.text for c in item}
for item in canal.iter("item")]Los campos de cada item (título, enlace, fecha) son los de RSS 2.0; los concretos del canal de la CNMV hay que
comprobarlos en el propio canal antes de depender de ellos. Lo importante es la fecha: con un RSS se recoge lo
publicado al consultarlo y la hora de la publicación hay que leerla del propio elemento, no del momento de la descarga.
Los documentos sintéticos tipo CNMV del curso son Markdown, con una tabla de magnitudes, una nota al pie y la previsión en texto. El cambio de régimen está en los datos:
cnmv = docs[docs["formato"] == "markdown"]
print(cnmv.groupby("tipo")["ts"].agg(["count", "min", "max"]))
hora_madrid = cnmv["ts"].dt.tz_convert("Europe/Madrid")
print("hora de Madrid:", hora_madrid.dt.strftime("%H:%M").min(),
"a", hora_madrid.dt.strftime("%H:%M").max())
ultimo = cnmv["texto"].iloc[-1]
print(ultimo.split("## Principales magnitudes")[1]
.split("## Evolución")[0].strip()) count min max
tipo
HR 60 2019-02-04 06:50:10+00:00 2019-11-18 06:58:48+00:00
OIR 360 2020-02-10 06:21:06+00:00 2025-11-14 06:53:55+00:00
hora de Madrid: 07:17 a 08:12
| (millones de USD) | Tercer trimestre 2025 |
|---|---|
| Ventas | 5.044,2 |
| Resultado neto¹ | 196,8 |
| Beneficio por acción (USD) | 8,28 |
| Patrimonio neto | 4.140,6 |
¹ Atribuido a la sociedad dominante.Todas se publican entre las 7:17 y las 8:12 de Madrid: antes de la apertura del mercado continuo español (9:00) y,
con más motivo, de la NYSE. La tabla Markdown se convierte en un DataFrame partiendo por |, con la convención
española de números (punto de miles, coma decimal) y la nota al pie separada de la cifra:
def tabla_markdown(texto):
filas = [l.strip().strip("|").split("|")
for l in texto.splitlines()
if l.startswith("|") and not l.startswith("|---")]
cab, *cuerpo = [[c.strip() for c in f] for f in filas]
return pd.DataFrame(cuerpo, columns=cab)
def numero_es(x):
x = re.sub(r"[¹²³]", "", x).replace(".", "").replace(",", ".")
return float(x)
t = tabla_markdown(cnmv["texto"].iloc[-1])
t["valor"] = t.iloc[:, 1].map(numero_es)
t["nota"] = t.iloc[:, 0].str.extract(r"([¹²³])", expand=False)
t (millones de USD) Tercer trimestre 2025 valor nota
0 Ventas 5.044,2 5044.20 NaN
1 Resultado neto¹ 196,8 196.80 ¹
2 Beneficio por acción (USD) 8,28 8.28 NaN
3 Patrimonio neto 4.140,6 4140.60 NaNDocumentos tipo SEC: del HTML a secciones y tablas
Los documentos tipo SEC del curso son HTML con secciones <h2> (los Items), tablas y notas <sup>. Los reales son
mucho más largos y desordenados (estilos en línea, tablas anidadas, XBRL en línea), pero la técnica es la misma:
BeautifulSoup para recorrer el árbol y pandas.read_html para las tablas. Un 8-K de resultados (Item 2.02) trae el
comunicado como anexo:
from io import StringIO
from bs4 import BeautifulSoup
ocho_k = docs[(docs["tipo"] == "8-K") & (docs["items"] == "2.02")]
sopa = BeautifulSoup(ocho_k["texto"].iloc[0], "html.parser")
for h2 in sopa.find_all("h2"):
print("·", h2.get_text())
print([li.get_text() for li in sopa.find_all("li")])
tabla = pd.read_html(StringIO(str(sopa.find("table"))))[0]
tabla· Item 2.02 Results of Operations and Financial Condition
· Exhibit 99.1
['Revenue of $22,638 million', 'Net loss of $11.5 million, or $-0.01 per share', 'Stores at quarter end: 441.']
(USD millions) Q4 2018
0 Revenue 22637.9
1 Net income (loss) (11.5)
2 Diluted EPS (USD) -0.01El Item 2.02 se suministra (furnished) y no se presenta (filed) a efectos de la responsabilidad de la
sección 18 de la Exchange Act; el plazo general de un 8-K es de cuatro días hábiles desde el hecho7Nota 7 · SEC, Form 8-K, instrucciones generales B.1 y B.2 (https://www.sec.gov/files/form8-k.pdf): plazo de cuatro días
hábiles y la información del Item 2.02 «shall not be deemed to be "filed" for purposes of Section 18».. Para un quant,
dos detalles prácticos: la cifra negativa va entre paréntesis ((11.5)), que read_html deja como texto, y el
número de la tabla (22.637,9 millones) aparece redondeado en la lista (22.638), así que hay que decidir qué cifra
manda. Convertir y comprobar:
def numero_en(x):
x = str(x).replace(",", "").replace("$", "").strip()
negativo = x.startswith("(") and x.endswith(")")
return -float(x.strip("()")) if negativo else float(x)
tabla["valor"] = tabla.iloc[:, 1].map(numero_en)
tabla (USD millions) Q4 2018 valor
0 Revenue 22637.9 22637.90
1 Net income (loss) (11.5) -11.50
2 Diluted EPS (USD) -0.01 -0.01Para las señales de texto interesa más el texto por secciones que las tablas. La función siguiente devuelve un diccionario que asocia a cada sección su texto en cualquier documento del corpus; la lección 6.1.3 la usa con la sección de riesgos de los 10-K:
def secciones(html):
sopa = BeautifulSoup(html, "html.parser")
out = {}
for h2 in sopa.find_all("h2"):
partes = []
for nodo in h2.find_next_siblings():
if nodo.name == "h2":
break
partes.append(nodo.get_text(" ", strip=True))
out[h2.get_text(strip=True)] = " ".join(partes)
return out
s10k = secciones(next(iter(textos.values())))
{k: len(v.split()) for k, v in s10k.items()}{'Item 1. Business': 14, 'Item 1A. Risk Factors': 223, 'Part II, Item 8. Financial Statements': 23, "Item 7. Management's Discussion and Analysis": 13}Hechos XBRL: duplicados y el cuarto trimestre
La lección 1.5.1 explicó los hechos XBRL y su versión point-in-time. Los del corpus (filings6_xbrl.parquet) tienen
dos trampas de los reales. La primera: un 10-Q del primer trimestre trae el trimestre y el acumulado del año, que son
el mismo periodo, así que el mismo hecho aparece dos veces:
clave = ["id_doc", "concepto", "periodo_inicio", "periodo_fin"]
print("filas:", len(xbrl),
"| duplicadas:", xbrl.duplicated(clave).sum())
print("duplicadas con distinto valor:",
xbrl.duplicated(clave).sum()
- xbrl.duplicated(clave + ["valor"]).sum())
hechos = xbrl.drop_duplicates(clave)filas: 13067 | duplicadas: 900
duplicadas con distinto valor: 0La segunda: el 10-K da el año y no hay 10-Q del cuarto trimestre. Las ventas del cuarto trimestre se calculan
como las del año menos el acumulado de nueve meses del tercer 10-Q, con los hechos de los documentos originales (sin
las enmiendas, que la lección 1.5.1 trata como versiones posteriores). Se comprueba contra las ventas trimestrales de acciones6_ventas.csv:
ventas = hechos[hechos["concepto"] == "us-gaap:Revenues"].copy()
ventas["dias"] = (ventas["periodo_fin"]
- ventas["periodo_inicio"]).dt.days
anual = ventas[(ventas["forma"] == "10-K") & (ventas["dias"] > 360)]
nueve = ventas[(ventas["forma"] == "10-Q")
& ventas["dias"].between(270, 275)]
nueve = nueve.assign(periodo_fin=nueve["periodo_fin"]
+ pd.offsets.QuarterEnd(1))
q4 = anual.merge(nueve, on=["cik", "periodo_fin"],
suffixes=("_año", "_9m"))
q4["q4_musd"] = (q4["valor_año"] - q4["valor_9m"]) / 1e6
emisores = docs.drop_duplicates("emisor").set_index("emisor")
cik_tk = (xbrl.merge(docs[["id_doc", "emisor"]], on="id_doc")
.drop_duplicates("cik").set_index("cik")["emisor"]
.map(emisores["ticker"]))
q4["ticker"] = q4["cik"].map(cik_tk)
v = pd.read_csv("acciones6_ventas.csv", parse_dates=["fin_trimestre"])
q4 = q4.merge(v, left_on=["ticker", "periodo_fin"],
right_on=["ticker", "fin_trimestre"])
error = (q4["q4_musd"] - q4["ventas"]).abs()
print(len(q4), "cuartos trimestres; error máximo",
round(error.max(), 2), "millones")342 cuartos trimestres; error máximo 0.0 millonesLos 342 cuartos trimestres calculados coinciden exactamente con las ventas publicadas: los hechos están redondeados a
100.000 USD (decimales = −5), la misma precisión que la tabla de ventas en millones con un decimal. Con datos reales hay que añadir los cambios de concepto entre años y los ejercicios que no acaban en diciembre
(lección 1.5.1). Las 6 empresas que cambiaron de ticker exigen unir por el ticker vigente en cada fecha, o mejor por
un identificador permanente (id_empresa, el CIK).
Transcripciones de conference calls
Una transcripción es una lista de turnos con hablante, rol y texto. La estructura importa más que el texto completo: la presentación la escribe la dirección y casi siempre es optimista; las preguntas de los analistas reflejan lo que preocupa al mercado. El corpus tiene 840 llamadas de 30 empresas:
llam = pd.read_parquet("llamadas6_transcripciones.parquet")
una = llam[llam["id_llamada"] == llam["id_llamada"].iloc[0]]
print(una[["turno", "seccion", "rol", "hablante"]]
.head(7).to_string(index=False))
print(llam.groupby(["seccion", "rol"]).size()) turno seccion rol hablante
1 presentacion operador Operadora
2 presentacion relaciones_inversores Alba Salbasgon
3 presentacion directivo Alba Varlazu
4 presentacion directivo Nils Bastel
5 preguntas operador Operadora
6 preguntas analista Rocco Basbasmi
7 preguntas directivo Nils Bastel
seccion rol
preguntas analista 3790
directivo 3790
operador 1680
presentacion directivo 1680
operador 840
relaciones_inversores 840
dtype: int64Con los turnos separados, el tono de la dirección y el de los analistas se miden por separado con el léxico de la lección 6.1.1:
lex = pd.read_csv("texto6_lexico.csv").query("lista == 'financiera'")
listas = {i: (set(g.loc[g["categoria"] == "positiva", "palabra"]),
set(g.loc[g["categoria"] == "negativa", "palabra"]))
for i, g in lex.groupby("idioma")}
def tono(texto, idioma):
pos, neg = listas[idioma]
w = re.findall(r"[a-záéíóúüñ]+", texto.lower())
p, n = sum(x in pos for x in w), sum(x in neg for x in w)
return (p - n) / (p + n + 1)
grupo = np.where(llam["rol"] == "analista", "analistas",
np.where(llam["rol"] == "directivo", "direccion",
"otros"))
textos_ll = (llam.assign(grupo=grupo)
.groupby(["id_llamada", "grupo", "idioma"])["texto"]
.agg(" ".join).reset_index())
textos_ll["tono"] = [tono(t, i) for t, i in
zip(textos_ll["texto"], textos_ll["idioma"])]
t_ll = textos_ll.pivot(index="id_llamada", columns="grupo",
values="tono")
ver_ll = pd.read_csv("llamadas6_verdad.csv").set_index("id_llamada")
print(t_ll[["direccion", "analistas"]].mean().round(2))
print("correlación con el tono verdadero de los analistas:",
round(t_ll["analistas"].corr(ver_ll["tono_analistas"]), 2))grupo
direccion 0.75
analistas 0.01
dtype: float64
correlación con el tono verdadero de los analistas: 0.89La dirección suena positiva de media (0,75) y los analistas, casi neutros (0,01). El tono de diccionario de las preguntas se correlaciona mucho con el verdadero (0,89) porque las preguntas del generador reutilizan frases de plantilla, la mayoría con palabras del léxico: con transcripciones reales no cabe esperar tanto y conviene un modelo (lecciones 6.1.1 y 6.2.3, donde se extraen previsiones y tono con un LLM).
Redes sociales: cashtags, spam y bots
Las publicaciones de redes son cortas, informales y ruidosas. El corpus imita los problemas típicos: cashtags
($TICKER) para saber de qué empresa hablan, spam con varios cashtags y un enlace, cuentas automatizadas que copian
un texto minutos después y campañas de pump (subir el precio de una empresa pequeña con publicaciones coordinadas).
La verdad (social6_verdad.parquet) dice qué es cada publicación:
posts = pd.read_parquet("social6_posts.parquet")
clase = pd.read_parquet("social6_verdad.parquet")[["id_post", "clase"]]
posts = posts.merge(clase, on="id_post")
posts["cashtags"] = posts["texto"].str.findall(r"\$([A-Z]{3,5})\b")
posts["n_tags"] = posts["cashtags"].str.len()
print(posts["clase"].value_counts().to_dict())
print(f"sin cashtag reconocible: {(posts['n_tags'] == 0).mean():.1%}")
posts.groupby("clase")[["n_tags", "seguidores_autor",
"antiguedad_cuenta_dias"]].median(){'organica': 47720, 'bot_copia': 9956, 'spam': 2090, 'pump': 1931}
sin cashtag reconocible: 0.0%
n_tags seguidores_autor antiguedad_cuenta_dias
clase
bot_copia 1.0 29.0 150.0
organica 1.0 97.0 1763.0
pump 1.0 30.0 483.0
spam 3.0 29.0 188.5Todas las publicaciones del corpus llevan al menos un cashtag reconocible; en datos reales, las que no lo llevan no se pueden asignar a una empresa sin resolver nombres y apodos (lección 6.7.5) y se descartan para la señal por empresa. Las medianas ya separan las clases: el spam lleva varios cashtags, y los bots y las cuentas de los pumps tienen pocos seguidores y cuentas mucho más jóvenes que las orgánicas. Tres reglas sencillas, sin mirar la verdad:
posts = posts.sort_values("ts")
spam = (posts["n_tags"] >= 2) & posts["texto"].str.contains(
r"\.example|link|canal|bot|VIP|gratis|free", case=False)
# copia: el mismo texto ya se publicó en las 2 horas anteriores
anterior = posts.groupby("texto")["ts"].shift()
copia = (posts["ts"] - anterior) < pd.Timedelta(hours=2)
posts["regla"] = np.select([spam, copia], ["spam", "copia"], "ok")
pd.crosstab(posts["clase"], posts["regla"])regla copia ok spam
clase
bot_copia 9956 0 0
organica 8 47712 0
pump 95 1836 0
spam 0 0 2090La regla de spam encuentra las 2.090 publicaciones de spam sin tocar ninguna orgánica. La de copias encuentra las 9.956 copias de bots, con un coste pequeño: 8 publicaciones orgánicas que, por casualidad, repiten la frase de otra persona en dos horas (en redes reales, las frases hechas y los textos de plantilla de algunas aplicaciones hacen lo mismo). Las campañas de pump apenas las detectan estas reglas (95 de 1.931 publicaciones): sus textos varían y no llevan enlaces. Se reconocen por el conjunto (muchas cuentas nuevas que hablan de golpe de una empresa pequeña y un volumen anormal), y es mejor tratarlas como un riesgo de la señal que como una señal: en el corpus, las cuatro campañas suben el precio un 48-58 % y lo devuelven en pocas sesiones (README del nivel 6).
Copias de agencia: una historia, un evento
En el corpus de noticias, 12.266 de las 39.570 piezas son copias sindicadas de otra: la misma historia en otra agencia, de 2 minutos a 6 horas después, con el titular retocado («ACTUALIZA:», «Última hora:»), el cuerpo recortado o traducida al otro idioma. La lección 6.1.3 mide el daño de contarlas como eventos. Para agruparlas sin la verdad, dos claves: el titular normalizado (sin prefijos ni mayúsculas) y una huella de cifras (los números del texto, normalizados entre la convención española y la inglesa), las dos con la empresa y una ventana de 6 horas:
noticias = pd.read_parquet("noticias6_corpus.parquet").sort_values("ts")
PREFIJO = re.compile(r"^(ACTUALIZA|Última hora|UPDATE|Breaking):\s*")
def cifras(texto, idioma):
out = set()
for x in re.findall(r"\d[\d.,]*\d|\d", texto):
if idioma == "es":
x = x.replace(".", "").replace(",", ".")
else:
x = x.replace(",", "")
out.add(round(float(x), 1))
return tuple(sorted(out))
def agrupar(df, usar_cifras=True, ventana=pd.Timedelta(hours=6)):
grupo, visto = np.arange(len(df)), {}
for i, r in enumerate(df.itertuples()):
claves = [("t", r.ticker, PREFIJO.sub("", r.titular).lower())]
c = cifras(r.titular + " " + r.cuerpo, r.idioma)
if usar_cifras and c:
claves.append(("c", r.ticker, c))
for k in claves:
if k in visto and r.ts - visto[k][1] <= ventana:
grupo[i] = visto[k][0]
break
for k in claves:
visto[k] = (grupo[i], r.ts)
return grupo
noticias["g_titular"] = agrupar(noticias, usar_cifras=False)
noticias["g_cifras"] = agrupar(noticias)
print(noticias[["g_titular", "g_cifras"]].nunique().to_dict()){'g_titular': 30675, 'g_cifras': 27898}¿Cuánto se parecen esos grupos a las historias verdaderas? Dos medidas por pares de noticias: la precisión (de los pares que el método pone juntos, cuántos son de verdad la misma historia) y la exhaustividad (de los pares que son la misma historia, cuántos junta el método):
from collections import Counter
historia = pd.read_csv("noticias6_verdad.csv")[["id_noticia",
"id_historia"]]
noticias = noticias.merge(historia, on="id_noticia")
def pares(etiquetas):
return sum(n * (n - 1) // 2 for n in Counter(etiquetas).values())
def medir(col):
juntos = pares(zip(noticias[col], noticias["id_historia"]))
return pd.Series({
"grupos": noticias[col].nunique(),
"precision": juntos / pares(noticias[col]),
"exhaustividad": juntos / pares(noticias["id_historia"])})
print("historias verdaderas:", noticias["id_historia"].nunique())
metodos = ["g_titular", "g_cifras"]
pd.DataFrame({c: medir(c) for c in metodos}).T.round(3)historias verdaderas: 27162
grupos precision exhaustividad
g_titular 30675.0 0.999 0.653
g_cifras 27898.0 0.997 0.929El titular normalizado no junta casi nunca historias distintas (precisión del 99,9 %), pero solo encuentra dos de cada tres pares de copias: se le escapan las traducciones y los titulares reescritos. La huella de cifras sube la exhaustividad al 92,9 % con una pérdida mínima de precisión (99,7 %) y deja 27.898 grupos frente a 27.162 historias verdaderas: las que quedan fuera son traducciones sin ninguna cifra en el texto (un nombramiento, una junta), que solo comparten la empresa y la hora. La figura de la cabecera resume el resultado. Para esas, la herramienta es un embedding multilingüe: dos textos con el mismo significado en dos idiomas quedan cerca (lo que MiniLM, inglés, no consigue; lección 6.1.1).
Ejercicios
- La regla de copias de redes marca también publicaciones orgánicas. Añade una condición sobre la cuenta (por ejemplo, antigüedad menor de un año) y mide de nuevo contra la verdad. ¿Qué ganas y qué pierdes?
Solución
joven = posts["antiguedad_cuenta_dias"] < 365
posts["regla2"] = np.select([spam, copia & joven],
["spam", "copia"], "ok")
pd.crosstab(posts["clase"], posts["regla2"])regla2 copia ok spam
clase
bot_copia 5711 4245 0
organica 1 47719 0
pump 47 1884 0
spam 0 0 2090La condición sobre la cuenta reduce las orgánicas marcadas como copia de 8 a 1, pero deja escapar 4.245 de las 9.956 copias, las que publican bots con cuentas de más de un año. Es el compromiso habitual de cualquier filtro: cada regla se mide con una tabla como esta (con datos reales, sobre una muestra etiquetada a mano) y se elige según qué error es más caro para la señal.
- Los 8-K de reexpresión (Item 4.02) anuncian que unos estados financieros ya publicados no son fiables. Lista los del corpus con su hora de Nueva York y di en qué sesión podría reaccionar el precio.
Solución
r402 = docs[docs["items"] == "4.02"].copy()
r402["hora_ny"] = r402["ts"].dt.tz_convert("America/New_York")
print(len(r402), "reexpresiones")
print(r402["hora_ny"].dt.strftime("%H:%M").agg(["min", "max"]))
r402[["ticker", "hora_ny"]].head(3)120 reexpresiones
min 06:30
max 07:53
Name: hora_ny, dtype: str
ticker hora_ny
0 MURI 2019-01-02 07:43:04-05:00
2 ZEZU 2019-01-08 06:32:00-05:00
4 DITI 2019-01-24 07:18:08-05:00Todos se presentan antes de la apertura de la NYSE, así que la sesión de reacción es la del mismo día (lección 6.1.3). En el corpus, el 10-Q/A o 10-K/A con las cifras corregidas llega esa misma tarde: el anuncio y las cifras son dos eventos distintos, con horas distintas.
- Diseña un proceso diario que recoja los 8-K de 500 empresas de EDGAR. ¿Cuántas peticiones necesita, cuánto tarda respetando la política de acceso y qué guardarías de cada envío?
Solución
Una petición de submissions por empresa (500) más una por documento nuevo (unas decenas al día): con una espera de
0,15 s entre peticiones, unos 80 segundos, muy por debajo de las 10 peticiones por segundo. Alternativa: el índice
diario de EDGAR, una sola descarga con todos los envíos del día. De cada envío se guardaría el CIK, el número de
registro, el formulario, los items, acceptanceDateTime (con zona), filingDate, la URL y el documento original sin
modificar (para poder reprocesarlo con un parser mejor), además de la hora de descarga. El User-Agent, con el nombre
de la organización y un buzón de rol, en la configuración; reintentos con espera creciente ante 429 y 5xx; y un
registro de errores para no perder envíos.
Footnotes
-
SEC, «EDGAR Application Programming Interfaces» (https://www.sec.gov/search-filings/edgar-application-programming-interfaces, consultada el 2026-10-23): historial en
data.sec.gov/submissions/CIK##########.json, «at least one year's of filing or to 1,000 (whichever is more)», ficheros adicionales para los anteriores, y «these APIs do not require any authentication or API keys to access». ↩ -
SEC, «Accessing EDGAR Data» (https://www.sec.gov/os/accessing-edgar-data, consultada el 2026-10-23): «Current max request rate: 10 requests/second», cabecera
User-Agentdeclarada, rutas/Archives/edgar/data/<CIK>/<número de registro>/…e índices diarios y completos desde 1994. ↩ ↩2 -
17 CFR 232.13(a)(2) (https://www.law.cornell.edu/cfr/text/17/232.13): lo enviado después de las 17:30 de la costa este «shall be deemed filed as of the next business day»; las excepciones hasta las 22:00, en (a)(3) y (a)(4). ↩
-
SEC, «EDGAR Full Text Search FAQ» (https://www.sec.gov/edgar/search/efts-faq.html, consultada el 2026-10-23): búsqueda en el texto de los envíos electrónicos desde 2001. La página no documenta el servicio JSON que usa. ↩
-
CNMV, registros oficiales de entidades emisoras: «Hechos relevantes de emisores de valores hasta 08/02/2020», «Información privilegiada» y «Otra información relevante» (https://www.cnmv.es/portal/otra-informacion-relevante/consulta-oir?lang=es); Ley 6/2023, de 17 de marzo, de los Mercados de Valores y de los Servicios de Inversión, artículos 226 y 227 (https://www.boe.es/buscar/act.php?id=BOE-A-2023-7053). Consultados el 2026-10-23. ↩
-
CNMV, canales RSS (https://www.cnmv.es/portal/gpage?id=RSS, consultado el 2026-10-23): información privilegiada, otra información relevante, notas de prensa y otros contenidos. ↩
-
SEC, Form 8-K, instrucciones generales B.1 y B.2 (https://www.sec.gov/files/form8-k.pdf): plazo de cuatro días hábiles y la información del Item 2.02 «shall not be deemed to be "filed" for purposes of Section 18». ↩
Respuesta plegada en cada pregunta
Autoevaluación
La SEC pide declarar un
User-Agenten las peticiones automáticas a EDGAR. ¿Qué pondrías en el código de un repositorio compartido?- AEl nombre de la organización y un buzón de contacto genérico de la organización, leído de la configuración; nunca un correo personal escrito en el código
- BTu nombre y tu correo personal, escritos en el código
- CNada: la cabecera es opcional
- DEl
User-Agentde un navegador para pasar desapercibido
Ver respuesta
Respuesta A. El nombre de la organización y un buzón de contacto genérico de la organización, leído de la configuración; nunca un correo personal escrito en el código
La política de acceso de la SEC pide identificarse (su ejemplo es «Sample Company Name AdminContact@<dominio>.com») y limita el ritmo a 10 peticiones por segundo. Un buzón de rol y la configuración fuera del código cumplen sin publicar datos personales; disfrazarse de navegador incumple la política.
Un 10-Q se acepta en EDGAR a las 17:52 de Nueva York de un martes. ¿Qué fecha de presentación (
filingDate) tendrá y qué fecha conviene usar en un estudio de eventos?- AEl martes, y el martes
- BEl miércoles (lo aceptado después de las 17:30 se considera presentado el siguiente día hábil); para el evento cuenta la hora de aceptación: el precio puede reaccionar desde la sesión del miércoles
- CEl lunes
- DEl jueves, porque la SEC tarda dos días
Ver respuesta
Respuesta B. El miércoles (lo aceptado después de las 17:30 se considera presentado el siguiente día hábil); para el evento cuenta la hora de aceptación: el precio puede reaccionar desde la sesión del miércoles
La regla 13 de la Regulation S-T fija las 17:30 de la costa este como límite del día de presentación. La hora de aceptación (
acceptanceDateTime) es la que dice cuándo pudo conocerse; la sesión de reacción se calcula con ella (lección 6.1.3).Desde el 8 de febrero de 2020, ¿cómo publican los emisores españoles en la CNMV lo que antes eran «hechos relevantes»?
- ASiguen llamándose hechos relevantes
- BSolo por nota de prensa
- CSeparados en información privilegiada (artículo 17 del Reglamento de abuso de mercado) y otra información relevante (OIR), en dos registros distintos
- DSolo en inglés
Ver respuesta
Respuesta C. Separados en información privilegiada (artículo 17 del Reglamento de abuso de mercado) y otra información relevante (OIR), en dos registros distintos
La CNMV mantiene el registro de hechos relevantes hasta el 08/02/2020 y, desde entonces, los de información privilegiada y OIR. La Ley 6/2023 del Mercado de Valores regula hoy su difusión (artículos 226 y 227). Una serie histórica tiene que unir los dos regímenes.
Una noticia se publica en una agencia en español y, dos horas después, otra la traduce al inglés. ¿Qué detecta mejor la copia?
- AEl titular idéntico
- BLa similitud de palabras
- CEl idioma
- DLa empresa, una ventana de pocas horas y las cifras del texto normalizadas (3.075,9 y 3,075.9 son el mismo número)
Ver respuesta
Respuesta D. La empresa, una ventana de pocas horas y las cifras del texto normalizadas (3.075,9 y 3,075.9 son el mismo número)
Una traducción no comparte palabras, pero sí la empresa y las cifras. En el corpus, añadir la huella de cifras (con una ventana de 6 horas) sube la exhaustividad por pares del 65 % al 93 % sin apenas unir historias distintas (precisión del 99,7 %).
En el corpus de redes, las copias exactas de bots las publican cuentas nuevas minutos después del original. ¿Por qué conviene quitarlas antes de medir el sentimiento minorista?
- APorque multiplican una sola opinión y la hacen pasar por consenso, y su volumen puede ser fabricado (como en las campañas de pump)
- BPorque ocupan disco
- CPorque los bots siempre son negativos
- DNo conviene: más publicaciones dan más señal
Ver respuesta
Respuesta A. Porque multiplican una sola opinión y la hacen pasar por consenso, y su volumen puede ser fabricado (como en las campañas de pump)
La señal minorista es la opinión de personas distintas. Una copia de bot es la misma frase repetida; contarla infla el volumen y sesga el sentimiento hacia lo que el operador del bot quiere.
Ideas clave
- Cada fuente tiene su formato (HTML, Markdown, JSON, texto libre), su hora y su forma de acceso. La hora de publicación es parte del dato: sin ella no hay estudio de eventos honesto.
- EDGAR da el historial de cada entidad en
data.sec.gov/submissions/CIK##########.json, sin claves, con un máximo de 10 peticiones por segundo y unUser-Agentdeclarado. En código compartido, un buzón de rol de la organización, nunca datos personales. - Lo aceptado después de las 17:30 de Nueva York recibe la fecha de presentación del siguiente día hábil: para el evento, la hora de aceptación. En el corpus, el 29 % de los 10-K, 10-Q y sus enmiendas.
- En la CNMV, los hechos relevantes se publican hasta el 8 de febrero de 2020; después, información privilegiada y otra información relevante, con sus registros, avisos y canales RSS.
- Los hechos XBRL vienen duplicados entre periodos que coinciden (el primer trimestre es también el acumulado del año) y el cuarto trimestre no se publica: se calcula como año menos nueve meses.
- Copias de agencia, traducciones, spam y bots se eliminan antes de medir. En el corpus, una huella de titular y cifras agrupa las copias con una precisión del 99,7 % y una exhaustividad del 92,9 %; los bots se reconocen por texto repetido desde cuentas nuevas.
Recursos
Documentación
- SEC: EDGAR Application Programming Interfaces (submissions y XBRL)
- SEC: Accessing EDGAR Data (acceso justo, User-Agent, estructura de directorios)
- SEC: preguntas frecuentes de la búsqueda de texto completo de EDGAR
- 17 CFR 232.13: fecha de presentación de los documentos electrónicos (Cornell LII)
- CNMV: consulta de otra información relevante
- CNMV: canales RSS (información privilegiada, OIR y otros)