Lección 4.3.1 · 70 min

Implementation shortfall, VWAP, TWAP y Almgren-Chriss

Lo que cuesta ejecutar una orden grande no se ve en el precio de una operación: está repartido entre la horquilla, el impacto que se paga al momento, el que se queda en el precio y el movimiento del mercado mientras se ejecuta. Esta lección lo mide en 727 órdenes de una mesa ficticia con el impacto conocido, recupera la ley de la raíz cuadrada, comprueba por qué el impacto permanente es tan difícil de estimar y resuelve el problema de Almgren-Chriss con el impacto que tienen los datos.

Fig. — Ley de la raíz cuadrada en las 92.409 hijas de la mesa: coste temporal medio por tramo de participación, en escala logarítmica. Pendiente 0,500 y coeficiente 0,400, los del generador
Lecciones del nivel 4
  1. 4.1 · Teoría y optimización de carteras

    1. 4.1.1 Markowitz: frontera eficiente y restricciones reales
    2. 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
    3. 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
    4. 4.1.4 Risk parity, mínima varianza, HRP y HERC
    5. 4.1.5 Optimización con CVaR, drawdown, rotación y costes
    6. 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
    7. 4.1.7 Rebalanceo y carteras de estrategias
    8. 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
  2. 4.2 · Gestión del riesgo y tamaño de posición

    1. 4.2.1 Riesgo por operación, stops y riesgo de ruina
    2. 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
    3. 4.2.3 VaR, Expected Shortfall y backtesting del VaR
    4. 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
    5. 4.2.5 Control del drawdown y kill switch
    6. 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
  3. 4.3 · Microestructura y ejecución

    1. 4.3.1 Implementation shortfall, VWAP, TWAP y Almgren-Chriss
    2. 4.3.2 Order flow imbalance, señales de microestructura y latencia
  4. 4.4 · Brokers y APIs

    1. 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
    2. 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
    4. 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
    5. 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
  5. 4.5 · Sistema de trading en vivo

    1. 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
    2. 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
    3. 4.5.3 Paper trading, shadow trading y seguimiento del slippage
    4. 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
    5. 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
    6. 4.5.6 NautilusTrader o LEAN en real: del backtest a producción con el mismo código

La cartera de papel y la real

En 1988, André Perold propuso medir la ejecución comparando dos carteras: la de papel, que compra o vende todo al precio del momento de la decisión y sin costes, y la real, que paga lo que paga y quizá no completa la orden. La diferencia es el implementation shortfall (IS). Sus partes cuentan una historia que el precio medio de ejecución no cuenta:

retrasoejecuciónoportunidadDecisiónLlegadaPrecio medioCierre: no hecho
Fuente Mermaid
flowchart TB
  D[Decisión] -- retraso --> L[Llegada]
  L -- ejecución --> E[Precio medio]
  L -- oportunidad --> C[Cierre: no hecho]

Para una orden de QQ acciones con lado ss (+1 compra, −1 venta), precio de decisión PdP_d, precio de llegada al mercado P0P_0, ejecución de qq acciones a un precio medio P‾\overline{P} y cierre PcP_c, en fracción del valor de decisión:

IS=s (P0−Pd)Pd+s (P‾−P0)Pd qQ+s (Pc−P0)Pd Q−qQ\text{IS} = \frac{s\,(P_0 - P_d)}{P_d} + \frac{s\,(\overline{P} - P_0)}{P_d}\,\frac{q}{Q} + \frac{s\,(P_c - P_0)}{P_d}\,\frac{Q - q}{Q}

Los tres sumandos son, por orden, el retraso, la ejecución y la oportunidad. El retraso es lo que se mueve el precio entre la decisión y el envío (responsabilidad de quien decide o del proceso); la ejecución, lo que cuesta ejecutar; la oportunidad, lo que se deja de ganar con lo que no se ejecutó. La lección 1.1.5 introdujo la idea; aquí se mide.

Los datos son sintéticos: cuatro acciones ficticias (ARVA, muy líquida, a DUNE, poco líquida y volátil) en 60 sesiones de la NYSE de 2025, con 727 órdenes padre de una mesa (TWAP, VWAP, POV e IS), sus 92.409 hijas y las barras de 1 minuto del mercado, que ya llevan el impacto de esas órdenes. El impacto tiene parámetros conocidos (ejecucion4_acciones.csv): temporal η σd ρ0,5\eta\,\sigma_d\,\rho^{0{,}5} con η=0,40\eta = 0{,}40 y ρ\rho la participación de la orden en el volumen del minuto, y permanente lineal γ σd q/ADV\gamma\,\sigma_d\,q/\text{ADV} con γ=1,0\gamma = 1{,}0.

IN [01]python
import numpy as np
import pandas as pd

acc = pd.read_csv("ejecucion4_acciones.csv", index_col="ticker")
ord_ = pd.read_csv("ejecucion4_ordenes.csv",
                   parse_dates=["inicio", "fin_real"])
ord_["lado_s"] = np.where(ord_["lado"] == "compra", 1, -1)
ord_["sigma"] = ord_["ticker"].map(acc["sigma_diaria"])
ord_["adv"] = ord_["ticker"].map(acc["adv_acciones"])
ord_["minutos"] = (ord_["fin_real"] - ord_["inicio"]
                   ).dt.total_seconds() / 60
