25. Implementación de un árbol binomial y validación de resultados

El árbol binomial, versión de producción: un núcleo genérico, dos calibraciones y una batería de validaciones contra la paridad, las cotas y Black-Scholes.

25.1 Del cálculo puntual al árbol de producción

Los temas anteriores construyeron las piezas por separado: la réplica de un período (tema 19), las probabilidades neutrales (tema 20), el árbol de varios períodos (tema 21), la decisión de ejercicio (tema 22), la fórmula de Black-Scholes (tema 23) y la biblioteca de payoffs (tema 24). Cada cálculo se hizo con una función artesanal distinta. Este tema da el paso de ingeniería: un árbol de producción —un núcleo genérico, dos modos de calibración y una rutina de validación— pensado para reutilizarse en los temas 26 y 27.

CALIBRARu, d, p y descuento desde los datos del mercado
RECORRERhacia atrás, nodo por nodo, con decisión de ejercicio
VALIDARparidad, cotas, ejercicios y propiedades
COMPARARcontra Black-Scholes y los casos del curso

Idea central: un modelo sin validaciones es una opinión con decimales. La validación no es un apéndice: es parte del código, porque solo un árbol que reproduce todo lo conocido merece confianza cuando se lo usa en un caso nuevo.

25.2 La geometría del árbol: precios por nodo

En un árbol con pasos períodos, el nodo del nivel i que acumuló j subidas tiene el precio:

S(i, j) = S0 × uj × di − j    (j = 0, 1, …, i)

La función es de una línea: precioNodo(spot, u, d, i, j) = spot × uj × di − j. El nivel i tiene i + 1 nodos y no 2i: el árbol recombina, porque subir y después bajar deja el mismo precio que bajar y después subir (la multiplicación es conmutativa). Gracias a esa propiedad, el recorrido hacia atrás necesita muy poca memoria: solo el arreglo del nivel que se está procesando.

Precios por nodo con u = 1,2 y d = 0,8 (los valores del curso).
Nivel iPrecios de los nodos
0 (hoy)100,00
1120,00 / 80,00
2144,00 / 96,00 / 64,00
3172,80 / 115,20 / 76,80 / 51,20

25.3 El núcleo: un recorrido hacia atrás genérico

El corazón de la biblioteca es una sola función. Recibe los parámetros del movimiento (u, d, p), el factor de descuento por paso, la cantidad de pasos, una función de pago y el estilo de ejercicio. Construye la capa final con el pago de cada nodo, y luego recorre los niveles hacia atrás reutilizando el mismo arreglo: cada nodo solo necesita los valores de sus dos hijos.

function valorarArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  let valores = [];
  for (let j = 0; j <= pasos; j++) {
    valores.push(pago(spot * Math.pow(u, j) * Math.pow(d, pasos - j)));
  }
  const americana = estilo === 'americana';
  for (let i = pasos - 1; i >= 0; i--) {
    for (let j = 0; j <= i; j++) {
      const continuacion = descuento * (p * valores[j + 1] + (1 - p) * valores[j]);
      if (americana) {
        const precio = spot * Math.pow(u, j) * Math.pow(d, i - j);
        valores[j] = Math.max(continuacion, pago(precio));
      } else {
        valores[j] = continuacion;
      }
    }
  }
  return valores[0];
}

const p = ((1 + 0.05) - 0.8) / (1.2 - 0.8);
const call = valorarArbol(100, 1.2, 0.8, p, 1 / 1.05, 2, precio => Math.max(precio - 100, 0), 'europea');
console.log(call.toFixed(4)); // "15.5896"
const putAmericana = valorarArbol(100, 1.2, 0.8, p, 1 / 1.05, 2, precio => Math.max(100 - precio, 0), 'americana');
console.log(putAmericana.toFixed(4)); // "7.9932"

El pago llega como función (callback): eso conecta el árbol con la biblioteca del tema 24, porque cualquier payoff —incluidos los de estrategias completas— puede alimentar la capa final. La complejidad es O(N²) en tiempo y O(N) en memoria: con 500 pasos no hay ningún problema, y esta es la versión que conviene llevar a producción.

25.4 Las dos calibraciones: discreta y CRR continua

El núcleo no sabe de dónde salen u, d y p: eso lo resuelven dos funciones envoltorio, una para cada contexto del curso.

Los dos modos de calibración del árbol.
ModoDatos de entradaParámetros por pasoDescuento por paso
Discreto (temas 19–22)u, d, tasa por período rp = (1 + r − d) / (u − d)1 / (1 + r)
Continuo CRR (tema 23)tasa anual r, volatilidad anual σ, tiempo Tu = eσ√Δt, d = 1 / u, p = (erΔt − d) / (u − d)e−rΔt

