Skip to content

markowitz

markowitz

markowitz-optimizer -- research-grade mean-variance portfolio toolkit.

Public entry point. Exposes the installed package version via :pydata:markowitz.__version__ and re-exports the headline public API so that from markowitz import MeanVariance works without users having to remember submodule paths.

AbsoluteView(asset: str, expected_return: float, confidence: float | None = None) dataclass

A point estimate on a single asset's expected excess return.

AnalyticFrontier(mu: npt.ArrayLike, Sigma: npt.ArrayLike)

Closed-form mean-variance frontier for (mu, Sigma).

Construction is the only point at which Sigma is factored; all subsequent queries (gmv, tangency, efficient_return, frontier) are O(n) once the cached basis vectors are available.

Source code in src/markowitz/core/frontier.py
def __init__(self, mu: npt.ArrayLike, Sigma: npt.ArrayLike) -> None:
    mu_arr = np.asarray(mu, dtype=np.float64)
    Sigma_arr = np.asarray(Sigma, dtype=np.float64)

    if mu_arr.ndim != 1:
        raise NumericalError(f"mu must be 1-D, got shape {mu_arr.shape!r}")
    if Sigma_arr.ndim != 2 or Sigma_arr.shape[0] != Sigma_arr.shape[1]:
        raise NumericalError(f"Sigma must be square 2-D, got shape {Sigma_arr.shape!r}")
    if mu_arr.shape[0] != Sigma_arr.shape[0]:
        raise NumericalError(
            "Dimension mismatch between mu and Sigma: "
            f"{mu_arr.shape[0]} vs {Sigma_arr.shape[0]}"
        )
    if not np.all(np.isfinite(mu_arr)):
        raise NumericalError("mu contains non-finite values")

    # Validate that Sigma is symmetric positive definite.  This
    # raises SingularCovarianceError on failure, matching the
    # documented contract.
    psd_check(Sigma_arr)

    n = mu_arr.shape[0]
    ones = np.ones(n, dtype=np.float64)
    rhs = np.column_stack((ones, mu_arr))
    sol = cholesky_solve(Sigma_arr, rhs)
    Sinv_one = sol[:, 0]
    Sinv_mu = sol[:, 1]

    A = float(ones @ Sinv_one)
    B = float(ones @ Sinv_mu)
    C = float(mu_arr @ Sinv_mu)
    D = A * C - B * B

    self._mu = mu_arr
    self._Sigma = Sigma_arr
    self._Sinv_one = Sinv_one
    self._Sinv_mu = Sinv_mu
    self._A = A
    self._B = B
    self._C = C
    self._D = D
    self._n = int(mu_arr.shape[0])

abcd: MertonABCD property

The Merton scalars associated with (mu, Sigma).

is_degenerate: bool property

True when D is essentially zero.

A degenerate frontier collapses to a single point in mean- variance space because mu is collinear with 1 (every asset has the same expected return). Efficient-return queries are still well-defined at the GMV expected return but are infeasible for any other target.

n_assets: int property

Number of assets in the investment universe.

efficient_return(mu_p: float) -> Portfolio

Return the minimum-variance portfolio with expected return mu_p.

Source code in src/markowitz/core/frontier.py
def efficient_return(self, mu_p: float) -> Portfolio:
    """Return the minimum-variance portfolio with expected return ``mu_p``."""

    from markowitz.core.portfolio import Portfolio  # noqa: PLC0415 - circular-import guard

    mu_target = float(mu_p)
    if self.is_degenerate:
        raise InfeasibleFrontierError(
            "Frontier is degenerate (D ~ 0); efficient_return is "
            "undefined for arbitrary target returns."
        )

    lam1 = (self._A * mu_target - self._B) / self._D
    lam2 = (self._C - self._B * mu_target) / self._D
    w = lam1 * self._Sinv_mu + lam2 * self._Sinv_one
    var = (self._A * mu_target * mu_target - 2.0 * self._B * mu_target + self._C) / self._D
    # Floor to zero to absorb sub-eps rounding at the GMV vertex.
    var = max(var, 0.0)
    return Portfolio(
        weights=w,
        expected_return=mu_target,
        volatility=float(np.sqrt(var)),
        sharpe=None,
    )

frontier(n_points: int = 100, *, mu_min: float | None = None, mu_max: float | None = None) -> list[Portfolio]

Return n_points portfolios spanning [mu_min, mu_max].

Defaults straddle the GMV expected return symmetrically: if no bounds are supplied, the frontier extends from mu_GMV to mu_GMV + 2 * (max(mu) - mu_GMV). Callers that need a specific range should pass it explicitly.

Source code in src/markowitz/core/frontier.py
def frontier(
    self,
    n_points: int = 100,
    *,
    mu_min: float | None = None,
    mu_max: float | None = None,
) -> list[Portfolio]:
    """Return ``n_points`` portfolios spanning ``[mu_min, mu_max]``.

    Defaults straddle the GMV expected return symmetrically: if no
    bounds are supplied, the frontier extends from ``mu_GMV`` to
    ``mu_GMV + 2 * (max(mu) - mu_GMV)``.  Callers that need a
    specific range should pass it explicitly.
    """

    if n_points < 2:
        raise ValueError(f"n_points must be >= 2, got {n_points}")

    mu_gmv = self._B / self._A
    if mu_min is None:
        mu_min = float(mu_gmv)
    if mu_max is None:
        top = float(np.max(self._mu))
        mu_max = float(mu_gmv + 2.0 * (top - mu_gmv))
        if mu_max <= mu_min:
            mu_max = mu_min + 1.0

    if mu_max < mu_min:
        raise ValueError(f"mu_max ({mu_max}) must be >= mu_min ({mu_min})")

    grid = np.linspace(float(mu_min), float(mu_max), int(n_points))
    return [self.efficient_return(float(m)) for m in grid]

gmv() -> Portfolio

Return the global minimum-variance portfolio.

Source code in src/markowitz/core/frontier.py
def gmv(self) -> Portfolio:
    """Return the global minimum-variance portfolio."""

    from markowitz.core.portfolio import Portfolio  # noqa: PLC0415 - circular-import guard

    w = self._Sinv_one / self._A
    var = 1.0 / self._A
    mu_p = self._B / self._A
    return Portfolio(
        weights=w,
        expected_return=float(mu_p),
        volatility=float(np.sqrt(var)),
        sharpe=None,
    )