print(acc[["adv_acciones", "sigma_diaria"]])
ord_.groupby("algoritmo").agg(
    ordenes=("id_orden", "size"),
    pct_adv_mediana=("pct_adv", "median"),
    minutos_mediana=("minutos", "median"),
    completas=("cantidad_ejecutada",
               lambda q: (q == ord_.loc[q.index, "cantidad"]).mean()),
).round(3)
OUT [01]Órdenes de una mesa
        adv_acciones  sigma_diaria
ticker
ARVA        11000000         0.014
BELO         6500000         0.018
CIRA         1750000         0.023
DUNE          640000         0.030
           ordenes  pct_adv_mediana  minutos_mediana  completas
algoritmo
IS             154            0.010             75.5      1.000
POV            198            0.012             39.5      0.808
TWAP           171            0.012            106.0      1.000
VWAP           204            0.016             92.5      1.000

Las órdenes van del 0,1 % al 20 % del volumen medio diario (ADV), con medianas del 1 % al 1,6 % según el algoritmo, y duran de media hora a una sesión entera (medianas de 40 a 106 minutos). Las de TWAP, VWAP e IS se completan siempre; las de POV toman una participación fija del volumen y el 19 % no termina antes del cierre. Como precio de decisión se usa el cierre de la sesión anterior (las órdenes del primer día no lo tienen y se dejan fuera):

IN [02]python
def pb(x, ref):
    return 1e4 * x / ref


o = ord_.dropna(subset=["cierre_anterior"]).copy()
s, Q, q = o["lado_s"], o["cantidad"], o["cantidad_ejecutada"]
dec, lleg = o["cierre_anterior"], o["precio_llegada"]
o["retraso"] = pb(s * (lleg - dec), dec)
o["ejecucion"] = pb(s * (o["precio_medio"] - lleg), dec) * q / Q
o["oportunidad"] = pb(s * (o["cierre"] - lleg), dec) * (Q - q) / Q
o["is_total"] = o[["retraso", "ejecucion", "oportunidad"]].sum(axis=1)
print(len(o), "órdenes con cierre anterior")
o.groupby("algoritmo")[["retraso", "ejecucion", "oportunidad",
                        "is_total"]].mean().round(1)
OUT [02]Implementation shortfall de Perold, por partes
710 órdenes con cierre anterior
           retraso  ejecucion  oportunidad  is_total
algoritmo
IS             1.8       40.6          0.0      42.5
POV           17.2       33.1          2.4      52.7
TWAP          -8.0       47.2          0.0      39.2
VWAP          -2.5       44.8          0.0      42.3

El IS medio es de 39 a 53 pb según el algoritmo, y casi todo es ejecución (33 a 47 pb). El retraso es ruido del mercado entre el cierre y el envío: +17,2 pb en POV, −8,0 en TWAP, sin relación con el algoritmo. La oportunidad solo aparece en POV (2,4 pb), el único que deja órdenes sin completar. Es un recordatorio útil: un algoritmo que completa menos parece más barato si solo se mira lo ejecutado.

Cuatro precios de referencia

La parte de ejecución se puede medir frente a varias referencias, y cada una cuenta una cosa distinta:

ReferenciaQué esQué premia
LlegadaMedio al enviar la ordenEjecutar barato frente al momento de decidir
VWAP del intervaloPrecio medio ponderado por volumen del mercado mientras se ejecutaSeguir al mercado
TWAP del intervaloMedia simple de los precios por minuto en el intervaloRepartir por igual en el tiempo
CierrePrecio de cierreCoincidir con quien valora la cartera al cierre
IN [03]python
barras = pd.read_parquet("ejecucion4_barras_1min.parquet")
por_ticker = {t: g.set_index("ts") for t, g in barras.groupby("ticker")}


def twap_intervalo(r):
    b = por_ticker[r.ticker].loc[r.inicio:r.fin_real
                                 - pd.Timedelta(seconds=1)]
    return b["vwap"].mean()


ord_["twap_intervalo"] = ord_.apply(twap_intervalo, axis=1)
ref = {"llegada": "precio_llegada", "vwap": "vwap_intervalo",
       "twap": "twap_intervalo", "cierre": "cierre"}
for nombre, col in ref.items():
    ord_[f"frente_{nombre}"] = pb(ord_["lado_s"] * (
        ord_["precio_medio"] - ord_[col]), ord_[col])
cols = [f"frente_{n}" for n in ref]
print(ord_.groupby("algoritmo")[cols].mean().round(1))
ord_[cols].std().round(1)
OUT [03]Cuatro precios de referencia
           frente_llegada  frente_vwap  frente_twap  frente_cierre
algoritmo
IS                   40.2         29.1         29.5            9.5
POV                  37.5         29.8         30.1           39.4
TWAP                 46.6         26.9         26.0           22.5
VWAP                 44.2         27.9         26.7           24.6
frente_llegada     72.6
frente_vwap        17.0
frente_twap        18.1
frente_cierre     140.3
dtype: float64

Frente a la llegada, las órdenes cuestan de 37,5 a 46,6 pb; frente al VWAP o al TWAP del intervalo, de 26,0 a 30,1: unos 10 a 20 pb menos. Las desviaciones típicas explican por qué los gestores prefieren el VWAP: 17,0 pb frente a 72,6 de la llegada, porque el VWAP se mueve con el mercado durante la orden y elimina casi todo su ruido. El cierre es la referencia más ruidosa (140,3 pb): no dice nada de la calidad de una ejecución que terminó horas antes.

Lo que el VWAP elimina no es solo ruido:

IN [04]python
def volumen_intervalo(r):
    b = por_ticker[r.ticker].loc[r.inicio:r.fin_real
                                 - pd.Timedelta(seconds=1)]
    return b["volumen"].sum()


