Skip to content

Black Scholes Wrapper

Background

Black Scholes Wrapper

Educational script demonstrating black scholes wrapper concepts.


Code

```python """ Black Scholes Wrapper

Educational script demonstrating black scholes wrapper concepts. """

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

black_scholes/black_scholes_wrapper.py

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

import matplotlib.pyplot as plt import numpy as np import scipy.stats as stats import time from .black_scholes_formula import BlackScholesFormula from .black_scholes_greeks import BlackScholesGreeks from .black_scholes_implied_vol import BlackScholesImpliedVol from .black_scholes_monte_carlo import BlackScholesMonteCarlo from .black_scholes_numerical import BlackScholesNumericalSolver from .black_scholes_utils import simulate_gbm_paths, draw_finite_difference_grid, theta, rho

class BlackScholes: """ A unified interface for all Black-Scholes option pricing functionality.

This wrapper combines analytical formulas, Greeks calculation, Monte Carlo simulation,
finite difference methods, and implied volatility computation into a single cohesive interface.

Attributes:
-----------
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 of the underlying asset
q : float
    Continuous dividend yield (default: 0)
formula : BlackScholesFormula
    Analytical pricing component
greeks : BlackScholesGreeks
    Greeks calculation component
monte_carlo : BlackScholesMonteCarlo
    Monte Carlo simulation component
numerical : BlackScholesNumericalSolver
    Finite difference solver component
implied_vol : BlackScholesImpliedVol
    Implied volatility component

Methods:
--------
price_analytical()
    Calculate option prices using analytical Black-Scholes formula
price_monte_carlo(num_paths=10000, **kwargs)
    Price options using Monte Carlo simulation
price_numerical(method='explicit', **kwargs)
    Price options using finite difference methods
calculate_greeks()
    Calculate all Greeks (delta, gamma, vega, theta, rho)
calculate_implied_volatility(market_price, option_type='call', **kwargs)
    Calculate implied volatility from market price
simulate_paths(num_paths=1000, num_steps=252, **kwargs)
    Simulate stock price paths using geometric Brownian motion
compare_methods(option_type='call', **kwargs)
    Compare prices across different methods
plot_convergence(**kwargs)
    Plot convergence analysis for numerical methods
"""

def __init__(self, S0, K, T, r, sigma, q=0):
    # Store parameters
    self.S0 = S0
    self.K = K
    self.T = T
    self.r = r
    self.sigma = sigma
    self.q = q

    # Initialize all components
    self.formula = BlackScholesFormula(S0, K, T, r, sigma, q)
    self.greeks = BlackScholesGreeks(S0, K, T, r, sigma, q)
    self.monte_carlo = BlackScholesMonteCarlo(S0, K, T, r, sigma, q)
    self.numerical = BlackScholesNumericalSolver(S0, K, T, r, sigma, q)
    self.implied_vol = BlackScholesImpliedVol(S0, K, T, r, sigma, q)

def price_analytical(self):
    """
    Calculate European option prices using analytical Black-Scholes formula.

    Returns:
    --------
    tuple: (call_price, put_price)
    """
    return self.formula.price()

def price_monte_carlo(self, num_paths=10000, steps_per_year=252, seed=None, 
                     plot_histogram=True, **kwargs):
    """
    Price options using Monte Carlo simulation.

    Parameters:
    -----------
    num_paths : int
        Number of simulation paths (default: 10000)
    steps_per_year : int
        Number of time steps per year (default: 252)
    seed : int, optional
        Random seed for reproducibility
    plot_histogram : bool
        Whether to plot histograms of option prices (default: True)
    **kwargs : dict
        Additional arguments passed to Monte Carlo pricer

    Returns:
    --------
    tuple: (call_price, put_price, call_price_std, put_price_std, 
            call_ci, put_ci, call_prices, put_prices)
    """
    return self.monte_carlo.price(
        num_paths=num_paths,
        steps_per_year=steps_per_year,
        seed=seed,
        plot_histogram=plot_histogram,
        **kwargs
    )

def price_numerical(self, method='explicit', option_type='put', **kwargs):
    """
    Price options using finite difference methods.

    Parameters:
    -----------
    method : str
        Numerical method: 'explicit', 'implicit', 'cn', 'explicit_log', 
        'implicit_log', 'cn_log' (default: 'explicit')
    option_type : str
        Option type: 'call' or 'put' (default: 'put')
    **kwargs : dict
        Additional arguments for the numerical solver

    Returns:
    --------
    tuple: (S_grid, option_values)
    """
    return self.numerical.solve(method=method, option_type=option_type, **kwargs)

def calculate_greeks(self):
    """
    Calculate all option Greeks using analytical formulas.

    Returns:
    --------
    dict: Dictionary containing all Greeks
        - delta_call, delta_put : Delta values
        - gamma : Gamma (same for calls and puts)
        - vega : Vega (same for calls and puts)
        - theta_call, theta_put : Theta values
        - rho_call, rho_put : Rho values
    """
    delta_call, delta_put = self.greeks.delta()
    gamma_val = self.greeks.gamma()
    vega_val = self.greeks.vega()
    theta_call, theta_put = theta(self.S0, self.K, self.T, self.r, self.sigma, self.q)
    rho_call, rho_put = rho(self.S0, self.K, self.T, self.r, self.sigma, self.q)

    return {
        'delta_call': delta_call,
        'delta_put': delta_put,
        'gamma': gamma_val,
        'vega': vega_val,
        'theta_call': theta_call,
        'theta_put': theta_put,
        'rho_call': rho_call,
        'rho_put': rho_put
    }

def calculate_implied_volatility(self, market_price, option_type='call', 
                               sigma_0=0.2, **kwargs):
    """
    Calculate implied volatility from market price.

    Parameters:
    -----------
    market_price : float
        Observed market price of the option
    option_type : str
        Option type: 'call' or 'put' (default: 'call')
    sigma_0 : float
        Initial guess for volatility (default: 0.2)
    **kwargs : dict
        Additional arguments for the implied volatility solver

    Returns:
    --------
    float: Implied volatility
    """
    return self.implied_vol.compute(
        market_price=market_price,
        sigma_0=sigma_0,
        option_type=option_type,
        **kwargs
    )

def simulate_paths(self, num_paths=1000, num_steps=252, risk_neutral=True, 
                  mu=None, seed=None, **kwargs):
    """
    Simulate stock price paths using geometric Brownian motion.

    Parameters:
    -----------
    num_paths : int
        Number of paths to simulate (default: 1000)
    num_steps : int
        Number of time steps (default: 252)
    risk_neutral : bool
        Whether to use risk-neutral drift (default: True)
    mu : float, optional
        Physical drift rate (used if risk_neutral=False)
    seed : int, optional
        Random seed for reproducibility
    **kwargs : dict
        Additional arguments for path simulation

    Returns:
    --------
    tuple: (time_grid, stock_paths)
    """
    return simulate_gbm_paths(
        S0=self.S0,
        T=self.T,
        r=self.r,
        sigma=self.sigma,
        num_paths=num_paths,
        num_steps=num_steps,
        mu=mu,
        risk_neutral=risk_neutral,
        seed=seed,
        **kwargs
    )

def compare_methods(self, option_type='call', mc_paths=50000, 
                   numerical_method='cn', **kwargs):
    """
    Compare option prices across different pricing methods.

    Parameters:
    -----------
    option_type : str
        Option type: 'call' or 'put' (default: 'call')
    mc_paths : int
        Number of Monte Carlo paths (default: 50000)
    numerical_method : str
        Numerical method for finite difference (default: 'cn')
    **kwargs : dict
        Additional arguments for numerical methods

    Returns:
    --------
    dict: Comparison results with prices from different methods
    """
    # Analytical price
    call_analytical, put_analytical = self.price_analytical()
    analytical_price = call_analytical if option_type == 'call' else put_analytical

    # Monte Carlo price
    mc_results = self.price_monte_carlo(
        num_paths=mc_paths, 
        plot_histogram=False
    )
    mc_price = mc_results[0] if option_type == 'call' else mc_results[1]
    mc_std = mc_results[2] if option_type == 'call' else mc_results[3]

    # Numerical price
    S_grid, option_values = self.price_numerical(
        method=numerical_method,
        option_type=option_type,
        **kwargs
    )
    # Find price at current stock price
    idx = np.argmin(np.abs(S_grid - self.S0))
    numerical_price = option_values[idx]

    return {
        'analytical': analytical_price,
        'monte_carlo': {
            'price': mc_price,
            'std_error': mc_std / np.sqrt(mc_paths),
            'confidence_interval': (mc_price - 1.96 * mc_std / np.sqrt(mc_paths),
                                   mc_price + 1.96 * mc_std / np.sqrt(mc_paths))
        },
        'numerical': numerical_price,
        'differences': {
            'mc_vs_analytical': abs(mc_price - analytical_price),
            'numerical_vs_analytical': abs(numerical_price - analytical_price),
            'mc_vs_numerical': abs(mc_price - numerical_price)
        }
    }

def plot_convergence(self, option_type='call', methods=['explicit', 'implicit', 'cn'],
                            grid_points=None, S_max=None, **kwargs):
    """
    Enhanced convergence plot with detailed analysis showing error vs step size.

    Parameters:
    -----------
    option_type : str
        Option type: 'call' or 'put' (default: 'call')
    methods : list
        List of numerical methods to compare
    grid_points : list, optional
        List of spatial grid points to test (default: comprehensive range)
    S_max : float, optional
        Maximum stock price for domain (default: 2*S0)
    **kwargs : dict
        Additional arguments for plotting (excluding 'grid_sizes')
    """
    # Remove 'grid_sizes' from kwargs if present (to avoid conflicts)
    clean_kwargs = {k: v for k, v in kwargs.items() if k != 'grid_sizes'}

    # Default comprehensive range of grid points
    if grid_points is None:
        grid_points = [25, 50, 75, 100, 150, 200, 300, 400, 600, 800, 1000]

    # Set default S_max if not provided
    if S_max is None:
        S_max = 2 * self.S0

    # Get analytical benchmark
    call_analytical, put_analytical = self.price_analytical()
    benchmark = call_analytical if option_type == 'call' else put_analytical

    # Create subplot for detailed analysis
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(18, 8))

    # Add supertitle
    fig.suptitle(f'Black-Scholes PDE Numerical Methods Convergence Analysis\n'
                f'S₀={self.S0}, K={self.K}, T={self.T}, r={self.r:.1%}, σ={self.sigma:.1%}, q={self.q:.1%}\n'
                f'Analytical {option_type}: {benchmark:.6f}, Domain: S ∈ [0, {S_max}]',
                fontsize=14, fontweight='bold', y=0.95)

    # Colors and styles
    colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b']
    linestyles = ['-', '--', '-.', ':', '-', '--']
    markers = ['o', 's', '^', 'D', 'v', 'P']

    all_errors = {}

    for i, method in enumerate(methods):
        errors = []
        step_sizes = []
        valid_grid_points = []
        computation_times = []

        for num_points in grid_points:
            try:
                start_time = time.time()

                # Use ORIGINAL numerical methods with ORIGINAL defaults
                if method.lower() == 'explicit':
                    S_grid, option_values = self.numerical.explicit(
                        option_type=option_type, 
                        Smin=0, Smax=S_max,  # ORIGINAL defaults
                        NS=num_points,
                        **clean_kwargs
                    )
                elif method.lower() == 'implicit':
                    S_grid, option_values = self.numerical.implicit(
                        option_type=option_type,
                        Smin=1e-3, Smax=S_max,  # ORIGINAL defaults 
                        NS=num_points,
                        **clean_kwargs
                    )
                elif method.lower() == 'cn':
                    S_grid, option_values = self.numerical.cn(
                        option_type=option_type,
                        Smin=0, Smax=S_max,  # ORIGINAL defaults
                        NS=num_points,
                        **clean_kwargs
                    )

                computation_time = time.time() - start_time

                # Find price at current stock price
                idx = np.argmin(np.abs(S_grid - self.S0))
                numerical_price = option_values[idx]
                error = abs(numerical_price - benchmark)

                # Calculate spatial step size
                ds = S_max / (num_points - 1)

                errors.append(error)
                step_sizes.append(ds)
                valid_grid_points.append(num_points)
                computation_times.append(computation_time)

            except Exception as e:
                print(f"Error with {method} at {num_points} grid points: {e}")
                continue

        if errors:
            all_errors[method] = (valid_grid_points, errors, computation_times, step_sizes)

            # Plot 1: Error vs Spatial Step Size (loglog)
            ax1.loglog(step_sizes, errors, 
                        marker=markers[i % len(markers)], 
                        color=colors[i % len(colors)],
                        linestyle=linestyles[i % len(linestyles)],
                        label=f'{method.upper()}', 
                        linewidth=2.5, 
                        markersize=8,
                        markerfacecolor='white',
                        markeredgewidth=2,
                        alpha=0.85)

            # Plot 2: Error vs Computation Time (loglog)
            ax2.loglog(computation_times, errors,
                    marker=markers[i % len(markers)], 
                    color=colors[i % len(colors)],
                    linestyle=linestyles[i % len(linestyles)],
                    label=f'{method.upper()}', 
                    linewidth=2.5, 
                    markersize=8,
                    markerfacecolor='white',
                    markeredgewidth=2,
                    alpha=0.85)

    # Get shared y-axis limits
    if all_errors:
        all_error_values = [error for _, errors, _, _ in all_errors.values() for error in errors]
        y_min = min(all_error_values) * 0.5
        y_max = max(all_error_values) * 2.0

        # Apply same y-axis limits to both plots
        ax1.set_ylim(y_min, y_max)
        ax2.set_ylim(y_min, y_max)

    # Calculate all step sizes for formatting
    all_step_sizes = [S_max / (n - 1) for n in grid_points]

    # Format Plot 1 (Error vs Spatial Step Size) - loglog
    ax1.set_xlim(min(all_step_sizes) * 0.8, max(all_step_sizes) * 1.2)

    # Format Plot 2 (Error vs Computation Time) - loglog
    ax2.set_xlabel('Computation Time (seconds)', fontsize=14, fontweight='bold')
    # Remove y-axis label for ax2 since it shares scale with ax1
    ax2.set_title('Efficiency Analysis\n(Error vs Computation Time)', fontsize=14, fontweight='bold')

    # Formatting for both plots
    for ax in [ax1, ax2]:
        ax.grid(True, alpha=0.3, linestyle='-', linewidth=0.5)
        ax.grid(True, which='minor', alpha=0.15, linestyle='-', linewidth=0.3)
        ax.tick_params(axis='both', which='major', labelsize=11, width=1.5, length=6)
        ax.tick_params(axis='both', which='minor', labelsize=9, width=1, length=3)
        ax.minorticks_on()

        legend = ax.legend(loc='best', frameon=True, fancybox=True, 
                        shadow=True, fontsize=10)
        legend.get_frame().set_facecolor('white')
        legend.get_frame().set_alpha(0.9)

    # Plot 1 specific formatting
    ax1.set_xlabel('Spatial Step Size (Δs)', fontsize=14, fontweight='bold')
    ax1.set_ylabel('Absolute Error vs Analytical Solution', fontsize=14, fontweight='bold')
    ax1.set_title('Convergence Analysis\n(Error vs Spatial Step Size)', fontsize=14, fontweight='bold')

    plt.tight_layout()
    plt.show()

    # Print numerical results with step size focus
    print("\n" + "="*80)
    print("CONVERGENCE ANALYSIS RESULTS (Error vs Step Size)")
    print("="*80)

    for method, (points, errors, times, step_sizes) in all_errors.items():
        print(f"\n{method.upper()} Method:")
        print(f"{'Grid Points':<12} {'Δs':<10} {'Error':<12} {'Time (s)':<10} {'Error Ratio':<12}")
        print("-" * 70)

        for i, (num_points, ds, error, exec_time) in enumerate(zip(points, step_sizes, errors, times)):
            if i > 0:
                error_ratio = errors[i-1] / error
            else:
                error_ratio = float('inf')

            print(f"{num_points:<12} {ds:<10.4f} {error:<12.2e} {exec_time:<10.4f} {error_ratio:<12.2f}")

    print(f"\nNote: Δs = Spatial step size = S_max/(N-1)")
    print(f"      Error should scale as O(Δs) for first-order methods or O(Δs²) for second-order methods")
    print(f"      Smaller Δs → better accuracy (until round-off errors dominate)")

def plot_finite_difference_grid(self, M=5, N=5):
    """
    Visualize the finite difference grid structure.

    Parameters:
    -----------
    M : int
        Number of spatial steps (default: 5)
    N : int
        Number of time steps (default: 5)
    """
    draw_finite_difference_grid(M=M, N=N)

def _plot_single_gbm(self, ax, num_paths=1000, num_steps=252, 
                 max_paths_display=50, risk_neutral=True, 
                 mu=None, seed=None, title=None, show_stats=True,
                 show_histogram=True, show_theoretical_pdf=True):
    """
    Core plotting logic for a single GBM simulation.

    Parameters:
    -----------
    ax : matplotlib.axes.Axes
        Axis to plot on
    num_paths : int
        Number of paths to simulate
    num_steps : int
        Number of time steps
    max_paths_display : int
        Maximum number of paths to display
    risk_neutral : bool
        Whether to use risk-neutral drift
    mu : float, optional
        Physical drift rate (used if risk_neutral=False)
    seed : int, optional
        Random seed for reproducibility
    title : str, optional
        Plot title
    show_stats : bool
        Whether to show statistics text box
    show_histogram : bool
        Whether to show histogram
    show_theoretical_pdf : bool
        Whether to show theoretical PDF

    Returns:
    --------
    dict: Contains final_prices, mean_final, std_final, theoretical_mean
    """
    # Simulate paths
    t, S_paths = self.simulate_paths(
        num_paths=num_paths,
        num_steps=num_steps,
        risk_neutral=risk_neutral,
        mu=mu,
        seed=seed
    )

    # 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 = self.r if risk_neutral else (mu if mu is not None else self.r)
    theoretical_mean = self.S0 * np.exp(drift_rate * self.T)

    # Add expected value line
    ax.axhline(y=self.S0, color='red', linestyle='--', alpha=0.8, 
            label=f'S₀ = {self.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 = self.T * 0.05
        hist_position = self.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(self.S0) + (drift_rate - 0.5 * self.sigma**2) * self.T
            sigma_ln = self.sigma * np.sqrt(self.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, self.T * 1.15)
    else:
        ax.set_xlim(0, self.T)

    # Add statistics text if requested
    if show_stats:
        stats_text = f"""Simulation Statistics:
Initial Stock Price: {self.S0:.2f}
Strike:  {self.K:.2f}
Maturity: {self.T:.2f}
Interest Rate: {self.r:.2f}
Volatility: {self.sigma:.2f}
Dividend Yield: {self.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 plot_paths_and_histogram(self, num_paths=1000, num_steps=252, 
                            max_paths_display=50, risk_neutral=True, 
                            mu=None, seed=None, figsize=(12, 8), **kwargs):
    """
    Plot GBM paths with final price distribution and theoretical lognormal PDF.

    Parameters:
    -----------
    num_paths : int
        Number of paths to simulate (default: 1000)
    num_steps : int
        Number of time steps (default: 252)
    max_paths_display : int
        Maximum number of paths to display (default: 50)
    risk_neutral : bool
        Whether to use risk-neutral drift (default: True)
    mu : float, optional
        Physical drift rate (used if risk_neutral=False)
    seed : int, optional
        Random seed for reproducibility
    figsize : tuple
        Figure size (default: (12, 8))
    **kwargs : dict
        Additional arguments passed to _plot_single_gbm

    Returns:
    --------
    fig, ax : matplotlib objects
        Figure and axis objects
    """        
    fig, ax = plt.subplots(figsize=figsize)

    # Call core plotting method
    self._plot_single_gbm(
        ax=ax,
        num_paths=num_paths,
        num_steps=num_steps,
        max_paths_display=max_paths_display,
        risk_neutral=risk_neutral,
        mu=mu,
        seed=seed,
        **kwargs
    )

    plt.tight_layout()
    plt.show()

    return fig, ax

def plot_gbm_comparison(self, mu, num_paths=1000, num_steps=252, 
                    max_paths_display=30, seed=42, **kwargs):
    """
    Compare risk-neutral vs real-world GBM simulations side by side.

    Parameters:
    -----------
    mu : float
        Real-world drift rate
    num_paths : int
        Number of paths to simulate (default: 1000)
    num_steps : int
        Number of time steps (default: 252)
    max_paths_display : int
        Max paths to display (default: 30)
    seed : int
        Random seed (default: 42)
    **kwargs : dict
        Additional arguments passed to _plot_single_gbm

    Returns:
    --------
    fig, (ax1, ax2) : matplotlib objects
        Figure and axis objects
    """
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6), sharey=True)

    # Common parameters for both plots
    common_params = {
        'num_paths': num_paths,
        'num_steps': num_steps,
        'max_paths_display': max_paths_display,
        'show_stats': False,  # Don't show stats box in comparison view
        **kwargs
    }

    # Risk-neutral simulation
    self._plot_single_gbm(
        ax=ax1,
        risk_neutral=True,
        seed=seed,
        title=f'Risk-Neutral GBM (drift = {self.r:.1%})',
        **common_params
    )

    # Real-world simulation
    self._plot_single_gbm(
        ax=ax2,
        risk_neutral=False,
        mu=mu,
        seed=seed+1,  # Different seed for different paths
        title=f'Real-World GBM (drift = {mu:.1%})',
        **common_params
    )

    plt.tight_layout()
    plt.show()

    return fig, (ax1, ax2)

# Properties for easy access to parameters (backward compatibility)
@property
def spot_price(self):
    """Current stock price"""
    return self.S0

@property
def strike_price(self):
    """Strike price"""
    return self.K

@property
def time_to_maturity(self):
    """Time to maturity"""
    return self.T

@property
def risk_free_rate(self):
    """Risk-free interest rate"""
    return self.r

@property
def volatility(self):
    """Volatility"""
    return self.sigma

@property
def dividend_yield(self):
    """Dividend yield"""
    return self.q

def __repr__(self):
    """String representation of the model"""
    return (f"BlackScholes(S0={self.S0}, K={self.K}, T={self.T}, "
            f"r={self.r}, sigma={self.sigma}, q={self.q})")

def summary(self):
    """Print a comprehensive summary of the model and calculated prices"""
    print(f"{'='*80}")
    print("BLACK-SCHOLES MODEL SUMMARY")
    print(f"{'='*80}")
    print(f"Parameters:")
    print(f"  Spot Price (S0):      {self.S0:>8.2f}")
    print(f"  Strike Price (K):     {self.K:>8.2f}")
    print(f"  Time to Maturity (T): {self.T:>8.2f}")
    print(f"  Risk-free Rate (r):   {self.r:>8.2f}")
    print(f"  Volatility (σ):       {self.sigma:>8.2f}")
    print(f"  Dividend Yield (q):   {self.q:>8.2f}")
    print()
    print(f"Moneyness:              {self.S0/self.K:8.2f}")

    # Calculate and display analytical prices
    call_price, put_price = self.price_analytical()
    print(f"\nAnalytical Prices:")
    print(f"  Call Option:          {call_price:>8.2f}")
    print(f"  Put Option:           {put_price:>8.2f}")

    # Calculate and display Greeks
    greeks = self.calculate_greeks()
    print(f"\nGreeks:")
    print(f"  Delta (Call/Put):     {greeks['delta_call']:>10.4f} / {greeks['delta_put']:>10.4f}")
    print(f"  Gamma:                {greeks['gamma']:>10.4f}")
    print(f"  Vega:                 {greeks['vega']:>10.4f}")
    print(f"  Theta (Call/Put):     {greeks['theta_call']:>10.4f} / {greeks['theta_put']:>10.4f}")
    print(f"  Rho (Call/Put):       {greeks['rho_call']:>10.4f} / {greeks['rho_put']:>10.4f}")
    print(f"{'='*80}")

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

Exercises

Exercise 1. The BlackScholes wrapper unifies formula, Greeks, Monte Carlo, numerical PDE, and implied volatility into one interface. Describe the design pattern used and its advantages.

Solution to Exercise 1

The wrapper uses the Facade pattern: it provides a simplified, unified interface to a complex subsystem of classes. Internally, it delegates to BlackScholesFormula, BlackScholesGreeks, BlackScholesMonteCarlo, BlackScholesNumericalSolver, and BlackScholesImpliedVol.

Advantages: (1) Users interact with one class instead of five. (2) Method names are consistent and discoverable. (3) Cross-method comparisons (e.g., analytical vs MC vs PDE) are easy. (4) Parameter passing is centralized -- no risk of inconsistent parameters across methods.


Exercise 2. Write a code snippet using the BlackScholes wrapper that prices a European call using all three methods (formula, MC, PDE) and compares the results.

Solution to Exercise 2

```python bs = BlackScholes(S0=100, K=100, T=1, r=0.05, sigma=0.2) c_formula, _ = bs.price() c_mc, _ = bs.monte_carlo_price(num_paths=100000, seed=42) S_grid, V_grid = bs.numerical_price(method="cn")

