Skip to content

Black Scholes Utils

Background

Black Scholes Utils

Educational script demonstrating black scholes utils concepts.


Code

```python """ Black Scholes Utils

Educational script demonstrating black scholes utils concepts. """

============================================================================

black_scholes/black_scholes_utils.py

============================================================================

import numpy as np import matplotlib.cm as cm import matplotlib.pyplot as plt import pandas as pd import os import requests import scipy.stats as stats import warnings from typing import Tuple, Optional, Dict, List

=========================================================================

UTILITIES FOR FORMULA

=========================================================================

def d1_d2(S: float, K: float, T: float, r: float, sigma: float, q: float = 0) -> Tuple[float, float]: """Calculate d1 and d2 parameters for Black-Scholes formula - UNCHANGED""" d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) d2 = d1 - sigma * np.sqrt(T) return d1, d2

def bs_call_price(S: float, K: float, T: float, r: float, sigma: float, q: float = 0) -> float: """Black-Scholes call option price - UNCHANGED""" d1, d2 = d1_d2(S, K, T, r, sigma, q) return S * np.exp(-q * T) * stats.norm.cdf(d1) - K * np.exp(-r * T) * stats.norm.cdf(d2)

def bs_put_price(S: float, K: float, T: float, r: float, sigma: float, q: float = 0) -> float: """Black-Scholes put option price - UNCHANGED""" d1, d2 = d1_d2(S, K, T, r, sigma, q) return K * np.exp(-r * T) * stats.norm.cdf(-d2) - S * np.exp(-q * T) * stats.norm.cdf(-d1)

def delta(S: float, K: float, T: float, r: float, sigma: float, q: float = 0) -> Tuple[float, float]: """Calculate delta for call and put options - UNCHANGED""" d1, _ = d1_d2(S, K, T, r, sigma, q) delta_call = np.exp(-q * T) * stats.norm.cdf(d1) delta_put = delta_call - np.exp(-q * T) return delta_call, delta_put

def gamma(S: float, K: float, T: float, r: float, sigma: float, q: float = 0) -> float: """Calculate gamma (same for calls and puts) - UNCHANGED""" d1, _ = d1_d2(S, K, T, r, sigma, q) return np.exp(-q * T) * stats.norm.pdf(d1) / (S * sigma * np.sqrt(T))

def vega(S: float, K: float, T: float, r: float, sigma: float, q: float = 0) -> float: """Calculate vega (same for calls and puts) - UNCHANGED""" d1, _ = d1_d2(S, K, T, r, sigma, q) return S * np.exp(-q * T) * stats.norm.pdf(d1) * np.sqrt(T)

def theta(S: float, K: float, T: float, r: float, sigma: float, q: float = 0) -> Tuple[float, float]: """Calculate theta for call and put options - UNCHANGED""" d1, d2 = d1_d2(S, K, T, r, sigma, q)

# Common term
term1 = -S * np.exp(-q * T) * stats.norm.pdf(d1) * sigma / (2 * np.sqrt(T))
term2_call = -r * K * np.exp(-r * T) * stats.norm.cdf(d2)
term2_put = r * K * np.exp(-r * T) * stats.norm.cdf(-d2)
term3 = q * S * np.exp(-q * T)

theta_call = term1 + term2_call - term3 * stats.norm.cdf(d1)
theta_put = term1 + term2_put + term3 * stats.norm.cdf(-d1)

return theta_call, theta_put

def rho(S: float, K: float, T: float, r: float, sigma: float, q: float = 0) -> Tuple[float, float]: """Calculate rho for call and put options - UNCHANGED""" d1, d2 = d1_d2(S, K, T, r, sigma, q) rho_call = K * T * np.exp(-r * T) * stats.norm.cdf(d2) rho_put = -K * T * np.exp(-r * T) * stats.norm.cdf(-d2) return rho_call, rho_put

def portfolio_greeks(portfolio: List[Dict]) -> Dict[str, float]: """ Calculate portfolio-level Greeks. NEW FUNCTION - doesn't break existing code. """ total_delta = 0 total_gamma = 0 total_vega = 0 total_theta = 0 total_rho = 0 total_value = 0

for position in portfolio:
    size = position['position_size']

    # Get price and Greeks using existing utility functions
    if position['option_type'].lower() == 'call':
        price = bs_call_price(position['S0'], position['K'], position['T'], 
                            position['r'], position['sigma'], position.get('q', 0))
    else:
        price = bs_put_price(position['S0'], position['K'], position['T'], 
                           position['r'], position['sigma'], position.get('q', 0))

    # Get Greeks using existing functions
    delta_call, delta_put = delta(position['S0'], position['K'], position['T'], 
                                position['r'], position['sigma'], position.get('q', 0))
    gamma_val = gamma(position['S0'], position['K'], position['T'], 
                     position['r'], position['sigma'], position.get('q', 0))
    vega_val = vega(position['S0'], position['K'], position['T'], 
                   position['r'], position['sigma'], position.get('q', 0))
    theta_call, theta_put = theta(position['S0'], position['K'], position['T'], 
                                position['r'], position['sigma'], position.get('q', 0))
    rho_call, rho_put = rho(position['S0'], position['K'], position['T'], 
                          position['r'], position['sigma'], position.get('q', 0))

    # Aggregate
    total_value += size * price
    total_gamma += size * gamma_val
    total_vega += size * vega_val

    if position['option_type'].lower() == 'call':
        total_delta += size * delta_call
        total_theta += size * theta_call
        total_rho += size * rho_call
    else:
        total_delta += size * delta_put
        total_theta += size * theta_put
        total_rho += size * rho_put

return {
    'portfolio_value': total_value,
    'delta': total_delta,
    'gamma': total_gamma,
    'vega': total_vega,
    'theta': total_theta,
    'rho': total_rho
}

=========================================================================

UTILITIES FOR IMPLIED VOL

=========================================================================

def analyze_atm_term_structure(options_data, S0): """ Analyze at-the-money volatility term structure.

Parameters:
-----------
options_data : pd.DataFrame
    Options data with IMP_VOL column
S0 : float
    Current underlying price

Returns:
--------
pd.DataFrame
    ATM volatility data with term structure metrics
"""
atm_data = []

for maturity in options_data['MATURITY'].unique():
    maturity_options = options_data[
        (options_data['MATURITY'] == maturity) & 
        (options_data['IMP_VOL'].notna()) &
        (options_data['IMP_VOL'] > 0)
    ]

    if len(maturity_options) > 0:
        # Find ATM option (closest to S0)
        atm_option = maturity_options.loc[
            (maturity_options['STRIKE'] - S0).abs().idxmin()
        ]

        atm_data.append({
            'Maturity': maturity,
            'Days_to_Expiry': atm_option['TTM'] * 365,
            'Strike': atm_option['STRIKE'],
            'ATM_Vol': atm_option['IMP_VOL'],
            'Moneyness': atm_option['STRIKE'] / S0
        })

if not atm_data:
    return pd.DataFrame()

atm_df = pd.DataFrame(atm_data).sort_values('Days_to_Expiry')

# Calculate term structure metrics
if len(atm_df) > 1:
    correlation = np.corrcoef(atm_df['Days_to_Expiry'], atm_df['ATM_Vol'])[0,1]
    slope = np.polyfit(atm_df['Days_to_Expiry'], atm_df['ATM_Vol'], 1)[0]

    atm_df.attrs['correlation'] = correlation
    atm_df.attrs['slope'] = slope
    atm_df.attrs['shape'] = 'Upward sloping' if slope > 0 else 'Downward sloping'

return atm_df

def analyze_volatility_smile_characteristics(vol_surface, S0): """ Analyze volatility smile characteristics across maturities.

Parameters:
-----------
vol_surface : pd.DataFrame
    Volatility surface (strikes x maturities)
S0 : float
    Current underlying price

Returns:
--------
pd.DataFrame
    Smile characteristics by maturity
"""
smile_stats = []

for maturity in vol_surface.columns:
    maturity_data = vol_surface[maturity].dropna()

    if len(maturity_data) >= 3:  # Need at least 3 points
        strikes = maturity_data.index.values
        vols = maturity_data.values

        # Find ATM point
        atm_idx = np.argmin(np.abs(strikes - S0))
        atm_vol = vols[atm_idx]

        # Calculate smile characteristics
        vol_range = vols.max() - vols.min()
        vol_std = np.std(vols)

        # Calculate skew (OTM put vol - OTM call vol)
        otm_put_vol = vols[0] if len(vols) > 0 else np.nan
        otm_call_vol = vols[-1] if len(vols) > 0 else np.nan
        skew = otm_put_vol - otm_call_vol if not (np.isnan(otm_put_vol) or np.isnan(otm_call_vol)) else np.nan

        smile_stats.append({
            'Maturity': maturity.strftime('%Y-%m-%d'),
            'Days_to_Expiry': (maturity - pd.Timestamp('2014-03-31')).days,
            'ATM_Vol': atm_vol,
            'Vol_Range': vol_range,
            'Vol_Std': vol_std,
            'Skew': skew,
            'Data_Points': len(maturity_data)
        })

return pd.DataFrame(smile_stats) if smile_stats else pd.DataFrame()

def compute_batch_implied_volatility(options_data, futures_data, S0, sigma_0=2.0, num_iter=100, tol=0.5, r=0.0, q=0.0): """ Compute implied volatility for all options in the dataset.

Parameters:
-----------
options_data : pd.DataFrame
    Options data with columns: MATURITY, STRIKE, TTM, PRICE
futures_data : pd.DataFrame  
    Futures data with columns: MATURITY, PRICE
sigma_0 : float
    Initial guess for volatility
num_iter : int
    Maximum iterations
tol : float
    Moneyness tolerance
r : float
    Risk-free rate
q : float
    Dividend yield

Returns:
--------
pd.DataFrame : Options data with IMP_VOL column populated
"""
print(f"⚡ Computing implied volatilities for {len(options_data)} options...")

options_copy = options_data.copy()
processed_count = 0
success_count = 0

for idx in options_copy.index:
    maturity = options_copy.loc[idx, 'MATURITY']
    forward_prices = futures_data[futures_data['MATURITY'] == maturity]['PRICE'].values

    if len(forward_prices) == 0:
        continue

    forward = forward_prices[0]
    strike = options_copy.loc[idx, 'STRIKE']

    # Only process options with moneyness within tolerance
    if forward * (1 - tol) < strike < forward * (1 + tol):
        processed_count += 1

        call_market = options_copy.loc[idx, 'PRICE']
        T = options_copy.loc[idx, 'TTM']

        try:
            implied_vol = implied_volatility(
                S0=S0,
                K=strike,
                T=T,
                r=r,
                market_price=call_market,
                sigma_0=sigma_0,
                q=q,
                num_iter=num_iter,
                option_type="call"
            )

            if not np.isnan(implied_vol) and 0.001 < implied_vol < 50:
                options_copy.loc[idx, 'IMP_VOL'] = implied_vol
                success_count += 1

        except Exception as e:
            print(f"⚠️  Error computing implied vol for option {idx}: {e}")
            continue

print(f"✅ Processed {processed_count} options, {success_count} successful calculations")
return options_copy

def compute_implied_volatilities_and_summarize(model, sigma_0=2.0, print_summary=True): """ Compute implied volatilities and print summary (DRY function).

Parameters:
-----------
model : BlackScholesImpliedVol
    Model with loaded data
sigma_0 : float
    Initial volatility guess
print_summary : bool
    Whether to print summary statistics

Returns:
--------
dict
    Summary statistics
"""
print(f"⚡ Computing implied volatilities (σ₀={sigma_0})...")

# Compute implied volatilities
model.compute_implied_volatility_batch(sigma_0=sigma_0)

# Get summary statistics
stats = model.get_summary_statistics()

if print_summary:
    print(f"📊 Results Summary:")
    if 'message' in stats:
        print(f"   ⚠️  {stats['message']}")
    else:
        print(f"   ✅ Valid computations: {stats['count']:,}")
        print(f"   📈 Mean volatility: {stats['mean']:.4f} ({stats['mean']*100:.2f}%)")
        print(f"   📏 Range: {stats['min']:.4f} - {stats['max']:.4f}")
        print(f"   📊 Std deviation: {stats['std']:.4f}")

return stats

def compute_volatility_risk_metrics(options_data): """ Compute risk metrics from implied volatility data.

Parameters:
-----------
options_data : pd.DataFrame
    Options data with IMP_VOL column

Returns:
--------
dict
    Dictionary containing various risk metrics
"""
valid_vols = options_data[
    (options_data['IMP_VOL'].notna()) &
    (options_data['IMP_VOL'] > 0) &
    (options_data['IMP_VOL'] < 50)
]['IMP_VOL']

if len(valid_vols) == 0:
    return {"error": "No valid volatility data"}

# Calculate metrics
mean_vol = valid_vols.mean()
std_vol = valid_vols.std()

# Value at Risk (VaR)
var_95 = np.percentile(valid_vols, 95)
var_99 = np.percentile(valid_vols, 99)

# Expected Shortfall (CVaR)
cvar_95 = valid_vols[valid_vols >= var_95].mean()
cvar_99 = valid_vols[valid_vols >= var_99].mean()

# Outlier detection
outliers = valid_vols[(valid_vols < mean_vol - 2*std_vol) | (valid_vols > mean_vol + 2*std_vol)]

return {
    'mean_vol': mean_vol,
    'vol_of_vol': std_vol,
    'var_95': var_95,
    'var_99': var_99,
    'cvar_95': cvar_95,
    'cvar_99': cvar_99,
    'outlier_count': len(outliers),
    'outlier_pct': len(outliers) / len(valid_vols) * 100,
    'total_observations': len(valid_vols)
}

def create_volatility_smile_3d(vol_surface, title="3D Volatility Smiles", save_path="./data/3d_volatility_smiles.png"): """ Create a 3D plot showing volatility smiles across different maturities.

Parameters:
-----------
vol_surface : pd.DataFrame
    Volatility surface (strikes x maturities)
title : str
    Plot title
save_path : str
    Path to save the plot

Returns:
--------
tuple
    (matplotlib.Figure, matplotlib.Axes)
"""
if vol_surface.empty:
    print("❌ No volatility surface data available")
    return None, None

fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection='3d')

colors = plt.cm.tab10(np.linspace(0, 1, len(vol_surface.columns)))

for i, maturity in enumerate(vol_surface.columns):
    maturity_data = vol_surface[maturity].dropna()

    if len(maturity_data) > 1:
        strikes = maturity_data.index.values
        vols = maturity_data.values
        days = (maturity - pd.Timestamp('2014-03-31')).days

        # Plot line and scatter points
        ax.plot(strikes, [days] * len(strikes), vols,
               color=colors[i], linewidth=2, label=f"{days} days")
        ax.scatter(strikes, [days] * len(strikes), vols,
                  color=colors[i], s=30, alpha=0.7)

ax.set_xlabel('Strike Price')
ax.set_ylabel('Days to Expiry')
ax.set_zlabel('Implied Volatility')
ax.set_title(title)
ax.legend(bbox_to_anchor=(1.1, 1), loc='upper left')

plt.tight_layout()
plt.show()

# Save plot
os.makedirs(os.path.dirname(save_path), exist_ok=True)
fig.savefig(save_path, dpi=300, bbox_inches='tight')

return fig, ax

def download_vstoxx_data(url=None, filename="vstoxx_data_31032014.h5", data_dir="./data"): """Download VSTOXX data file from specified URL to data directory.""" if url is None: url = "https://github.com/yhilpisch/py4fi/raw/master/jupyter36/source/vstoxx_data_31032014.h5"

os.makedirs(data_dir, exist_ok=True)
file_path = os.path.join(data_dir, filename)

try:
    with requests.get(url, stream=True, timeout=30) as response:
        response.raise_for_status()
        with open(file_path, 'wb') as file:
            for chunk in response.iter_content(chunk_size=8192):
                if chunk:
                    file.write(chunk)
    return file_path

except requests.RequestException as e:
    alt_url = "https://github.com/NanguangChou/BSM_call_option/raw/master/vstoxx_data_31032014.h5"
    if url != alt_url:
        return download_vstoxx_data(alt_url, filename, data_dir)
    raise

def get_implied_vol_summary_stats(options_data): """Get summary statistics of the implied volatilities.""" implied_vols = options_data['IMP_VOL'].dropna() implied_vols = implied_vols[(implied_vols > 0) & (implied_vols < 50)]

if len(implied_vols) == 0:
    return {"message": "No valid implied volatilities computed yet."}

return {
    "count": len(implied_vols),
    "mean": implied_vols.mean(),
    "std": implied_vols.std(),
    "min": implied_vols.min(),
    "max": implied_vols.max(),
    "median": implied_vols.median(),
    "25th_percentile": implied_vols.quantile(0.25),
    "75th_percentile": implied_vols.quantile(0.75)
}

def get_volatility_surface(options_data, strikes=None, maturities=None): """Extract implied volatility surface for visualization.""" filtered_data = options_data.dropna(subset=['IMP_VOL']) filtered_data = filtered_data[ (filtered_data['IMP_VOL'] > 0) & (filtered_data['IMP_VOL'] < 50) ]

if strikes is not None:
    filtered_data = filtered_data[filtered_data['STRIKE'].isin(strikes)]

if maturities is not None:
    filtered_data = filtered_data[filtered_data['MATURITY'].isin(maturities)]

vol_surface = filtered_data.pivot_table(
    values='IMP_VOL',
    index='STRIKE',
    columns='MATURITY',
    aggfunc='mean'
)

return vol_surface

def implied_volatility(S0, K, T, r, market_price, sigma_0, q=0, num_iter=100, option_type="call", tol=1e-6): """ Calculate implied volatility using Newton-Raphson method.

Parameters:
-----------
S0 : float
    Current stock price
K : float
    Strike price
T : float
    Time to maturity
r : float
    Risk-free rate
market_price : float
    Observed market price of the option
sigma_0 : float
    Initial guess for volatility
q : float, optional
    Dividend yield (default: 0)
num_iter : int, optional
    Maximum iterations (default: 100)
option_type : str, optional
    'call' or 'put' (default: 'call')
tol : float, optional
    Convergence tolerance (default: 1e-6)

Returns:
--------
float : Implied volatility or np.nan if no convergence
"""
# Newton-Raphson iteration
sigma = sigma_0

for _ in range(num_iter):
    # Compute option price using existing functions
    if option_type == "call":
        price = bs_call_price(S0, K, T, r, sigma, q)
    elif option_type == "put":
        price = bs_put_price(S0, K, T, r, sigma, q)
    else:
        raise ValueError("option_type must be 'call' or 'put'")

    # Compute vega using existing function
    option_vega = vega(S0, K, T, r, sigma, q)

    # Calculate difference
    diff = price - market_price

    # Check convergence
    if abs(diff) < tol:
        return sigma

    # Avoid division by zero or tiny vega (numerical instability)
    if option_vega < 1e-6:
        break

    # Newton-Raphson update
    sigma -= diff / option_vega

    # Clamp sigma to avoid runaway (volatility should be positive and reasonable)
    sigma = max(1e-4, min(sigma, 5.0))

# Return NaN if no convergence
return np.nan

def load_vstoxx_data(data_path='./data/vstoxx_data_31032014.h5', auto_download=True): """Load VSTOXX futures and options data from HDF5 file.""" if not os.path.exists(data_path) and auto_download: print(f"📂 Data file not found at {data_path}") data_dir = os.path.dirname(data_path) or "./data" filename = os.path.basename(data_path) data_path = download_vstoxx_data(filename=filename, data_dir=data_dir)

try:
    print(f"📊 Loading data from: {data_path}")
    h5 = pd.HDFStore(data_path, 'r')
    futures_data = h5['futures_data'].copy()
    options_data = h5['options_data'].copy()
    h5.close()

    # Convert datetime columns
    futures_data, options_data = prepare_datetime_columns(futures_data, options_data)

    # Add column for implied volatilities
    options_data['IMP_VOL'] = np.NaN

    print(f"✅ Data loaded successfully.")
    print(f"   Futures data shape: {futures_data.shape}")
    print(f"   Options data shape: {options_data.shape}")

    return futures_data, options_data

except Exception as e:
    print(f"❌ Error loading data: {e}")
    raise

def plot_volatility_smiles(options_data, title="VSTOXX Implied Volatility Smile"): """Plot volatility smiles for different maturities.""" print("📊 Creating volatility smile plots...")

plot_data = options_data[
    (options_data['IMP_VOL'] > 0) & 
    (options_data['IMP_VOL'] < 50)
].copy()

if plot_data.empty:
    print("❌ No valid implied volatilities to plot")
    return None, None

maturities = sorted(plot_data['MATURITY'].unique())
fig, ax = plt.subplots(figsize=(12, 8))
colors = plt.cm.tab10(np.linspace(0, 1, len(maturities)))

for i, maturity in enumerate(maturities):
    maturity_data = plot_data[plot_data['MATURITY'] == maturity].sort_values('STRIKE')

    if len(maturity_data) > 1:
        strikes = maturity_data['STRIKE'].values
        imp_vols = maturity_data['IMP_VOL'].values

        ax.plot(strikes, imp_vols, 
               color=colors[i], linewidth=2, label=maturity.strftime('%Y-%m-%d'))
        ax.scatter(strikes, imp_vols, 
                  color=colors[i], s=50, alpha=0.7)

ax.grid(True, alpha=0.3)
ax.set_xlabel('Strike Price', fontsize=12)
ax.set_ylabel('Implied Volatility', fontsize=12)
ax.set_title(title, fontsize=14, fontweight='bold')
ax.legend(title='Maturity', bbox_to_anchor=(1.05, 1), loc='upper left')

plt.tight_layout()

os.makedirs('./data', exist_ok=True)
plt.savefig('./data/vstoxx_volatility_smiles.png', dpi=300, bbox_inches='tight')
plt.show()

print(f"✅ Plotted volatility smiles for {len(maturities)} maturities")
return fig, ax

def plot_3d_volatility_surface(vol_surface, title="3D Implied Volatility Surface", save_path="./data/3d_volatility_surface.png"): """ Create a 3D surface plot of implied volatility.

Parameters:
-----------
vol_surface : pd.DataFrame
    Volatility surface (strikes x maturities)
title : str
    Plot title
save_path : str
    Path to save the plot

Returns:
--------
tuple
    (matplotlib.Figure, matplotlib.Axes) or (None, None) if insufficient data
"""
if vol_surface.empty:
    print("❌ No volatility surface data available for 3D plot")
    return None, None

# Clean data
vol_surface_clean = vol_surface.dropna(how='all', axis=0).dropna(how='all', axis=1)

if vol_surface_clean.shape[0] < 2 or vol_surface_clean.shape[1] < 2:
    print("❌ Insufficient data points for 3D surface (need at least 2x2)")
    return None, None

# Prepare data
strikes = vol_surface_clean.index.values
maturities = vol_surface_clean.columns
days_to_expiry = [(mat - pd.Timestamp('2014-03-31')).days for mat in maturities]

# Create meshgrid
X, Y = np.meshgrid(strikes, days_to_expiry)
Z = vol_surface_clean.T.values

# Create the plot
fig = plt.figure(figsize=(14, 10))
ax = fig.add_subplot(111, projection='3d')

# Plot surface
surface = ax.plot_surface(X, Y, Z, 
                         cmap=cm.viridis,
                         alpha=0.8,
                         linewidth=0.5,
                         antialiased=True,
                         edgecolors='gray')

# Add contour lines
contours = ax.contour(X, Y, Z, 
                     levels=10, 
                     zdir='z', 
                     offset=np.nanmin(Z) - 0.05,
                     cmap=cm.viridis,
                     alpha=0.6)

# Add data points
for i, strike in enumerate(strikes):
    for j, days in enumerate(days_to_expiry):
        if not np.isnan(Z[j, i]):
            ax.scatter([strike], [days], [Z[j, i]], 
                      color='red', s=20, alpha=0.7)

# Customize plot
ax.set_xlabel('Strike Price', fontsize=12, labelpad=10)
ax.set_ylabel('Days to Expiry', fontsize=12, labelpad=10)
ax.set_zlabel('Implied Volatility', fontsize=12, labelpad=10)
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)

# Add colorbar
cbar = plt.colorbar(surface, ax=ax, shrink=0.5, aspect=20, pad=0.1)
cbar.set_label('Implied Volatility', rotation=270, labelpad=20)

# Set viewing angle
ax.view_init(elev=25, azim=45)
ax.grid(True, alpha=0.3)

# Add statistics
valid_vols = vol_surface_clean.values[~np.isnan(vol_surface_clean.values)]
if len(valid_vols) > 0:
    stats_text = f"""Surface Statistics:

Data Points: {len(valid_vols)} Min Vol: {valid_vols.min():.4f} Max Vol: {valid_vols.max():.4f} Mean Vol: {valid_vols.mean():.4f} Strike Range: {strikes.min():.1f} - {strikes.max():.1f} Days Range: {min(days_to_expiry)} - {max(days_to_expiry)}"""

    ax.text2D(0.02, 0.98, stats_text, transform=ax.transAxes, 
             verticalalignment='top', fontsize=9,
             bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))

plt.tight_layout()

# Save plot
os.makedirs(os.path.dirname(save_path), exist_ok=True)
plt.savefig(save_path, dpi=300, bbox_inches='tight')

plt.show()

# return fig, ax

def prepare_datetime_columns(futures_data, options_data): """Convert date columns to datetime format.""" import pandas as pd

date_columns = ['DATE', 'MATURITY']

for col in date_columns:
    if col in futures_data.columns:
        futures_data[col] = pd.to_datetime(futures_data[col])
    if col in options_data.columns:
        options_data[col] = pd.to_datetime(options_data[col])

return futures_data, options_data

def run_advanced_analysis_on_implied_vol(model, print_results=True): """ Run all advanced analysis features (DRY function).

Parameters:
-----------
model : BlackScholesImpliedVol
    Model with computed implied volatilities
print_results : bool
    Whether to print results

Returns:
--------
dict
    Dictionary containing all analysis results
"""
results = {}

if print_results:
    print(f"\n🎯 Advanced Analysis:")

# ATM Term Structure Analysis
try:
    atm_analysis = model.analyze_atm_term_structure()
    results['atm_analysis'] = atm_analysis

    if print_results:
        if not atm_analysis.empty:
            print(f"   📊 ATM Analysis: {len(atm_analysis)} maturities")
            if hasattr(atm_analysis, 'attrs') and 'slope' in atm_analysis.attrs:
                print(f"      Term structure slope: {atm_analysis.attrs['slope']:.6f}")
        else:
            print(f"   📊 ATM Analysis: No data available")
except Exception as e:
    results['atm_analysis'] = None
    if print_results:
        print(f"   ❌ ATM Analysis failed: {e}")

# Volatility Smile Analysis
try:
    smile_analysis = model.analyze_smile_characteristics()
    results['smile_analysis'] = smile_analysis

    if print_results:
        if not smile_analysis.empty:
            print(f"   😊 Smile Analysis: {len(smile_analysis)} maturities")
            print(f"      Average skew: {smile_analysis['Skew'].mean():.4f}")
        else:
            print(f"   😊 Smile Analysis: No data available")
except Exception as e:
    results['smile_analysis'] = None
    if print_results:
        print(f"   ❌ Smile Analysis failed: {e}")

# Risk Metrics
try:
    risk_metrics = model.compute_risk_metrics()
    results['risk_metrics'] = risk_metrics

    if print_results:
        if 'error' not in risk_metrics:
            print(f"   ⚠️  Risk Metrics: VaR(95%)={risk_metrics['var_95']:.4f}")
            print(f"      Outliers: {risk_metrics['outlier_count']} ({risk_metrics['outlier_pct']:.1f}%)")
        else:
            print(f"   ⚠️  Risk Metrics: {risk_metrics['error']}")
except Exception as e:
    results['risk_metrics'] = None
    if print_results:
        print(f"   ❌ Risk Metrics failed: {e}")

# Volatility Surface
try:
    vol_surface = model.get_implied_volatility_surface()
    results['vol_surface'] = vol_surface

    if print_results:
        if not vol_surface.empty:
            coverage = vol_surface.notna().sum().sum() / vol_surface.size * 100
            print(f"   📈 Volatility Surface: {vol_surface.shape[0]}×{vol_surface.shape[1]} ({coverage:.1f}% coverage)")
        else:
            print(f"   📈 Volatility Surface: No data available")
except Exception as e:
    results['vol_surface'] = None
    if print_results:
        print(f"   ❌ Volatility Surface failed: {e}")

return results

=========================================================================

UTILITIES FOR MONTE CARLO

=========================================================================

def demo_monte_carlo_convergence(S0=100, K=100, T=1, r=0.05, sigma=0.2, q=0): """ Demonstrate Monte Carlo convergence for different path counts. """ # Analytical benchmark analytical_call = bs_call_price(S0, K, T, r, sigma, q)

# Test different path counts
path_counts = [10000, 50000, 100000]

results = []
for n_paths in path_counts:
    # Enhanced MC
    result_enh = monte_carlo_pricing(
        S0, K, T, r, sigma, q, n_paths=n_paths, seed=42,
        antithetic=True, control_variate=True
    )

    enh_error = abs(result_enh['call_price'] - analytical_call)
    results.append((n_paths, result_enh['call_price'], enh_error))

return results

def monte_carlo_pricing(S0: float, K: float, T: float, r: float, sigma: float, q: float = 0, n_paths: int = 100000, n_steps: int = 252, seed: Optional[int] = None, antithetic: bool = True, control_variate: bool = True) -> Dict[str, float]: """ ENHANCED Monte Carlo option pricing with variance reduction techniques.

FIXED VERSION - now returns actual simulated price arrays and correct statistics.

Parameters:
-----------
S0 : float
    Initial stock price
K : float
    Strike price
T : float
    Time to maturity (in years)
r : float
    Risk-free interest rate
sigma : float
    Volatility
q : float, optional
    Dividend yield (default: 0)
n_paths : int, optional
    Number of simulation paths (default: 100000)
n_steps : int, optional
    Number of time steps (default: 252)
seed : int, optional
    Random seed for reproducibility
antithetic : bool, optional
    Whether to use antithetic variates (default: True)
control_variate : bool, optional
    Whether to use control variates (default: True)

Returns:
--------
Dict containing:
    - call_price, put_price: Option prices
    - call_std, put_std: Standard deviations of option prices
    - call_std_error, put_std_error: Standard errors of the means
    - call_ci, put_ci: 95% confidence intervals for the means
    - call_prices, put_prices: Arrays of actual simulated option prices
    - n_paths: Number of paths used
    - variance_reduction: Whether variance reduction was used
"""
if seed is not None:
    np.random.seed(seed)

dt = T / n_steps

# Generate random numbers with optional antithetic variates
if antithetic:
    n_sim = n_paths // 2
    z = np.random.normal(0, 1, (n_sim, n_steps))
    z_anti = np.concatenate([z, -z], axis=0)
    # Handle odd number of paths
    if n_paths % 2 == 1:
        z_anti = np.concatenate([z_anti, np.random.normal(0, 1, (1, n_steps))], axis=0)
else:
    z_anti = np.random.normal(0, 1, (n_paths, n_steps))

# Simulate stock price paths using log-normal process
log_returns = (r - q - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * z_anti
log_S = np.log(S0) + np.cumsum(log_returns, axis=1)
S_T = np.exp(log_S[:, -1])  # Final stock prices

# Calculate option payoffs
call_payoffs = np.maximum(S_T - K, 0)
put_payoffs = np.maximum(K - S_T, 0)

# Discount to present value
discount = np.exp(-r * T)
call_prices = discount * call_payoffs
put_prices = discount * put_payoffs

# Apply control variate adjustment if requested
if control_variate:
    # Use the fact that E[S_T] = S0 * exp((r-q)*T) is known analytically
    expected_S_T = S0 * np.exp((r - q) * T)
    control_var = S_T - expected_S_T

    # Control variate for call options
    cov_call = np.cov(call_prices, control_var)[0, 1]
    var_control = np.var(control_var)
    beta_call = cov_call / var_control if var_control > 0 else 0
    call_prices_cv = call_prices - beta_call * control_var

    # Control variate for put options  
    cov_put = np.cov(put_prices, control_var)[0, 1]
    beta_put = cov_put / var_control if var_control > 0 else 0
    put_prices_cv = put_prices - beta_put * control_var
else:
    call_prices_cv = call_prices.copy()
    put_prices_cv = put_prices.copy()

# Calculate final statistics
call_mean = np.mean(call_prices_cv)
put_mean = np.mean(put_prices_cv)
call_std = np.std(call_prices_cv, ddof=1)  # Sample standard deviation
put_std = np.std(put_prices_cv, ddof=1)    # Sample standard deviation
call_std_error = call_std / np.sqrt(n_paths)  # Standard error of the mean
put_std_error = put_std / np.sqrt(n_paths)    # Standard error of the mean

# 95% confidence intervals for the mean estimates
z_score = 1.96  # 95% confidence level
call_ci = (call_mean - z_score * call_std_error, call_mean + z_score * call_std_error)
put_ci = (put_mean - z_score * put_std_error, put_mean + z_score * put_std_error)

return {
    'call_price': call_mean, 
    'put_price': put_mean,
    'call_std': call_std,                    # FIX: Actual standard deviation
    'put_std': put_std,                      # FIX: Actual standard deviation
    'call_std_error': call_std_error,        # Standard error of the mean
    'put_std_error': put_std_error,          # Standard error of the mean
    'call_ci': call_ci, 
    'put_ci': put_ci,
    'call_prices': call_prices_cv,           # FIX: Actual simulated prices
    'put_prices': put_prices_cv,             # FIX: Actual simulated prices
    'n_paths': n_paths, 
    'variance_reduction': antithetic or control_variate
}

def plot_gbm_paths_with_distribution(t, S_paths, S0, T, r, sigma, q=0, K=None, ax=None, max_paths_display=50, risk_neutral=True, mu=None, title=None, show_stats=True, show_histogram=True, show_theoretical_pdf=True): """ Plot GBM paths with final price distribution and theoretical lognormal PDF.

Parameters:
-----------
t : array
    Time points
S_paths : array
    Simulated price paths (shape: num_paths x num_steps)
S0, T, r, sigma, q, K : float
    Model parameters
ax : matplotlib axis, optional
    Axis to plot on (if None, current axis is used)
... (other parameters as before)

Returns:
--------
dict : Statistics and final prices
"""
if ax is None:
    ax = plt.gca()

# Plot a subset of paths
num_paths_to_plot = min(max_paths_display, S_paths.shape[0])
indices = np.random.choice(S_paths.shape[0], num_paths_to_plot, replace=False)

for i in indices:
    ax.plot(t, S_paths[i], alpha=0.6, linewidth=0.8)

# Add grid lines for better readability
ax.grid(True, alpha=0.3)

# Get final prices for statistics and histogram
final_prices = S_paths[:, -1]
mean_final = np.mean(final_prices)
std_final = np.std(final_prices)

# Calculate theoretical mean
drift_rate = r if risk_neutral else (mu if mu is not None else r)
theoretical_mean = S0 * np.exp(drift_rate * T)

# Add expected value line
ax.axhline(y=S0, color='red', linestyle='--', alpha=0.8, 
        label=f'S₀ = {S0}')
ax.axhline(y=theoretical_mean, color='orange', linestyle='--', 
        alpha=0.8, label=f'E[S(T)] = {theoretical_mean:.1f}')

# Show histogram and theoretical PDF if requested
if show_histogram or show_theoretical_pdf:
    # Create histogram bins
    hist_counts, bin_edges = np.histogram(final_prices, bins=50, density=True)
    bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2

    # Define histogram width and position
    hist_width = T * 0.05
    hist_position = T + 0.005

    if show_histogram:
        # Normalize histogram counts
        max_hist_count = np.max(hist_counts)
        normalized_hist_counts = hist_counts / max_hist_count

        # Create bar histogram
        bin_width = bin_centers[1] - bin_centers[0]
        ax.barh(bin_centers, normalized_hist_counts * hist_width, 
                left=hist_position, height=bin_width * 0.9, alpha=0.7, 
                color='lightblue', edgecolor='darkblue', linewidth=0.3, 
                label='Simulated Distribution')

    if show_theoretical_pdf:
        # Calculate theoretical lognormal distribution
        mu_ln = np.log(S0) + (drift_rate - 0.5 * sigma**2) * T
        sigma_ln = sigma * np.sqrt(T)

        S_range = np.linspace(np.min(final_prices), np.max(final_prices), 200)
        theoretical_pdf = stats.lognorm.pdf(S_range, s=sigma_ln, scale=np.exp(mu_ln))

        # Normalize theoretical PDF
        max_theoretical_pdf = np.max(theoretical_pdf)
        normalized_theoretical_pdf = theoretical_pdf / max_theoretical_pdf

        # Plot theoretical PDF
        theoretical_x = hist_position + normalized_theoretical_pdf * hist_width
        ax.plot(theoretical_x, S_range, 'r-', linewidth=3, label='Lognormal PDF')

 # Formatting
ax.set_xlabel('Time', fontsize=12)
ax.set_ylabel('Stock Price', fontsize=12)

# Set title
if title:
    ax.set_title(title, fontsize=12)
else:
    ax.set_title('Sample GBM Paths with Final Price Distribution', fontsize=14, pad=20)

# Legend
ax.legend(loc='upper right', bbox_to_anchor=(0.98, 0.98))

# Extend x-axis to accommodate histogram
if show_histogram or show_theoretical_pdf:
    ax.set_xlim(0, T * 1.15)
else:
    ax.set_xlim(0, T)

# Add statistics text if requested
if show_stats:
    stats_text = f"""Simulation Statistics:
Initial Stock Price: {S0:.2f}
Strike:  {K:.2f}
Maturity: {T:.2f}
Interest Rate: {r:.2f}
Volatility: {sigma:.2f}
Dividend Yield: {q:.2f}
Number of Paths: {S_paths.shape[0]:,}
Final Price Mean: {mean_final:.2f}
Final Price Std: {std_final:.2f}
Theoretical Mean: {theoretical_mean:.2f}"""

    ax.text(0.02, 0.98, stats_text, transform=ax.transAxes, 
                verticalalignment='top', 
                bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))

return {
    'final_prices': final_prices,
    'mean_final': mean_final,
    'std_final': std_final,
    'theoretical_mean': theoretical_mean
}

def simulate_gbm_paths(S0: float, T: float, r: float, sigma: float, num_paths: int, num_steps: int, risk_neutral: bool = True, seed: Optional[int] = None, mu: Optional[float] = None) -> Tuple[np.ndarray, np.ndarray]: """ Simulate geometric Brownian motion paths - UNCHANGED API """ if seed is not None: np.random.seed(seed)

dt = T / num_steps
t = np.linspace(0, T, num_steps + 1)

# Generate random increments
dW = np.random.normal(0, np.sqrt(dt), (num_paths, num_steps))

# Construct Brownian motion
W = np.zeros((num_paths, num_steps + 1))
W[:, 1:] = np.cumsum(dW, axis=1)

# Determine drift
drift = r if risk_neutral else (mu if mu is not None else r)

# Simulate GBM paths
drift_term = (drift - 0.5 * sigma**2) * t
diffusion_term = sigma * W

S_paths = S0 * np.exp(drift_term + diffusion_term)

return t, S_paths

def test_variance_reduction_effectiveness(): """ Test to demonstrate the effectiveness of variance reduction techniques. """ # Test parameters S0, K, T, r, sigma, q = 100, 100, 1, 0.05, 0.2, 0 n_paths = 100000 seed = 42

# Standard Monte Carlo (no variance reduction)
result_standard = monte_carlo_pricing(
    S0, K, T, r, sigma, q, n_paths=n_paths, seed=seed,
    antithetic=False, control_variate=False
)

# Enhanced Monte Carlo (with variance reduction)
result_enhanced = monte_carlo_pricing(
    S0, K, T, r, sigma, q, n_paths=n_paths, seed=seed,
    antithetic=True, control_variate=True
)

# Calculate variance reduction ratios
call_var_reduction = (result_standard['call_std']**2) / (result_enhanced['call_std']**2)
put_var_reduction = (result_standard['put_std']**2) / (result_enhanced['put_std']**2)

return {
    'call_var_reduction': call_var_reduction,
    'put_var_reduction': put_var_reduction,
    'meets_expectations': call_var_reduction >= 1.5 and put_var_reduction >= 1.5
}

def validate_monte_carlo_implementation(): """ Validation function to test the enhanced Monte Carlo implementation. """ # Test parameters (ATM European call) S0, K, T, r, sigma, q = 100, 100, 1, 0.05, 0.2, 0

# Analytical Black-Scholes price for comparison
analytical_call = bs_call_price(S0, K, T, r, sigma, q)
analytical_put = bs_put_price(S0, K, T, r, sigma, q)

# Test enhanced Monte Carlo with large sample
result = monte_carlo_pricing(S0, K, T, r, sigma, q, n_paths=500000, seed=42)

# Calculate errors
call_error = abs(result['call_price'] - analytical_call)
put_error = abs(result['put_price'] - analytical_put)
call_error_pct = call_error / analytical_call * 100
put_error_pct = put_error / analytical_put * 100

# Validation checks
call_within_ci = result['call_ci'][0] <= analytical_call <= result['call_ci'][1]
put_within_ci = result['put_ci'][0] <= analytical_put <= result['put_ci'][1]
reasonable_call_error = call_error_pct < 0.5
reasonable_put_error = put_error_pct < 0.5

all_tests_pass = call_within_ci and put_within_ci and reasonable_call_error and reasonable_put_error

return all_tests_pass

=========================================================================

UTILITIES FOR NUMERICAL

=========================================================================

def draw_finite_difference_grid(M=5, N=5): """ Draw an enhanced annotated finite difference grid for Black-Scholes equation with clear distinction between different types of grid points.

Parameters:
-----------
M : int
    Number of spatial steps (default: 5)
N : int
    Number of time steps (default: 5)
"""
import matplotlib.pyplot as plt
import numpy as np

fig, ax = plt.subplots(figsize=(12, 10))

# Draw grid lines
for n in range(N+1):
    ax.plot(range(M+1), [n]*(M+1), color='black', linewidth=0.5, alpha=0.3)
for m in range(M+1):
    ax.plot([m]*(N+1), range(N+1), color='black', linewidth=0.5, alpha=0.3)

# Define point styles for different types of grid points
point_styles = {
    'initial': {'color': 'red', 'marker': 'o', 'size': 120, 'label': 'Option Payoff (t=T)'},
    'boundary_spatial': {'color': 'green', 'marker': 's', 'size': 100, 'label': 'Boundary Condition (S=0, S=Smax)'},
    'interior': {'color': 'blue', 'marker': '^', 'size': 80, 'label': 'Interior Points (computed)'},
    'corner': {'color': 'purple', 'marker': 'D', 'size': 100, 'label': 'Corner Points'},
    'computed_boundary': {'color': 'orange', 'marker': 'v', 'size': 80, 'label': 'Current Option Value (computed)'}
}

# Draw grid points with different styles based on their role
legend_handles = {}
for n in range(N+1):
    for m in range(M+1):
        # Determine point type
        if n == N:  # Initial condition (t = T, expiration)
            if m == 0 or m == M:  # Corner points at expiration
                style_key = 'corner'
            else:  # Interior points at expiration
                style_key = 'initial'
        elif m == 0 or m == M:  # Spatial boundaries (S = 0 or S = Smax)
            if n == 0:  # Corner at t = 0
                style_key = 'corner'
            else:  # Spatial boundary points
                style_key = 'boundary_spatial'
        elif n == 0:  # Time boundary at t = 0 (today)
            style_key = 'computed_boundary'
        else:  # Interior points
            style_key = 'interior'

        style = point_styles[style_key]
        scatter = ax.scatter(m, n, c=style['color'], marker=style['marker'], 
                           s=style['size'], alpha=0.8, edgecolors='black', 
                           linewidth=1, zorder=3)

        # Store handle for legend (avoid duplicates)
        if style_key not in legend_handles:
            legend_handles[style_key] = scatter

# Create appropriate tick labels based on M and N
if M <= 6:
    x_labels = [f'(i = {i})' for i in range(M+1)]
    x_positions = list(range(M+1))
else:
    x_labels = ['(i = 0)', '(i = 1)', '⋯', f'(i = {M-1})', f'(i = {M})']
    x_positions = [0, 1, M//2, M-1, M]

if N <= 6:
    y_labels = [f'(n = {i})' for i in range(N+1)]
    y_positions = list(range(N+1))
else:
    y_labels = ['(n = 0)', '(n = 1)', '⋯', f'(n = {N-1})', f'(n = {N})']
    y_positions = [0, 1, N//2, N-1, N]

# Set axis ticks and labels
ax.set_xticks(x_positions)
ax.set_xticklabels(x_labels)
ax.set_yticks(y_positions)
ax.set_yticklabels(y_labels)

# Axis labels
ax.set_xlabel('S (Stock Price)', fontsize=14, fontweight='bold')
ax.set_ylabel('t (Time)', fontsize=14, fontweight='bold')

# Boundary labels with better positioning
ax.text(-0.3, -0.3, r'$S_{\min}$', fontsize=12, ha='center', color='darkgreen', fontweight='bold')
ax.text(M+0.3, -0.3, r'$S_{\max}$', fontsize=12, ha='center', color='darkgreen', fontweight='bold')
ax.text(-0.7, 0, r'$t = 0$', fontsize=12, va='center', color='darkorange', fontweight='bold')
ax.text(-0.7, N, r'$t = T$', fontsize=12, va='center', color='darkred', fontweight='bold')

# Add arrows showing solution direction and time stepping (positioned to avoid grid points)
if M >= 3 and N >= 3:
    # Simple directional arrow without text box
    ax.annotate('', xy=(M//2-2/3, 0.5), xytext=(M//2-2/3, N-0.5),
               arrowprops=dict(arrowstyle='->', lw=3, color='darkred'))
    ax.text(M//2-1/3, N//2, 'Solution\nDirection', rotation=-90, ha='center', va='center',
           fontsize=10, color='darkred', fontweight='bold')

# Frame and scale
ax.set_xlim(-1.2, M+1.2)
ax.set_ylim(-0.8, N+1.2)
ax.set_aspect('equal')

# Add subtle grid
ax.grid(True, alpha=0.2, linestyle=':', color='gray')

# Create legend with proper ordering
legend_order = ['initial', 'boundary_spatial', 'interior', 'computed_boundary', 'corner']
legend_elements = []
for key in legend_order:
    if key in legend_handles:
        style = point_styles[key]
        legend_elements.append(plt.scatter([], [], c=style['color'], marker=style['marker'], 
                                         s=style['size'], alpha=0.8, edgecolors='black', 
                                         linewidth=1, label=style['label']))

ax.legend(handles=legend_elements, loc='upper right',
         frameon=True, fancybox=True, shadow=True, fontsize=9)

# Enhanced title
title = f"Finite Difference Grid for Black-Scholes PDE\n"
title += f"Spatial Grid: {M+1} points, Time Grid: {N+1} points"
plt.title(title, fontsize=16, pad=20, fontweight='bold')

# Simplified explanation positioned outside the grid area
explanation = """Key Points:

• Red circles: Initial conditions at expiration (t=T) • Green squares: Spatial boundary conditions (S=0, S=Smax) • Blue triangles: Interior points solved by finite difference • Orange triangles: Time boundary at t=0 (computed) • Purple diamonds: Corner points Process: Start with payoff at expiration → solve backwards to today"""

ax.text(0.013, 0.99, explanation, transform=ax.transAxes, 
        verticalalignment='top', horizontalalignment='left',
        bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.9),
        fontsize=9)

plt.tight_layout()
plt.show()

return fig, ax

def numerical_pricing(S0: float, K: float, T: float, r: float, sigma: float, q: float = 0, method: str = 'crank_nicolson', n_space: int = 100, n_time: int = 100, option_type: str = 'put', early_exercise: bool = False, S_max: float = None) -> Tuple[np.ndarray, np.ndarray]: """ ENHANCED finite difference pricing with improved stability. NEW FUNCTION - doesn't break existing code. """ if S_max is None: S_max = 3 * max(S0, K)

# Create grids
S_grid = np.linspace(0, S_max, n_space)
dt = T / n_time
dS = S_grid[1] - S_grid[0]

# Initialize option values at maturity
if option_type.lower() == 'call':
    payoff = np.maximum(S_grid - K, 0)
else:
    payoff = np.maximum(K - S_grid, 0)

V = np.zeros((n_space, n_time + 1))
V[:, -1] = payoff

# Stability check for explicit method
if method.lower() == 'explicit':
    max_coeff = sigma**2 * S_max**2 * dt / dS**2
    if max_coeff > 1:
        warnings.warn(f"Explicit scheme may be unstable: max coefficient = {max_coeff:.3f} > 1")

# Time stepping (simplified implementation)
for n in range(n_time, 0, -1):
    for i in range(1, n_space - 1):
        S = S_grid[i]

        # Finite difference coefficients
        alpha = 0.5 * dt * (sigma**2 * S**2 / dS**2 - (r - q) * S / dS)
        beta = 1 - dt * (sigma**2 * S**2 / dS**2 + r)
        gamma = 0.5 * dt * (sigma**2 * S**2 / dS**2 + (r - q) * S / dS)

        V[i, n-1] = alpha * V[i-1, n] + beta * V[i, n] + gamma * V[i+1, n]

    # Boundary conditions
    tau = (n_time - n + 1) * dt
    discount = np.exp(-r * tau)

    if option_type.lower() == 'call':
        V[0, n-1] = 0
        V[-1, n-1] = S_grid[-1] - K * discount
    else:
        V[0, n-1] = K * discount
        V[-1, n-1] = 0

    # Early exercise
    if early_exercise:
        if option_type.lower() == 'call':
            V[:, n-1] = np.maximum(V[:, n-1], np.maximum(S_grid - K, 0))
        else:
            V[:, n-1] = np.maximum(V[:, n-1], np.maximum(K - S_grid, 0))

return S_grid, V[:, 0]

if name == "main": pass ```

