Skip to content

Analysis API

PortfolioAnalysis

Analyze a weighted portfolio of assets.

Parameters:

Name Type Description Default
data DataFrame

Historical price data with datetime index

required
weights list of float

Portfolio weights (must sum to 1.0)

required

Examples:

>>> portfolio = PortfolioAnalysis(data, [0.6, 0.4])
>>> print(f"Return: {portfolio.calculate_portfolio_return():.2%}")
>>> print(f"Volatility: {portfolio.calculate_portfolio_volatility():.2%}")
>>> print(f"Sharpe: {portfolio.calculate_portfolio_sharpe_ratio():.2f}")
Source code in portfolio_analysis/analysis/portfolio.py
class PortfolioAnalysis:
    """
    Analyze a weighted portfolio of assets.

    Parameters
    ----------
    data : pd.DataFrame
        Historical price data with datetime index
    weights : list of float
        Portfolio weights (must sum to 1.0)

    Examples
    --------
    >>> portfolio = PortfolioAnalysis(data, [0.6, 0.4])
    >>> print(f"Return: {portfolio.calculate_portfolio_return():.2%}")
    >>> print(f"Volatility: {portfolio.calculate_portfolio_volatility():.2%}")
    >>> print(f"Sharpe: {portfolio.calculate_portfolio_sharpe_ratio():.2f}")
    """

    TRADING_DAYS = 252

    def __init__(self, data: pd.DataFrame, weights: list[float]):
        self.data = data
        self.weights = np.array(weights)

        if len(weights) != len(data.columns):
            raise ValueError(
                f"Weights length ({len(weights)}) must match "
                f"number of assets ({len(data.columns)})"
            )

        if not np.isclose(sum(weights), 1.0):
            raise ValueError(f"Weights must sum to 1.0, got {sum(weights):.4f}")

    def calculate_portfolio_return(self) -> float:
        """
        Calculate annualized portfolio return.

        Returns
        -------
        float
            Annualized portfolio return
        """
        returns = self.data.pct_change().mean()
        portfolio_return = np.dot(self.weights, returns) * self.TRADING_DAYS
        return portfolio_return

    def calculate_portfolio_volatility(self) -> float:
        """
        Calculate annualized portfolio volatility.

        Uses the covariance matrix to account for asset correlations.

        Returns
        -------
        float
            Annualized portfolio volatility (standard deviation)
        """
        returns = self.data.pct_change()
        covariance_matrix = returns.cov() * self.TRADING_DAYS
        portfolio_volatility = np.sqrt(
            np.dot(self.weights.T, np.dot(covariance_matrix, self.weights))
        )
        return portfolio_volatility

    def calculate_portfolio_sharpe_ratio(self, risk_free_rate: float = 0.02) -> float:
        """
        Calculate portfolio Sharpe ratio.

        Parameters
        ----------
        risk_free_rate : float, default 0.02
            Annual risk-free rate

        Returns
        -------
        float
            Sharpe ratio
        """
        portfolio_return = self.calculate_portfolio_return()
        portfolio_volatility = self.calculate_portfolio_volatility()
        sharpe_ratio = (portfolio_return - risk_free_rate) / portfolio_volatility
        return sharpe_ratio

    def calculate_portfolio_returns(self) -> pd.Series:
        """
        Calculate daily portfolio returns.

        Returns
        -------
        pd.Series
            Daily weighted portfolio returns
        """
        returns = self.data.pct_change().dropna()
        return returns.dot(self.weights)

    def calculate_cumulative_returns(self) -> pd.Series:
        """
        Calculate cumulative portfolio returns.

        Returns
        -------
        pd.Series
            Cumulative returns (growth of $1)
        """
        returns = self.calculate_portfolio_returns()
        return (1 + returns).cumprod()

    def calculate_max_drawdown(self) -> float:
        """
        Calculate portfolio maximum drawdown.

        Returns
        -------
        float
            Maximum drawdown (negative value)
        """
        cumulative = self.calculate_cumulative_returns()
        peak = cumulative.expanding(min_periods=1).max()
        drawdown = (cumulative / peak) - 1
        return drawdown.min()

    def calculate_sortino_ratio(self, risk_free_rate: float = 0.02) -> float:
        """
        Calculate portfolio Sortino ratio.

        Parameters
        ----------
        risk_free_rate : float, default 0.02
            Annual risk-free rate

        Returns
        -------
        float
            Sortino ratio
        """
        returns = self.calculate_portfolio_returns()
        downside_returns = returns[returns < 0]
        downside_deviation = downside_returns.std() * np.sqrt(self.TRADING_DAYS)

        portfolio_return = self.calculate_portfolio_return()
        return (portfolio_return - risk_free_rate) / downside_deviation

    def get_summary(self, risk_free_rate: float = 0.02) -> dict:
        """
        Get all portfolio metrics as a dictionary.

        Parameters
        ----------
        risk_free_rate : float, default 0.02
            Annual risk-free rate

        Returns
        -------
        dict
            Dictionary of portfolio metrics
        """
        return {
            "annual_return": self.calculate_portfolio_return(),
            "annual_volatility": self.calculate_portfolio_volatility(),
            "sharpe_ratio": self.calculate_portfolio_sharpe_ratio(risk_free_rate),
            "sortino_ratio": self.calculate_sortino_ratio(risk_free_rate),
            "max_drawdown": self.calculate_max_drawdown(),
        }

    def print_summary(self, risk_free_rate: float = 0.02) -> None:
        """Print a formatted summary of portfolio metrics."""
        summary = self.get_summary(risk_free_rate)

        print("\n" + "=" * 40)
        print("PORTFOLIO SUMMARY")
        print("=" * 40)
        print(f"Annual Return:     {summary['annual_return']*100:>10.2f}%")
        print(f"Annual Volatility: {summary['annual_volatility']*100:>10.2f}%")
        print(f"Sharpe Ratio:      {summary['sharpe_ratio']:>10.2f}")
        print(f"Sortino Ratio:     {summary['sortino_ratio']:>10.2f}")
        print(f"Max Drawdown:      {summary['max_drawdown']*100:>10.2f}%")
        print("=" * 40)