tangency(rf: float) -> Portfolio

Return the tangency portfolio for risk-free rate rf.

Raises:

Type Description
InfeasibleFrontierError

If rf coincides with the GMV expected return (i.e. B - A * rf is numerically zero), making the tangency line parallel to the frontier.

Source code in src/markowitz/core/frontier.py
def tangency(self, rf: float) -> Portfolio:
    """Return the tangency portfolio for risk-free rate ``rf``.

    Raises
    ------
    InfeasibleFrontierError
        If ``rf`` coincides with the GMV expected return (i.e.
        ``B - A * rf`` is numerically zero), making the tangency
        line parallel to the frontier.
    """

    from markowitz.core.portfolio import Portfolio  # noqa: PLC0415 - circular-import guard

    rf_f = float(rf)
    denom = self._B - self._A * rf_f
    scale = max(abs(self._B), self._A * abs(rf_f), 1.0)
    if abs(denom) < 1e-12 * scale:
        raise InfeasibleFrontierError(
            "Tangency portfolio is undefined: risk-free rate coincides "
            f"with mu_GMV = {self._B / self._A:.10g} (B - A*rf = {denom:.3e})"
        )

    # w = Sigma^-1 (mu - rf * 1) / (B - A * rf)
    # Reuse cached basis vectors instead of a second Cholesky solve.
    w = (self._Sinv_mu - rf_f * self._Sinv_one) / denom
    excess = self._C - 2.0 * self._B * rf_f + self._A * rf_f * rf_f
    # Algebraically excess >= 0 for a PD Sigma, but floor it to guard
    # against floating-point chatter near zero.
    excess = max(excess, 0.0)
    sharpe = float(np.sqrt(excess))
    er = float(w @ self._mu)
    var = float(w @ self._Sigma @ w)
    vol = float(np.sqrt(max(var, 0.0)))
    return Portfolio(
        weights=w,
        expected_return=er,
        volatility=vol,
        sharpe=sharpe,
    )

BlackLitterman(cov: pd.DataFrame, market_weights: pd.Series, views: Views | None = None, *, tau: float = 0.05, delta: float = 2.5, omega: np.ndarray | None = None, omega_method: str = 'he_litterman', posterior_covariance_mode: str = 'full', risk_free_rate: float | None = None)

Construct a Black-Litterman posterior from a covariance prior and views.

Parameters:

Name Type Description Default
cov DataFrame

Prior covariance matrix as a labelled DataFrame.

required
market_weights Series

Market-cap weights series aligned to the covariance index.

required
views Views | None

:class:Views container with absolute and/or relative view specs. Pass None (or an empty container) for the no-views special case in which the posterior collapses to the prior.

None
tau float

Prior-scaling parameter. Must be strictly positive. Defaults to 0.05.

0.05
delta float

Representative-investor risk-aversion coefficient. Defaults to 2.5.

2.5
omega ndarray | None

Optional pre-computed view-uncertainty matrix; if supplied, omega_method is ignored.

None
omega_method str

Strategy used to derive Omega when not supplied: one of "he_litterman", "idzorek_exact", "idzorek_approx".

'he_litterman'
posterior_covariance_mode str

"full" returns Sigma_BL = Sigma + M (default), "prior_only" returns the prior covariance unchanged.

'full'
risk_free_rate float | None

Optional metadata used by :meth:summary to label the regime; not used in any numerical step (all quantities are excess returns).

None
Source code in src/markowitz/views/black_litterman.py
def __init__(
    self,
    cov: pd.DataFrame,
    market_weights: pd.Series,
    views: Views | None = None,
    *,
    tau: float = 0.05,
    delta: float = 2.5,
    omega: np.ndarray | None = None,
    omega_method: str = "he_litterman",
    posterior_covariance_mode: str = "full",
    risk_free_rate: float | None = None,
) -> None:
    self._validate_inputs(cov, market_weights, tau, delta)
    if omega_method not in _OMEGA_METHODS:
        raise OmegaSpecificationError(
            f"omega_method must be one of {sorted(_OMEGA_METHODS)}, got {omega_method!r}."
        )
    if posterior_covariance_mode not in _COV_MODES:
        raise BlackLittermanError(
            f"posterior_covariance_mode must be one of {sorted(_COV_MODES)}, "
            f"got {posterior_covariance_mode!r}."
        )

    self._cov: pd.DataFrame = cov
    self._assets: list[str] = list(cov.index)
    self._market_weights: pd.Series = market_weights.reindex(cov.index)
    if self._market_weights.isna().any():
        missing = self._market_weights.index[self._market_weights.isna()].tolist()
        raise BlackLittermanError(f"Market weights missing entries for: {missing}.")
    self._tau: float = float(tau)
    self._delta: float = float(delta)
    self._omega_method: str = omega_method
    self._posterior_cov_mode: str = posterior_covariance_mode
    self._risk_free_rate: float | None = risk_free_rate

    self._views: Views | None = views if views is not None and len(views) > 0 else None
    self._omega_user: np.ndarray | None = omega

    # Cached numerical pieces (built lazily).
    self._pi: pd.Series | None = None
    self._mu_bl: pd.Series | None = None
    self._sigma_bl: pd.DataFrame | None = None

implied_returns() -> pd.Series

Return the equilibrium-implied prior pi = delta * Sigma * w_mkt.

Source code in src/markowitz/views/black_litterman.py
def implied_returns(self) -> pd.Series:
    """Return the equilibrium-implied prior ``pi = delta * Sigma * w_mkt``."""
    if self._pi is None:
        self._pi = _implied_returns(self._cov, self._market_weights, delta=self._delta)
    return self._pi.copy()

posterior_covariance() -> pd.DataFrame

Return the posterior covariance matrix.

Source code in src/markowitz/views/black_litterman.py
def posterior_covariance(self) -> pd.DataFrame:
    """Return the posterior covariance matrix."""
    if self._sigma_bl is None:
        self._compute_posterior()
    assert self._sigma_bl is not None
    out: pd.DataFrame = self._sigma_bl.copy()
    return out

posterior_returns() -> pd.Series

Return the Black-Litterman posterior excess-return vector.