ord_["participacion_media"] = (ord_["cantidad_ejecutada"]
                               / ord_.apply(volumen_intervalo, axis=1))
tramos = pd.cut(ord_["participacion_media"],
                [0, 0.02, 0.05, 0.10, 0.20, 1.0])
ord_.groupby(tramos, observed=True)[
    ["frente_llegada", "frente_vwap"]].agg(["mean", "size"]).round(1)
OUT [04]Un VWAP que se mueve con uno mismo
                    frente_llegada      frente_vwap
                              mean size        mean size
participacion_media
(0.0, 0.02]                   25.0  160        20.8  160
(0.02, 0.05]                  38.6  126        23.2  126
(0.05, 0.1]                   29.2  113        28.1  113
(0.1, 0.2]                    38.6  203        32.0  203
(0.2, 1.0]                    84.9  125        38.1  125

Con menos de un 2 % del volumen, la llegada y el VWAP casi coinciden (25,0 y 20,8 pb). Con más de un 20 %, la orden cuesta 84,9 pb frente a la llegada y 38,1 frente al VWAP: la mitad del coste ha desaparecido de la medida. Las propias ejecuciones son una parte grande del volumen del intervalo y su impacto sube el VWAP; la referencia se mueve con la orden. Un operador evaluado frente al VWAP no tiene incentivo para ejecutar con menos impacto; uno evaluado frente a la llegada, sí.

Fig. — Las 727 órdenes de la mesa por participación media en el volumen: coste frente al precio de llegada y frente al VWAP del intervalo. Cuanto más pesa la orden en el volumen, más coste esconde el VWAP

Qué hay dentro del coste

El generador guarda, para cada orden, la descomposición verdadera de su shortfall frente a la llegada (ejecucion4_ordenes_verdad.csv): media horquilla, impacto temporal, impacto permanente que pagan sus propias hijas y movimiento del medio sin impacto (el mercado). En la realidad esa descomposición no se conoce; aquí sirve para ver de qué está hecho el número:

IN [05]python
verdad = pd.read_csv("ejecucion4_ordenes_verdad.csv")
ord_ = ord_.merge(verdad, on="id_orden")
partes = ["coste_horquilla_pb", "coste_temporal_pb",
          "coste_permanente_pb", "mercado_pb"]
tabla = ord_.groupby("ticker")[partes + ["frente_llegada"]].mean()
tabla.columns = ["horquilla", "temporal", "permanente", "mercado",
                 "IS"]
print(tabla.round(1))
ord_[partes + ["frente_llegada"]].std().round(1)
OUT [05]La descomposición verdadera
        horquilla  temporal  permanente  mercado    IS
ticker
ARVA          0.3      18.0         1.9     -3.8  16.3
BELO          0.9      24.1         2.5      3.2  30.7
CIRA          4.8      32.0         3.5      2.8  43.0
DUNE         13.6      47.0         5.3     10.3  76.1
coste_horquilla_pb      5.5
coste_temporal_pb      18.7
coste_permanente_pb     4.7
mercado_pb             66.5
frente_llegada         72.6
dtype: float64

El coste crece al pasar de ARVA a DUNE por las tres partes: la horquilla (0,3 a 13,6 pb), el temporal (18,0 a 47,0) y el permanente (1,9 a 5,3). El temporal es el grueso del coste en las cuatro acciones. Pero las desviaciones típicas cambian la lectura: 18,7 pb la del temporal, 4,7 la del permanente y 66,5 la del mercado. Orden a orden, lo que decide el resultado es el movimiento del mercado durante la ejecución, que el operador no controla. Para medir el impacto hacen falta muchas órdenes.

La lección 2.4.7 usó la ley de la raíz cuadrada como supuesto, con su coeficiente declarado: el impacto de una orden de QQ acciones ejecutada a lo largo de un día es aproximadamente Y σdQ/ADVY\,\sigma_d \sqrt{Q/\text{ADV}}, con YY del orden de 1 en los estudios empíricos (Tóth y otros, 2011, la explican y la recogen). Con las ejecuciones se puede estimar. Cada hija paga, frente al medio vigente justo antes, la media horquilla más el impacto temporal del minuto, η σd ρβ\eta\,\sigma_d\,\rho^{\beta}; restando la media horquilla, una regresión sin constante sobre σdρ\sigma_d\sqrt{\rho} da η\eta, y los costes medios por tramo de participación en escala doble logarítmica dan el exponente β\beta:

IN [06]python
hijas = pd.read_parquet("ejecucion4_hijas.parquet")
hijas = hijas.merge(ord_[["id_orden", "lado_s", "sigma"]],
                    on="id_orden")
hijas["minuto"] = hijas["ts"].dt.floor("min")
propias = hijas.groupby(["ticker", "minuto"])["cantidad"].sum()
vol = barras.set_index(["ticker", "ts"])["volumen"]
vol.index = vol.index.set_names(["ticker", "minuto"])
hijas = hijas.join(propias.rename("propias"), on=["ticker", "minuto"])
hijas = hijas.join(vol, on=["ticker", "minuto"])
hijas["rho"] = hijas["propias"] / hijas["volumen"]
hijas["coste"] = (hijas["lado_s"] * (hijas["precio"]
                                     - hijas["medio_antes"])
                  - hijas["horquilla"] / 2) / hijas["medio_antes"]