El modo discreto reproduce los casos del curso (u = 1,2; d = 0,8; r = 5 % por período: nótese que no cumple u × d = 1, y no hace falta, porque son dos parámetros libres). El modo CRR, en cambio, deriva todo de la volatilidad y es el que converge a Black-Scholes. Para que un árbol tenga sentido económico hay que respetar la condición de no arbitraje del paso: el factor de crecimiento del bono tiene que quedar entre d y u, o equivalentemente, la probabilidad neutral tiene que caer en (0, 1).

d < g < u ⟺ 0 < p < 1    con g = 1 + r (modo discreto) o g = erΔt (modo CRR)

Calibración inválida: si g ≤ d o g ≥ u, la probabilidad neutral sale de (0, 1) y el árbol deja de representar un mercado sin arbitraje. Antes de valorar hay que verificar la condición; el laboratorio lo hace y avisa cuando no se cumple.

function pagoDeOpcion(tipo, strike) {
  return tipo === 'call'
    ? (precio) => Math.max(precio - strike, 0)
    : (precio) => Math.max(strike - precio, 0);
}

function valorarArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  let valores = [];
  for (let j = 0; j <= pasos; j++) {
    valores.push(pago(spot * Math.pow(u, j) * Math.pow(d, pasos - j)));
  }
  const americana = estilo === 'americana';
  for (let i = pasos - 1; i >= 0; i--) {
    for (let j = 0; j <= i; j++) {
      const continuacion = descuento * (p * valores[j + 1] + (1 - p) * valores[j]);
      if (americana) {
        const precio = spot * Math.pow(u, j) * Math.pow(d, i - j);
        valores[j] = Math.max(continuacion, pago(precio));
      } else {
        valores[j] = continuacion;
      }
    }
  }
  return valores[0];
}

function arbolDiscreto(spot, strike, u, d, tasaPeriodo, pasos, tipo, estilo) {
  const p = ((1 + tasaPeriodo) - d) / (u - d);
  const descuento = 1 / (1 + tasaPeriodo);
  return valorarArbol(spot, u, d, p, descuento, pasos, pagoDeOpcion(tipo, strike), estilo);
}

function arbolCRR(spot, strike, tasa, volatilidad, tiempo, pasos, tipo, estilo) {
  const dt = tiempo / pasos;
  const u = Math.exp(volatilidad * Math.sqrt(dt));
  const d = 1 / u;
  const g = Math.exp(tasa * dt);
  const p = (g - d) / (u - d);
  return valorarArbol(spot, u, d, p, 1 / g, pasos, pagoDeOpcion(tipo, strike), estilo);
}

console.log(arbolDiscreto(100, 100, 1.2, 0.8, 0.05, 3, 'call', 'europea').toFixed(4)); // "21.1235"
console.log(arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'call', 'europea').toFixed(4));       // "10.4306"

25.5 Europeas y americanas con la misma función

La única diferencia entre una opción europea y una americana está en la línea de decisión de cada nodo:

Europea: Vnodo = descuento × [ p × Vsubida + (1 − p) × Vbajada ]
Americana: Vnodo = max( descuento × [ p × Vsubida + (1 − p) × Vbajada ] , pago inmediato )

Como el estilo es un parámetro, las dos variantes salen del mismo recorrido. Los casos canónicos del tema 22 se reproducen exactamente: con los parámetros del curso, la put europea de dos períodos vale 6,2925 y la americana 7,9932 (una prima de ejercicio anticipado de 1,7007); con tres períodos, 7,5073 contra 8,9043 (prima 1,3970). Y la call americana, sin dividendos, coincide con la europea: nunca conviene ejercerla antes del vencimiento.

25.6 Validación 1: los casos del curso

La primera batería de pruebas compara el árbol con todo lo calculado a mano en los temas 19, 21 y 22. Si estas siete líneas no dan OK, hay un error de implementación y ningún caso nuevo merece confianza.

function pagoDeOpcion(tipo, strike) {
  return tipo === 'call'
    ? (precio) => Math.max(precio - strike, 0)
    : (precio) => Math.max(strike - precio, 0);
}

function valorarArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  let valores = [];
  for (let j = 0; j <= pasos; j++) {
    valores.push(pago(spot * Math.pow(u, j) * Math.pow(d, pasos - j)));
  }
  const americana = estilo === 'americana';
  for (let i = pasos - 1; i >= 0; i--) {
    for (let j = 0; j <= i; j++) {
      const continuacion = descuento * (p * valores[j + 1] + (1 - p) * valores[j]);
      if (americana) {
        const precio = spot * Math.pow(u, j) * Math.pow(d, i - j);
        valores[j] = Math.max(continuacion, pago(precio));
      } else {
        valores[j] = continuacion;
      }
    }
  }
  return valores[0];
}