Source code in src/markowitz/views/black_litterman.py
def posterior_returns(self) -> pd.Series:
    """Return the Black-Litterman posterior excess-return vector."""
    if self._mu_bl is None:
        self._compute_posterior()
    assert self._mu_bl is not None
    return self._mu_bl.copy()

posterior_weights(*, constrained: bool = False) -> pd.Series

Return the unconstrained tangency weights implied by the posterior.

w_BL = (delta * Sigma_BL)^{-1} * mu_BL.

Parameters:

Name Type Description Default
constrained bool

If True, project to the long-only simplex by clipping at zero and renormalising to sum to 1. This is a convenience for quick inspection -- production code should use the full :mod:markowitz.optimizer pipeline.

False
Source code in src/markowitz/views/black_litterman.py
def posterior_weights(self, *, constrained: bool = False) -> pd.Series:
    """Return the unconstrained tangency weights implied by the posterior.

    ``w_BL = (delta * Sigma_BL)^{-1} * mu_BL``.

    Parameters
    ----------
    constrained
        If ``True``, project to the long-only simplex by clipping at zero
        and renormalising to sum to 1.  This is a convenience for quick
        inspection -- production code should use the full
        :mod:`markowitz.optimizer` pipeline.
    """
    mu = self.posterior_returns().to_numpy()
    sigma_bl = self.posterior_covariance().to_numpy()
    c_factor = cho_factor(self._delta * sigma_bl)
    w = cho_solve(c_factor, mu)
    if constrained:
        w = np.clip(w, 0.0, None)
        total = w.sum()
        if total > 0.0:
            w = w / total
    return pd.Series(w, index=self._assets, name="w_bl")

summary() -> pd.DataFrame

Return a per-asset table with prior, posterior and weight tilts.

Source code in src/markowitz/views/black_litterman.py
def summary(self) -> pd.DataFrame:
    """Return a per-asset table with prior, posterior and weight tilts."""
    pi = self.implied_returns()
    mu_bl = self.posterior_returns()
    w_mkt = self._market_weights.copy()
    w_bl = self.posterior_weights()
    df = pd.DataFrame(
        {
            "pi": pi,
            "mu_bl": mu_bl,
            "delta_mu": mu_bl - pi,
            "w_mkt": w_mkt,
            "w_bl": w_bl,
            "delta_w": w_bl - w_mkt,
        },
        index=self._assets,
    )
    return df

CAPMReturns(*, risk_free_rate: float = 0.0, market_premium: float | None = None, annualize: bool = True, periods_per_year: int | None = None)

Bases: _BaseMean

CAPM-implied expected returns from per-asset OLS betas.

Source code in src/markowitz/estimators/means.py
def __init__(
    self,
    *,
    risk_free_rate: float = 0.0,
    market_premium: float | None = None,
    annualize: bool = True,
    periods_per_year: int | None = None,
) -> None:
    self.risk_free_rate = float(risk_free_rate)
    self.market_premium = market_premium
    self.annualize = bool(annualize)
    self.periods_per_year = periods_per_year

CustomConstraint(builder: Callable[[cp.Variable, ConstraintContext], list[cp.Constraint]], description: str = 'custom') dataclass

Escape hatch for user-defined CVXPY constraints.

builder is a callable (w, ctx) -> list[cvxpy.Constraint]; it is invoked freshly each solve so it should not close over solver state.

Source code in src/markowitz/optimizer/constraints.py
def __init__(
    self,
    builder: Callable[[cp.Variable, ConstraintContext], list[cp.Constraint]],
    description: str = "custom",
) -> None:
    object.__setattr__(self, "builder", builder)
    object.__setattr__(self, "_description", description)

EWMACovariance(*, halflife_years: float | None = None, lam: float | None = None, burn_in: int | Literal['auto'] = 'auto', annualize: bool = True, periods_per_year: int | None = None)

Bases: _BaseCov

Exponentially weighted covariance (RiskMetrics recursion).

Source code in src/markowitz/estimators/covariance.py
def __init__(
    self,
    *,
    halflife_years: float | None = None,
    lam: float | None = None,
    burn_in: int | Literal["auto"] = "auto",
    annualize: bool = True,
    periods_per_year: int | None = None,
) -> None:
    if halflife_years is not None and lam is not None:
        raise EstimatorConfigError("Provide at most one of halflife_years or lam, not both.")
    self.halflife_years = halflife_years
    self.lam = lam
    self.burn_in = burn_in
    self.annualize = bool(annualize)
    self.periods_per_year = periods_per_year

EWMAMean(*, halflife_years: float | None = None, lam: float | None = None, annualize: bool = True, periods_per_year: int | None = None)

Bases: _BaseMean

Exponentially weighted mean using RiskMetrics-style decay.

Source code in src/markowitz/estimators/means.py
def __init__(
    self,
    *,
    halflife_years: float | None = None,
    lam: float | None = None,
    annualize: bool = True,
    periods_per_year: int | None = None,
) -> None:
    if (halflife_years is None) == (lam is None):
        raise EstimatorConfigError("Exactly one of halflife_years or lam must be provided.")
    self.halflife_years = halflife_years
    self.lam = lam
    self.annualize = bool(annualize)
    self.periods_per_year = periods_per_year

ImpliedReturns(*, delta: float = 2.5)

Bases: _BaseMean

Reverse-optimization implied returns μ = δ · Σ · w.

Source code in src/markowitz/estimators/means.py
def __init__(self, *, delta: float = 2.5) -> None:
    if delta <= 0.0:
        raise EstimatorConfigError(f"delta must be positive; got {delta}")
    self.delta = float(delta)

InfeasibleError(message: str, *, solver_status: str | None = None, solver_message: str | None = None, constraints_violated: list[str] | None = None)

Bases: OptimizationError

Raised when the feasible region is empty.

Either the solver returned infeasible / infeasible_inaccurate, or an upfront sanity check (e.g. max(mu) <= rf for max-Sharpe) detected that no admissible portfolio exists.

Source code in src/markowitz/optimizer/exceptions.py
def __init__(
    self,
    message: str,
    *,
    solver_status: str | None = None,
    solver_message: str | None = None,
    constraints_violated: list[str] | None = None,
) -> None:
    super().__init__(message)
    self.solver_status = solver_status
    self.solver_message = solver_message
    self.constraints_violated = constraints_violated