calculate_portfolio_return()

Calculate annualized portfolio return.

Returns:

Type Description
float

Annualized portfolio return

Source code in portfolio_analysis/analysis/portfolio.py
def calculate_portfolio_return(self) -> float:
    """
    Calculate annualized portfolio return.

    Returns
    -------
    float
        Annualized portfolio return
    """
    returns = self.data.pct_change().mean()
    portfolio_return = np.dot(self.weights, returns) * self.TRADING_DAYS
    return portfolio_return

calculate_portfolio_volatility()

Calculate annualized portfolio volatility.

Uses the covariance matrix to account for asset correlations.

Returns:

Type Description
float

Annualized portfolio volatility (standard deviation)

Source code in portfolio_analysis/analysis/portfolio.py
def calculate_portfolio_volatility(self) -> float:
    """
    Calculate annualized portfolio volatility.

    Uses the covariance matrix to account for asset correlations.

    Returns
    -------
    float
        Annualized portfolio volatility (standard deviation)
    """
    returns = self.data.pct_change()
    covariance_matrix = returns.cov() * self.TRADING_DAYS
    portfolio_volatility = np.sqrt(
        np.dot(self.weights.T, np.dot(covariance_matrix, self.weights))
    )
    return portfolio_volatility

calculate_portfolio_sharpe_ratio(risk_free_rate=0.02)

Calculate portfolio Sharpe ratio.

Parameters:

Name Type Description Default
risk_free_rate float

Annual risk-free rate

0.02

Returns:

Type Description
float

Sharpe ratio

Source code in portfolio_analysis/analysis/portfolio.py
def calculate_portfolio_sharpe_ratio(self, risk_free_rate: float = 0.02) -> float:
    """
    Calculate portfolio Sharpe ratio.

    Parameters
    ----------
    risk_free_rate : float, default 0.02
        Annual risk-free rate

    Returns
    -------
    float
        Sharpe ratio
    """
    portfolio_return = self.calculate_portfolio_return()
    portfolio_volatility = self.calculate_portfolio_volatility()
    sharpe_ratio = (portfolio_return - risk_free_rate) / portfolio_volatility
    return sharpe_ratio