function arbolDiscreto(spot, strike, u, d, tasaPeriodo, pasos, tipo, estilo) {
  const p = ((1 + tasaPeriodo) - d) / (u - d);
  const descuento = 1 / (1 + tasaPeriodo);
  return valorarArbol(spot, u, d, p, descuento, pasos, pagoDeOpcion(tipo, strike), estilo);
}

function deltaArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  const valorArriba = valorarArbol(spot * u, u, d, p, descuento, Math.max(pasos - 1, 0), pago, estilo);
  const valorAbajo = valorarArbol(spot * d, u, d, p, descuento, Math.max(pasos - 1, 0), pago, estilo);
  return (valorArriba - valorAbajo) / (spot * u - spot * d);
}

function casoEsperado(nombre, obtenido, esperado) {
  const estado = Math.abs(obtenido - esperado) < 0.0001 ? 'OK' : 'REVISAR';
  console.log(`${nombre}: ${obtenido.toFixed(4)} (esperado ${esperado.toFixed(4)}) ${estado}`);
}

const disco = (pasos, tipo, estilo) => arbolDiscreto(100, 100, 1.2, 0.8, 0.05, pasos, tipo, estilo);

casoEsperado('Call europea N=1', disco(1, 'call', 'europea'), 11.9048);
casoEsperado('Call europea N=2', disco(2, 'call', 'europea'), 15.5896);
casoEsperado('Call europea N=3', disco(3, 'call', 'europea'), 21.1235);
casoEsperado('Put europea N=2', disco(2, 'put', 'europea'), 6.2925);
casoEsperado('Put americana N=2', disco(2, 'put', 'americana'), 7.9932);
casoEsperado('Put americana N=3', disco(3, 'put', 'americana'), 8.9043);
const pDisco = ((1.05 - 0.8) / (1.2 - 0.8));
casoEsperado('Delta N=1 (réplica del tema 19)', deltaArbol(100, 1.2, 0.8, pDisco, 1 / 1.05, 1, pagoDeOpcion('call', 100), 'europea'), 0.5);

La tolerancia es 0,0001 porque los valores «esperados» se escriben redondeados a cuatro decimales: la comparación es contra el número que quedó registrado en los temas anteriores. El último chequeo valida el delta contra la réplica del tema 19 (Δ = 0,5), cerrando el círculo entre el álgebra de la cartera y el árbol.

25.7 Validación 2: la paridad put-call en el árbol

La paridad del tema 17 también tiene que cumplirse dentro del árbol, en las dos calibraciones:

Modo discreto: C − P = S0 − K / (1 + r)N
Modo continuo: C − P = S0 − K × e−rT

No es una coincidencia: el árbol es libre de arbitraje por construcción —cada paso replica exactamente—, así que la paridad se cumple hasta la precisión de la máquina. Cualquier desvío visible indica un error (por ejemplo, un descuento mal aplicado).

function pagoDeOpcion(tipo, strike) {
  return tipo === 'call'
    ? (precio) => Math.max(precio - strike, 0)
    : (precio) => Math.max(strike - precio, 0);
}

function valorarArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  let valores = [];
  for (let j = 0; j <= pasos; j++) {
    valores.push(pago(spot * Math.pow(u, j) * Math.pow(d, pasos - j)));
  }
  const americana = estilo === 'americana';
  for (let i = pasos - 1; i >= 0; i--) {
    for (let j = 0; j <= i; j++) {
      const continuacion = descuento * (p * valores[j + 1] + (1 - p) * valores[j]);
      if (americana) {
        const precio = spot * Math.pow(u, j) * Math.pow(d, i - j);
        valores[j] = Math.max(continuacion, pago(precio));
      } else {
        valores[j] = continuacion;
      }
    }
  }
  return valores[0];
}

function arbolDiscreto(spot, strike, u, d, tasaPeriodo, pasos, tipo, estilo) {
  const p = ((1 + tasaPeriodo) - d) / (u - d);
  const descuento = 1 / (1 + tasaPeriodo);
  return valorarArbol(spot, u, d, p, descuento, pasos, pagoDeOpcion(tipo, strike), estilo);
}

function arbolCRR(spot, strike, tasa, volatilidad, tiempo, pasos, tipo, estilo) {
  const dt = tiempo / pasos;
  const u = Math.exp(volatilidad * Math.sqrt(dt));
  const d = 1 / u;
  const g = Math.exp(tasa * dt);
  const p = (g - d) / (u - d);
  return valorarArbol(spot, u, d, p, 1 / g, pasos, pagoDeOpcion(tipo, strike), estilo);
}