JorionBayesStein(*, base_cov: np.ndarray | None = None, annualize: bool = True, periods_per_year: int | None = None)

Bases: _BaseMean

Bayes-Stein shrinkage of the sample mean toward the minimum-variance mean.

Source code in src/markowitz/estimators/means.py
def __init__(
    self,
    *,
    base_cov: np.ndarray | None = None,
    annualize: bool = True,
    periods_per_year: int | None = None,
) -> None:
    self.base_cov = base_cov
    self.annualize = bool(annualize)
    self.periods_per_year = periods_per_year

LedoitWolfShrinkage(*, target: Literal['identity', 'constant_corr'] = 'identity', annualize: bool = True, periods_per_year: int | None = None)

Bases: _BaseCov

Ledoit-Wolf shrinkage covariance, sklearn-compatible for the identity target.

Source code in src/markowitz/estimators/covariance.py
def __init__(
    self,
    *,
    target: Literal["identity", "constant_corr"] = "identity",
    annualize: bool = True,
    periods_per_year: int | None = None,
) -> None:
    if target not in ("identity", "constant_corr"):
        raise EstimatorConfigError(
            f"target must be 'identity' or 'constant_corr'; got {target!r}"
        )
    self.target = target
    self.annualize = bool(annualize)
    self.periods_per_year = periods_per_year

LeverageCap(max_leverage: float) dataclass

Gross-exposure cap sum(|w_i|) <= max_leverage.

LongOnly() dataclass

No short sales: w_i >= 0 for every asset.

MeanVariance(mu: pd.Series | np.ndarray, sigma: pd.DataFrame | np.ndarray, weight_bounds: tuple[float | None, float | None] = (0.0, 1.0), *, solver: str = 'CLARABEL', solver_options: dict[str, Any] | None = None, regularize: float = _REGULARIZE_DEFAULT)

Convex mean-variance optimizer over a single horizon.

Parameters:

Name Type Description Default
mu Series | ndarray

Expected returns; either a pandas.Series indexed by ticker or a 1-D numpy array. When an ndarray is passed the assets are named "A0", "A1", ....

required
sigma DataFrame | ndarray

Covariance matrix; either a pandas.DataFrame whose row / column index matches mu or a 2-D numpy array.

required
weight_bounds tuple[float | None, float | None]

Default (lower, upper) box constraint applied to every asset. Use (0.0, 1.0) for long-only, (-1.0, 1.0) for unconstrained long-short. Pass (None, None) to disable the box.

(0.0, 1.0)
solver str

CVXPY solver name forwarded to :func:solve_problem.

'CLARABEL'
solver_options dict[str, Any] | None

Extra keyword arguments forwarded to the solver backend.

None
regularize float

Ridge term added to Sigma (Sigma + regularize * I) before building the QP. Helps numerical stability on ill-conditioned covariance matrices; should be left at 0 for parity with analytical baselines.

_REGULARIZE_DEFAULT
Source code in src/markowitz/optimizer/mean_variance.py
def __init__(
    self,
    mu: pd.Series | np.ndarray,
    sigma: pd.DataFrame | np.ndarray,
    weight_bounds: tuple[float | None, float | None] = (0.0, 1.0),
    *,
    solver: str = "CLARABEL",
    solver_options: dict[str, Any] | None = None,
    regularize: float = _REGULARIZE_DEFAULT,
) -> None:
    if not _CVXPY_AVAILABLE:  # pragma: no cover - tested via importorskip
        raise ImportError(
            "MeanVariance requires the optional 'cvxpy' dependency. "
            "Install with: pip install 'markowitz-optimizer[robust]'"
        ) from _CVXPY_IMPORT_ERROR

    tickers, mu_arr = _canonicalise_mu(mu)
    sigma_arr = _canonicalise_sigma(sigma, tickers)

    if regularize < 0:
        raise ValueError("regularize must be non-negative")
    if regularize > 0:
        sigma_arr = sigma_arr + regularize * np.eye(sigma_arr.shape[0])

    self._tickers: tuple[str, ...] = tuple(tickers)
    self._mu: np.ndarray = mu_arr
    self._sigma: np.ndarray = sigma_arr
    self._weight_bounds: tuple[float | None, float | None] = weight_bounds
    self._solver: str = solver
    self._solver_options: dict[str, Any] = dict(solver_options or {})
    self._regularize: float = float(regularize)
    self._constraints: list[Constraint] = []
    self._last_weights: pd.Series | None = None

add_constraint(constraint: Constraint) -> MeanVariance

Append a constraint and return self for chaining.

Source code in src/markowitz/optimizer/mean_variance.py
def add_constraint(self, constraint: Constraint) -> MeanVariance:
    """Append a constraint and return ``self`` for chaining."""
    self._constraints.append(constraint)
    return self

clear_constraints() -> MeanVariance

Drop every previously-added constraint.

Source code in src/markowitz/optimizer/mean_variance.py
def clear_constraints(self) -> MeanVariance:
    """Drop every previously-added constraint."""
    self._constraints.clear()
    return self

efficient_return(target_return: float) -> pd.Series

Minimise variance subject to mu^T w >= target_return.

Source code in src/markowitz/optimizer/mean_variance.py
def efficient_return(self, target_return: float) -> pd.Series:
    """Minimise variance subject to ``mu^T w >= target_return``."""
    w = _cp.Variable(self.n_assets, name="w")
    objective = _cp.Minimize(_cp.quad_form(w, _cp.psd_wrap(self._sigma)))
    constraints = (
        self._base_constraints(w)
        + self._user_constraints(w)
        + [self._mu @ w >= float(target_return)]
    )
    problem = _cp.Problem(objective, constraints)
    try:
        solve_problem(
            problem,
            solver=self._solver,
            solver_options=self._solver_options,
        )
    except OptimizationError as exc:
        if isinstance(exc, InfeasibleError):
            raise InfeasibleError(
                f"No feasible portfolio attains target_return={target_return}.",
                solver_status=exc.solver_status,
            ) from exc
        raise
    return self._finalise(w.value)

efficient_risk(target_volatility: float) -> pd.Series

