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.

Fig. — Días entre el cierre del trimestre y la presentación de sus cifras: 10-Q y 10-K de los diez valores de fundamentales_pit.csv, 2018–2025
Lecciones del nivel 1
  1. 1.1 · Cómo funcionan los mercados

    1. 1.1.1 Participantes y centros de negociación
    2. 1.1.2 Sesiones, subastas, festivos y calendarios
    3. 1.1.3 Libro de órdenes: bid, ask, spread y profundidad
    4. 1.1.4 Tipos de órdenes
    5. 1.1.5 Costes, liquidación, apalancamiento y margen
  2. 1.2 · Clases de activos

    1. 1.2.1 Acciones: splits, dividendos y eventos corporativos
    2. 1.2.2 ETFs: réplica, tracking error, apalancados y UCITS
    3. 1.2.3 Futuros: especificaciones, margen, roll y contango
    4. 1.2.4 Forex: pares, pips, lotes, rollover y CFD
    5. 1.2.5 Opciones: call, put, valor intrínseco y valor temporal
    6. 1.2.6 Cripto: spot, perpetuos, funding y custodia
    7. 1.2.7 Renta fija y tipos de interés; CFDs y productos apalancados
  3. 1.3 · Fuentes de datos de mercado

    1. 1.3.1 Tipos de datos de mercado: EOD, barras, ticks, libro y referencia
    2. 1.3.2 Fuentes de datos gratuitas
    3. 1.3.3 APIs de datos retail y prosumer
    4. 1.3.4 Proveedores de datos profesionales
    5. 1.3.5 Datos de cripto y on-chain
    6. 1.3.6 Brókeres como fuente de datos
    7. 1.3.7 Licencias y términos de uso de datos
    8. 1.3.8 Cómo elegir proveedor de datos
  4. 1.4 · Ingeniería de datos financieros

    1. 1.4.1 Descarga por REST y websockets: paginación, límites y reintentos
    2. 1.4.2 Limpieza de datos: faltantes, ticks erróneos, huecos y zonas horarias
    3. 1.4.3 Ajustes por splits y dividendos: precios ajustados y sin ajustar
    4. 1.4.4 Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo
    5. 1.4.5 Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio
    6. 1.4.6 Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio
    7. 1.4.7 Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB
    8. 1.4.8 Pipeline reproducible: descarga, validación, actualización incremental y tests de calidad
  5. 1.5 · Datos fundamentales y macro

    1. 1.5.1 Estados financieros, ratios, XBRL y SEC EDGAR
    2. 1.5.2 Datos macro, calendarios económicos, estimaciones y transcripciones
  6. 1.6 · Análisis exploratorio financiero

    1. 1.6.1 Rentabilidades, volatilidad, correlaciones móviles y drawdown
    2. 1.6.2 Hechos estilizados: colas gruesas, agrupamiento, autocorrelación y apalancamiento
    3. 1.6.3 Estacionalidad: día de la semana, mes, cambio de mes y sesiones intradía
  7. 1.7 · Análisis técnico programado

    1. 1.7.1 Indicadores técnicos en Python
    2. 1.7.2 Implementación propia frente a TA-Lib: validar resultados
    3. 1.7.3 Patrones de velas y soportes y resistencias
    4. 1.7.4 Multi-timeframe sin anticipación

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:

EstadoQué mideTipo de partidaEjemplos
Cuenta de resultadosLo que se ganó o perdió durante el periodoFlujoIngresos, beneficio neto, BPA
BalanceLo que se tiene y se debe en una fechaSaldoActivo total, patrimonio neto, deuda
Flujos de efectivoEl dinero que entró y salió durante el periodoFlujoFlujo 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.

25-47 díasdespués del cierremeses despuésCierre trimestrePresentación 10-QSesión siguienteReexpresión
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).

IN [01]python
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]
OUT [01]Fundamentales point-in-time
(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        1

El 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?

IN [02]python
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"])
OUT [02]Retraso de publicación
                    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  326

Los 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:

T4=10-K−(T1+T2+T3)\text{T4} = \text{10-K} - (\text{T1} + \text{T2} + \text{T3})

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.

IN [03]python
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]
OUT [03]Trimestres a partir del 10-K
    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        1

