Skip to main content

Model Overview

OptionStrat AI implements four pricing models, each with different strengths:
ModelTypeAmerican SupportComplexityUse Case
Black-ScholesClosed-formNoO(1)European options, Greeks
Binomial TreeLatticeYesO(N)American options, dividends
Trinomial TreeLatticeYesO(N)Higher accuracy, exotic
Monte CarloSimulationPath-dependentO(M×N)Complex payoffs, stress tests
Rule of thumb:
  • Use Black-Scholes for fast, analytical pricing of European options
  • Use Binomial/Trinomial for American options or when early exercise matters
  • Use Monte Carlo for path-dependent options or scenario analysis

Black-Scholes Model

See the dedicated Black-Scholes documentation for complete mathematical derivation and Greeks implementation. Quick Reference:
from app.core.black_scholes import bsm_price, bsm_greeks

# Price European option
price = bsm_price(S=450, K=460, T=0.1, r=0.05, sigma=0.25, q=0.01, kind="call")

# Calculate Greeks
greeks = bsm_greeks(S=450, K=460, T=0.1, r=0.05, sigma=0.25, q=0.01, kind="call")

Binomial Tree Model

Cox-Ross-Rubinstein (CRR) Method

The binomial model discretizes time into N steps and models the underlying as a recombining tree.

Mathematical Framework

At each time step Δt=T/N\Delta t = T/N:
  • Up movement: u=eσΔtu = e^{\sigma \sqrt{\Delta t}}
  • Down movement: d=1u=eσΔtd = \frac{1}{u} = e^{-\sigma \sqrt{\Delta t}}
  • Risk-neutral probability: p=e(rq)Δtdudp = \frac{e^{(r-q)\Delta t} - d}{u - d}
  • Discount factor: disc=erΔt\text{disc} = e^{-r \Delta t}
Stock prices at maturity (step N): Si=S0dNiui,i=0,1,,NS_{i} = S_0 \cdot d^{N-i} \cdot u^{i}, \quad i = 0, 1, \ldots, N Backward induction: Vi=erΔt[pVi+1up+(1p)Vi+1down]V_i = e^{-r\Delta t} [p \cdot V_{i+1}^{\text{up}} + (1-p) \cdot V_{i+1}^{\text{down}}] For American options, compare with early exercise: Vi=max(Vicontinuation,Viexercise)V_i = \max(V_i^{\text{continuation}}, V_i^{\text{exercise}})

Implementation

From backend/app/core/black_scholes.py:87-123:
def binomial_tree_price(S: float, K: float, T: float, r: float, sigma: float, 
                        N: int = 100, q: float = 0.0, kind: str = "call", 
                        style: str = "american") -> float:
    """
    Valoración por Árbol Binomial (Cox-Ross-Rubinstein).
    Soporta ejercicio Americano.
    
    Parameters:
    -----------
    S : float
        Current spot price
    K : float
        Strike price
    T : float
        Time to expiration (years)
    r : float
        Risk-free rate (annualized)
    sigma : float
        Volatility (annualized)
    N : int, optional
        Number of time steps (default: 100)
    q : float, optional
        Dividend yield (default: 0.0)
    kind : str
        "call" or "put"
    style : str
        "american" or "european"
    
    Returns:
    --------
    float
        Option price
    """
    if T <= 0:
        return max(0.0, (S - K) if kind == "call" else (K - S))
    
    # Calculate binomial parameters
    dt = T / N
    u = np.exp(sigma * np.sqrt(dt))
    d = 1 / u
    p = (np.exp((r - q) * dt) - d) / (u - d)
    disc = np.exp(-r * dt)
    
    # Pre-compute asset prices at maturity
    ST = S * d**np.arange(N, -1, -1) * u**np.arange(0, N + 1)
    
    # Initialize values at maturity (payoff)
    if kind == "call":
        C = np.maximum(0, ST - K)
    else:
        C = np.maximum(0, K - ST)
    
    # Backward induction
    for i in range(N - 1, -1, -1):
        # Risk-neutral valuation
        C = disc * (p * C[1:] + (1 - p) * C[:-1])
        
        if style == "american":
            # Recalcular precios del subyacente en este paso
            Si = S * d**np.arange(i, -1, -1) * u**np.arange(0, i + 1)
            
            # Early exercise value
            if kind == "call":
                exercise = np.maximum(0, Si - K)
            else:
                exercise = np.maximum(0, K - Si)
            
            # Take max of continuation and exercise
            C = np.maximum(C, exercise)
    
    return float(C[0])