Maximise mu^T w subject to sqrt(w^T Sigma w) <= target_vol.

Source code in src/markowitz/optimizer/mean_variance.py
def efficient_risk(self, target_volatility: float) -> pd.Series:
    """Maximise ``mu^T w`` subject to ``sqrt(w^T Sigma w) <= target_vol``."""
    if target_volatility <= 0:
        raise ValueError("target_volatility must be strictly positive")
    w = _cp.Variable(self.n_assets, name="w")
    objective = _cp.Maximize(self._mu @ w)
    risk_cap = _cp.quad_form(w, _cp.psd_wrap(self._sigma)) <= (float(target_volatility) ** 2)
    constraints = self._base_constraints(w) + self._user_constraints(w) + [risk_cap]
    problem = _cp.Problem(objective, constraints)
    solve_problem(
        problem,
        solver=self._solver,
        solver_options=self._solver_options,
    )
    return self._finalise(w.value)

max_quadratic_utility(risk_aversion: float = 1.0) -> pd.Series

Maximise mu^T w - (lambda/2) w^T Sigma w.

With risk_aversion = lambda. lambda > 0 is required.

Source code in src/markowitz/optimizer/mean_variance.py
def max_quadratic_utility(self, risk_aversion: float = 1.0) -> pd.Series:
    """Maximise ``mu^T w - (lambda/2) w^T Sigma w``.

    With ``risk_aversion = lambda``.  ``lambda > 0`` is required.
    """
    if risk_aversion <= 0:
        raise ValueError("risk_aversion must be strictly positive")
    w = _cp.Variable(self.n_assets, name="w")
    utility = self._mu @ w - 0.5 * risk_aversion * _cp.quad_form(w, _cp.psd_wrap(self._sigma))
    objective = _cp.Maximize(utility)
    constraints = self._base_constraints(w) + self._user_constraints(w)
    problem = _cp.Problem(objective, constraints)
    solve_problem(
        problem,
        solver=self._solver,
        solver_options=self._solver_options,
    )
    return self._finalise(w.value)

max_sharpe(risk_free_rate: float = 0.0) -> pd.Series

Maximise the Sharpe ratio via the Cornuejols--Tutuncu reformulation.

Source code in src/markowitz/optimizer/mean_variance.py
def max_sharpe(self, risk_free_rate: float = 0.0) -> pd.Series:
    """Maximise the Sharpe ratio via the Cornuejols--Tutuncu reformulation."""
    long_only = bool(self._weight_bounds[0] is not None and self._weight_bounds[0] >= 0)
    detect_degeneracy(self._mu, risk_free_rate, self._weight_bounds, long_only=long_only)

    # Translate the user's linear (in w) constraints into y-space:
    # the substitution w = y / kappa means any "Aw <= b * 1" becomes
    # "Ay <= b * kappa".  Box constraints we already wired in above.
    # For now we only allow user constraints on the *standard* QP path
    # (min-vol / efficient-* / max-quadratic-utility).  If users have
    # added extra constraints we still respect the basic LongOnly /
    # WeightBounds ones via reformulation; richer constraints fall
    # through to a runtime error rather than being silently dropped.
    extra_builders = self._collect_y_space_builders()

    reform = reformulate_max_sharpe(
        self._mu,
        self._sigma,
        risk_free_rate=risk_free_rate,
        weight_bounds=self._weight_bounds,
        long_only=long_only,
        extra_constraint_builders=extra_builders,
    )
    solve_problem(
        reform.problem,
        solver=self._solver,
        solver_options=self._solver_options,
    )
    if reform.y.value is None:  # pragma: no cover - defensive
        raise SolverError(
            "Max-Sharpe reformulation produced no y vector.",
            solver_status="no_solution",
        )
    weights = back_transform(np.asarray(reform.y.value))
    return self._finalise(weights)

min_volatility() -> pd.Series

Minimise w^T Sigma w subject to sum(w) = 1 and constraints.

Source code in src/markowitz/optimizer/mean_variance.py
def min_volatility(self) -> pd.Series:
    """Minimise ``w^T Sigma w`` subject to ``sum(w) = 1`` and constraints."""
    w = _cp.Variable(self.n_assets, name="w")
    objective = _cp.Minimize(_cp.quad_form(w, _cp.psd_wrap(self._sigma)))
    constraints = self._base_constraints(w) + self._user_constraints(w)
    problem = _cp.Problem(objective, constraints)
    solve_problem(
        problem,
        solver=self._solver,
        solver_options=self._solver_options,
    )
    return self._finalise(w.value)

portfolio_performance(weights: pd.Series | np.ndarray | None = None, *, risk_free_rate: float = 0.0) -> tuple[float, float, float]

Return (expected_return, volatility, sharpe_ratio).

If weights is None the most-recently-computed weights are used; an error is raised when no solve has happened yet.

Source code in src/markowitz/optimizer/mean_variance.py
def portfolio_performance(
    self,
    weights: pd.Series | np.ndarray | None = None,
    *,
    risk_free_rate: float = 0.0,
) -> tuple[float, float, float]:
    """Return ``(expected_return, volatility, sharpe_ratio)``.

    If ``weights`` is ``None`` the most-recently-computed weights are
    used; an error is raised when no solve has happened yet.
    """
    if weights is None:
        if self._last_weights is None:
            raise ValueError("No weights available -- call an optimizer first or pass weights.")
        w_arr = self._last_weights.to_numpy(dtype=float)
    else:
        w_arr = _align_weights(weights, self._tickers)

    expected_return = float(self._mu @ w_arr)
    variance = float(w_arr @ self._sigma @ w_arr)
    volatility = float(np.sqrt(max(variance, 0.0)))
    if volatility <= 0.0:
        sharpe = float("nan")
    else:
        sharpe = (expected_return - float(risk_free_rate)) / volatility
    return expected_return, volatility, sharpe

OneOverN

Equal-weight benchmark of DeMiguel, Garlappi, Uppal (2009).

OptimizationError(message: str, *, solver_status: str | None = None, solver_message: str | None = None, constraints_violated: list[str] | None = None)

Bases: Exception

Base class for any failure originating in the optimizer layer.

Parameters:

Name Type Description Default
message str

Human-readable description of what went wrong.

required
solver_status str | None