x = hijas["sigma"] * np.sqrt(hijas["rho"])
eta = (x * hijas["coste"]).sum() / (x**2).sum()
print(f"{len(hijas)} hijas; participación mediana "
      f"{hijas['rho'].median():.3f}; eta = {eta:.3f}")

hijas["tramo"] = pd.qcut(np.log(hijas["rho"]), 20)
cubos = hijas.groupby("tramo", observed=True).agg(
    rho=("rho", "mean"), coste_sigma=("coste", "sum"),
    n=("coste", "size"), sigma=("sigma", "sum"))
cubos["coste_sigma"] = cubos["coste_sigma"] / cubos["sigma"]
pendiente, corte = np.polyfit(np.log(cubos["rho"]),
                              np.log(cubos["coste_sigma"]), 1)
print(f"Exponente {pendiente:.3f}; coeficiente {np.exp(corte):.3f}")
OUT [06]Ley de la raíz cuadrada en las hijas
92409 hijas; participación mediana 0.098; eta = 0.400
Exponente 0.500; coeficiente 0.400

Con 92.409 hijas la recuperación es exacta en la práctica: η=0,400\eta = 0{,}400, exponente 0,500 y coeficiente del ajuste logarítmico 0,400, los parámetros del generador. La figura de la cabecera enseña los veinte tramos sobre la recta de pendiente 1/2. La alternativa lineal (pendiente 1), la que supone la fórmula cerrada de Almgren-Chriss, solo coincide con la raíz en un punto: exagera el coste de participar mucho y lo infravalora con participaciones bajas.

En real, esta estimación pide los datos de cada hija con el medio vigente justo antes de enviarla, que un bróker no siempre da. Con datos de órdenes padre:

IN [07]python
hs = hijas.assign(h=1e4 * hijas["horquilla"] / 2
                  / hijas["medio_antes"] * hijas["cantidad"])
hs = hs.groupby("id_orden")[["h", "cantidad"]].sum()
ord_ = ord_.join((hs["h"] / hs["cantidad"]).rename("horq_obs"),
                 on="id_orden")
ord_["impacto"] = ord_["frente_llegada"] - ord_["horq_obs"]


def sin_constante(y, x):
    b = (x * y).sum() / (x**2).sum()
    e = np.sqrt(((y - b * x)**2).sum() / (len(y) - 1)
                / (x**2).sum())
    return b, e


filas = {}
for alg, d in ord_.groupby("algoritmo"):
    x = 1e4 * d["sigma"] * np.sqrt(d["participacion_media"])
    b, e = sin_constante(d["impacto"], x)
    b2, _ = sin_constante(d["impacto"] - d["mercado_pb"], x)
    filas[alg] = {"Y": b, "error": e, "Y_sin_mercado": b2}
pd.DataFrame(filas).T.round(3)
OUT [07]La misma ley con las órdenes padre
          Y  error  Y_sin_mercado
IS    0.570  0.046          0.538
POV   0.442  0.055          0.446
TWAP  0.584  0.087          0.497
VWAP  0.573  0.052          0.494

Con las órdenes padre, frente a la llegada y restando la media horquilla observable, el coeficiente sale entre 0,44 y 0,58 según el algoritmo, con errores de 0,05 a 0,09. Está sesgado hacia arriba por dos razones: la orden padre paga también la mitad de su propio impacto permanente, que crece con el tamaño, y el ruido del mercado. Restando el movimiento verdadero del mercado (algo que en la realidad no se puede hacer) el sesgo se reduce (0,45 a 0,54) pero no desaparece, porque el permanente sigue dentro. POV sale más cerca de 0,4 porque sus órdenes son cortas (40 minutos de mediana) y acumulan menos permanente.

El impacto permanente es lo que queda en el precio después de la orden. Con lo observable, se estima restando al coste de cada orden la horquilla y el temporal (este con el η\eta de las hijas) y comparando el resto con σd X/2\sigma_d\,X/2, la mitad del impacto permanente total de una orden de X=q/ADVX = q/\text{ADV}:

IN [08]python
t = hijas.assign(t=1e4 * eta * hijas["sigma"] * np.sqrt(hijas["rho"])
                 * hijas["cantidad"])
t = t.groupby("id_orden")["t"].sum() / hs["cantidad"]
ord_["temporal_est"] = ord_["id_orden"].map(t)
X = ord_["cantidad_ejecutada"] / ord_["adv"]
z = 1e4 * ord_["sigma"] * X / 2
resto = ord_["impacto"] - ord_["temporal_est"]
g, e = sin_constante(resto, z)
g2, e2 = sin_constante(resto - ord_["mercado_pb"], z)
print(f"gamma con lo observable: {g:.2f} ± {e:.2f}")
print(f"gamma restando el mercado (verdad): {g2:.3f} ± {e2:.3f}")
n_necesarias = len(ord_) * (e / 0.10)**2
print(f"Órdenes para un error de ±0,10: {n_necesarias:,.0f}")
OUT [08]El impacto permanente, casi invisible
gamma con lo observable: 1.76 ± 0.42
gamma restando el mercado (verdad): 0.983 ± 0.008
Órdenes para un error de ±0,10: 12,881

Con las 727 órdenes, γ=1,76±0,42\gamma = 1{,}76 \pm 0{,}42, cuando la verdad es 1,0: a menos de dos errores típicos, pero inútil para decidir. Restando el movimiento verdadero del mercado sale 0,983±0,0080{,}983 \pm 0{,}008: el permanente está ahí, tapado por un ruido decenas de veces mayor. Para un error de ±0,10 harían falta unas 12.900 órdenes como estas. Es la razón por la que las estimaciones del impacto permanente se hacen con cientos de miles de órdenes de muchas mesas, y por la que en un modelo de costes propio el permanente es un supuesto que hay que declarar y variar, no una cifra medida.