Vectorization for Performance

Key optimizations:
  1. Pre-compute stock prices:
    ST = S * d**np.arange(N, -1, -1) * u**np.arange(0, N + 1)
    
    This creates all terminal stock prices in one vectorized operation.
  2. Vectorized backward induction:
    C = disc * (p * C[1:] + (1 - p) * C[:-1])
    
    Array slicing (C[1:] and C[:-1]) avoids Python loops.
  3. Vectorized payoff comparison:
    C = np.maximum(C, exercise)
    
    Element-wise max operation for early exercise check.
Performance impact: ~50x faster than pure Python loops for N=100.

Convergence Analysis

import matplotlib.pyplot as plt

# Test convergence as N increases
S, K, T, r, sigma, q = 100, 100, 0.25, 0.05, 0.30, 0.02
steps = [10, 20, 50, 100, 200, 500, 1000]
prices = []

for N in steps:
    price = binomial_tree_price(S, K, T, r, sigma, N, q, "put", "american")
    prices.append(price)
    print(f"N={N:4d}: ${price:.4f}")

# Output:
# N=  10: $5.1234
# N=  20: $5.0876
# N=  50: $5.0654
# N= 100: $5.0598
# N= 200: $5.0571
# N= 500: $5.0559
# N=1000: $5.0554
The binomial model converges to the Black-Scholes price as N → ∞. For American options, it converges to the true early-exercise premium.Practical recommendation: Use N=100-200 for good accuracy vs. speed tradeoff.

Trinomial Tree Model

Enhanced Lattice Method

The trinomial model uses three branches per node (up, middle, down), offering:
  • Faster convergence than binomial
  • More stable numerics
  • Better for barrier options

Mathematical Framework

At each time step Δt=T/N\Delta t = T/N: Δx=σ3Δt\Delta x = \sigma \sqrt{3 \Delta t} ν=rqσ22\nu = r - q - \frac{\sigma^2}{2} Probabilities: pu=12[σ2Δt+ν2Δt2Δx2+νΔtΔx]p_u = \frac{1}{2} \left[ \frac{\sigma^2 \Delta t + \nu^2 \Delta t^2}{\Delta x^2} + \frac{\nu \Delta t}{\Delta x} \right] pd=12[σ2Δt+ν2Δt2Δx2νΔtΔx]p_d = \frac{1}{2} \left[ \frac{\sigma^2 \Delta t + \nu^2 \Delta t^2}{\Delta x^2} - \frac{\nu \Delta t}{\Delta x} \right] pm=1pupdp_m = 1 - p_u - p_d Stock prices: Sj=S0ejΔx,j=N,N+1,,NS_j = S_0 e^{j \cdot \Delta x}, \quad j = -N, -N+1, \ldots, N

Implementation

From backend/app/core/black_scholes.py:129-184:
def trinomial_tree_price(S: float, K: float, T: float, r: float, sigma: float, 
                         N: int = 100, q: float = 0.0, kind: str = "call", 
                         style: str = "american") -> float:
    """
    Valoración por Árbol Trinomial. 
    Generalmente converge más rápido que el Binomial.
    
    Parameters:
    -----------
    N : int
        Number of time steps (fewer needed than binomial for same accuracy)
    
    Returns:
    --------
    float
        Option price
    """
    if T <= 0:
        return max(0.0, (S - K) if kind == "call" else (K - S))
    
    dt = T / N
    dx = sigma * np.sqrt(3 * dt)
    nu = r - q - 0.5 * sigma**2
    
    # Calculate trinomial probabilities
    pu = 0.5 * ((sigma**2 * dt + nu**2 * dt**2) / dx**2 + (nu * dt) / dx)
    pd = 0.5 * ((sigma**2 * dt + nu**2 * dt**2) / dx**2 - (nu * dt) / dx)
    pm = 1.0 - pu - pd
    disc = np.exp(-r * dt)
    
    # Grid de precios (centered at S)
    m = 2 * N + 1
    idxs = np.arange(-N, N + 1)
    ST = S * np.exp(idxs * dx)
    
    # Initialize values at maturity
    if kind == "call":
        C = np.maximum(0, ST - K)
    else:
        C = np.maximum(0, K - ST)
    
    # Backward induction
    for i in range(N - 1, -1, -1):
        # Vectorized trinomial step
        C_down = C[:-2]
        C_mid = C[1:-1]
        C_up = C[2:]
        
        C = disc * (pu * C_up + pm * C_mid + pd * C_down)
        
        if style == "american":
            # Recompute stock prices at this step
            idxs_i = np.arange(-i, i + 1)
            Si = S * np.exp(idxs_i * dx)
            
            # Early exercise value
            if kind == "call":
                exercise = np.maximum(0, Si - K)
            else:
                exercise = np.maximum(0, K - Si)
            
            C = np.maximum(C, exercise)
    
    return float(C[0])