The raw status string returned by the underlying solver (e.g. "infeasible", "unbounded", "solver_error"). May be None when the error is raised before any solver was invoked (e.g. by an upfront feasibility check).

None
solver_message str | None

Free-form additional message produced by the solver, when available.

None
constraints_violated list[str] | None

Names of the constraints that the post-solve verification step flagged as violated, when the error was raised after a solve.

None
Source code in src/markowitz/optimizer/exceptions.py
def __init__(
    self,
    message: str,
    *,
    solver_status: str | None = None,
    solver_message: str | None = None,
    constraints_violated: list[str] | None = None,
) -> None:
    super().__init__(message)
    self.solver_status = solver_status
    self.solver_message = solver_message
    self.constraints_violated = constraints_violated

Portfolio(weights: FloatArray, expected_return: float, volatility: float, sharpe: float | None = None) dataclass

A snapshot of a portfolio's weights and analytic performance.

Attributes:

Name Type Description
weights FloatArray

Length-n float64 array. Stored as a read-only view; the caller-provided array is copied to guarantee immutability of the :class:Portfolio instance.

expected_return float

w^T mu evaluated at construction time.

volatility float

sqrt(w^T Sigma w) evaluated at construction time.

sharpe float | None

Sharpe ratio relative to whatever risk-free rate the caller chose; None if undefined.

variance() -> float

Return the portfolio variance volatility ** 2.

Source code in src/markowitz/core/portfolio.py
def variance(self) -> float:
    """Return the portfolio variance ``volatility ** 2``."""

    return self.volatility * self.volatility

RelativeView(long_leg: Mapping[str, float], short_leg: Mapping[str, float], spread: float, confidence: float | None = None) dataclass

A spread view between a long and a short basket of assets.

The basket weights specify how each leg is constructed: long_leg maps asset names to non-negative coefficients and likewise short_leg. The resulting pick row is long_leg - short_leg and must sum to zero (this is verified at construction time).

SampleCovariance(*, annualize: bool = True, periods_per_year: int | None = None, ddof: int = 1)

Bases: _BaseCov

Plain sample covariance np.cov(returns.T, ddof=ddof).

Source code in src/markowitz/estimators/covariance.py
def __init__(
    self,
    *,
    annualize: bool = True,
    periods_per_year: int | None = None,
    ddof: int = 1,
) -> None:
    if ddof < 0:
        raise EstimatorConfigError(f"ddof must be >= 0; got {ddof}")
    self.annualize = bool(annualize)
    self.periods_per_year = periods_per_year
    self.ddof = int(ddof)

SampleMean(*, annualize: bool = True, periods_per_year: int | None = None)

Bases: _BaseMean

Plain sample mean μ̂ = mean(returns, axis=0).

Source code in src/markowitz/estimators/means.py
def __init__(
    self,
    *,
    annualize: bool = True,
    periods_per_year: int | None = None,
) -> None:
    self.annualize = bool(annualize)
    self.periods_per_year = periods_per_year

SectorCap(sector_mapping: Mapping[str, str], caps: Mapping[str, float], floors: Mapping[str, float] | None = None) dataclass

Per-sector exposure caps (and optional floors).

sector_mapping maps each ticker to the name of its sector; caps (and optionally floors) map sector names to scalar weight bounds. Tickers not mentioned in the mapping are simply ignored from the grouping (treated as their own singleton "sector").

SolverError(message: str, *, solver_status: str | None = None, solver_message: str | None = None, constraints_violated: list[str] | None = None)

Bases: OptimizationError

Raised when the solver fails for a reason other than (in)feasibility.

Examples include solver-internal numerical breakdowns, license errors, or a status string the optimizer does not recognise.

Source code in src/markowitz/optimizer/exceptions.py
def __init__(
    self,
    message: str,
    *,
    solver_status: str | None = None,
    solver_message: str | None = None,
    constraints_violated: list[str] | None = None,
) -> None:
    super().__init__(message)
    self.solver_status = solver_status
    self.solver_message = solver_message
    self.constraints_violated = constraints_violated

TurnoverCap(prev_weights: pd.Series | np.ndarray, max_turnover: float) dataclass

L1 turnover budget against a previous-period weight vector.

sum(|w - prev|) <= max_turnover.

UnboundedError(message: str, *, solver_status: str | None = None, solver_message: str | None = None, constraints_violated: list[str] | None = None)

Bases: OptimizationError

Raised when the solver reports the objective is unbounded.

This typically signals a missing leverage / box constraint on a problem that permits arbitrarily large weights.

Source code in src/markowitz/optimizer/exceptions.py
def __init__(
    self,
    message: str,
    *,
    solver_status: str | None = None,
    solver_message: str | None = None,
    constraints_violated: list[str] | None = None,
) -> None:
    super().__init__(message)
    self.solver_status = solver_status
    self.solver_message = solver_message
    self.constraints_violated = constraints_violated

Views(views: Sequence[ViewSpec], assets: Sequence[str])

Container that validates and serialises a list of :class:ViewSpec.

Source code in src/markowitz/views/view_specs.py
def __init__(
    self,
    views: Sequence[ViewSpec],
    assets: Sequence[str],
) -> None:
    self._views: tuple[ViewSpec, ...] = tuple(views)
    self._assets: tuple[str, ...] = tuple(assets)
    self._asset_index: dict[str, int] = {a: i for i, a in enumerate(self._assets)}
    self._validate()

build_omega_he_litterman(tau: float, cov: np.ndarray) -> np.ndarray

Return the He-Litterman diagonal Omega = diag(P tau Sigma P^T).

Source code in src/markowitz/views/view_specs.py
def build_omega_he_litterman(self, tau: float, cov: np.ndarray) -> np.ndarray:
    """Return the He-Litterman diagonal ``Omega = diag(P tau Sigma P^T)``."""
    if tau <= 0.0:
        raise ViewValidationError(f"tau must be > 0, got {tau}.")
    if cov.shape[0] != cov.shape[1] or cov.shape[0] != len(self._assets):
        raise ViewValidationError(
            f"Covariance shape {cov.shape} incompatible with universe "
            f"of size {len(self._assets)}."
        )
    p_mat, _ = self.build_pq()
    diag = np.einsum("ki,ij,kj->k", p_mat, tau * cov, p_mat)
    return np.diag(diag)