calculate_portfolio_returns()

Calculate daily portfolio returns.

Returns:

Type Description
Series

Daily weighted portfolio returns

Source code in portfolio_analysis/analysis/portfolio.py
def calculate_portfolio_returns(self) -> pd.Series:
    """
    Calculate daily portfolio returns.

    Returns
    -------
    pd.Series
        Daily weighted portfolio returns
    """
    returns = self.data.pct_change().dropna()
    return returns.dot(self.weights)

calculate_cumulative_returns()

Calculate cumulative portfolio returns.

Returns:

Type Description
Series

Cumulative returns (growth of $1)

Source code in portfolio_analysis/analysis/portfolio.py
def calculate_cumulative_returns(self) -> pd.Series:
    """
    Calculate cumulative portfolio returns.

    Returns
    -------
    pd.Series
        Cumulative returns (growth of $1)
    """
    returns = self.calculate_portfolio_returns()
    return (1 + returns).cumprod()

calculate_max_drawdown()

Calculate portfolio maximum drawdown.

Returns:

Type Description
float

Maximum drawdown (negative value)

Source code in portfolio_analysis/analysis/portfolio.py
def calculate_max_drawdown(self) -> float:
    """
    Calculate portfolio maximum drawdown.

    Returns
    -------
    float
        Maximum drawdown (negative value)
    """
    cumulative = self.calculate_cumulative_returns()
    peak = cumulative.expanding(min_periods=1).max()
    drawdown = (cumulative / peak) - 1
    return drawdown.min()

calculate_sortino_ratio(risk_free_rate=0.02)

Calculate portfolio Sortino ratio.

Parameters:

Name Type Description Default
risk_free_rate float

Annual risk-free rate

0.02

Returns:

Type Description
float

Sortino ratio

Source code in portfolio_analysis/analysis/portfolio.py
def calculate_sortino_ratio(self, risk_free_rate: float = 0.02) -> float:
    """
    Calculate portfolio Sortino ratio.

    Parameters
    ----------
    risk_free_rate : float, default 0.02
        Annual risk-free rate

    Returns
    -------
    float
        Sortino ratio
    """
    returns = self.calculate_portfolio_returns()
    downside_returns = returns[returns < 0]
    downside_deviation = downside_returns.std() * np.sqrt(self.TRADING_DAYS)

    portfolio_return = self.calculate_portfolio_return()
    return (portfolio_return - risk_free_rate) / downside_deviation

get_summary(risk_free_rate=0.02)

Get all portfolio metrics as a dictionary.

Parameters:

Name Type Description Default
risk_free_rate float

Annual risk-free rate

0.02

Returns:

Type Description
dict

Dictionary of portfolio metrics

Source code in portfolio_analysis/analysis/portfolio.py
def get_summary(self, risk_free_rate: float = 0.02) -> dict:
    """
    Get all portfolio metrics as a dictionary.

    Parameters
    ----------
    risk_free_rate : float, default 0.02
        Annual risk-free rate

    Returns
    -------
    dict
        Dictionary of portfolio metrics
    """
    return {
        "annual_return": self.calculate_portfolio_return(),
        "annual_volatility": self.calculate_portfolio_volatility(),
        "sharpe_ratio": self.calculate_portfolio_sharpe_ratio(risk_free_rate),
        "sortino_ratio": self.calculate_sortino_ratio(risk_free_rate),
        "max_drawdown": self.calculate_max_drawdown(),
    }

print_summary(risk_free_rate=0.02)

Print a formatted summary of portfolio metrics.

Source code in portfolio_analysis/analysis/portfolio.py
def print_summary(self, risk_free_rate: float = 0.02) -> None:
    """Print a formatted summary of portfolio metrics."""
    summary = self.get_summary(risk_free_rate)

    print("\n" + "=" * 40)
    print("PORTFOLIO SUMMARY")
    print("=" * 40)
    print(f"Annual Return:     {summary['annual_return']*100:>10.2f}%")
    print(f"Annual Volatility: {summary['annual_volatility']*100:>10.2f}%")
    print(f"Sharpe Ratio:      {summary['sharpe_ratio']:>10.2f}")
    print(f"Sortino Ratio:     {summary['sortino_ratio']:>10.2f}")
    print(f"Max Drawdown:      {summary['max_drawdown']*100:>10.2f}%")
    print("=" * 40)