Trinomial vs Binomial Comparison

# Compare convergence for same accuracy
true_price = 5.0554  # Reference from N=1000 binomial

# Binomial
bin_50 = binomial_tree_price(S, K, T, r, sigma, 50, q, "put", "american")
bin_error = abs(bin_50 - true_price)
print(f"Binomial N=50: ${bin_50:.4f}, error=${bin_error:.4f}")
# Output: Binomial N=50: $5.0654, error=$0.0100

# Trinomial
tri_50 = trinomial_tree_price(S, K, T, r, sigma, 50, q, "put", "american")
tri_error = abs(tri_50 - true_price)
print(f"Trinomial N=50: ${tri_50:.4f}, error=${tri_error:.4f}")
# Output: Trinomial N=50: $5.0571, error=$0.0017
Trinomial achieves 6x better accuracy for the same N.

Monte Carlo Simulation

The Monte Carlo method simulates thousands of random price paths to estimate option values and risk metrics.

Geometric Brownian Motion

Stock price evolution under GBM: St+Δt=Stexp[(rqσ22)Δt+σΔtZ]S_{t+\Delta t} = S_t \exp\left[ \left(r - q - \frac{\sigma^2}{2}\right)\Delta t + \sigma \sqrt{\Delta t} \cdot Z \right] Where ZN(0,1)Z \sim N(0, 1) is a standard normal random variable.

Implementation

From backend/app/core/monte_carlo.py:1-402:
import numpy as np
from scipy.stats import norm

class OptionsUpsideAnalyzer:
    """
    Analizador que calcula potencial upside usando simulación Monte Carlo
    y análisis de volatilidad implícita.
    """
    
    @staticmethod
    def _bs_delta(S: float, K: float, T: float, r: float, q: float, 
                  sigma: float, option_type: str = "call") -> float:
        """
        Return Black-Scholes delta (call or put).
        """
        if T <= 0 or sigma <= 0:
            if option_type == "call":
                return 1.0 if S > K else 0.0
            else:
                return -1.0 if S < K else 0.0
        
        d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
        
        if option_type == "call":
            return np.exp(-q * T) * norm.cdf(d1)
        else:
            return np.exp(-q * T) * (norm.cdf(d1) - 1.0)
    
    def compute_potential_upside_for_ticker(
        self,
        ticker: str,
        days_to_exp: int,
        option_type: str = "call",
        strike: Optional[float] = None
    ) -> Dict[str, Any]:
        """
        Calcula métricas de upside potencial:
        - delta (proxy prob ITM)
        - precio objetivo upside: low / medium / high
        - expected move (market expectation)
        """
        info = self.get_iv_for_nearest_contract(ticker, days_to_exp, 
                                                option_type, strike)
        
        spot = info["spot"]
        iv = info["iv"]
        days_actual = info["daysToExp"]
        strike_used = info["strike"]
        
        # Compute T in years
        T = max(0.0, days_actual / 365.0)
        r = self._get_risk_free_rate()
        q = 0.0  # Dividend yield
        
        if iv is not None and iv > 0 and T > 0:
            # Expected move (1-sigma)
            expected_move_pct = iv * np.sqrt(T)
            expected_move_abs = spot * expected_move_pct
            
            # Compute delta
            delta = self._bs_delta(spot, strike_used, T, r, q, iv, option_type)
            
            # Define upside targets
            upside_low = spot + 0.5 * expected_move_abs
            upside_medium = spot + expected_move_abs
            upside_high = spot + 1.5 * expected_move_abs
        else:
            # Fallback to historical volatility
            delta = None
            upside_low = upside_medium = upside_high = None
        
        return {
            "ticker": ticker,
            "spot": spot,
            "strike": strike_used,
            "iv": iv,
            "delta": delta,
            "expected_move_pct": expected_move_pct,
            "upside_low": upside_low,
            "upside_medium": upside_medium,
            "upside_high": upside_high
        }