Exercises

Exercise 1. Derive the formulas for \(d_1\) and \(d_2\) in the Black-Scholes model with continuous dividends. Show that \(d_1 - d_2 = \sigma\sqrt{T}\).

Solution to Exercise 1
\[ d_1 = \frac{\ln(S/K) + (r - q + \frac{1}{2}\sigma^2)T}{\sigma\sqrt{T}}, \quad d_2 = \frac{\ln(S/K) + (r - q - \frac{1}{2}\sigma^2)T}{\sigma\sqrt{T}} \]

Subtracting:

\[ d_1 - d_2 = \frac{(r - q + \frac{1}{2}\sigma^2)T - (r - q - \frac{1}{2}\sigma^2)T}{\sigma\sqrt{T}} = \frac{\sigma^2 T}{\sigma\sqrt{T}} = \sigma\sqrt{T} \]

Exercise 2. The implied_volatility function uses Newton-Raphson with vega as the derivative. Explain why vega is always positive, which ensures the BS price is monotonically increasing in \(\sigma\).

Solution to Exercise 2

Vega is \(\nu = S e^{-qT}\phi(d_1)\sqrt{T}\). Since \(S > 0\), \(e^{-qT} > 0\), \(\phi(d_1) > 0\) (Gaussian PDF is always positive), and \(\sqrt{T} > 0\), we have \(\nu > 0\) for all valid parameters.

