Skip to content

Monte Carlo example

This offline example fits a price-path model to a small caller-owned sample, generates stable paths, inspects convergence, and computes portfolio outcomes without retaining per-path backtests.

Fit an explicit model

import pandas as pd

from persistra.monte_carlo import (
    MaximumDrawdown,
    MonteCarloExecution,
    MonteCarloExperiment,
    PortfolioBacktestEvaluator,
    TerminalReturn,
    ThresholdBreach,
    fit_geometric_brownian_motion,
    run_experiment,
)

historical_log_returns = pd.DataFrame(
    {
        "stock": [0.004, -0.002, 0.006, 0.001, -0.003, 0.005],
        "bond": [0.001, 0.002, -0.001, 0.001, 0.000, 0.002],
    },
    index=pd.date_range("2025-01-02", periods=6, freq="B"),
)
initial_prices = pd.Series({"stock": 100.0, "bond": 100.0})
model = fit_geometric_brownian_motion(
    historical_log_returns,
    initial_prices=initial_prices,
    periods_per_year=252.0,
)

Generate reproducible paths

future_index = pd.date_range("2025-02-03", periods=12, freq="B", name="date")
experiment = MonteCarloExperiment(
    model=model,
    output_index=future_index,
    time_steps=(1.0 / 252.0,) * len(future_index),
    path_count=32,
    root_seed=50,
    metrics=(
        TerminalReturn("stock", initial_level=100.0),
        MaximumDrawdown("stock"),
        ThresholdBreach("stock", threshold=95.0),
    ),
    retain_paths=True,
    convergence_checkpoints=(8, 16),
)
result = run_experiment(
    experiment,
    MonteCarloExecution(backend="threaded", workers=2, batch_size=7),
)

assert result.paths is not None
assert result.paths.shape == (32, 12, 2)
assert result.convergence.index.get_level_values("checkpoint").unique().tolist() == [8, 16, 32]
first_path = result.path_frame(0)
assert first_path.index.equals(future_index)
assert first_path.columns.tolist() == ["stock", "bond"]

Evaluate portfolio outcomes

targets = pd.DataFrame(
    [[0.6, 0.4]],
    index=pd.DatetimeIndex([future_index[0]], name="date"),
    columns=["stock", "bond"],
)
evaluator = PortfolioBacktestEvaluator(
    targets,
    transaction_cost_bps=pd.Series({"stock": 2.0, "bond": 1.0}),
    path_kind="prices",
)
portfolio_result = run_experiment(
    MonteCarloExperiment(
        model=model,
        output_index=future_index,
        time_steps=(1.0 / 252.0,) * len(future_index),
        path_count=32,
        root_seed=50,
        retain_paths=False,
    ),
    MonteCarloExecution(batch_size=5),
    evaluator=evaluator,
)

assert portfolio_result.paths is None
assert portfolio_result.metrics.columns.tolist() == list(evaluator.metric_names)
assert portfolio_result.summary.loc["portfolio_terminal_equity", "count"] == 32

The second run uses the same seed, axes, and model, so it evaluates the same 32 paths regardless of its different batch size. Only bounded metric tables, summary statistics, convergence, and provenance remain in memory.