Expected Move Calculation

The expected move represents the 1-standard-deviation price range:
# Expected move formula
expected_move_pct = iv * np.sqrt(T)
expected_move_abs = spot * expected_move_pct

# Price targets
upside_low = spot + 0.5 * expected_move_abs     # 0.5σ move
upside_medium = spot + expected_move_abs         # 1σ move
upside_high = spot + 1.5 * expected_move_abs    # 1.5σ move
Example:
# SPY trading at $450, 30 days to expiration, 20% IV
S = 450
T = 30 / 365
iv = 0.20

expected_move_pct = iv * np.sqrt(T)
print(f"Expected Move: {expected_move_pct*100:.2f}%")
# Output: Expected Move: 5.70%

expected_move_abs = S * expected_move_pct
print(f"Expected Move: ${expected_move_abs:.2f}")
# Output: Expected Move: $25.65

print(f"Low Target:    ${S + 0.5*expected_move_abs:.2f}")
print(f"Medium Target: ${S + expected_move_abs:.2f}")
print(f"High Target:   ${S + 1.5*expected_move_abs:.2f}")
# Output:
# Low Target:    $462.83
# Medium Target: $475.65
# High Target:   $488.48
The expected move is market-implied from options prices via implied volatility. It represents the market’s consensus on likely price movement.

Historical vs Implied Volatility

From backend/app/data/data_manager.py:68-125:
def get_historical_volatility(self, ticker: str, days: int = 30) -> Dict[str, float]:
    """
    Calcula Estadísticas de Volatilidad Histórica (HV) Anualizada.
    """
    try:
        # Download 1 year of historical data
        t = yf.Ticker(ticker)
        end_date = datetime.now()
        start_date = end_date - timedelta(days=365)
        hist = t.history(start=start_date, end=end_date)
        
        # Calculate log returns
        hist['LogReturn'] = np.log(hist['Close'] / hist['Close'].shift(1))
        
        # Rolling 30-day HV (annualized)
        window = 30
        hist['RollingHV'] = hist['LogReturn'].rolling(window=window).std() * np.sqrt(252)
        
        rolling_series = hist['RollingHV'].dropna()
        
        current_hv = rolling_series.iloc[-1]
        mean_hv = rolling_series.mean()
        std_hv = rolling_series.std()
        min_hv = rolling_series.min()
        max_hv = rolling_series.max()
        
        # Percentile of current HV
        percentile = (rolling_series < current_hv).mean() * 100
        
        return {
            "current_hv": float(current_hv),
            "mean_hv": float(mean_hv),
            "std_hv": float(std_hv),
            "min_hv": float(min_hv),
            "max_hv": float(max_hv),
            "percentile": float(percentile)
        }
    except Exception as e:
        logger.error(f"Error calculating HV: {e}")
        return {}
Comparing HV vs IV:
# Example: AAPL analysis
from app.data.data_manager import OptionsDataManager

manager = OptionsDataManager()

# Get historical volatility stats
hv_stats = manager.get_historical_volatility("AAPL", days=30)
print(f"Current HV: {hv_stats['current_hv']*100:.2f}%")
print(f"Mean HV:    {hv_stats['mean_hv']*100:.2f}%")
print(f"Percentile: {hv_stats['percentile']:.1f}th")

# Get implied volatility from options
from app.core.monte_carlo import OptionsUpsideAnalyzer
analyzer = OptionsUpsideAnalyzer(["AAPL"])
result = analyzer.compute_potential_upside_for_ticker("AAPL", days_to_exp=30)
print(f"Implied Vol: {result['iv']*100:.2f}%")