build_pq() -> tuple[np.ndarray, np.ndarray]

Return the (P, Q) matrices for the current view set.

Source code in src/markowitz/views/view_specs.py
def build_pq(self) -> tuple[np.ndarray, np.ndarray]:
    """Return the ``(P, Q)`` matrices for the current view set."""
    k = len(self._views)
    n = len(self._assets)
    p_mat = np.zeros((k, n), dtype=float)
    q_vec = np.zeros(k, dtype=float)

    for i, view in enumerate(self._views):
        if isinstance(view, AbsoluteView):
            p_mat[i, self._asset_index[view.asset]] = 1.0
            q_vec[i] = float(view.expected_return)
        else:
            for asset, w in view.long_leg.items():
                p_mat[i, self._asset_index[asset]] += float(w)
            for asset, w in view.short_leg.items():
                p_mat[i, self._asset_index[asset]] -= float(w)
            q_vec[i] = float(view.spread)
    return p_mat, q_vec

confidences() -> np.ndarray | None

Return per-view confidences as an array, or None if unspecified.

Source code in src/markowitz/views/view_specs.py
def confidences(self) -> np.ndarray | None:
    """Return per-view confidences as an array, or ``None`` if unspecified."""
    if not self._views or self._views[0].confidence is None:
        return None
    return np.asarray([v.confidence for v in self._views], dtype=float)

WalkForward(returns: pd.DataFrame, strategies: Mapping[str, Strategy], *, rebalance: str = 'M', lookback: int = 120, rf: pd.Series | float | None = None, cost_bps: float = 10.0, debug_no_lookahead: bool = False)

Rolling-window walk-forward backtester.

The engine slides a fixed-length lookback window across returns, fits every supplied strategy on the strictly in-sample slice, and holds the resulting weights until the next rebalance date.

Parameters:

Name Type Description Default
returns DataFrame

Wide DataFrame of per-period simple returns indexed by date.

required
strategies Mapping[str, Strategy]

Mapping label -> Strategy (any object implementing the :class:~markowitz.backtest.strategies.Strategy protocol).

required
rebalance str

Pandas offset alias. "M" (default) rebalances every period.

'M'
lookback int

Length of the rolling estimation window, in periods.

120
rf Series | float | None

Risk-free rate; scalar or Series aligned to returns.

None
cost_bps float

Proportional transaction cost in basis points per unit turnover.

10.0
debug_no_lookahead bool

If True, raise on any rebalance whose window contains an observation dated at-or-after the rebalance date. Used by the regression test-suite.

False
Source code in src/markowitz/backtest/walk_forward.py
def __init__(
    self,
    returns: pd.DataFrame,
    strategies: Mapping[str, Strategy],
    *,
    rebalance: str = "M",
    lookback: int = 120,
    rf: pd.Series | float | None = None,
    cost_bps: float = 10.0,
    debug_no_lookahead: bool = False,
) -> None:
    if returns.empty:
        raise AlignmentError("returns DataFrame is empty")
    if returns.isna().any().any():
        raise AlignmentError("returns DataFrame contains NaNs; please pre-clean")
    if lookback < 2:
        raise InsufficientHistoryError("lookback must be >= 2")
    if lookback >= len(returns):
        raise InsufficientHistoryError(f"lookback={lookback} >= len(returns)={len(returns)}")
    if not strategies:
        raise AlignmentError("at least one strategy is required")

    self.returns = returns.sort_index()
    self.strategies = dict(strategies)
    self.rebalance = rebalance
    self.lookback = int(lookback)
    self.cost_bps = float(cost_bps)
    self.debug_no_lookahead = bool(debug_no_lookahead)

    if rf is None:
        self.rf: pd.Series = pd.Series(0.0, index=self.returns.index)
    elif isinstance(rf, pd.Series):
        self.rf = rf.reindex(self.returns.index).fillna(0.0)
    else:
        self.rf = pd.Series(float(rf), index=self.returns.index)

run() -> BacktestResult

Execute the walk-forward loop and return a :class:BacktestResult.

Source code in src/markowitz/backtest/walk_forward.py
def run(self) -> BacktestResult:
    """Execute the walk-forward loop and return a :class:`BacktestResult`."""
    idx = self.returns.index
    assets = list(self.returns.columns)
    n_assets = len(assets)

    # Trading dates start strictly after the first feasible window end.
    trading_dates = idx[self.lookback :]
    # pragma: defensive guard; ctor already enforces lookback < len(returns)
    if len(trading_dates) == 0:  # pragma: no cover
        raise InsufficientHistoryError("no trading periods after lookback")

    rebal_mask = self._build_rebalance_mask(trading_dates)
    rebalance_dates: list[pd.Timestamp] = [
        t for t, flag in zip(trading_dates, rebal_mask, strict=True) if flag
    ]

    gross = pd.DataFrame(
        0.0, index=trading_dates, columns=list(self.strategies.keys()), dtype=float
    )
    turnover_df = pd.DataFrame(
        0.0, index=trading_dates, columns=list(self.strategies.keys()), dtype=float
    )
    weights_records: dict[str, dict[pd.Timestamp, np.ndarray]] = {
        name: {} for name in self.strategies
    }
    current_w: dict[str, np.ndarray] = {
        name: np.full(n_assets, 1.0 / n_assets) for name in self.strategies
    }

    for i, t in enumerate(trading_dates):
        pos = self.lookback + i
        window = self.returns.iloc[pos - self.lookback : pos]
        if self.debug_no_lookahead and len(window) > 0 and window.index.max() >= t:
            raise AssertionError(
                f"look-ahead detected: window ends at {window.index.max()} >= {t}"
            )
        r_t = self.returns.iloc[pos].to_numpy(dtype=float)
        rf_t = float(self.rf.iloc[pos])

        for name, strat in self.strategies.items():
            w_prev = current_w[name]
            if rebal_mask[i]:
                w_new = np.asarray(strat.fit(window, rf=rf_t), dtype=float).ravel()
                if w_new.shape[0] != n_assets:
                    raise AlignmentError(
                        f"strategy {name} returned {w_new.shape[0]} weights, "
                        f"expected {n_assets}"
                    )
                to_t = compute_turnover(w_new, w_prev, r_t)
                weights_records[name][t] = w_new
            else:
                w_new = w_prev
                to_t = 0.0
            gross.at[t, name] = float(np.dot(w_prev, r_t))
            turnover_df.at[t, name] = to_t

            # Drift weights forward for the next period.
            denom = 1.0 + float(np.dot(w_prev, r_t))
            if denom != 0.0 and np.isfinite(denom):
                drifted = w_prev * (1.0 + r_t) / denom
            else:
                drifted = w_prev.copy()
            # Apply this period's rebalance *after* booking the return.
            current_w[name] = w_new if rebal_mask[i] else drifted

    net = pd.DataFrame(index=trading_dates, columns=list(self.strategies.keys()), dtype=float)
    for name in self.strategies:
        net[name] = apply_transaction_costs(gross[name], turnover_df[name], bps=self.cost_bps)

    weights_frames: dict[str, pd.DataFrame] = {}
    for name, recs in weights_records.items():
        if recs:
            weights_frames[name] = pd.DataFrame.from_dict(
                recs, orient="index", columns=assets
            ).sort_index()
        else:  # pragma: no cover  # defensive: rebal_mask always sets index 0
            weights_frames[name] = pd.DataFrame(columns=assets)

    return BacktestResult(
        returns_gross=gross,
        returns_net=net,
        weights=weights_frames,
        turnover=turnover_df,
        rebalance_dates=rebalance_dates,
    )