MonteCarloSimulation

Monte Carlo simulation for portfolio performance projection.

Simulates multiple future paths for a portfolio based on historical return distributions, accounting for asset correlations.

Parameters:

Name Type Description Default
data DataFrame

Historical price data for portfolio assets

required
weights array - like

Portfolio weights (must sum to 1.0)

required
num_simulations int

Number of simulation paths to generate

1000
time_horizon int

Number of trading days to simulate (252 = 1 year)

252
initial_investment float

Starting portfolio value

10000

Examples:

>>> mc = MonteCarloSimulation(data, weights, num_simulations=1000, time_horizon=252)
>>> mc.simulate()
>>> mc.print_summary()
>>> mc.plot_simulation()
Source code in portfolio_analysis/analysis/montecarlo.py
class MonteCarloSimulation:
    """
    Monte Carlo simulation for portfolio performance projection.

    Simulates multiple future paths for a portfolio based on historical
    return distributions, accounting for asset correlations.

    Parameters
    ----------
    data : pd.DataFrame
        Historical price data for portfolio assets
    weights : array-like
        Portfolio weights (must sum to 1.0)
    num_simulations : int, default 1000
        Number of simulation paths to generate
    time_horizon : int, default 252
        Number of trading days to simulate (252 = 1 year)
    initial_investment : float, default 10000
        Starting portfolio value

    Examples
    --------
    >>> mc = MonteCarloSimulation(data, weights, num_simulations=1000, time_horizon=252)
    >>> mc.simulate()
    >>> mc.print_summary()
    >>> mc.plot_simulation()
    """

    def __init__(
        self,
        data: pd.DataFrame,
        weights: list[float],
        num_simulations: int = DEFAULT_NUM_SIMULATIONS,
        time_horizon: int = DEFAULT_PROJECTION_DAYS,
        initial_investment: float = 10000,
    ):
        self.data = data
        self.weights = np.array(weights)
        self.num_simulations = num_simulations
        self.time_horizon = time_horizon
        self.initial_investment = initial_investment

        # Validate weights
        if len(self.weights) != len(data.columns):
            raise ValidationError(
                f"Number of weights ({len(self.weights)}) must match "
                f"number of assets ({len(data.columns)})"
            )
        if not np.isclose(sum(self.weights), 1.0, atol=WEIGHT_SUM_TOLERANCE):
            raise ValidationError(
                f"Weights must sum to 1.0, got {sum(self.weights):.6f}"
            )

        self._results = None

    def simulate(self) -> np.ndarray:
        """
        Run Monte Carlo simulation.

        Returns
        -------
        np.ndarray
            Array of shape (num_simulations, time_horizon) containing
            portfolio values for each simulation path over time.
        """
        returns = self.data.pct_change().dropna()
        mean_returns = returns.mean().values
        cov_matrix = returns.cov().values

        results = np.zeros((self.num_simulations, self.time_horizon))

        for i in range(self.num_simulations):
            # Generate correlated random returns for all assets
            sim_returns = np.random.multivariate_normal(
                mean_returns, cov_matrix, self.time_horizon
            )

            # Calculate weighted portfolio returns at each time step
            portfolio_returns = sim_returns @ self.weights

            # Track cumulative portfolio value over time
            cumulative_returns = np.cumprod(1 + portfolio_returns)
            results[i, :] = self.initial_investment * cumulative_returns

        self._results = results
        return results

    def get_statistics(self, percentiles: list[int] = [5, 25, 50, 75, 95]) -> dict:
        """
        Calculate statistics across all simulation paths.

        Parameters
        ----------
        percentiles : list of int
            Percentiles to calculate

        Returns
        -------
        dict
            Dictionary containing percentiles, mean, std, and final value statistics
        """
        if self._results is None:
            self.simulate()

        results = self._results
        final_values = results[:, -1]

        return {
            "percentiles": {p: np.percentile(results, p, axis=0) for p in percentiles},
            "mean": np.mean(results, axis=0),
            "std": np.std(results, axis=0),
            "final_values": {
                "mean": np.mean(final_values),
                "median": np.median(final_values),
                "std": np.std(final_values),
                "min": np.min(final_values),
                "max": np.max(final_values),
                "percentile_5": np.percentile(final_values, 5),
                "percentile_95": np.percentile(final_values, 95),
                "prob_loss": np.mean(final_values < self.initial_investment) * 100,
            },
        }

    def plot_simulation(
        self,
        show_percentiles: bool = True,
        num_paths: int = 100,
        ax: Optional[plt.Axes] = None,
        show: bool = True,
    ) -> plt.Axes:
        """
        Plot Monte Carlo simulation results with percentile bands.

        Parameters
        ----------
        show_percentiles : bool, default True
            Whether to show percentile bands
        num_paths : int, default 100
            Number of individual paths to plot
        ax : matplotlib.axes.Axes, optional
            Axes to plot on
        show : bool, default True
            Whether to display the plot. Set to False for automated/server contexts.
            Only applies when ax is None.

        Returns
        -------
        matplotlib.axes.Axes
            The axes with the plot
        """
        if self._results is None:
            self.simulate()

        results = self._results
        stats = self.get_statistics()

        created_fig = False
        if ax is None:
            fig, ax = plt.subplots(figsize=(12, 7))
            created_fig = True

        # Plot a subset of individual paths
        paths_to_plot = min(num_paths, self.num_simulations)
        for i in range(paths_to_plot):
            ax.plot(results[i, :], color="lightblue", alpha=0.3, linewidth=0.5)

        if show_percentiles:
            days = np.arange(self.time_horizon)

            # Plot percentile bands
            ax.fill_between(
                days,
                stats["percentiles"][5],
                stats["percentiles"][95],
                color="blue",
                alpha=0.2,
                label="5th-95th percentile",
            )
            ax.fill_between(
                days,
                stats["percentiles"][25],
                stats["percentiles"][75],
                color="blue",
                alpha=0.3,
                label="25th-75th percentile",
            )

            # Plot median
            ax.plot(
                stats["percentiles"][50],
                color="darkblue",
                linewidth=2,
                label="Median (50th percentile)",
            )

        # Plot initial investment line
        ax.axhline(
            y=self.initial_investment,
            color="red",
            linestyle="--",
            linewidth=1,
            label=f"Initial: ${self.initial_investment:,.0f}",
        )

        ax.set_title(
            f"Monte Carlo Simulation ({self.num_simulations:,} paths, {self.time_horizon} days)"
        )
        ax.set_xlabel("Trading Days")
        ax.set_ylabel("Portfolio Value ($)")
        ax.legend(loc="upper left")
        ax.grid(True, alpha=0.3)

        # Add summary statistics text box
        final_stats = stats["final_values"]
        textstr = "Final Value Statistics:\n"
        textstr += f"Median: ${final_stats['median']:,.0f}\n"
        textstr += f"5th %: ${final_stats['percentile_5']:,.0f}\n"
        textstr += f"95th %: ${final_stats['percentile_95']:,.0f}\n"
        textstr += f"Prob. of Loss: {final_stats['prob_loss']:.1f}%"

        props = dict(boxstyle="round", facecolor="wheat", alpha=0.8)
        ax.text(
            0.98,
            0.02,
            textstr,
            transform=ax.transAxes,
            fontsize=9,
            verticalalignment="bottom",
            horizontalalignment="right",
            bbox=props,
        )

        if created_fig and show:
            plt.show()

        return ax

    def print_summary(self) -> None:
        """Print a summary of simulation results."""
        if self._results is None:
            self.simulate()

        stats = self.get_statistics()
        final = stats["final_values"]

        print("\n" + "=" * 50)
        print("MONTE CARLO SIMULATION SUMMARY")
        print("=" * 50)
        print(f"Initial Investment: ${self.initial_investment:,.0f}")
        print(f"Time Horizon: {self.time_horizon} trading days")
        print(f"Number of Simulations: {self.num_simulations:,}")
        print("-" * 50)
        print("Final Portfolio Value Statistics:")
        print(f"  Mean:     ${final['mean']:,.0f}")
        print(f"  Median:   ${final['median']:,.0f}")
        print(f"  Std Dev:  ${final['std']:,.0f}")
        print(f"  Min:      ${final['min']:,.0f}")
        print(f"  Max:      ${final['max']:,.0f}")
        print("-" * 50)
        print("Percentile Projections:")
        print(f"  5th percentile:  ${final['percentile_5']:,.0f}")
        print(f"  95th percentile: ${final['percentile_95']:,.0f}")
        print("-" * 50)
        print(f"Probability of Loss: {final['prob_loss']:.1f}%")
        print("=" * 50)