const paridadDiscreta = arbolDiscreto(100, 100, 1.2, 0.8, 0.05, 2, 'call', 'europea')
  - arbolDiscreto(100, 100, 1.2, 0.8, 0.05, 2, 'put', 'europea');
console.log(paridadDiscreta.toFixed(4), (100 - 100 / Math.pow(1.05, 2)).toFixed(4)); // "9.2971 9.2971"

const diferencia = Math.abs(
  (arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'call', 'europea') - arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'put', 'europea'))
  - (100 - 100 * Math.exp(-0.05))
);
console.log(diferencia.toFixed(8)); // "0.00000000"

Con N = 2 en modo discreto: C − P = 15,5896 − 6,2925 = 9,2971, que es exactamente 100 − 100 / 1,05² = 9,2971. En modo continuo con N = 100, la diferencia entre el miembro izquierdo y el derecho es 0,00000000 a ocho decimales.

25.8 Validación 3: convergencia a Black-Scholes

La prueba más exigente: bajo la calibración CRR, el árbol tiene que converger a la fórmula cerrada del tema 23 cuando el número de pasos crece.

function normalEstandar(x) {
  const t = 1 / (1 + 0.2316419 * Math.abs(x));
  const densidad = Math.exp(-x * x / 2) / Math.sqrt(2 * Math.PI);
  const polinomio = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
  const acumulada = 1 - densidad * polinomio;
  return x >= 0 ? acumulada : 1 - acumulada;
}

function bsCall(S, K, r, s, T) {
  const d1 = (Math.log(S / K) + (r + s * s / 2) * T) / (s * Math.sqrt(T));
  const d2 = d1 - s * Math.sqrt(T);
  return S * normalEstandar(d1) - K * Math.exp(-r * T) * normalEstandar(d2);
}

function pagoDeOpcion(tipo, strike) {
  return tipo === 'call'
    ? (precio) => Math.max(precio - strike, 0)
    : (precio) => Math.max(strike - precio, 0);
}

function valorarArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  let valores = [];
  for (let j = 0; j <= pasos; j++) {
    valores.push(pago(spot * Math.pow(u, j) * Math.pow(d, pasos - j)));
  }
  const americana = estilo === 'americana';
  for (let i = pasos - 1; i >= 0; i--) {
    for (let j = 0; j <= i; j++) {
      const continuacion = descuento * (p * valores[j + 1] + (1 - p) * valores[j]);
      if (americana) {
        const precio = spot * Math.pow(u, j) * Math.pow(d, i - j);
        valores[j] = Math.max(continuacion, pago(precio));
      } else {
        valores[j] = continuacion;
      }
    }
  }
  return valores[0];
}

function arbolCRR(spot, strike, tasa, volatilidad, tiempo, pasos, tipo, estilo) {
  const dt = tiempo / pasos;
  const u = Math.exp(volatilidad * Math.sqrt(dt));
  const d = 1 / u;
  const g = Math.exp(tasa * dt);
  const p = (g - d) / (u - d);
  return valorarArbol(spot, u, d, p, 1 / g, pasos, pagoDeOpcion(tipo, strike), estilo);
}

[10, 50, 100, 500].forEach(pasos => {
  const valor = arbolCRR(100, 100, 0.05, 0.2, 1, pasos, 'call', 'europea');
  console.log(pasos, valor.toFixed(4), (valor - bsCall(100, 100, 0.05, 0.2, 1)).toFixed(4));
});

La salida —10 10.2534 -0.1972, 50 10.4107 -0.0399, 100 10.4306 -0.0200 y 500 10.4466 -0.0040— muestra las tres propiedades de la convergencia: el valor oscila alrededor del límite (por paridad/imparidad de N), el error se desploma con más pasos, y con 500 pasos la diferencia con Black-Scholes (10,4506) es menor a medio centavo. Para uso práctico, N = 100 ya da precisión de dos centavos; para valores de referencia, conviene N grande.

25.9 Validación 4: propiedades estructurales

Además de los valores exactos, hay cuatro propiedades que ningún árbol correcto puede violar: la call americana sin dividendos iguala a la europea, la put americana vale al menos la europea, y ambas opciones respetan las cotas de no arbitraje del tema 16.

function pagoDeOpcion(tipo, strike) {
  return tipo === 'call'
    ? (precio) => Math.max(precio - strike, 0)
    : (precio) => Math.max(strike - precio, 0);
}

function valorarArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  let valores = [];
  for (let j = 0; j <= pasos; j++) {
    valores.push(pago(spot * Math.pow(u, j) * Math.pow(d, pasos - j)));
  }
  const americana = estilo === 'americana';
  for (let i = pasos - 1; i >= 0; i--) {
    for (let j = 0; j <= i; j++) {
      const continuacion = descuento * (p * valores[j + 1] + (1 - p) * valores[j]);
      if (americana) {
        const precio = spot * Math.pow(u, j) * Math.pow(d, i - j);
        valores[j] = Math.max(continuacion, pago(precio));
      } else {
        valores[j] = continuacion;
      }
    }
  }
  return valores[0];
}

function arbolCRR(spot, strike, tasa, volatilidad, tiempo, pasos, tipo, estilo) {
  const dt = tiempo / pasos;
  const u = Math.exp(volatilidad * Math.sqrt(dt));
  const d = 1 / u;
  const g = Math.exp(tasa * dt);
  const p = (g - d) / (u - d);
  return valorarArbol(spot, u, d, p, 1 / g, pasos, pagoDeOpcion(tipo, strike), estilo);
}

function deltaArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  const valorArriba = valorarArbol(spot * u, u, d, p, descuento, Math.max(pasos - 1, 0), pago, estilo);
  const valorAbajo = valorarArbol(spot * d, u, d, p, descuento, Math.max(pasos - 1, 0), pago, estilo);
  return (valorArriba - valorAbajo) / (spot * u - spot * d);
}

function deltaCRR(spot, strike, tasa, volatilidad, tiempo, pasos, tipo, estilo) {
  const dt = tiempo / pasos;
  const u = Math.exp(volatilidad * Math.sqrt(dt));
  const d = 1 / u;
  const g = Math.exp(tasa * dt);
  const p = (g - d) / (u - d);
  return deltaArbol(spot, u, d, p, 1 / g, pasos, pagoDeOpcion(tipo, strike), estilo);
}

const callEuropea = arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'call', 'europea');
const callAmericana = arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'call', 'americana');
const putEuropea = arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'put', 'europea');
const putAmericana = arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'put', 'americana');
const descuentoStrike = 100 * Math.exp(-0.05);

console.log(Math.abs(callAmericana - callEuropea).toFixed(8));              // "0.00000000"
console.log((putAmericana - putEuropea).toFixed(4));                        // "0.5288"
console.log((callEuropea - Math.max(100 - descuentoStrike, 0)).toFixed(4)); // "5.5536"
console.log((putEuropea - Math.max(descuentoStrike - 100, 0)).toFixed(4));  // "5.5536"
console.log(deltaCRR(100, 100, 0.05, 0.2, 1, 100, 'call', 'europea').toFixed(4)); // "0.6365"

Lectura: la call americana coincide con la europea hasta ocho decimales; la put americana agrega una prima de ejercicio anticipado de 0,5288; las cotas inferiores se cumplen con margen amplio (5,5536 sobre el mínimo); y el delta del árbol, 0,6365, ya está casi pegado al N(d₁) = 0,6368 que Black-Scholes predice —otra convergencia, esta vez sobre la primera de las griegas, que será protagonista del tema 26.

25.10 La rutina de validación completa

Todos los controles anteriores se empaquetan en una sola función: recibe los parámetros del modelo, corre los siete chequeos y devuelve una tabla legible. Es la red de seguridad que conviene ejecutar antes de confiar en cualquier número nuevo.

function normalEstandar(x) {
  const t = 1 / (1 + 0.2316419 * Math.abs(x));
  const densidad = Math.exp(-x * x / 2) / Math.sqrt(2 * Math.PI);
  const polinomio = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
  const acumulada = 1 - densidad * polinomio;
  return x >= 0 ? acumulada : 1 - acumulada;
}

function bsCall(S, K, r, s, T) {
  const d1 = (Math.log(S / K) + (r + s * s / 2) * T) / (s * Math.sqrt(T));
  const d2 = d1 - s * Math.sqrt(T);
  return S * normalEstandar(d1) - K * Math.exp(-r * T) * normalEstandar(d2);
}

function pagoDeOpcion(tipo, strike) {
  return tipo === 'call'
    ? (precio) => Math.max(precio - strike, 0)
    : (precio) => Math.max(strike - precio, 0);
}

function valorarArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  let valores = [];
  for (let j = 0; j <= pasos; j++) {
    valores.push(pago(spot * Math.pow(u, j) * Math.pow(d, pasos - j)));
  }
  const americana = estilo === 'americana';
  for (let i = pasos - 1; i >= 0; i--) {
    for (let j = 0; j <= i; j++) {
      const continuacion = descuento * (p * valores[j + 1] + (1 - p) * valores[j]);
      if (americana) {
        const precio = spot * Math.pow(u, j) * Math.pow(d, i - j);
        valores[j] = Math.max(continuacion, pago(precio));
      } else {
        valores[j] = continuacion;
      }
    }
  }
  return valores[0];
}