Urgencia: el problema de Almgren y Chriss

Ejecutar deprisa sube el impacto temporal (más participación); ejecutar despacio deja la orden expuesta más tiempo al movimiento del mercado. Almgren y Chriss (2000) lo plantearon como una cartera: elegir el calendario de ejecución xkx_k (lo que queda por ejecutar al final de cada tramo) que minimiza

E[coste]+λ Var[coste],Var[coste]=σ2∑kτ xk2E[\text{coste}] + \lambda\,\text{Var}[\text{coste}], \qquad \text{Var}[\text{coste}] = \sigma^2 \sum_k \tau\, x_k^2

con λ\lambda la aversión al riesgo. Con impacto temporal lineal en la velocidad de ejecución y liquidez constante, la solución es cerrada: x(t)=Xsinh⁡(κ(T−t))/sinh⁡(κT)x(t) = X \sinh(\kappa (T - t)) / \sinh(\kappa T), con κ2=λσ2/η~\kappa^2 = \lambda \sigma^2 / \tilde\eta. Con λ=0\lambda = 0 sale TWAP; cuanto mayor λ\lambda, más se adelanta la ejecución. Las órdenes IS de la mesa siguen esa trayectoria con urgencias κ∈{0,5; 2; 5}\kappa \in \{0{,}5;\ 2;\ 5\}. Con la descomposición verdadera se ve el intercambio:

IN [09]python
d = ord_[ord_["algoritmo"] == "IS"].copy()
d["mercado_en_sigmas"] = d["mercado_pb"] / (1e4 * d["sigma"])
d.groupby("urgencia_kappa").agg(
    ordenes=("id_orden", "size"), minutos=("minutos", "median"),
    temporal=("coste_temporal_pb", "mean"),
    riesgo=("mercado_en_sigmas", "std")).round(3)
OUT [09]Urgencia en los datos: las órdenes IS
                ordenes  minutos  temporal  riesgo
urgencia_kappa
0.5                  40     95.5    25.028   0.237
2.0                  53     98.0    31.611   0.233
5.0                  61     60.0    38.690   0.136

Al pasar de κ=0,5\kappa = 0{,}5 a 5, el impacto temporal medio sube de 25,0 a 38,7 pb y el riesgo, medido como la desviación típica del movimiento del mercado durante la orden en desviaciones diarias, baja de 0,237 a 0,136. Las órdenes urgentes duran menos (60 minutos de mediana frente a 96) y pagan más por participar más. Entre 0,5 y 2 apenas cambia el riesgo: las dos urgencias más bajas tienen duraciones parecidas.

Los datos no cumplen el supuesto de la fórmula cerrada: el impacto temporal es η σd ρ0,5\eta\,\sigma_d\,\rho^{0{,}5}, en raíz de la participación, y el volumen sigue una curva en U. El coste de un tramo kk con nkn_k acciones y un volumen de mercado VkV_k es η σd nk1,5/Vk0,5\eta\,\sigma_d\, n_k^{1{,}5} / V_k^{0{,}5}: convexo en nkn_k, así que el problema sigue siendo convexo y cvxpy lo resuelve con cp.power(u, 1.5) (un cono de potencia que CLARABEL admite). El caso: vender un 10 % del ADV de DUNE en una sesión, en 13 tramos de 30 minutos, con la curva de volumen media de los datos, η\eta y γ\gamma del generador y la varianza de Almgren y Chriss con la volatilidad repartida por igual en la sesión (los datos la tienen en U: es una simplificación). El permanente no depende del calendario con impacto lineal permanente: suma siempre γ σd X/2\gamma\,\sigma_d\,X/2.

IN [10]python
import cvxpy as cp