simulate()

Run Monte Carlo simulation.

Returns:

Type Description
ndarray

Array of shape (num_simulations, time_horizon) containing portfolio values for each simulation path over time.

Source code in portfolio_analysis/analysis/montecarlo.py
def simulate(self) -> np.ndarray:
    """
    Run Monte Carlo simulation.

    Returns
    -------
    np.ndarray
        Array of shape (num_simulations, time_horizon) containing
        portfolio values for each simulation path over time.
    """
    returns = self.data.pct_change().dropna()
    mean_returns = returns.mean().values
    cov_matrix = returns.cov().values

    results = np.zeros((self.num_simulations, self.time_horizon))

    for i in range(self.num_simulations):
        # Generate correlated random returns for all assets
        sim_returns = np.random.multivariate_normal(
            mean_returns, cov_matrix, self.time_horizon
        )

        # Calculate weighted portfolio returns at each time step
        portfolio_returns = sim_returns @ self.weights

        # Track cumulative portfolio value over time
        cumulative_returns = np.cumprod(1 + portfolio_returns)
        results[i, :] = self.initial_investment * cumulative_returns

    self._results = results
    return results

get_statistics(percentiles=[5, 25, 50, 75, 95])

Calculate statistics across all simulation paths.

Parameters:

Name Type Description Default
percentiles list of int

