Coverage for src / monkeys / portfolio.py: 100%
38 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 02:14 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 02:14 +0000
1"""Portfolio simulation module for random allocation strategies.
3This module provides tools for simulating random portfolio allocation strategies
4("monkeys") that generate random weights and track portfolio performance.
5"""
7from __future__ import annotations
9from typing import TYPE_CHECKING
11import numpy as np
13if TYPE_CHECKING:
14 from numpy.typing import NDArray
17def generate_weight_history(
18 n_assets: int,
19 n_periods: int,
20 seed: int | None = None,
21 probabilities: NDArray[np.float64] | None = None,
22) -> NDArray[np.float64]:
23 """Generate a history of random portfolio weights over multiple periods.
25 Simulates a monkey rebalancing the portfolio each period with new random
26 weights.
28 Args:
29 n_assets: Number of assets in the portfolio. Must be positive.
30 n_periods: Number of time periods to simulate. Must be positive.
31 seed: Random seed for reproducibility.
32 probabilities: Selection probabilities for each asset.
34 Returns:
35 2D array of shape (n_periods, n_assets) where each row sums to 1.0.
37 Raises:
38 ValueError: If n_assets < 1 or n_periods < 1.
40 Examples:
41 >>> history = generate_weight_history(3, 10, seed=42)
42 >>> history.shape
43 (10, 3)
44 >>> np.allclose(history.sum(axis=1), 1.0)
45 True
46 """
47 if n_periods < 1:
48 msg = f"n_periods must be positive, got {n_periods}"
49 raise ValueError(msg)
51 rng = np.random.default_rng(seed)
53 # Generate all random samples at once for efficiency
54 if probabilities is None:
55 probabilities = np.ones(n_assets) / n_assets
56 elif not np.isclose(probabilities.sum(), 1.0):
57 msg = f"probabilities must sum to 1.0, got {probabilities.sum()}"
58 raise ValueError(msg)
60 uniform_samples = rng.uniform(0, 1, (n_periods, n_assets))
61 raw_weights = probabilities * uniform_samples
63 # Normalize each row
64 row_sums = raw_weights.sum(axis=1, keepdims=True)
65 weights: NDArray[np.float64] = raw_weights / row_sums
67 return weights
70def simulate_portfolio_returns(
71 returns: NDArray[np.float64],
72 seed: int | None = None,
73 probabilities: NDArray[np.float64] | None = None,
74 rebalance_frequency: int = 1,
75) -> NDArray[np.float64]:
76 """Simulate portfolio returns using random weight allocation.
78 Simulates a monkey managing a portfolio over time, rebalancing at the
79 specified frequency with random weights.
81 Args:
82 returns: 2D array of asset returns with shape (n_periods, n_assets).
83 seed: Random seed for reproducibility.
84 probabilities: Selection probabilities for each asset.
85 rebalance_frequency: Number of periods between rebalancing. Default is 1
86 (rebalance every period).
88 Returns:
89 1D array of portfolio returns for each period.
91 Raises:
92 ValueError: If returns is not 2D or rebalance_frequency < 1.
94 Examples:
95 >>> asset_returns = np.array([
96 ... [0.01, 0.02, -0.01],
97 ... [0.02, -0.01, 0.03],
98 ... [-0.01, 0.01, 0.02],
99 ... ])
100 >>> portfolio_returns = simulate_portfolio_returns(asset_returns, seed=42)
101 >>> len(portfolio_returns)
102 3
103 """
104 if returns.ndim != 2:
105 msg = f"returns must be 2D array, got {returns.ndim}D"
106 raise ValueError(msg)
108 if rebalance_frequency < 1:
109 msg = f"rebalance_frequency must be positive, got {rebalance_frequency}"
110 raise ValueError(msg)
112 n_periods, n_assets = returns.shape
113 rng = np.random.default_rng(seed)
115 if probabilities is None:
116 probabilities = np.ones(n_assets) / n_assets
118 portfolio_returns = np.zeros(n_periods)
119 current_weights = None
121 for t in range(n_periods):
122 # Rebalance if needed
123 if t % rebalance_frequency == 0 or current_weights is None:
124 uniform_samples = rng.uniform(0, 1, n_assets)
125 raw_weights = probabilities * uniform_samples
126 current_weights = raw_weights / raw_weights.sum()
128 portfolio_returns[t] = np.dot(current_weights, returns[t])
130 return portfolio_returns