Interpolate PDE result at S0

import numpy as np c_pde = np.interp(100, S_grid, V_grid[:, 0]) print(f"Formula: {c_formula:.4f}") print(f"MC: {c_mc:.4f}") print(f"PDE: {c_pde:.4f}") ```

All three should agree to within their respective numerical tolerances.


Exercise 3. Explain why the wrapper maintains backward compatibility by supporting both "enhanced" and "legacy" MC modes. When would a user choose the legacy mode?

Solution to Exercise 3

Backward compatibility ensures that existing code continues to work after upgrading. The legacy mode reproduces the original MC implementation (without variance reduction), which is useful for:

  1. Education: Understanding basic MC before learning variance reduction.
  2. Debugging: Isolating whether a discrepancy comes from variance reduction or the base MC implementation.
  3. Benchmarking: Quantifying the variance reduction ratio by comparing enhanced vs legacy.
  4. Reproducibility: Matching results from earlier versions of the code for published work.

Exercise 4. The wrapper includes plot_paths_and_histogram for GBM visualization. Describe what this plot shows and how it connects the SDE dynamics to the option pricing framework.

Solution to Exercise 4

The plot shows: (left) sample GBM paths \(S_t\) for \(t \in [0, T]\), and (right) a histogram of terminal values \(S_T\) overlaid with the log-normal density.

This connects SDE dynamics to pricing because: the option price is the discounted expected payoff \(e^{-rT}E[\max(S_T - K, 0)]\), which is an integral over the terminal distribution. The histogram approximates this distribution from simulated paths, and the theoretical log-normal density confirms the simulation is correct. The strike \(K\) can be marked on the histogram to visualize which paths contribute to the payoff (those with \(S_T > K\)).