Lección 1.5.1 · 65 min
Estados financieros, ratios, XBRL y SEC EDGAR
Un trimestre termina el 31 de marzo, pero sus cifras no existen para el mercado hasta que la empresa las presenta, semanas después, y pueden cambiar meses más tarde. Esta lección construye ratios trimestrales y TTM tal como se conocían cada día y mide cuánto adelanta el join ingenuo por fecha de periodo.
- Módulo
- 1.5 · Datos fundamentales y macro
- Duración
- 65 min
Lecciones del nivel 1
1.1 · Cómo funcionan los mercados
1.2 · Clases de activos
- 1.2.1 Acciones: splits, dividendos y eventos corporativos
- 1.2.2 ETFs: réplica, tracking error, apalancados y UCITS
- 1.2.3 Futuros: especificaciones, margen, roll y contango
- 1.2.4 Forex: pares, pips, lotes, rollover y CFD
- 1.2.5 Opciones: call, put, valor intrínseco y valor temporal
- 1.2.6 Cripto: spot, perpetuos, funding y custodia
- 1.2.7 Renta fija y tipos de interés; CFDs y productos apalancados
1.3 · Fuentes de datos de mercado
- 1.3.1 Tipos de datos de mercado: EOD, barras, ticks, libro y referencia
- 1.3.2 Fuentes de datos gratuitas
- 1.3.3 APIs de datos retail y prosumer
- 1.3.4 Proveedores de datos profesionales
- 1.3.5 Datos de cripto y on-chain
- 1.3.6 Brókeres como fuente de datos
- 1.3.7 Licencias y términos de uso de datos
- 1.3.8 Cómo elegir proveedor de datos
1.4 · Ingeniería de datos financieros
- 1.4.1 Descarga por REST y websockets: paginación, límites y reintentos
- 1.4.2 Limpieza de datos: faltantes, ticks erróneos, huecos y zonas horarias
- 1.4.3 Ajustes por splits y dividendos: precios ajustados y sin ajustar
- 1.4.4 Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo
- 1.4.5 Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio
- 1.4.6 Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio
- 1.4.7 Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB
- 1.4.8 Pipeline reproducible: descarga, validación, actualización incremental y tests de calidad
1.5 · Datos fundamentales y macro
1.6 · Análisis exploratorio financiero
1.7 · Análisis técnico programado
Tres estados y dos fechas
Las empresas cotizadas en EE. UU. presentan sus cuentas a la SEC en dos formularios: el 10-Q, trimestral, para los tres primeros trimestres del ejercicio, y el 10-K, anual y auditado, en lugar del cuarto 10-Q. Los dos traen los mismos tres estados:
| Estado | Qué mide | Tipo de partida | Ejemplos |
|---|---|---|---|
| Cuenta de resultados | Lo que se ganó o perdió durante el periodo | Flujo | Ingresos, beneficio neto, BPA |
| Balance | Lo que se tiene y se debe en una fecha | Saldo | Activo total, patrimonio neto, deuda |
| Flujos de efectivo | El dinero que entró y salió durante el periodo | Flujo | Flujo operativo, inversiones, dividendos |
La diferencia entre flujo y saldo decide cómo se agregan. Los ingresos de un año son la suma de los de sus cuatro trimestres; el patrimonio de un año no es la suma de nada: es el del último día. Y en el 10-K las partidas de flujo vienen para el ejercicio completo: no hay un «cuarto trimestre» publicado, hay que obtenerlo restando.
Cada cifra tiene además dos fechas. La fecha de periodo (periodo_fin) es el último día que cubre: el 31 de marzo
para el primer trimestre de un ejercicio natural. La fecha de publicación (fecha_publicacion) es el día en que la
empresa presenta el formulario y la cifra empieza a existir para el mercado. Entre las dos pasan semanas: la SEC da 40
días para el 10-Q a los emisores grandes y acelerados (45 a los demás) y 60, 75 o 90 días para el 10-K según el
tamaño del emisor1Nota 1 · Plazos de las instrucciones generales de los formularios de la SEC: 10-Q, 40 días tras el trimestre para large
accelerated y accelerated filers y 45 para el resto, sin 10-Q del cuarto trimestre
(https://www.sec.gov/files/form10-q.pdf); 10-K, 60, 75 y 90 días tras el ejercicio según la categoría
(https://www.sec.gov/files/form10-k.pdf).. Después, la cifra todavía puede cambiar.
Fuente Mermaid
flowchart TB C[Cierre trimestre] -->|25-47 días| P[Presentación 10-Q] P -->|después del cierre| D[Sesión siguiente] D -->|meses después| R[Reexpresión]
Un backtest que decide con datos de la cuenta de resultados tiene que usar la segunda fecha. Con la primera, cada trimestre llega al modelo antes de que nadie lo conociera. Es el sesgo de anticipación de la lección 0.1.1, en su forma más frecuente con datos fundamentales, y la versión contable del point-in-time de la lección 1.4.4.
Los datos: estados tal como se conocían
fundamentales_pit.csv tiene los estados trimestrales de diez valores ficticios del universo de la lección 1.4.4, de
2018 a 2025, con una fila por publicación. Dos de los valores dejan de presentar: uno quiebra en 2023 y otro es
absorbido en 2024. Las cifras van en millones de dólares (acciones en millones).
import pandas as pd
fu = pd.read_csv("fundamentales_pit.csv",
parse_dates=["periodo_fin", "fecha_publicacion"])
print(fu.shape, fu["ticker"].nunique(), "valores")
cols = ["periodo_fin", "fecha_publicacion", "formulario",
"ingresos", "beneficio_neto", "version"]
fu.loc[(fu["ticker"] == "ZXNW")
& (fu["periodo_fin"].dt.year == 2021), cols](295, 10) 10 valores
periodo_fin fecha_publicacion formulario ingresos beneficio_neto version
101 2021-03-31 2021-05-07 10-Q 3319.3 383.3 1
102 2021-06-30 2021-08-09 10-Q 3662.3 519.0 1
103 2021-09-30 2021-10-26 10-Q 3556.9 554.3 1
104 2021-09-30 2022-02-22 10-Q 3361.8 392.4 2
105 2021-12-31 2022-02-25 10-K 14055.8 1804.0 1El ejercicio 2021 de ZXNW enseña las tres cosas de esta lección. Los tres 10-Q traen sus trimestres. El 10-K trae 14.055,8 millones de ingresos: el año entero, no el cuarto trimestre. Y el tercer trimestre aparece dos veces: la versión 1, presentada el 26 de octubre de 2021, y la versión 2, una reexpresión a la baja presentada el 22 de febrero de 2022, casi cinco meses después del cierre del trimestre.
¿Cuánto tarda cada formulario?
fecha_pub, fecha_fin = fu["fecha_publicacion"], fu["periodo_fin"]
fu["retraso"] = (fecha_pub - fecha_fin).dt.days
fu.groupby(["formulario", "version"])["retraso"].agg(
["count", "min", "median", "max"]) count min median max
formulario version
10-K 1 66 45 54.0 62
10-Q 1 225 25 36.0 47
2 4 145 234.0 326Los 10-Q se presentan entre 25 y 47 días después del cierre (mediana, 36); los 10-K, entre 45 y 62 (mediana, 54), porque llevan auditoría; la figura de la cabecera dibuja las dos distribuciones. Las cuatro reexpresiones llegan entre 145 y 326 días después del periodo que corrigen. Ese retraso es exactamente lo que adelanta un join por fecha de periodo.
Del 10-K al cuarto trimestre
Para tener una serie trimestral hay que convertir cada 10-K en un trimestre. En las partidas de flujo:
El detalle point-in-time está en qué T1, T2 y T3 se restan: los que se conocían en la fecha del 10-K. Si un trimestre se reexpresó antes, se resta la versión nueva (el 10-K ya suma las cifras reexpresadas); si se hubiera reexpresado después, la antigua. Las partidas de saldo (activo, patrimonio) no se tocan: el 10-K da el saldo a 31 de diciembre, que es el del cuarto trimestre.
FLUJOS = ["ingresos", "beneficio_neto"]
def trimestrales(fu):
"""Una fila por publicación con los flujos del trimestre.
En el 10-K, T4 = ejercicio - (T1 + T2 + T3), con las
versiones de T1-T3 conocidas en la fecha del 10-K."""
filas = []
for _, g in fu.groupby("ticker"):
for _, f in g.iterrows():
f = f.copy()
if f["formulario"] == "10-K":
ano = f["periodo_fin"].year
previos = g[(g["periodo_fin"].dt.year == ano)
& (g["formulario"] == "10-Q")
& (g["fecha_publicacion"]
<= f["fecha_publicacion"])]
ultimos = (previos.sort_values("version")
.groupby("periodo_fin").last())
assert len(ultimos) == 3, "faltan trimestres"
for c in FLUJOS:
f[c] = f[c] - ultimos[c].sum()
filas.append(f)
return pd.DataFrame(filas).reset_index(drop=True)
tr = trimestrales(fu)
tr.loc[(tr["ticker"] == "ZXNW")
& (tr["periodo_fin"].dt.year == 2021), cols] periodo_fin fecha_publicacion formulario ingresos beneficio_neto version
101 2021-03-31 2021-05-07 10-Q 3319.3 383.3 1
102 2021-06-30 2021-08-09 10-Q 3662.3 519.0 1
103 2021-09-30 2021-10-26 10-Q 3556.9 554.3 1
104 2021-09-30 2022-02-22 10-Q 3361.8 392.4 2
105 2021-12-31 2022-02-25 10-K 3712.4 509.3 1El cuarto trimestre de 2021 de ZXNW queda en 3.712,4 millones de ingresos: 14.055,8 menos los tres trimestres, con el
tercero en su versión reexpresada, que se presentó tres días antes que el 10-K. El assert no es decorativo: un valor
con un 10-Q que falta daría un T4 inflado sin ningún aviso.
TTM point-in-time
Un trimestre suelto es ruidoso y estacional. Los ratios de valoración y rentabilidad se calculan sobre los últimos doce meses (TTM, trailing twelve months): la suma de los cuatro últimos trimestres en las partidas de flujo y el último saldo en las de balance.
| Ratio | Fórmula | Qué mide |
|---|---|---|
| BPA TTM | beneficio TTM / acciones diluidas | Beneficio por acción del último año |
| PER | precio / BPA TTM | Cuántos años de beneficio paga el precio |
| ROE | beneficio TTM / patrimonio neto | Rentabilidad del capital de los accionistas |
| Margen neto | beneficio TTM / ingresos TTM | Qué parte de cada dólar vendido queda como beneficio |
Point-in-time significa recalcular el TTM en cada publicación con lo que se sabía ese día: una fila por fecha de conocimiento, no por periodo. La función recorre las publicaciones de cada valor en orden y guarda la última versión conocida de cada trimestre:
def ttm_pit(tr):
out = []
orden = tr.sort_values(["fecha_publicacion", "version"])
for tk, g in orden.groupby("ticker"):
conocido = {} # periodo_fin -> última versión conocida
for _, f in g.iterrows():
conocido[f["periodo_fin"]] = f
ult = pd.DataFrame(list(conocido.values()))
ult4 = ult.sort_values("periodo_fin").tail(4)
dias = (ult4["periodo_fin"].iloc[-1]
- ult4["periodo_fin"].iloc[0]).days
if len(ult4) < 4 or dias > 280: # 4 seguidos
continue
out.append({
"ticker": tk,
"disponible": f["fecha_publicacion"],
"periodo_fin": ult4["periodo_fin"].iloc[-1],
"ing_ttm": ult4["ingresos"].sum(),
"ben_ttm": ult4["beneficio_neto"].sum(),
"patrimonio": ult4["patrimonio_neto"].iloc[-1],
"acciones": ult4["acciones_diluidas"].iloc[-1]})
return pd.DataFrame(out)
pit = ttm_pit(tr)
pit["bpa"] = pit["ben_ttm"] / pit["acciones"]
print(len(pit), "filas point-in-time")
ver = ["disponible", "periodo_fin", "ben_ttm", "bpa"]
pit.loc[pit["ticker"] == "ZXNW", ver].iloc[10:15].round(
{"ben_ttm": 1, "bpa": 2})265 filas point-in-time
disponible periodo_fin ben_ttm bpa
90 2021-08-09 2021-06-30 1938.4 15.63
91 2021-10-26 2021-09-30 2006.7 16.19
92 2022-02-22 2021-09-30 1844.8 14.88
93 2022-02-25 2021-12-31 1804.0 14.55
94 2022-04-27 2022-03-31 1936.5 15.72Las filas del 22 y del 25 de febrero de 2022 enseñan la diferencia con una tabla por periodo. El 22 de febrero no
termina ningún trimestre nuevo: llega la reexpresión del tercero y el BPA TTM baja de 16,19 a 14,88 ese día. Tres días
después llega el 10-K y el TTM incorpora el cuarto trimestre. Una tabla indexada por periodo_fin no puede representar
ninguna de las dos cosas.
El join ingenuo y cuántos días adelanta
La forma ingenua de construir la misma serie es la que da una base de datos de «fundamentales por periodo»: la
versión final de cada trimestre, indexada por la fecha de cierre. Al unirla con precios por periodo_fin, cada
trimestre se usa desde el día en que termina.
final = (tr.sort_values("version")
.groupby(["ticker", "periodo_fin"]).last()
.reset_index().sort_values(["ticker", "periodo_fin"]))
g = final.groupby("ticker")
final["ben_ttm"] = g["beneficio_neto"].transform(
lambda s: s.rolling(4).sum())
final["ing_ttm"] = g["ingresos"].transform(
lambda s: s.rolling(4).sum())
final["bpa"] = final["ben_ttm"] / final["acciones_diluidas"]
ingenuo = final.dropna(subset=["ben_ttm"])
print(len(ingenuo), "filas por periodo")261 filas por periodoPara comparar las dos versiones se unen con las sesiones de cada valor (universo_precios.parquet). La point-in-time
usa allow_exact_matches=False: una cifra presentada un día cualquiera se usa desde la sesión siguiente, porque no
sabemos si se presentó antes o después del cierre (lección 1.1.1).
pr = pd.read_parquet("universo_precios.parquet")
pr = (pr[pr["ticker"].isin(fu["ticker"].unique())]
.sort_values("fecha"))
d_pit = pd.merge_asof(
pr, pit.sort_values("disponible"), left_on="fecha",
right_on="disponible", by="ticker", allow_exact_matches=False)
d_ing = pd.merge_asof(
pr, ingenuo[["ticker", "periodo_fin", "bpa", "ben_ttm"]]
.sort_values("periodo_fin"),
left_on="fecha", right_on="periodo_fin", by="ticker")
both = d_pit.merge(d_ing, on=["fecha", "ticker"],
suffixes=("_pit", "_ing"))
both = both.dropna(subset=["bpa_pit", "bpa_ing"])
adelanta = both["periodo_fin_ing"] > both["periodo_fin_pit"]
print(f"Sesiones: {len(both)}")
print(f"Con un trimestre aún no publicado: {adelanta.mean():.1%}")Sesiones: 16116
Con un trimestre aún no publicado: 43.9%Casi la mitad de las sesiones. Con trimestres de unos 91 días y retrasos de 25 a 62, el join ingenuo va por delante del real durante el 40-60 % de cada trimestre. En un estudio de sorpresas de resultados, o en cualquier factor construido sobre fundamentales (value, quality), ese adelanto es exactamente el periodo en que el precio reacciona a la publicación: el modelo vería la noticia antes que el mercado.
sesiones = pd.to_datetime(["2022-01-03", "2022-02-22",
"2022-02-23", "2022-02-25",
"2022-02-28"])
z = both[(both["ticker"] == "ZXNW")
& both["fecha"].isin(sesiones)]
z.set_index("fecha")[["periodo_fin_pit", "bpa_pit",
"periodo_fin_ing", "bpa_ing"]].round(
{"bpa_pit": 2, "bpa_ing": 2}) periodo_fin_pit bpa_pit periodo_fin_ing bpa_ing
fecha
2022-01-03 2021-09-30 16.19 2021-12-31 14.55
2022-02-22 2021-09-30 16.19 2021-12-31 14.55
2022-02-23 2021-09-30 14.88 2021-12-31 14.55
2022-02-25 2021-09-30 14.88 2021-12-31 14.55
2022-02-28 2021-12-31 14.55 2021-12-31 14.55Durante enero y casi todo febrero de 2022, la versión ingenua ya «conoce» el ejercicio 2021 completo, con el tercer trimestre reexpresado: BPA de 14,55. La point-in-time sigue con el TTM a septiembre en su versión original (16,19) hasta el 23 de febrero, la sesión siguiente a la reexpresión, en que baja a 14,88. El 28 de febrero, la sesión siguiente al 10-K (presentado el viernes 25), las dos coinciden. La ingenua estuvo casi dos meses por delante, y en la dirección de una sorpresa negativa.
Ratios del universo en una fecha
Con la tabla point-in-time, una foto de los ratios de todos los valores en una sesión es un filtro. El 28 de junio de 2024 ZXBY ya ha sido absorbido y ZXSW ha quebrado: no aparecen, como en el mercado de ese día.
foto = d_pit[d_pit["fecha"] == "2024-06-28"].set_index("ticker")
foto = foto.assign(
roe=100 * foto["ben_ttm"] / foto["patrimonio"],
margen=100 * foto["ben_ttm"] / foto["ing_ttm"],
per=foto["cierre"] / foto["bpa"])
foto[["periodo_fin", "bpa", "roe", "margen", "per"]].round(
{"bpa": 2, "roe": 1, "margen": 1, "per": 1}) periodo_fin bpa roe margen per
ticker
ZXLJ 2024-03-31 16.22 20.3 19.1 1.4
ZXHN 2024-03-31 0.40 13.2 11.6 926.8
ZXZP 2024-03-31 4.79 19.9 19.1 67.5
ZXZE 2024-03-31 3.60 18.7 19.5 155.3
ZXYC 2024-03-31 6.68 8.0 7.7 37.7
ZXZN 2024-03-31 10.72 15.0 14.7 5.2
ZXSI 2024-03-31 12.82 17.1 17.5 10.1
ZXNW 2024-03-31 18.88 15.1 14.4 1.3ROE y margen son coherentes entre valores: márgenes del 7,7 al 19,5 % y ROE del 8,0 al 20,3 %. El PER, no: va de 1,3 a más de 900. Son datos sintéticos y los precios del universo y los estados se generaron por separado, así que el nivel del PER no significa nada. Lo que sí es real es su mecánica: cambia cada día con el precio y da un salto en cada publicación, y ese salto solo puede ocurrir en la fecha en que se publicó.
¿Cuánto se equivoca el ratio ingenuo en las sesiones en que adelanta?
err = 100 * (both["bpa_ing"] / both["bpa_pit"] - 1)
print(err[adelanta].abs().describe(
percentiles=[0.5, 0.9]).round(2))count 7071.00
mean 7.63
std 32.83
min 0.04
50% 3.52
90% 9.21
max 543.64
dtype: float64En las 7.071 sesiones en que adelanta, la diferencia mediana es del 3,5 % y una de cada diez supera el 9,2 %; los máximos, de cientos de puntos, son del valor que quiebra, cuyo beneficio TTM pasa cerca de cero. Parece poco, pero lo que importa no es el tamaño sino la naturaleza: el error ingenuo no es ruido, es información sobre el trimestre siguiente, que es justo lo que un modelo aprende a explotar.
Reexpresiones: versiones, no correcciones
Una reexpresión cambia una cifra ya publicada. En una tabla point-in-time es una fila nueva con su fecha, y la cifra antigua sigue siendo la que se conocía hasta entonces. Las cuatro de estos datos:
v1 = fu[fu["version"] == 1]
v2 = fu[fu["version"] == 2]
rx = v2.merge(v1, on=["ticker", "periodo_fin"],
suffixes=("_v2", "_v1"))
rx["dias"] = (rx["fecha_publicacion_v2"]
- rx["fecha_publicacion_v1"]).dt.days
for c in ["ingresos", "beneficio_neto"]:
rx[f"{c}_%"] = 100 * (rx[f"{c}_v2"] / rx[f"{c}_v1"] - 1)
rx[["ticker", "periodo_fin", "dias", "ingresos_%",
"beneficio_neto_%"]].round({"ingresos_%": 1,
"beneficio_neto_%": 1}) ticker periodo_fin dias ingresos_% beneficio_neto_%
0 ZXBY 2019-06-30 197 -4.9 -24.0
1 ZXHN 2019-06-30 191 -4.4 -17.6
2 ZXLJ 2023-03-31 281 -2.2 -18.4
3 ZXNW 2021-09-30 119 -5.5 -29.2Todas son a la baja y afectan más al beneficio (del −17,6 % al −29,2 %) que a los ingresos (del −2,2 % al −5,5 %): un recorte de ingresos pequeño con costes fijos se lleva una parte grande del beneficio. Entre la cifra original y la corregida pasan de 119 a 281 días. Un backtest con la última versión habría usado durante esos meses un beneficio que el mercado no conocía y, peor, habría «evitado» la sorpresa negativa de la reexpresión.
Fuente Mermaid
stateDiagram-v2 [*] --> Original: 10-Q Original --> Reexpresada: 10-Q/A o 10-K Original --> Original: sin cambios Reexpresada --> [*]
En EDGAR una reexpresión puede llegar en una enmienda (formularios terminados en /A, como 10-Q/A) o en los
comparativos del 10-K siguiente. En los dos casos, lo que la hace point-in-time es la fecha de presentación del
documento que la trae.
XBRL y la API de EDGAR
Desde hace años, los estados financieros que se presentan a la SEC llevan las cifras etiquetadas en XBRL: cada
cifra es un «hecho» con un concepto de una taxonomía (us-gaap:NetIncomeLoss, us-gaap:Revenues…), un periodo, una
unidad y el documento que la trae. EDGAR publica esos hechos en JSON en data.sec.gov, sin autenticación ni clave de
API2Nota 2 · SEC, «EDGAR Application Programming Interfaces»: las API de data.sec.gov no requieren autenticación ni claves;
el CIK es de diez dígitos con ceros a la izquierda; los JSON se actualizan a lo largo del día, a medida que se
difunden las presentaciones.:
| Endpoint | Qué devuelve |
|---|---|
/submissions/CIK##########.json | Historial de presentaciones de una entidad |
/api/xbrl/companyconcept/CIK##########/us-gaap/<Concepto>.json | Todos los hechos de un concepto para una entidad |
/api/xbrl/companyfacts/CIK##########.json | Todos los conceptos de una entidad en una sola llamada |
/api/xbrl/frames/us-gaap/<Concepto>/USD/CY2024Q1.json | Un hecho por entidad para un periodo natural |
El CIK es el identificador de la entidad en EDGAR, de diez dígitos con ceros a la izquierda. La estructura de
companyfacts es un árbol:
Fuente Mermaid
flowchart TB A[companyfacts] --> B[cik, entityName] A --> C[facts] C --> D[us-gaap] C --> E[dei] D --> F[NetIncomeLoss] F --> G[label, units] G --> H[USD: hechos]
Cada hecho de units → USD lleva start y end (el periodo), val, accn (número de registro del documento),
fy y fp (ejercicio y periodo del documento), form y filed. Un mismo periodo aparece en varios documentos:
el 10-Q de un trimestre y, como comparativo, el del mismo trimestre del año siguiente, o el 10-K de tres ejercicios
después. Por eso fy no es el año del periodo. Y el campo frame (CY2024Q1) marca, según la documentación de la
SEC, el hecho presentado en último lugar que mejor encaja en ese periodo natural: es la versión final, la de la
tabla ingenua.
El ejemplo siguiente es una respuesta inventada con esa estructura: una empresa ficticia, tres trimestres de 2024, su 10-K y el 10-Q de 2025 que repite como comparativo el primer trimestre de 2024, reexpresado.
import json
respuesta = json.loads("""{
"cik": 1, "entityName": "EJEMPLO CORP (ficticia)",
"facts": {"us-gaap": {"NetIncomeLoss": {
"label": "Net Income (Loss)",
"units": {"USD": [
{"start": "2024-01-01", "end": "2024-03-31", "val": 412000000,
"accn": "A-24-1", "fy": 2024, "fp": "Q1", "form": "10-Q",
"filed": "2024-05-06"},
{"start": "2024-04-01", "end": "2024-06-30", "val": 455000000,
"accn": "A-24-2", "fy": 2024, "fp": "Q2", "form": "10-Q",
"filed": "2024-08-05"},
{"start": "2024-07-01", "end": "2024-09-30", "val": 430000000,
"accn": "A-24-3", "fy": 2024, "fp": "Q3", "form": "10-Q",
"filed": "2024-11-04", "frame": "CY2024Q3"},
{"start": "2024-01-01", "end": "2024-12-31", "val": 1650000000,
"accn": "A-25-1", "fy": 2024, "fp": "FY", "form": "10-K",
"filed": "2025-02-24", "frame": "CY2024"},
{"start": "2024-01-01", "end": "2024-03-31", "val": 380000000,
"accn": "A-25-2", "fy": 2025, "fp": "Q1", "form": "10-Q",
"filed": "2025-05-05", "frame": "CY2024Q1"}
]}}}}}""")
hechos = respuesta["facts"]["us-gaap"]["NetIncomeLoss"]
df = pd.DataFrame(hechos["units"]["USD"])
for c in ["start", "end", "filed"]:
df[c] = pd.to_datetime(df[c])
df["dias"] = (df["end"] - df["start"]).dt.days + 1
df["val_M"] = df["val"] / 1e6
df[["start", "end", "dias", "form", "fy", "fp",
"filed", "frame", "val_M"]] start end dias form fy fp filed frame val_M
0 2024-01-01 2024-03-31 91 10-Q 2024 Q1 2024-05-06 NaN 412.0
1 2024-04-01 2024-06-30 91 10-Q 2024 Q2 2024-08-05 NaN 455.0
2 2024-07-01 2024-09-30 92 10-Q 2024 Q3 2024-11-04 CY2024Q3 430.0
3 2024-01-01 2024-12-31 366 10-K 2024 FY 2025-02-24 CY2024 1650.0
4 2024-01-01 2024-03-31 91 10-Q 2025 Q1 2025-05-05 CY2024Q1 380.0Cinco hechos para cuatro periodos. El primer trimestre de 2024 aparece dos veces: 412 millones en el 10-Q de mayo de
2024 y 380 millones como comparativo en el 10-Q de mayo de 2025, con fy 2025 aunque el periodo sea de 2024. El
frame CY2024Q1 está en el segundo: el último presentado. La duración (dias) separa trimestres de ejercicios
completos. Convertirlo en una serie point-in-time es lo mismo que con el CSV: cada hecho es una versión del periodo
(start, end), conocida desde filed.
trim = df[df["dias"].between(80, 100)]
trim = trim.sort_values(["end", "filed"])
trim["version"] = trim.groupby("end").cumcount() + 1
def conocido_en(fecha):
vis = trim[trim["filed"] <= fecha]
return vis.groupby("end")["val_M"].last()
print("Lo que se sabía el 2024-12-31:")
print(conocido_en("2024-12-31").to_string())
print("Lo que dice frame (la última versión):")
print(trim.dropna(subset=["frame"])[["end", "val_M"]].to_string())Lo que se sabía el 2024-12-31:
end
2024-03-31 412.0
2024-06-30 455.0
2024-09-30 430.0
Lo que dice frame (la última versión):
end val_M
4 2024-03-31 380.0
2 2024-09-30 430.0El 31 de diciembre de 2024, el beneficio del primer trimestre era de 412 millones para todo el mercado; la versión de
380 no existió hasta mayo de 2025. Una serie construida con frame habría usado 380 desde el principio. El cuarto
trimestre se obtendría como en el CSV: el hecho anual del 10-K menos los tres trimestres conocidos el 24 de febrero de
2025.
La petición real es una línea con urllib o requests. La SEC pide declarar en la cabecera User-Agent quién hace
la petición (un nombre y un correo de contacto) y limita el acceso a 10 peticiones por segundo3Nota 3 · SEC, «Accessing EDGAR Data»: límite actual de 10 peticiones por segundo y cabecera User-Agent con el nombre
de quien accede y un correo de contacto.:
import json
import time
import urllib.request
CABECERAS = {"User-Agent": "Tu Nombre [email protected]"}
def companyfacts(cik: int) -> dict:
url = ("https://data.sec.gov/api/xbrl/companyfacts/"
f"CIK{cik:010d}.json")
req = urllib.request.Request(url, headers=CABECERAS)
with urllib.request.urlopen(req, timeout=30) as r:
datos = json.load(r)
time.sleep(0.15) # muy por debajo de 10 peticiones/s
return datos
datos = companyfacts(320193) # CIK de diez dígitos: 0000320193
usd = datos["facts"]["us-gaap"]["NetIncomeLoss"]["units"]["USD"]La respuesta es un JSON con la estructura de arriba, a menudo de varios megas, con todos los conceptos que la empresa
ha etiquetado. Los problemas prácticos no son de descarga sino de contenido: empresas que cambian de concepto entre
años (Revenues frente a RevenueFromContractWithCustomerExcludingAssessedTax), ejercicios fiscales que no coinciden
con el año natural, unidades distintas (USD, USD/shares, shares) y hechos duplicados entre documentos. Las
soluciones de proveedores (lección 1.3.4) venden precisamente esa limpieza, con su fecha de conocimiento incluida o
no; antes de comprar, pregunta cuál de las dos.
Lo que esto cambia en un backtest
- Dos fechas por cifra, siempre. Una tabla de fundamentales sin
fecha_publicacionno sirve para simular decisiones. Si un proveedor solo da la fecha de periodo, la aproximación prudente es retrasar cada cifra con el plazo máximo legal de su formulario, sabiendo que es una aproximación. - Una fila por publicación, no por periodo. La tabla point-in-time crece con cada versión: el join con precios es
un
merge_asofpor fecha de conocimiento, conallow_exact_matches=Falsesi no sabes la hora. - Supervivencia también aquí. Los valores que dejan de presentar (quiebra, absorción) tienen que seguir en la tabla hasta su última publicación; la lección 1.4.4 explica por qué.
- El nivel de un ratio depende de su construcción. TTM, último trimestre anualizado o ejercicio fiscal dan PER distintos. Decide uno, documéntalo y no lo cambies entre el estudio y la ejecución.
Ejercicios
- Separa los dos errores del join ingenuo. Construye una versión que use la fecha de publicación correcta pero la última versión de cada trimestre (sin reexpresiones point-in-time) y calcula en cuántas sesiones su BPA difiere del point-in-time. ¿Qué parte del error total se debe a la fecha y qué parte a las versiones?
Solución
fecha_ok = ingenuo.merge(
tr.groupby(["ticker", "periodo_fin"])["fecha_publicacion"]
.min().rename("primera_pub").reset_index(),
on=["ticker", "periodo_fin"])
# el T4 se conoce con el 10-K: su primera publicación
d_ver = pd.merge_asof(
pr, fecha_ok[["ticker", "primera_pub", "bpa"]]
.sort_values("primera_pub"),
left_on="fecha", right_on="primera_pub", by="ticker",
allow_exact_matches=False)
cmp = d_pit.merge(d_ver, on=["fecha", "ticker"],
suffixes=("_pit", "_ver"))
cmp = cmp.dropna(subset=["bpa_pit", "bpa_ver"])
distinto = (cmp["bpa_ver"] - cmp["bpa_pit"]).abs() > 1e-9
print(f"Sesiones con BPA distinto: {distinto.mean():.1%}")
print(cmp.loc[distinto, "ticker"].value_counts().to_string())Sesiones con BPA distinto: 3.3%
ticker
ZXLJ 192
ZXBY 135
ZXHN 131
ZXNW 81Con la fecha correcta desaparece el adelanto por fechas y solo queda el de las versiones: el 3,3 % de las sesiones, todas de los cuatro valores con reexpresión (de 81 a 192 sesiones cada uno), en los meses entre la cifra original y la corregida. En número de sesiones casi todo el error del join ingenuo es de fechas; el de versiones es pequeño, pero cae justo en los periodos de las sorpresas negativas.
- ZXSW quiebra en 2023. Usando
d_pit, ¿cuál es la última sesión con datos fundamentales y qué ROE y margen TTM conocía el mercado ese día? Compáralo con su primera fila point-in-time.
Solución
s = d_pit[d_pit["ticker"] == "ZXSW"].dropna(subset=["bpa"])
s = s.assign(roe=100 * s["ben_ttm"] / s["patrimonio"],
margen=100 * s["ben_ttm"] / s["ing_ttm"])
s.iloc[[0, -1]][["fecha", "periodo_fin", "ing_ttm",
"roe", "margen"]].round(
{"ing_ttm": 1, "roe": 1, "margen": 1}) fecha periodo_fin ing_ttm roe margen
10458 2019-02-28 2018-12-31 3562.6 11.4 10.2
20944 2023-04-28 2022-09-30 2893.7 -94.0 -8.4La última sesión con datos de ZXSW es el 28 de abril de 2023, y lo último que se sabía era el TTM a septiembre de 2022: ingresos de 2.893,7 millones (3.562,6 en su primera fila, en 2019), margen del −8,4 % y ROE del −94,0 %, con el patrimonio casi agotado. El deterioro estaba en los datos. Un backtest sin el valor (porque «ya no existe» en el proveedor) no vería nunca esa trayectoria ni la pérdida final; uno con el valor pero con la última versión de sus estados por fecha de periodo vería el deterioro semanas antes que el mercado.
- Un proveedor te ofrece fundamentales «point-in-time» con una columna
fecha_disponibleigual a la fecha de periodo más 45 días para todos los formularios. ¿Qué problemas tiene con los 10-K y con las reexpresiones?
Solución
Con los 10-K se queda corto: aquí se presentan entre 45 y 62 días después del cierre (y la SEC da 60, 75 o 90 según el emisor), así que la regla adelanta muchos cuartos trimestres unos días, justo en la temporada de resultados anuales. Con los 10-Q es demasiado conservadora en unos casos y correcta en otros, lo que mete ruido en el momento de la reacción. Y una regla fija no puede representar reexpresiones: la versión corregida llega meses después y necesita su propia fecha. Un dato point-in-time de verdad guarda la fecha de presentación de cada documento y una fila por versión.
Footnotes
-
Plazos de las instrucciones generales de los formularios de la SEC: 10-Q, 40 días tras el trimestre para large accelerated y accelerated filers y 45 para el resto, sin 10-Q del cuarto trimestre (https://www.sec.gov/files/form10-q.pdf); 10-K, 60, 75 y 90 días tras el ejercicio según la categoría (https://www.sec.gov/files/form10-k.pdf). ↩
-
SEC, «EDGAR Application Programming Interfaces»: las API de
data.sec.govno requieren autenticación ni claves; el CIK es de diez dígitos con ceros a la izquierda; los JSON se actualizan a lo largo del día, a medida que se difunden las presentaciones. ↩ -
SEC, «Accessing EDGAR Data»: límite actual de 10 peticiones por segundo y cabecera
User-Agentcon el nombre de quien accede y un correo de contacto. ↩
Respuesta plegada en cada pregunta
Autoevaluación
Un 10-Q del trimestre que termina el 30 de junio se presenta el 6 de agosto, después del cierre. ¿Desde cuándo puede usar sus cifras un backtest diario?
- ADesde el 30 de junio, la fecha del periodo
- BDesde la sesión del 7 de agosto, la siguiente a la presentación
- CDesde el 6 de agosto a la apertura
- DDesde el 1 de julio
Ver respuesta
Respuesta B. Desde la sesión del 7 de agosto, la siguiente a la presentación
La cifra no existe para el mercado hasta que se presenta. Si no sabes la hora de la presentación, lo prudente es usarla desde la sesión siguiente: es lo que hace
merge_asofconallow_exact_matches=False. Usarla desde el 30 de junio adelanta información 38 días.El 10-K de un ejercicio trae unos ingresos de 14.055,8 y los tres 10-Q del año suman 10.343,4. ¿Cuánto ingresó el cuarto trimestre?
- A14.055,8: el 10-K es el cuarto trimestre
- B3.712,4: el ejercicio menos los tres primeros trimestres
- C3.513,9: un cuarto del ejercicio
- DNo se puede saber sin un 10-Q del cuarto trimestre
Ver respuesta
Respuesta B. 3.712,4: el ejercicio menos los tres primeros trimestres
No hay 10-Q del cuarto trimestre: el 10-K informa del ejercicio completo en las partidas de flujo. El trimestre 4 sale de restar los tres trimestres, con las versiones que se conocían en la fecha del 10-K.
Una empresa reexpresa a la baja el beneficio de un trimestre siete meses después. Tu base de datos guarda solo la última versión, indexada por fecha de periodo. ¿Qué error introduce?
- ANinguno: la última versión es la correcta
- BDoble anticipación: usa la cifra antes de publicarse y, además, una versión que nadie conocía hasta meses después
- CSolo un error de redondeo
- DHace que el beneficio parezca mayor de lo que fue
Ver respuesta
Respuesta B. Doble anticipación: usa la cifra antes de publicarse y, además, una versión que nadie conocía hasta meses después
Durante siete meses el mercado operó con la cifra original. La última versión es la correcta para un estudio contable, no para simular decisiones: el backtest «sabría» que el beneficio iba a reexpresarse.
En la respuesta
companyfactsde EDGAR, ¿qué campo dice cuándo se conoció cada cifra?- A
end, el final del periodo - B
fy, el ejercicio fiscal - C
filed, la fecha de presentación del documento que la trae - D
frame, el periodo natural
Ver respuesta
Respuesta C.
filed, la fecha de presentación del documento que la traestartyenddelimitan el periodo;fyyfpson los del documento (un 10-K trae también los ejercicios anteriores como comparativos);frameseñala el hecho presentado en último lugar para un periodo natural. La fecha de conocimiento esfiled.- A
En la tabla de esta lección, ¿en qué parte de las sesiones usa el join ingenuo un trimestre que todavía no se había publicado?
- AEn ninguna
- BEn torno al 5 %
- CEn torno al 44 %
- DEn todas
Ver respuesta
Respuesta C. En torno al 44 %
Con retrasos de 25 a 62 días por trimestre de unos 91, el join por fecha de periodo va por delante del real casi la mitad del tiempo: en el 43,9 % de las sesiones de los diez valores.
Ideas clave
- La cuenta de resultados mide flujos de un periodo; el balance, saldos en una fecha. El 10-K trae el ejercicio completo: el cuarto trimestre se obtiene restando.
- Cada cifra tiene dos fechas: la del periodo y la de publicación. Un backtest solo puede usar la segunda, y mejor desde la sesión siguiente.
- El join ingenuo por fecha de periodo adelanta cada trimestre entre 25 y 62 días en estos datos; en el 43,9 % de las sesiones usa uno que aún no existía.
- Una reexpresión es una versión nueva con su fecha. Guardar solo la última versión mete información de meses después.
- EDGAR publica los estados en XBRL sin clave de API:
companyfactstrae cada hecho constart,end,val,formyfiled; la fecha de conocimiento esfiled.
Recursos
Documentación
- SEC: EDGAR Application Programming Interfaces (submissions, companyfacts, frames)
- SEC: Accessing EDGAR Data (User-Agent declarado, 10 peticiones por segundo)
- SEC: Form 10-Q, instrucciones generales (plazos de 40 y 45 días)
- SEC: Form 10-K, instrucciones generales (plazos de 60, 75 y 90 días)
- pandas: merge_asof