Percentiles to calculate

[5, 25, 50, 75, 95]

Returns:

Type Description
dict

Dictionary containing percentiles, mean, std, and final value statistics

Source code in portfolio_analysis/analysis/montecarlo.py
def get_statistics(self, percentiles: list[int] = [5, 25, 50, 75, 95]) -> dict:
    """
    Calculate statistics across all simulation paths.

    Parameters
    ----------
    percentiles : list of int
        Percentiles to calculate

    Returns
    -------
    dict
        Dictionary containing percentiles, mean, std, and final value statistics
    """
    if self._results is None:
        self.simulate()

    results = self._results
    final_values = results[:, -1]

    return {
        "percentiles": {p: np.percentile(results, p, axis=0) for p in percentiles},
        "mean": np.mean(results, axis=0),
        "std": np.std(results, axis=0),
        "final_values": {
            "mean": np.mean(final_values),
            "median": np.median(final_values),
            "std": np.std(final_values),
            "min": np.min(final_values),
            "max": np.max(final_values),
            "percentile_5": np.percentile(final_values, 5),
            "percentile_95": np.percentile(final_values, 95),
            "prob_loss": np.mean(final_values < self.initial_investment) * 100,
        },
    }

plot_simulation(show_percentiles=True, num_paths=100, ax=None, show=True)

Plot Monte Carlo simulation results with percentile bands.

Parameters:

Name Type Description Default
show_percentiles bool

Whether to show percentile bands

True
num_paths int

Number of individual paths to plot

100
ax Axes

Axes to plot on

None
show bool

Whether to display the plot. Set to False for automated/server contexts. Only applies when ax is None.

True

Returns:

Type Description
Axes

The axes with the plot