El 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.

RatioFórmulaQué mide
BPA TTMbeneficio TTM / acciones diluidasBeneficio por acción del último año
PERprecio / BPA TTMCuántos años de beneficio paga el precio
ROEbeneficio TTM / patrimonio netoRentabilidad del capital de los accionistas
Margen netobeneficio TTM / ingresos TTMQué 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:

IN [04]python
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})
OUT [04]TTM tal como se conocía
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.72

Las 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.

IN [05]python
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")
OUT [05]La versión ingenua: última versión, por fecha de periodo
261 filas por periodo

Para 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).

IN [06]python
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%}")
OUT [06]Las dos versiones sesión a sesión
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.

IN [07]python
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})
OUT [07]Enero y febrero de 2022 en ZXNW
           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.55
Fig. — BPA TTM de ZXNW, 2021–2023: la serie point-in-time cambia la sesión siguiente a cada publicación; la ingenua, el día en que termina el trimestre y con la versión final

Durante 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.

IN [08]python
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})
OUT [08]Ratios TTM point-in-time el 2024-06-28
       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.3

ROE 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?

IN [09]python
err = 100 * (both["bpa_ing"] / both["bpa_pit"] - 1)
print(err[adelanta].abs().describe(
    percentiles=[0.5, 0.9]).round(2))
OUT [09]Error del BPA ingenuo cuando adelanta
count    7071.00
mean        7.63
std        32.83
min         0.04
50%         3.52
90%         9.21
max       543.64
dtype: float64

En 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:

IN [10]python
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})
OUT [10]Las cuatro reexpresiones
  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.2
Fig. — Las cuatro reexpresiones de fundamentales_pit.csv: cuánto cambian el beneficio (barra superior) y los ingresos (inferior) del trimestre corregido, y cuántos días después de la cifra original

Todas 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.

10-Q10-Q/A o 10-Ksin cambiosOriginalReexpresada
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.:

EndpointQué devuelve
/submissions/CIK##########.jsonHistorial de presentaciones de una entidad
/api/xbrl/companyconcept/CIK##########/us-gaap/<Concepto>.jsonTodos los hechos de un concepto para una entidad
/api/xbrl/companyfacts/CIK##########.jsonTodos los conceptos de una entidad en una sola llamada
/api/xbrl/frames/us-gaap/<Concepto>/USD/CY2024Q1.jsonUn 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:

companyfactscik, entityNamefactsus-gaapdeiNetIncomeLosslabel, unitsUSD: hechos
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.

IN [11]python
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"]]
OUT [11]Parsear companyfacts (respuesta de ejemplo)
       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.0

Cinco 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.

IN [12]python
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())
OUT [12]De hechos XBRL a versiones point-in-time
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.0

El 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.:

IN [13]python
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_publicacion no 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_asof por fecha de conocimiento, con allow_exact_matches=False si 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

  1. 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
IN [14]python
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())
OUT [14]Fecha correcta, versión final
Sesiones con BPA distinto: 3.3%
ticker
ZXLJ    192
ZXBY    135
ZXHN    131
ZXNW     81

Con 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.

  1. 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
IN [15]python
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})
OUT [15]El último dato de un valor que quiebra
           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.4

La ú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.

  1. Un proveedor te ofrece fundamentales «point-in-time» con una columna fecha_disponible igual 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

  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). ↩

  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. ↩

  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. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. 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_asof con allow_exact_matches=False. Usarla desde el 30 de junio adelanta información 38 días.

  2. 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.

  3. 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.

  4. En la respuesta companyfacts de EDGAR, ¿qué campo dice cuándo se conoció cada cifra?

    • Aend, el final del periodo
    • Bfy, el ejercicio fiscal
    • Cfiled, la fecha de presentación del documento que la trae
    • Dframe, el periodo natural
    Ver respuesta

    Respuesta C. filed, la fecha de presentación del documento que la trae

    start y end delimitan el periodo; fy y fp son los del documento (un 10-K trae también los ejercicios anteriores como comparativos); frame señala el hecho presentado en último lugar para un periodo natural. La fecha de conocimiento es filed.

  5. 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: companyfacts trae cada hecho con start, end, val, form y filed; la fecha de conocimiento es filed.

Recursos