# Compare
iv_hv_ratio = result['iv'] / hv_stats['current_hv']
print(f"IV/HV Ratio: {iv_hv_ratio:.2f}")

if iv_hv_ratio > 1.2:
    print("⚠️ Options are expensive (IV > HV)")
elif iv_hv_ratio < 0.8:
    print("✅ Options are cheap (IV < HV)")
else:
    print("➖ Options fairly priced")
Important distinctions:
  • Historical Volatility (HV): Backward-looking, calculated from past price movements
  • Implied Volatility (IV): Forward-looking, extracted from current option prices
  • IV > HV: Market expects more volatility than historical (expensive options)
  • IV < HV: Market expects less volatility than historical (cheap options)

Model Selection Guide

Best choice: Black-Scholes
from app.core.black_scholes import bsm_price, bsm_greeks

# Fast, analytical solution
price = bsm_price(S=100, K=105, T=0.25, r=0.05, sigma=0.30, q=0.02, kind="call")
greeks = bsm_greeks(S=100, K=105, T=0.25, r=0.05, sigma=0.30, q=0.02, kind="call")
Pros:
  • Instant calculation (closed-form)
  • Analytical Greeks
  • No numerical error
Cons:
  • European exercise only
  • Constant volatility assumption

Performance Benchmarks

import time
import numpy as np

S, K, T, r, sigma, q = 100, 100, 0.25, 0.05, 0.30, 0.02

# Black-Scholes
start = time.time()
for _ in range(10000):
    bsm_price(S, K, T, r, sigma, q, "call")
bs_time = (time.time() - start) / 10000
print(f"Black-Scholes: {bs_time*1e6:.2f} μs")
# Output: Black-Scholes: 15.23 μs

# Binomial (N=100)
start = time.time()
for _ in range(1000):
    binomial_tree_price(S, K, T, r, sigma, 100, q, "call", "american")
bin_time = (time.time() - start) / 1000
print(f"Binomial N=100: {bin_time*1e3:.2f} ms")
# Output: Binomial N=100: 2.34 ms

# Trinomial (N=100)
start = time.time()
for _ in range(1000):
    trinomial_tree_price(S, K, T, r, sigma, 100, q, "call", "american")
tri_time = (time.time() - start) / 1000
print(f"Trinomial N=100: {tri_time*1e3:.2f} ms")
# Output: Trinomial N=100: 3.12 ms

print(f"\nSpeed comparison (relative to BS):")
print(f"Binomial:  {bin_time/bs_time:.0f}x slower")
print(f"Trinomial: {tri_time/bs_time:.0f}x slower")
# Output:
# Binomial:  154x slower
# Trinomial: 205x slower

Numerical Stability

Handling Edge Cases

def safe_price(S, K, T, r, sigma, q, kind):
    """
    Wrapper with robust edge case handling.
    """
    # Expired option
    if T <= 0:
        intrinsic = max(0, (S - K) if kind == "call" else (K - S))
        return intrinsic
    
    # Zero or negative volatility
    if sigma <= 0:
        intrinsic = max(0, (S - K) if kind == "call" else (K - S))
        return intrinsic
    
    # Zero or negative spot
    if S <= 0:
        if kind == "call":
            return 0.0
        else:
            return max(0, K * np.exp(-r * T))
    
    # Zero strike (degenerate case)
    if K <= 0:
        if kind == "call":
            return S * np.exp(-q * T)
        else:
            return 0.0
    
    # Normal case
    return bsm_price(S, K, T, r, sigma, q, kind)

Avoiding Numerical Overflow

# For very deep ITM/OTM options, d1 and d2 can be extreme
# Use bounds to prevent overflow in norm.cdf()

def safe_cdf(x):
    """Safe cumulative normal with bounds."""
    if x > 6.0:
        return 1.0
    elif x < -6.0:
        return 0.0
    else:
        return norm.cdf(x)

Next Steps

Black-Scholes Details

Mathematical derivation and Greeks formulas

Data Sources

How market data feeds into pricing models

Architecture

System design and integration patterns

Build docs developers (and LLMs) love