Source code in portfolio_analysis/analysis/montecarlo.py
def plot_simulation(
    self,
    show_percentiles: bool = True,
    num_paths: int = 100,
    ax: Optional[plt.Axes] = None,
    show: bool = True,
) -> plt.Axes:
    """
    Plot Monte Carlo simulation results with percentile bands.

    Parameters
    ----------
    show_percentiles : bool, default True
        Whether to show percentile bands
    num_paths : int, default 100
        Number of individual paths to plot
    ax : matplotlib.axes.Axes, optional
        Axes to plot on
    show : bool, default True
        Whether to display the plot. Set to False for automated/server contexts.
        Only applies when ax is None.

    Returns
    -------
    matplotlib.axes.Axes
        The axes with the plot
    """
    if self._results is None:
        self.simulate()

    results = self._results
    stats = self.get_statistics()

    created_fig = False
    if ax is None:
        fig, ax = plt.subplots(figsize=(12, 7))
        created_fig = True

    # Plot a subset of individual paths
    paths_to_plot = min(num_paths, self.num_simulations)
    for i in range(paths_to_plot):
        ax.plot(results[i, :], color="lightblue", alpha=0.3, linewidth=0.5)

    if show_percentiles:
        days = np.arange(self.time_horizon)

        # Plot percentile bands
        ax.fill_between(
            days,
            stats["percentiles"][5],
            stats["percentiles"][95],
            color="blue",
            alpha=0.2,
            label="5th-95th percentile",
        )
        ax.fill_between(
            days,
            stats["percentiles"][25],
            stats["percentiles"][75],
            color="blue",
            alpha=0.3,
            label="25th-75th percentile",
        )

        # Plot median
        ax.plot(
            stats["percentiles"][50],
            color="darkblue",
            linewidth=2,
            label="Median (50th percentile)",
        )

    # Plot initial investment line
    ax.axhline(
        y=self.initial_investment,
        color="red",
        linestyle="--",
        linewidth=1,
        label=f"Initial: ${self.initial_investment:,.0f}",
    )

    ax.set_title(
        f"Monte Carlo Simulation ({self.num_simulations:,} paths, {self.time_horizon} days)"
    )
    ax.set_xlabel("Trading Days")
    ax.set_ylabel("Portfolio Value ($)")
    ax.legend(loc="upper left")
    ax.grid(True, alpha=0.3)

    # Add summary statistics text box
    final_stats = stats["final_values"]
    textstr = "Final Value Statistics:\n"
    textstr += f"Median: ${final_stats['median']:,.0f}\n"
    textstr += f"5th %: ${final_stats['percentile_5']:,.0f}\n"
    textstr += f"95th %: ${final_stats['percentile_95']:,.0f}\n"
    textstr += f"Prob. of Loss: {final_stats['prob_loss']:.1f}%"

    props = dict(boxstyle="round", facecolor="wheat", alpha=0.8)
    ax.text(
        0.98,
        0.02,
        textstr,
        transform=ax.transAxes,
        fontsize=9,
        verticalalignment="bottom",
        horizontalalignment="right",
        bbox=props,
    )

    if created_fig and show:
        plt.show()

    return ax

print_summary()

Print a summary of simulation results.

Source code in portfolio_analysis/analysis/montecarlo.py
def print_summary(self) -> None:
    """Print a summary of simulation results."""
    if self._results is None:
        self.simulate()

    stats = self.get_statistics()
    final = stats["final_values"]

    print("\n" + "=" * 50)
    print("MONTE CARLO SIMULATION SUMMARY")
    print("=" * 50)
    print(f"Initial Investment: ${self.initial_investment:,.0f}")
    print(f"Time Horizon: {self.time_horizon} trading days")
    print(f"Number of Simulations: {self.num_simulations:,}")
    print("-" * 50)
    print("Final Portfolio Value Statistics:")
    print(f"  Mean:     ${final['mean']:,.0f}")
    print(f"  Median:   ${final['median']:,.0f}")
    print(f"  Std Dev:  ${final['std']:,.0f}")
    print(f"  Min:      ${final['min']:,.0f}")
    print(f"  Max:      ${final['max']:,.0f}")
    print("-" * 50)
    print("Percentile Projections:")
    print(f"  5th percentile:  ${final['percentile_5']:,.0f}")
    print(f"  95th percentile: ${final['percentile_95']:,.0f}")
    print("-" * 50)
    print(f"Probability of Loss: {final['prob_loss']:.1f}%")
    print("=" * 50)