function arbolCRR(spot, strike, tasa, volatilidad, tiempo, pasos, tipo, estilo) {
  const dt = tiempo / pasos;
  const u = Math.exp(volatilidad * Math.sqrt(dt));
  const d = 1 / u;
  const g = Math.exp(tasa * dt);
  const p = (g - d) / (u - d);
  return valorarArbol(spot, u, d, p, 1 / g, pasos, pagoDeOpcion(tipo, strike), estilo);
}

function validarModelo(S, K, tasa, volatilidad, tiempo, pasos) {
  const dt = tiempo / pasos;
  const u = Math.exp(volatilidad * Math.sqrt(dt));
  const d = 1 / u;
  const g = Math.exp(tasa * dt);
  const p = (g - d) / (u - d);
  const descuentoStrike = K * Math.exp(-tasa * tiempo);
  const callEuropea = arbolCRR(S, K, tasa, volatilidad, tiempo, pasos, 'call', 'europea');
  const putEuropea = arbolCRR(S, K, tasa, volatilidad, tiempo, pasos, 'put', 'europea');
  const callAmericana = arbolCRR(S, K, tasa, volatilidad, tiempo, pasos, 'call', 'americana');
  const putAmericana = arbolCRR(S, K, tasa, volatilidad, tiempo, pasos, 'put', 'americana');
  const bs = bsCall(S, K, tasa, volatilidad, tiempo);
  const chequeos = [
    ['No arbitraje del paso (p en (0,1))', p.toFixed(6), p > 0 && p < 1],
    ['Convergencia a Black-Scholes', Math.abs(callEuropea - bs).toFixed(4), Math.abs(callEuropea - bs) < 0.05],
    ['Paridad put-call europea', Math.abs((callEuropea - putEuropea) - (S - descuentoStrike)).toFixed(8), Math.abs((callEuropea - putEuropea) - (S - descuentoStrike)) < 0.000001],
    ['Cota inferior de la call', (callEuropea - Math.max(S - descuentoStrike, 0)).toFixed(4), callEuropea - Math.max(S - descuentoStrike, 0) > -0.000001],
    ['Cota inferior de la put', (putEuropea - Math.max(descuentoStrike - S, 0)).toFixed(4), putEuropea - Math.max(descuentoStrike - S, 0) > -0.000001],
    ['Call americana = europea', Math.abs(callAmericana - callEuropea).toFixed(8), Math.abs(callAmericana - callEuropea) < 0.000001],
    ['Put americana mayor o igual que europea', (putAmericana - putEuropea).toFixed(4), putAmericana - putEuropea > -0.000001]
  ];
  chequeos.forEach(([nombre, valor, ok]) => {
    console.log(`${ok ? 'OK' : 'REVISAR'} | ${nombre}: ${valor}`);
  });
}

validarModelo(100, 100, 0.05, 0.2, 1, 100);

Con los parámetros de referencia, las siete líneas salen OK. Esta rutina se reutiliza tal cual en el laboratorio y, más adelante, en el proyecto integrador del tema 27: cada vez que el valuador produzca un precio nuevo, la validación dirá si es confiable.

25.11 Laboratorio: banco de pruebas del árbol

Ajustá los parámetros y observá en vivo el árbol CRR contra Black-Scholes: los valores en las cajas, la tabla de chequeos con su estado, y la tabla de convergencia fija (N = 1, 2, 5, 10, 25, 50, 100, 250) para la rama europea del tipo elegido. Las cajas muestran la opción con el estilo seleccionado, su equivalente europea en Black-Scholes y el delta en la raíz. Con estilo americana aparece la prima de ejercicio anticipado en los chequeos.

Banco de pruebas del árbol

CRR con capitalización continua; la tabla de convergencia usa siempre la versión europea.

Árbol CRR (opción elegida)$10,43
Black-Scholes (europea)$10,45
Delta en la raíz0.6365
Chequeos de validación con los parámetros actuales.
ChequeoValor observadoEstado
Convergencia de la versión europea del tipo elegido hacia Black-Scholes.
Pasos (N)Árbol CRR (europea)Black-ScholesDiferencia

CRR con N=100: call europea 10.4306; Black-Scholes 10.4506; convergencia |árbol europea − BS| 0.0200; delta 0.6365.

25.12 Errores comunes y controles