T_ = "DUNE"
b = por_ticker[T_]
minuto = (b.index.hour * 60 + b.index.minute) - (13 * 60 + 30)
curva = b.groupby([b.index.date, minuto // 30])["volumen"].sum()
V = curva.groupby(level=1).mean().to_numpy()   # 13 tramos de 30 min
N, tau = len(V), 1 / len(V)                    # tau en días
ADV, sig = acc.at[T_, "adv_acciones"], acc.at[T_, "sigma_diaria"]
Xq = 0.10 * ADV                                # 10 % del ADV
ETA, GAMMA = 0.40, 1.0                         # los del generador


def coste(u):
    """Coste esperado y desviación (pb) del calendario u (fracción
    de la orden en cada tramo). Temporal en raíz de la participación,
    permanente lineal, varianza de lo que queda por ejecutar."""
    temporal = 1e4 * ETA * sig * np.sum(u**1.5 * np.sqrt(Xq / V))
    permanente = 1e4 * GAMMA * sig * Xq / ADV / 2
    resto = 1 - np.cumsum(u)
    desv = 1e4 * sig * np.sqrt(tau * np.sum(resto**2))
    return temporal + permanente, desv


def optimo(lam):
    u = cp.Variable(N, nonneg=True)
    temporal = 1e4 * ETA * sig * cp.sum(
        cp.multiply(np.sqrt(Xq / V), cp.power(u, 1.5)))
    varianza = (1e4 * sig)**2 * tau * cp.sum_squares(1 - cp.cumsum(u))
    cp.Problem(cp.Minimize(temporal + lam * varianza),
               [cp.sum(u) == 1]).solve(solver="CLARABEL")
    return np.clip(u.value, 0, None)


print("Volumen medio por tramo (miles):", np.round(V / 1e3, 1))
cal = {"TWAP": np.full(N, 1 / N), "VWAP": V / V.sum(),
       "optimo_lam0": optimo(0.0)}
pd.DataFrame({k: coste(u) for k, u in cal.items()},
             index=["coste_pb", "desv_pb"]).T.round(2)
OUT [10]El problema de Almgren-Chriss con impacto en raíz
Volumen medio por tramo (miles): [98.9 68.2 51.2 45.5 44.3 43.1 44.  43.4 44.9 44.5 47.4 52.5 92. ]
             coste_pb  desv_pb
TWAP            51.89   163.18
VWAP            50.78   153.48
optimo_lam0     50.78   153.48

VWAP cuesta 50,8 pb y TWAP 51,9: con impacto en raíz, el óptimo sin aversión al riesgo es VWAP, no TWAP. El coste total es proporcional a ∑knk1,5/Vk0,5\sum_k n_k^{1,5} / V_k^{0,5}, y minimizarlo con ∑nk\sum n_k fijo da nk∝Vkn_k \propto V_k: participación constante. La solución numérica con λ=0\lambda = 0 lo confirma (los mismos 50,78 pb). La desviación típica del coste, 153 pb, es tres veces el coste esperado: una orden del 10 % del ADV de una acción con un 3 % de volatilidad diaria es, sobre todo, una apuesta involuntaria sobre el precio de la sesión.

Para usar la fórmula cerrada hay que linealizar el impacto: elegir un η~\tilde\eta lineal que dé el mismo coste por acción que la raíz con el calendario TWAP, y aplicar la fórmula del seno hiperbólico. Se comparan los dos calendarios para varias λ\lambda, evaluados los dos con el impacto en raíz de los datos:

IN [11]python
# eta lineal que da el mismo coste por acción que la raíz con TWAP
n_tw = Xq / N
eta_lin = ETA * sig * tau / np.sqrt(n_tw * V.mean())


def cerrado(lam):
    """Calendario de Almgren-Chriss (2000) con impacto lineal:
    x(t) = sinh(k (T - t)) / sinh(k T), T = 1 día."""
    k = np.sqrt(lam * 1e4 * sig**2 / (eta_lin * Xq))
    t = np.arange(N + 1) / N
    x = np.sinh(k * (1 - t)) / np.sinh(k) if k > 1e-9 else 1 - t
    return -np.diff(x)


filas = {}
for lam in (1e-4, 1e-3, 3e-3, 1e-2, 3e-2):
    for nombre, u in (("raiz_cvxpy", optimo(lam)),
                      ("lineal_cerrado", cerrado(lam))):
        c, sd = coste(u)
        filas[(lam, nombre)] = {"coste_pb": c, "desv_pb": sd,
                                "objetivo": c + lam * sd**2,
                                "primer_tramo": u[0]}
frontera = pd.DataFrame(filas).T
frontera.round(3)
OUT [11]Frontera eficiente: numérica frente a linealizada
                       coste_pb  desv_pb  objetivo  primer_tramo
0.0001 raiz_cvxpy        50.911  144.054    52.986         0.160
       lineal_cerrado    51.809  160.198    54.375         0.083
0.0010 raiz_cvxpy        55.999   96.594    65.329         0.306
       lineal_cerrado    52.101  139.021    71.428         0.126
0.0030 raiz_cvxpy        65.556   61.825    77.024         0.465
       lineal_cerrado    54.772  112.042    92.432         0.192
0.0100 raiz_cvxpy        80.118   32.253    90.520         0.669
       lineal_cerrado    62.446   77.098   121.888         0.320
0.0300 raiz_cvxpy        93.072   15.553   100.329         0.826
       lineal_cerrado    72.325   49.674   146.349         0.487

Con la misma λ\lambda, el calendario de la fórmula lineal es mucho menos urgente que el óptimo con raíz: con λ=0,01\lambda = 0{,}01 ejecuta un 32 % en la primera media hora frente al 67 %, y su objetivo (coste más λ\lambda por la varianza) es de 121,9 frente a 90,5. La razón está en la forma del impacto: con el lineal, el coste total de un tramo crece con n2n^2; con la raíz, con n1,5n^{1,5}. La fórmula lineal exagera lo que cuesta concentrar la ejecución y la retrasa.

Fig. — Calendarios para vender un 10 % del ADV de DUNE en una sesión: VWAP, TWAP y el óptimo de Almgren-Chriss con aversión λ = 0,01, resuelto con impacto en raíz (cvxpy) y con la fórmula cerrada de impacto lineal

Como familia, en cambio, los calendarios de seno hiperbólico no están lejos de la frontera: quedan entre 1 y 2 pb por encima para el mismo riesgo. El error de la linealización no es tanto la forma del calendario como la urgencia que asigna a cada λ\lambda: quien calibra su aversión al riesgo con la fórmula lineal y ejecuta con impacto en raíz obtiene un calendario más lento y más arriesgado del que quería.

Fig. — Coste esperado frente a riesgo de los calendarios para vender un 10 % del ADV de DUNE, evaluados con el impacto en raíz de los datos: la frontera numérica y los calendarios de la fórmula cerrada lineal

Errores típicos

  • Medir solo lo ejecutado. Sin el coste de oportunidad, un algoritmo que no completa parece barato.
  • Evaluar frente al VWAP del intervalo las órdenes grandes: la referencia incluye su propio impacto.
  • Confundir ruido con coste. Con 73 pb de desviación por orden, comparar dos algoritmos con unas decenas de órdenes no dice nada; hace falta la media de muchas y su error típico.
  • Estimar el impacto con las órdenes padre sin separar el permanente: el coeficiente sale inflado.
  • Tratar el permanente como medido. Con una sola mesa es un supuesto.
  • Usar la fórmula cerrada de Almgren-Chriss con impacto en raíz sin decirlo: la urgencia sale equivocada.
  • Olvidar la curva de volumen. Con liquidez en U, el calendario neutral al riesgo es VWAP, no TWAP.

Ejercicios

  1. Estima el coeficiente YY de la ley de la raíz cuadrada en su forma clásica, Y σdQ/ADVY\,\sigma_d\sqrt{Q/\text{ADV}}, solo con las órdenes VWAP que duran la sesión entera (390 minutos o casi). ¿Cuántas hay y qué error tiene la estimación?
Solución
IN [12]python
dia = ord_[(ord_["algoritmo"] == "VWAP") & (ord_["minutos"] >= 380)]
x = 1e4 * dia["sigma"] * np.sqrt(dia["cantidad_ejecutada"]
                                 / dia["adv"])
Y, err = sin_constante(dia["impacto"], x)
Y2, _ = sin_constante(dia["impacto"] - dia["mercado_pb"], x)
print(f"{len(dia)} órdenes de un día: Y = {Y:.2f} ± {err:.2f};"
      f" sin el mercado, {Y2:.2f}")
OUT [12]La forma clásica con las órdenes de un día
18 órdenes de un día: Y = 0.94 ± 0.81; sin el mercado, 0.59

Solo hay 18 órdenes VWAP que duran la sesión entera, y la estimación es Y=0,94±0,81Y = 0{,}94 \pm 0{,}81: el error es casi tan grande como el coeficiente. Restando el movimiento verdadero del mercado sale 0,59, por encima de 0,40 porque la orden padre paga también la mitad de su impacto permanente. La forma clásica de la ley, la que se usa para dimensionar una estrategia, no se puede estimar con una mesa: por eso las hijas, con su participación minuto a minuto, son la fuente buena.

  1. Repite el óptimo de Almgren-Chriss con λ=0,01\lambda = 0{,}01 para un 10 % del ADV de ARVA, la acción más líquida y menos volátil. ¿Cuánto ejecuta en la primera media hora y cuánto cuesta frente a DUNE?
Solución
IN [13]python
def caso(ticker, pct=0.10, lam=1e-2):
    global V, ADV, sig, Xq
    b = por_ticker[ticker]
    m = (b.index.hour * 60 + b.index.minute) - (13 * 60 + 30)
    curva = b.groupby([b.index.date, m // 30])["volumen"].sum()
    V = curva.groupby(level=1).mean().to_numpy()
    ADV = acc.at[ticker, "adv_acciones"]
    sig = acc.at[ticker, "sigma_diaria"]
    Xq = pct * ADV
    u = optimo(lam)
    c, sd = coste(u)
    return {"primer_tramo": u[0], "coste_pb": c, "desv_pb": sd,
            "vwap_pb": coste(V / V.sum())[0]}


pd.DataFrame({t: caso(t) for t in ("ARVA", "DUNE")}).T.round(3)
OUT [13]El mismo problema con ARVA
      primer_tramo  coste_pb  desv_pb  vwap_pb
ARVA         0.547    33.311   23.138   23.912
DUNE         0.669    80.118   32.253   50.776

Con la misma aversión al riesgo, ARVA ejecuta un 54,7 % en la primera media hora frente al 66,9 % de DUNE, y le cuesta 33,3 pb con una desviación de 23,1, frente a 80,1 y 32,3. Su VWAP costaría 23,9 pb (50,8 en DUNE). Con menos volatilidad, el riesgo de esperar pesa menos y el calendario se adelanta menos; con λ\lambda en pb⁻¹, la misma aversión significa cosas distintas en acciones distintas, y en la práctica se fija por orden o por el tamaño en dinero del riesgo que se acepta.

  1. Un gestor decide comprar al cierre de ayer y su operador recibe la orden a mediodía. El retraso medio de la mesa es de varios puntos básicos, positivo o negativo según el día. ¿Quién debería responder de ese coste y cómo lo medirías para separarlo de la ejecución?
Solución

El retraso entre la decisión y la llegada no depende del operador, sino del proceso: cuándo se toma la decisión, cuándo se aprueba y cuándo se envía. Se mide aparte con el IS de Perold, que lo separa de la ejecución, y se evalúa en promedio sobre muchas órdenes: orden a orden es ruido del mercado (en la mesa, +17,2 pb en POV y −8,0 en TWAP, sin relación con el algoritmo). Si en promedio es positivo y significativo en las compras y negativo en las ventas, el precio se mueve en contra entre la decisión y el envío: hay información que se filtra o una señal que otros ven antes, y la solución es acortar el proceso, no cambiar de algoritmo. El operador responde de la ejecución frente a la llegada; el gestor, del retraso y del coste de oportunidad de lo que decidió no ejecutar.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Una orden de compra se decide al cierre de ayer (100,00), llega al mercado a 100,20 y se ejecuta la mitad a una media de 100,50; el resto no se ejecuta y la acción cierra a 101,00. ¿Cuánto vale el implementation shortfall en puntos básicos sobre la decisión?

    • A50 pb: solo cuenta lo ejecutado
    • B75 pb: 20 de retraso, 15 de ejecución y 40 de oportunidad
    • C30 pb: la ejecución frente a la llegada
    • D100 pb: del cierre de ayer al de hoy
    Ver respuesta

    Respuesta B. 75 pb: 20 de retraso, 15 de ejecución y 40 de oportunidad

    Retraso: 0,20 sobre 100 = 20 pb sobre toda la orden. Ejecución: (100,50 − 100,20) en la mitad ejecutada = 30 pb × 0,5 = 15. Oportunidad: (101,00 − 100,20) en la mitad no ejecutada = 80 × 0,5 = 40. Total, 75 pb: lo que la cartera real rinde por debajo de la cartera de papel.

  2. En los datos, las órdenes que pesan más del 20 % del volumen de su intervalo cuestan 84,9 pb frente a la llegada y 38,1 frente al VWAP del intervalo. ¿Por qué tanta diferencia?

    • APorque el VWAP es un mal cálculo
    • BPorque esas órdenes se ejecutan al cierre
    • CPorque la horquilla es mayor
    • DPorque las propias ejecuciones forman parte del VWAP: el impacto de la orden sube el precio medio del mercado y la referencia se mueve con ella
    Ver respuesta

    Respuesta D. Porque las propias ejecuciones forman parte del VWAP: el impacto de la orden sube el precio medio del mercado y la referencia se mueve con ella

    Con una participación alta, buena parte del volumen del intervalo es la propia orden. El VWAP del intervalo ya incluye su impacto, así que comparar con él premia a quien mueve el precio. La llegada no depende de la orden.

  3. La regresión de las 92.409 hijas da un impacto temporal de 0,400·σ·ρ^0,500. Con las órdenes padre, el mismo coeficiente sale entre 0,44 y 0,58. ¿Por qué?

    • APorque las hijas tienen errores
    • BPorque las órdenes padre no siguen la ley de la raíz cuadrada
    • CPorque el coste de una orden padre incluye también el impacto permanente que se acumula durante la orden y el ruido del mercado, que la regresión atribuye en parte al temporal
    • DPor el redondeo al céntimo
    Ver respuesta

    Respuesta C. Porque el coste de una orden padre incluye también el impacto permanente que se acumula durante la orden y el ruido del mercado, que la regresión atribuye en parte al temporal

    Cada hija se mide frente al medio vigente justo antes, que ya incluye el permanente acumulado; la orden padre se mide frente a la llegada, y paga además la mitad de su impacto permanente y el movimiento del mercado. Restando el movimiento del mercado (que en la realidad no se conoce) el coeficiente baja a 0,45-0,54.

  4. Con lo observable, el impacto permanente de las 727 órdenes sale γ = 1,76 ± 0,42 (verdad: 1,0). ¿Cuántas órdenes harían falta para un error de ±0,10?

    • AUnas 13.000: el error baja con la raíz del número de órdenes
    • BUnas 1.000
    • CCon 727 basta si se usan las hijas
    • DEs imposible medirlo
    Ver respuesta

    Respuesta A. Unas 13.000: el error baja con la raíz del número de órdenes

    Error de 0,42 con 727 órdenes; para 0,10 hay que dividirlo por 4,2, lo que pide 4,2² ≈ 17,7 veces más órdenes: unas 12.900. Por eso los estudios de impacto usan cientos de miles de órdenes de muchas mesas.

  5. Con impacto temporal en raíz cuadrada de la participación y sin aversión al riesgo, ¿qué calendario minimiza el coste esperado?

    • AVWAP: una participación constante en el volumen, porque el coste por acción solo depende de la participación
    • BTWAP: el mismo número de acciones cada minuto
    • CTodo al principio
    • DTodo al cierre
    Ver respuesta

    Respuesta A. VWAP: una participación constante en el volumen, porque el coste por acción solo depende de la participación

    El coste total es proporcional a la suma de n^1,5/V^0,5. Minimizarlo con la suma de n fija da n proporcional a V: participación constante. La fórmula de Almgren-Chriss con impacto lineal y liquidez constante da TWAP; con la curva de volumen en U de los datos, VWAP cuesta 50,8 pb frente a 51,9 de TWAP.

Ideas clave

  • El implementation shortfall de Perold compara la cartera real con la de papel a precios de decisión y se descompone en retraso, ejecución y oportunidad. En la mesa de la lección, unos 42 pb de media con una desviación de 73: el ruido del mercado domina orden a orden.
  • El VWAP del intervalo es una referencia cómoda pero blanda: incluye las propias ejecuciones. Con más de un 20 % del volumen, las órdenes cuestan 84,9 pb frente a la llegada y solo 38,1 frente al VWAP.
  • La ley de la raíz cuadrada se recupera con precisión en las hijas: exponente 0,500 y coeficiente 0,400, los del generador. Con las órdenes padre el coeficiente sale entre 0,44 y 0,58, porque se mezcla con el permanente y el ruido.
  • El impacto permanente es el parámetro difícil: 1,76 ± 0,42 con lo observable frente a 1,0 de verdad; solo restando el movimiento del mercado, que en real no se conoce, sale 0,98. Medirlo con un error de ±0,10 pediría unas 13.000 órdenes.
  • Almgren-Chriss equilibra coste esperado y riesgo. Con impacto temporal en raíz no hay fórmula cerrada: se resuelve con cvxpy (cp.power(u, 1.5)). Sin aversión al riesgo el óptimo es VWAP; con aversión, adelanta la ejecución mucho más que la fórmula lineal con la misma λ.

Recursos

Datos