WeightBounds(lower: float | Sequence[float], upper: float | Sequence[float]) dataclass

Element-wise box constraint lower <= w_i <= upper.

The bounds may be supplied as scalars (broadcast across all assets) or as iterables of length n_assets.

implied_returns(cov: pd.DataFrame, market_weights: pd.Series, delta: float = 2.5) -> pd.Series

Compute the equilibrium-implied excess-return vector.

Parameters:

Name Type Description Default
cov DataFrame

Asset return covariance matrix as a square DataFrame with matching row and column labels.

required
market_weights Series

Market-capitalisation weights indexed by asset. The index must align with cov (the function reindexes to the covariance ordering).

required
delta float

Representative-investor risk-aversion coefficient. Defaults to the commonly cited value of 2.5 from He & Litterman (1999).

2.5

Returns:

Type Description
Series

Implied excess returns pi = delta * Sigma * w_mkt aligned to the covariance index.

Raises:

Type Description
ValueError

If the covariance matrix is not square, weights cannot be aligned, or delta is not strictly positive.

Source code in src/markowitz/views/equilibrium.py
def implied_returns(
    cov: pd.DataFrame,
    market_weights: pd.Series,
    delta: float = 2.5,
) -> pd.Series:
    """Compute the equilibrium-implied excess-return vector.

    Parameters
    ----------
    cov
        Asset return covariance matrix as a square ``DataFrame`` with matching
        row and column labels.
    market_weights
        Market-capitalisation weights indexed by asset.  The index must align
        with ``cov`` (the function reindexes to the covariance ordering).
    delta
        Representative-investor risk-aversion coefficient.  Defaults to the
        commonly cited value of ``2.5`` from He & Litterman (1999).

    Returns
    -------
    pandas.Series
        Implied excess returns ``pi = delta * Sigma * w_mkt`` aligned to the
        covariance index.

    Raises
    ------
    ValueError
        If the covariance matrix is not square, weights cannot be aligned, or
        ``delta`` is not strictly positive.
    """
    if cov.shape[0] != cov.shape[1]:
        raise ValueError(f"Covariance matrix must be square, got shape {cov.shape}.")
    if not cov.index.equals(cov.columns):
        raise ValueError("Covariance matrix index and columns must be identical.")
    if delta <= 0.0:
        raise ValueError(f"Risk-aversion delta must be > 0, got {delta}.")

    aligned = market_weights.reindex(cov.index)
    if aligned.isna().any():
        missing = aligned.index[aligned.isna()].tolist()
        raise ValueError(f"Market weights missing entries for: {missing}.")

    pi = delta * cov.to_numpy() @ aligned.to_numpy()
    return pd.Series(pi, index=cov.index, name="pi")

infer_delta(market_returns: pd.Series, risk_free_rate: float, ddof: int = 1) -> float

Estimate the risk-aversion coefficient from market-return history.

Uses the standard identity delta = (E[r_m] - rf) / Var[r_m] where r_m is the market portfolio return series. The series is assumed to already be on the same periodicity as risk_free_rate (e.g. both annual or both daily).

Parameters:

Name Type Description Default
market_returns Series

Realised market returns (not excess).

required
risk_free_rate float

Periodic risk-free rate matching the periodicity of market_returns.

required
ddof int

Degrees-of-freedom passed through to :meth:pandas.Series.var. Defaults to 1 (sample variance).

1

Returns:

Type Description
float

The implied risk-aversion coefficient.

Raises:

Type Description
ValueError

If the input series has fewer than two observations or has zero variance.

Source code in src/markowitz/views/equilibrium.py
def infer_delta(
    market_returns: pd.Series,
    risk_free_rate: float,
    ddof: int = 1,
) -> float:
    """Estimate the risk-aversion coefficient from market-return history.

    Uses the standard identity ``delta = (E[r_m] - rf) / Var[r_m]`` where
    ``r_m`` is the market portfolio return series.  The series is assumed to
    already be on the same periodicity as ``risk_free_rate`` (e.g. both annual
    or both daily).

    Parameters
    ----------
    market_returns
        Realised market returns (not excess).
    risk_free_rate
        Periodic risk-free rate matching the periodicity of
        ``market_returns``.
    ddof
        Degrees-of-freedom passed through to :meth:`pandas.Series.var`.
        Defaults to ``1`` (sample variance).

    Returns
    -------
    float
        The implied risk-aversion coefficient.

    Raises
    ------
    ValueError
        If the input series has fewer than two observations or has zero
        variance.
    """
    if len(market_returns) < 2:
        raise ValueError("At least two market-return observations are required to estimate delta.")
    variance = float(market_returns.var(ddof=ddof))
    if not np.isfinite(variance) or variance <= 0.0:
        raise ValueError(f"Market-return variance must be strictly positive, got {variance}.")
    expected = float(market_returns.mean())
    return (expected - risk_free_rate) / variance