Los tropiezos más frecuentes al implementar árboles:

  • Calibración inválida: aceptar parámetros donde g ≤ d o g ≥ u. La probabilidad neutral sale de (0, 1) y el resultado es basura silenciosa (precios negativos, valores sin sentido). Verificar siempre.
  • Descontar mal: el descuento es por paso (1 / (1 + r) o e−rΔt), no total. Descontar el total en cada nivel subvalúa (o sobrevalúa) la opción.
  • Mezclar calibraciones: usar el modo discreto con una tasa anual continua, o el CRR con un r por período. Cada envoltorio espera sus propios datos.
  • Confundir el estilo: la comparación con el ejercicio inmediato va en todos los nodos de la americana; la europea solo mira el vencimiento.
  • Pasos no enteros o nulos: redondear el número de pasos y exigir al menos 1; con N chico no se puede pretender precisión de centavos.
  • Reutilizar nombres entre bloques: cada bloque del curso ejecuta aislado; hay que redefinir las funciones en cada uno.
  • Comparar con ===: los resultados son decimales de máquina; las validaciones usan tolerancias (0,0001, 0,000001), nunca igualdad exacta salvo casos construidos (call americana = europea).

Lista de control del árbol de producción:

  1. ¿Se cumple d < g < u (equivalentemente, p en (0, 1))?
  2. ¿El descuento se aplica una vez por paso?
  3. ¿El pago entra como función y se evalúa en los nodos terminales?
  4. ¿El estilo de ejercicio es el buscado?
  5. ¿El número de pasos es un entero positivo?
  6. ¿Pasan los siete chequeos de validarModelo?
  7. ¿Los casos históricos (temas 19, 21 y 22) siguen dando exactamente lo mismo?

Alcance educativo: las validaciones comprueban consistencia interna y convergencia bajo los supuestos del modelo; no garantizan que los supuestos del mercado se cumplan. Los valores son por unidad y no incluyen comisiones ni márgenes.

25.13 Ejercicio propuesto: validar el caso de un período

Volvé al primer árbol del curso: modo discreto, u = 1,2, d = 0,8, r = 5 % por período, S = K = 100, un solo paso.

  1. Calculá la call y la put europeas con valorarArbol y verificá 11,9048 y 7,1429.
  2. Comprobá la paridad discreta: C − P = S − K / (1 + r) = 4,7619.
  3. Calculá el delta del árbol y comparalo con Δ = 0,5 de la réplica del tema 19.
  4. ¿Por qué estos valores no se comparan directamente con los de Black-Scholes del tema 23 (call 10,4506)?
Ver orientación de resolución

Call: (0,625 × 20 + 0,375 × 0) / 1,05 = 11,9048. Put: (1 − 0,625) × 20 / 1,05 = 7,1429. Diferencia: 4,7619, igual a 100 − 100 / 1,05. El delta, (20 − 0) / (120 − 80) = 0,5000, coincide con la cartera replicante del tema 19. La comparación con Black-Scholes no es válida porque son calibraciones distintas: el tema 23 usó σ = 20 % anual y capitalización continua; el árbol del curso usa u = 1,2 y d = 0,8 por período, una dispersión equivalente mayor. Cada herramienta se compara solo bajo la misma calibración (como en el apartado 25.8).

function pagoDeOpcion(tipo, strike) {
  return tipo === 'call'
    ? (precio) => Math.max(precio - strike, 0)
    : (precio) => Math.max(strike - precio, 0);
}

function valorarArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  let valores = [];
  for (let j = 0; j <= pasos; j++) {
    valores.push(pago(spot * Math.pow(u, j) * Math.pow(d, pasos - j)));
  }
  const americana = estilo === 'americana';
  for (let i = pasos - 1; i >= 0; i--) {
    for (let j = 0; j <= i; j++) {
      const continuacion = descuento * (p * valores[j + 1] + (1 - p) * valores[j]);
      if (americana) {
        const precio = spot * Math.pow(u, j) * Math.pow(d, i - j);
        valores[j] = Math.max(continuacion, pago(precio));
      } else {
        valores[j] = continuacion;
      }
    }
  }
  return valores[0];
}

function deltaArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  const valorArriba = valorarArbol(spot * u, u, d, p, descuento, Math.max(pasos - 1, 0), pago, estilo);
  const valorAbajo = valorarArbol(spot * d, u, d, p, descuento, Math.max(pasos - 1, 0), pago, estilo);
  return (valorArriba - valorAbajo) / (spot * u - spot * d);
}

const p = ((1 + 0.05) - 0.8) / (1.2 - 0.8);
const call = valorarArbol(100, 1.2, 0.8, p, 1 / 1.05, 1, precio => Math.max(precio - 100, 0), 'europea');
const put = valorarArbol(100, 1.2, 0.8, p, 1 / 1.05, 1, precio => Math.max(100 - precio, 0), 'europea');

console.log(call.toFixed(4), put.toFixed(4));                        // "11.9048 7.1429"
console.log((call - put).toFixed(4), (100 - 100 / 1.05).toFixed(4)); // "4.7619 4.7619"
console.log(deltaArbol(100, 1.2, 0.8, p, 1 / 1.05, 1, precio => Math.max(precio - 100, 0), 'europea').toFixed(4)); // "0.5000"