Monotonicity of BS price in \(\sigma\) follows: \(\partial C/\partial\sigma = \nu > 0\), so the call price strictly increases with volatility. This guarantees that the implied volatility equation \(C_{\text{BS}}(\sigma) = C_{\text{market}}\) has a unique solution (assuming \(C_{\text{market}}\) is in the valid range), and Newton-Raphson converges from any reasonable starting point.


Exercise 3. The utility module includes GBM path simulation. Write the Euler-Maruyama discretization for GBM and the exact (log-normal) simulation. Which is more accurate per step?

Solution to Exercise 3

Euler-Maruyama: \(S_{t+\Delta t} = S_t + (r-q)S_t\Delta t + \sigma S_t\sqrt{\Delta t}\,Z\)

Exact simulation: \(S_{t+\Delta t} = S_t\exp\bigl((r-q-\frac{1}{2}\sigma^2)\Delta t + \sigma\sqrt{\Delta t}\,Z\bigr)\)

The exact method is second-order accurate (no discretization error in the SDE sense), while Euler-Maruyama has \(O(\Delta t)\) weak error and \(O(\sqrt{\Delta t})\) strong error. For GBM, the exact method is always preferred because it preserves positivity of \(S\) and has no time-stepping error.


Exercise 4. Explain the compute_batch_implied_volatility function. Why is batch computation useful for volatility surface construction?

Solution to Exercise 4

The function computes implied volatility for many (strike, maturity) pairs simultaneously. This is needed for volatility surface construction, where market prices are observed for a grid of strikes \(K_1, \ldots, K_m\) and maturities \(T_1, \ldots, T_n\).

Batch computation is efficient because: (1) the Newton-Raphson iterations can be vectorized, (2) initial guesses for neighboring strikes can be bootstrapped from already-computed values (warm starting), and (3) the resulting \(\sigma_{\text{imp}}(K, T)\) surface reveals market-implied information about future volatility dynamics, skewness, and kurtosis.