25.14 Ejercicio adicional: convergencia y ejercicio anticipado

Con la calibración CRR de referencia —S = K = 100, r = 5 %, σ = 20 %, T = 1— y N = 100 pasos:

  1. Armá la tabla de convergencia de la call europea para N = 10, 50, 100 y 500 y comprobá que se acerca a 10,4506.
  2. Calculá la put europea (5,5536) y la americana (6,0824) y explicá la prima de 0,5288.
  3. ¿La call americana cambia respecto de la europea?
  4. ¿Por qué la put americana sí tiene prima si el árbol no paga dividendos?
Ver orientación de resolución

La tabla muestra la oscilación característica y el error decreciente: −0,1972 en N = 10, −0,0399 en 50, −0,0200 en 100 y −0,0040 en 500. La put americana vale más porque en un nodo muy por encima del strike conviene ejercer hoy: se cobra K y se gana el interés hasta el vencimiento. Con r > 0 esa opción tiene valor y aparece la prima de 0,5288. La call americana no cambia (diferencia 0,00000000): ejercerla anticipadamente significaría pagar K antes y perder el interés, algo que nunca conviene sin dividendos.

function pagoDeOpcion(tipo, strike) {
  return tipo === 'call'
    ? (precio) => Math.max(precio - strike, 0)
    : (precio) => Math.max(strike - precio, 0);
}

function valorarArbol(spot, u, d, p, descuento, pasos, pago, estilo) {
  let valores = [];
  for (let j = 0; j <= pasos; j++) {
    valores.push(pago(spot * Math.pow(u, j) * Math.pow(d, pasos - j)));
  }
  const americana = estilo === 'americana';
  for (let i = pasos - 1; i >= 0; i--) {
    for (let j = 0; j <= i; j++) {
      const continuacion = descuento * (p * valores[j + 1] + (1 - p) * valores[j]);
      if (americana) {
        const precio = spot * Math.pow(u, j) * Math.pow(d, i - j);
        valores[j] = Math.max(continuacion, pago(precio));
      } else {
        valores[j] = continuacion;
      }
    }
  }
  return valores[0];
}

function arbolCRR(spot, strike, tasa, volatilidad, tiempo, pasos, tipo, estilo) {
  const dt = tiempo / pasos;
  const u = Math.exp(volatilidad * Math.sqrt(dt));
  const d = 1 / u;
  const g = Math.exp(tasa * dt);
  const p = (g - d) / (u - d);
  return valorarArbol(spot, u, d, p, 1 / g, pasos, pagoDeOpcion(tipo, strike), estilo);
}

[10, 50, 100, 500].forEach(pasos => {
  const valor = arbolCRR(100, 100, 0.05, 0.2, 1, pasos, 'call', 'europea');
  console.log(pasos, valor.toFixed(4));
});

const putEuropea = arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'put', 'europea');
const putAmericana = arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'put', 'americana');
const callEuropea = arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'call', 'europea');
const callAmericana = arbolCRR(100, 100, 0.05, 0.2, 1, 100, 'call', 'americana');

console.log('put europea N=100:', putEuropea.toFixed(4));   // "put europea N=100: 5.5536"
console.log('put americana N=100:', putAmericana.toFixed(4)); // "put americana N=100: 6.0824"
console.log('prima de ejercicio anticipado:', (putAmericana - putEuropea).toFixed(4)); // "prima de ejercicio anticipado: 0.5288"
console.log('call americana vs europea:', Math.abs(callAmericana - callEuropea).toFixed(8)); // "call americana vs europea: 0.00000000"

25.15 Próximo paso: sensibilidad y riesgos

El árbol de producción queda validado y listo. Sus dos usos inmediatos:

  • Tema 26: sensibilidad, escenarios y gestión de riesgos. El mismo árbol permite medir las griegas de forma numérica: el delta moviendo el spot (ya vimos que con N = 100 da 0,6365, contra 0,6368 de Black-Scholes), el gamma moviendo el delta, el vega variando σ y el theta acortando T. Las tablas de escenarios del tema 24 y la rutina de validación de este tema se combinan para construir paneles de riesgo.
  • Tema 27: el proyecto integrador. El valuador de derivados con detección de arbitrajes reúne la fórmula del tema 23, este árbol, la biblioteca de estrategias del tema 24 y validarModelo como control de calidad permanente.

La lección de este tema es transferible a cualquier modelo: implementar el cálculo es la mitad del trabajo; la otra mitad es demostrar que reproduce todo lo que ya sabíamos calcular a mano.