Portfolio Allocation

The allocation layer — keeks.allocation — sizes portfolios across N distribution-valued options: options described by a mean vector and covariance, a scenario matrix of joint simple returns, keeks-native binary bets, fitted parametric marginals, or any user-supplied sampler. Where the binary strategies return one stake fraction and the multi-outcome strategies return stake fractions over mutually exclusive legs, the allocation strategies return weights: one long-only weight per option, each in [0, 1], summing to at most one. The residual is cash held at zero return, so a keeks.bankroll.BankRoll is reused untouched and the aggregate-exposure guarantee transfers.

The input model is deliberately diverse and configurable. Allocators accept anything that can produce joint simple-return draws through one sampling contract — keeks.allocation.models.JointReturnModel — so binary bets, empirical histories, thin and incredibly fat-tailed marginals, and custom samplers all feed the same allocators. Models that know their closed-form moments expose them exactly via moments(); the rest are estimated from draws. The Kelly identity is the layer’s accent: mean-variance at risk aversion λ = 1 is the second-order Kelly allocation, and keeks.allocation.moments.RiskAversionScaling is the fractional-Kelly story — shrinkage toward cash.

Descriptive inputs bind at construction and are immutable thereafter — an allocator reprices by fresh construction, matching the multi-outcome doctrine. scipy is an optional extra, keeks[allocation]: the QP/LP solvers (MeanVariance, GlobalMinimumVariance, MaximumSharpe, MaximumDiversification) and MeanCVaR point at the extra when it is absent; everything else is numpy-only. For visualization see Allocation Visualization Helpers — matplotlib helpers for every object this API returns — and examples/allocation_etfs.py for the worked real-data example, which runs a six-ETF book through both input-model helpers and the simulator offline from a committed fixture.

The Strategy Contract

class keeks.allocation.base.BaseAllocationStrategy[source]

Bases: ParameterMixin, ABC

Abstract base class for portfolio allocation strategies.

This class defines the interface that all allocation strategies must implement. An allocation strategy sizes a portfolio across N distribution-valued options - options described by a mean vector and covariance, a scenario matrix, or any other joint-return description - in a single decision: every option settles every period, and the weight vector decides how much of the bankroll rides on each option’s simple return, with any unallocated residual held as cash at zero return.

Weights are long-only budget fractions, not stake fractions: each weight is in [0, 1] and the weights sum to no more than one, so the portfolio never promises more of the bankroll than it holds. This is the one contract every static method family (mean-variance, minimum variance, risk budgeting, hierarchical risk parity, …) and every online method shares.

Descriptive inputs bind at construction and are immutable thereafter - an allocator reprices by fresh construction, matching the multi-outcome convention. Weights are scale-free: they ignore the bankroll level and are all zeros for a nonpositive bankroll, like every keeks strategy.

Concrete strategy implementations should inherit from this class and implement the evaluate method, which the base then enforces: every concrete evaluate is wrapped so its returned weight vector is validated through _validate_weights() before the caller sees it - a subclass returning contract-violating weights fails its own evaluate() with the validator’s message plus the returned vector, instead of passing silently until the simulator’s boundary gate. The same holds for the multi-outcome stake contract.

Simulators resolve two optional hooks getattr-style: update_bankroll(current_bankroll) carries the bankroll path for plumbing, and online allocators additionally implement record_settlement(won, realized_returns) - called once per staked period with the period’s per-option outcome vector (True for a win, False for a loss, None when no settlement draw realized for that option) and the realized joint simple-return vector - as their only sanctioned stateful channel.

abstract evaluate(current_bankroll: float) → tuple[float, ...][source]

Evaluate the strategy for the current bankroll.

Every descriptive input - a mean vector and covariance, a scenario matrix, or whatever else the concrete strategy optimizes over - binds at construction, so the only call-time input is the bankroll. Implementations return one weight per option; the base class then validates the returned vector through _validate_weights() before the caller sees it, so implementations need not (but may - it is idempotent) validate internally.

Parameters:

current_bankroll (float) – The current bankroll to use for calculations.

Returns:

One long-only weight per option: len(result) == N, every element finite and within [0, 1], and sum(result) <= 1 + PROBABILITY_SUM_TOLERANCE. The shortfall below one is cash held at zero return. All zeros when current_bankroll <= 0: there is nothing left to allocate.

Return type:

tuple of float

Raises:

ValueError – If the weight vector leaving this strategy violates the contract above.

property weights_: ndarray

The fitted weight vector, under sklearn’s trailing-underscore convention.

A read-only view of the solved or adapted allocation state for the allocators that hold one - the solver-backed families’ optimum and the online family’s current adaptation state. Additive alongside the established weights attribute, which keeps its name and remains the primary accessor. Wrappers that recompute from a wrapped allocator instead of holding their own solve (RiskAversionScaling) raise AttributeError: read the wrapped allocator’s weights_, or this wrapper’s evaluate.

class keeks.allocation.base.AllocationResult(weights: ndarray, objective: float | None = None, converged: bool | None = None, iterations: int | None = None, expected_growth: float | None = None, volatility: float | None = None, all_cash_reason: str | None = None)[source]

Bases: object

Diagnostics from one allocation optimization.

optimize() returns one of these alongside the simulator-facing evaluate tuple, so solver diagnostics are first-class rather than a side dict. Every field except weights is optional: an allocator fills the fields its formulation produces and leaves the rest None.

Comparison is identity-based (eq=False): weights is a numpy.ndarray, so generated field-wise equality would raise on its ambiguous truth value. Compare the fields you care about instead.

weights

The optimal weight vector, shape (N,), one long-only weight per option.

Type:

numpy.ndarray

objective

The optimal objective value, when a solver ran.

Type:

float, optional

converged

Solver convergence, when a solver ran.

Type:

bool, optional

iterations

Solver iterations, when a solver ran.

Type:

int, optional

expected_growth

The estimated E[log(1 + w'R)] under the strategy’s inputs.

Type:

float, optional

volatility

sqrt(w' Σ w), when a covariance is available.

Type:

float, optional

all_cash_reason

An allocator-supplied explanation when the optimal weights are all effectively zero - the portfolio holds full cash. MeanCVaR’s unit-risk-aversion objective, for instance, holds full cash on daily-frequency market data, and the result carries that explanation rather than leaving it to example output. None when the portfolio takes risk or the allocator offers no explanation.

Type:

str, optional

class keeks.allocation.models.ModelInputMixin[source]

Bases: object

Mixin giving moment-based allocators a from_model constructor.

Allocators consume (mean, covariance) or scenario descriptors bound at construction; this mixin builds those descriptors from any JointReturnModel - keeks binary bets, fitted marginals, user samplers - so every allocator accepts every input through one door. The classmethod prefers the model’s exact moments() and falls back to estimate_moments() when the model cannot produce them.

Scenario-bound allocators consume draws rather than moments and override from_model; the constructor keyword arguments pass through to cls either way.

Examples

>>> class EqualWeights(ModelInputMixin):
...     def __init__(self, mean, covariance):
...         self.mean = mean
...         self.covariance = covariance
>>> model = binary_bets_model([(0.5, 2.0, 1.0)])
>>> EqualWeights.from_model(model).mean.tolist()
[0.0]
classmethod from_model(model: JointReturnModel, n_samples: int = 10000, seed: int | None = None, **kwargs) → BaseAllocationStrategy[source]

Build cls from a joint-return model’s inputs.

Exact moments win when the model carries them, so a binary-bets or thin-tailed parametric model needs no Monte Carlo at all; n_samples and seed are only consumed on the estimation fallback.

Parameters:
  • model (JointReturnModel) – The model to build inputs from. Any object with sample(n_samples, rng) (and optionally moments()) works.

  • n_samples (int, default=10000) – The draw count for the estimation fallback.

  • seed (int, optional) – Seed for the estimation fallback’s private stream.

  • **kwargs – Extra constructor keyword arguments for cls (a risk aversion, risk budgets, …).

Returns:

The allocator built from the model’s inputs.

Return type:

BaseAllocationStrategy

Joint-Return Input Models

The universal input currency is the model object: hyperparameters live in adapter constructors, and consumers see one sampling contract.

class keeks.allocation.models.JointReturnModel[source]

Bases: ABC

Source of joint simple-return draws across N options.

The allocation layer’s universal input: scenario-based methods consume draws directly, moment-based methods consume moments estimated from draws, and a model that knows its moments exactly exposes them so no estimation is needed. Implementations are deterministic given a numpy.random.Generator - the same generator state produces the same draws.

Row convention matches the scenario validators: one row per observation, one column per option, values are per-period simple returns.

moments() → tuple[ndarray, ndarray] | None[source]

Return the model’s exact (mean, covariance) when it knows them.

A binary bet, for instance, has closed-form moments in its hyperparameters, so its adapter returns them exactly and allocators built through from_model skip estimation entirely. Models without closed-form moments - Student-t marginals with nu <= 2 (infinite variance), Gaussian-copula dependence (no closed-form Pearson covariance for non-Gaussian marginals), user samplers - return None and are estimated from draws instead.

Returns:

The exact (mean, covariance) - a shape (N,) mean vector and an (N, N) covariance matrix - or None when the model does not know its moments exactly.

Return type:

tuple of numpy.ndarray or None

abstract sample(n_samples: int, rng: Generator) → ndarray[source]

Return joint simple-return draws.

Parameters:
  • n_samples (int) – The number of joint draws to produce. Must be a positive integer.

  • rng (numpy.random.Generator) – The generator driving the draws. Implementations consume it deterministically: the same generator state yields the same draws.

Returns:

An (n_samples, N) matrix of finite simple returns, one row per draw and one column per option.

Return type:

numpy.ndarray

class keeks.allocation.models.ScenarioModel(scenarios: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], probabilities: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes] | None = None)[source]

Bases: JointReturnModel

Empirical scenario rows as a joint-return model.

The rows of the scenario matrix are the model’s outcomes: sampling picks rows. Equally-weighted rows resample through the generator as an iid bootstrap of the observations; weighted rows draw by probability, and any probability mass below one is an all-cash period at zero return (a row of zeros), never renormalized away.

Parameters:
  • scenarios (array-like) – The (observations, options) matrix of joint simple returns.

  • probabilities (array-like, optional) – The probability of each scenario row, validated like every keeks probability vector. Omitted mass is cash at zero return.

Examples

>>> model = ScenarioModel([[0.01, 0.02], [-0.01, 0.0]])
>>> model.sample(2, np.random.default_rng(5)).shape
(2, 2)
moments() → tuple[ndarray, ndarray][source]

Return the exact probability-weighted empirical moments.

The model’s distribution is the discrete one over its rows, so the moments are exact sums - and when probabilities leave residual mass, the all-cash zero row carries it.

Returns:

The exact (mean, covariance) of the empirical distribution.

Return type:

tuple of numpy.ndarray

sample(n_samples: int, rng: Generator) → ndarray[source]

Resample scenario rows.

Equally-weighted rows draw uniform row indices; weighted rows draw uniforms against the cumulative probabilities, mapping the residual mass to the all-cash zero row.

Parameters:
  • n_samples (int) – The number of joint draws to produce.

  • rng (numpy.random.Generator) – The generator driving the draws.

Returns:

An (n_samples, N) matrix of simple returns.

Return type:

numpy.ndarray

keeks.allocation.models.scenario_model(scenarios: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], probabilities: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes] | None = None) → ScenarioModel[source]

Build a joint-return model from empirical scenario rows.

The thin factory over ScenarioModel; see there for the semantics. A historical returns series passed as scenarios becomes a first-class input: resample it with a seeded generator and the model is an iid bootstrap of the observations.

Parameters:
  • scenarios (array-like) – The (observations, options) matrix of joint simple returns.

  • probabilities (array-like, optional) – The probability of each scenario row. Omitted mass is cash at zero return.

Returns:

The empirical model.

Return type:

ScenarioModel

Examples

>>> model = scenario_model([[0.02, 0.01], [-0.01, 0.03], [0.0, -0.02]])
>>> model.sample(4, np.random.default_rng(9)).shape
(4, 2)
class keeks.allocation.models.BinaryBetsModel(bets: Sequence[tuple[float, float, float]], transaction_cost_rate: float = 0.0)[source]

Bases: JointReturnModel

Keeks-native binary bets as a joint-return model.

Each (probability, payoff, loss) triple becomes one option whose per-period simple return is the bet’s settlement per unit staked: (payoff - 1) - transaction_cost_rate on a win, -(loss + transaction_cost_rate) on a loss. This is the arithmetic keeks.multi_outcome.PortfolioSimulator settles with - a win adds (payoff - 1) * stake to the bankroll (payoff is decimal-odds gross including the returned stake) minus the fee - so allocation weights over these options replay portfolio stakes. Unlike here, the keeks.binary_strategies strategies read payoff as the net win per unit staked (decimal odds minus one): a KellyCriterion(payoff=2.0) bet is this model’s (p, 3.0, loss). transaction_cost_rate here is a per-unit-staked fee (the strategy-side fractional unit): it matches the portfolio simulator’s flat absolute fee on a unit stake.

Bets are independent: each settles on its own draw from its own stream, keyed by the bet’s exact values (BLAKE2b digest plus the occurrence ordinal of identical duplicates) beneath one per-call child seed drawn from the caller’s generator - so the same generator seed replays byte-identically, different seeds diverge, and adding, removing, or reordering bets never shifts a surviving bet’s stream.

Parameters:
  • bets (sequence of (probability, payoff, loss) triples) – One triple per independent bet, validated like the portfolio simulator’s bets.

  • transaction_cost_rate (float, default=0.0) – The per-unit-staked fee, subtracted from a win and added to a loss.

Examples

>>> model = BinaryBetsModel([(0.5, 2.0, 1.0), (0.25, 3.0, 1.0)])
>>> mean, covariance = model.moments()
>>> mean.tolist()
[0.0, -0.25]
>>> covariance.diagonal().tolist()
[1.0, 1.6875]
moments() → tuple[ndarray, ndarray][source]

Return the exact closed-form moments of the independent bets.

Per bet: mean = p * ((payoff - 1) - tc) - (1 - p) * (loss + tc) and variance from the second moment; independence across bets makes the covariance diagonal.

Returns:

The exact (mean, covariance).

Return type:

tuple of numpy.ndarray

sample(n_samples: int, rng: Generator) → ndarray[source]

Draw every bet’s per-period returns.

One uniform per bet per draw, from the bet’s own value-keyed stream; the per-call child seed consumes the caller’s generator, so repeated calls draw fresh independent streams while staying deterministic given the generator.

Parameters:
  • n_samples (int) – The number of joint draws to produce.

  • rng (numpy.random.Generator) – The generator driving the draws.

Returns:

An (n_samples, N) matrix of simple returns, one column per bet.

Return type:

numpy.ndarray

keeks.allocation.models.binary_bets_model(bets: Sequence[tuple[float, float, float]], transaction_cost_rate: float = 0.0) → BinaryBetsModel[source]

Build a joint-return model from keeks-native binary bets.

The thin factory over BinaryBetsModel; see there for the settlement arithmetic and the stream-keying contract.

Parameters:
  • bets (sequence of (probability, payoff, loss) triples) – One triple per independent bet.

  • transaction_cost_rate (float, default=0.0) – The per-unit-staked fee.

Returns:

The binary-bets model.

Return type:

BinaryBetsModel

Examples

>>> model = binary_bets_model([(0.5, 2.0, 1.0), (0.25, 3.0, 1.0)])
>>> model.sample(3, np.random.default_rng(11)).shape
(3, 2)
class keeks.allocation.models.MarginalModel(marginals: Sequence[tuple], dependence: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes] | None = None)[source]

Bases: JointReturnModel

Per-option parametric marginals joined by a configurable dependence.

Each option carries one built-in marginal family - normal, student_t, laplace, lognormal, or binary - and the dependence model joins them: None (the default) samples the marginals independently, which is numpy-only, and an (N, N) correlation matrix joins them through a Gaussian copula, which needs scipy and is gated behind keeks[allocation] at construction (spec decision D3).

Parameters:
  • marginals (sequence of (family, *parameters) tuples) – One spec per option: ("normal", mean, sd), ("student_t", nu) or ("student_t", nu, loc, scale), ("laplace", loc, scale), ("lognormal", mean, sd) (the lognormal variable’s own mean and standard deviation), or ("binary", probability, payoff, loss).

  • dependence (array-like, optional) – The Gaussian copula’s correlation matrix. None means independent.

Raises:

ImportError – When dependence is given and scipy is not installed.

Examples

>>> model = MarginalModel([("normal", 0.01, 0.02), ("binary", 0.5, 2.0, 1.0)])
>>> model.sample(3, np.random.default_rng(3)).shape
(3, 2)
moments() → tuple[ndarray, ndarray] | None[source]

Return the exact marginal moments when the model knows them.

Independence makes the joint moments the per-option ones (a diagonal covariance); any option with infinite variance - a Student-t with nu <= 2 - leaves the joint covariance undefined and returns None. A Gaussian copula preserves every marginal’s mean and variance but not the Pearson covariances, which have no closed form for non-Gaussian marginals, so copula models return None too.

Returns:

The exact (mean, covariance), or None when the model does not know its joint moments exactly.

Return type:

tuple of numpy.ndarray or None

sample(n_samples: int, rng: Generator) → ndarray[source]

Draw the joint simple returns.

Independent models draw each marginal’s column from the shared generator in option order; the Gaussian copula draws correlated normals from the generator, maps them to uniforms through the normal CDF, and maps those through each marginal’s inverse CDF.

Parameters:
  • n_samples (int) – The number of joint draws to produce.

  • rng (numpy.random.Generator) – The generator driving the draws.

Returns:

An (n_samples, N) matrix of simple returns.

Return type:

numpy.ndarray

keeks.allocation.models.marginals_model(marginals: Sequence[tuple], dependence: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes] | None = None) → MarginalModel[source]

Build a joint-return model from per-option parametric marginals.

The thin factory over MarginalModel; see there for the family specs and the dependence gating.

Parameters:
  • marginals (sequence of (family, *parameters) tuples) – One spec per option.

  • dependence (array-like, optional) – The Gaussian copula’s correlation matrix. None means independent.

Returns:

The marginals model.

Return type:

MarginalModel

Examples

>>> model = marginals_model([("normal", 0.01, 0.02), ("binary", 0.5, 2.0, 1.0)])
>>> mean, covariance = model.moments()
>>> mean.tolist()
[0.01, 0.5]
keeks.allocation.models.fit_marginals_model(returns: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], family: str = 'student_t', dependence: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes] | None = None) → MarginalModel[source]

Fit per-option parametric marginals to a historical returns series.

The “bring your own history” counterpart to marginals_model(): each column of the series is fitted to one built-in family and the fitted specs build a MarginalModel. The thin-tailed families are moment-matched to the column’s mean and (population) standard deviation; the Student-t is fitted by maximum likelihood through scipy.stats, which gates on the keeks[allocation] extra.

Parameters:
  • returns (array-like) – The (observations, options) matrix of historical simple returns.

  • family (str, default="student_t") – One of "normal", "student_t", "laplace", or "lognormal".

  • dependence (array-like, optional) – The Gaussian copula’s correlation matrix for the fitted marginals. None means independent.

Returns:

The fitted model.

Return type:

MarginalModel

Raises:
  • ImportError – When family is "student_t" and scipy is not installed.

  • ValueError – When the returns are invalid, the family is unknown, or a lognormal fit is asked for nonpositive observations.

Examples

>>> import numpy as np
>>> returns = [[0.01, 0.03], [0.03, -0.01], [-0.01, 0.01]]
>>> model = fit_marginals_model(returns, family="normal")
>>> mean, _ = model.moments()
>>> bool(np.allclose(mean, [0.01, 0.01]))
True
keeks.allocation.models.estimate_moments(model: JointReturnModel, n_samples: int, seed: int | None = None) → tuple[ndarray, ndarray][source]

Estimate a model’s (mean, covariance) from its own draws.

The fallback for models whose JointReturnModel.moments() is None: draw and take the sample mean and the unbiased sample covariance. Works on any object implementing the sampling contract - the escape hatch for user-supplied samplers.

Parameters:
  • model (JointReturnModel) – The model to draw from. Any object with sample(n_samples, rng) works.

  • n_samples (int) – The number of draws; at least two, since a covariance needs them.

  • seed (int, optional) – Seed for the private estimation stream. When omitted, a fresh generator drives the draws and no replay is promised.

Returns:

The estimated (mean, covariance).

Return type:

tuple of numpy.ndarray

Raises:

ValueError – If n_samples is not an integer of at least two, the seed is not a nonnegative integer or None, or the model’s draws violate the sampling contract.

Examples

>>> model = scenario_model([[0.01, -0.01], [0.03, 0.01]])
>>> mean, covariance = estimate_moments(model, 512, seed=0)
>>> mean.shape
(2,)
>>> mean2, _ = estimate_moments(model, 512, seed=0)
>>> bool(np.array_equal(mean, mean2))
True
keeks.allocation.scenarios.scenarios_to_moments(scenarios: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], probabilities: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes] | None = None) → tuple[ndarray, ndarray][source]

Bridge a scenario matrix to the (mean, covariance) moment descriptor.

The exact moments of the discrete distribution over the scenario rows - equally weighted, or weighted by probabilities - computed through keeks.allocation.ScenarioModel, so the validation and the residual-probability semantics are the model’s: probability mass below one is an all-cash period at zero return, never renormalized away. This is the door moment-based methods (MeanVariance, minimum variance, …) walk through to consume scenario inputs.

Parameters:
  • scenarios (array-like) – The (observations, options) matrix of joint simple returns.

  • probabilities (array-like, optional) – The probability of each scenario row, validated like every keeks probability vector. Omitted mass is cash at zero return.

Returns:

The exact (mean, covariance) of the scenario distribution - a shape (N,) mean vector and an (N, N) covariance matrix.

Return type:

tuple of numpy.ndarray

Raises:

ValueError – If the scenarios or the probabilities are invalid.

Examples

>>> mean, covariance = scenarios_to_moments(
...     [[0.02, 0.01], [-0.01, 0.01]], probabilities=[0.25, 0.25]
... )
>>> mean.tolist()  # the other half of the mass is an all-cash period
[0.0025, 0.005]

Moment-Based Methods

Static allocators over a mean vector and covariance matrix. Mean-variance at λ = 1 is the second-order Kelly allocation; the covariance-only methods (GMV, risk budgeting, maximum diversification) need no expected returns at all.

class keeks.allocation.moments.MeanVariance(mean: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], covariance: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], risk_aversion: float = 1.0)[source]

Bases: ModelInputMixin, BaseAllocationStrategy

Long-only mean-variance allocation: maximize w'mu - (l / 2) w'Sigma w.

The classic Markowitz problem over the long-only budget simplex: every weight in [0, 1], the weights summing to no more than one, the residual held as cash at zero return. The risk aversion l prices variance against expected return; at l = 1 the objective is the second-order approximation of expected log growth, so the optimum is the second-order Kelly allocation - optimize() reports the weights’ implied growth as expected_growth, and at unit risk aversion it is the maximized objective itself. Larger lambdas shrink the optimum toward all cash - with a cash option available, zero exposure minimizes variance - so RiskAversionScaling is the post-hoc way to dial any solved allocation toward cash while the risk aversion itself prices the growth-variance trade.

The quadratic program is convex and solved with scipy’s SLSQP behind the keeks[allocation] optional extra. Mean-variance methods assume finite second moments - a covariance that exists - so books with infinite-variance marginals belong to the scenario-based methods.

Weights are scale-free: they ignore the bankroll level and are all zeros for a nonpositive bankroll.

Parameters:
  • mean (array-like) – The expected simple return of each option.

  • covariance (array-like) – The covariance matrix of the options’ simple returns, matching the mean’s length.

  • risk_aversion (float, default=1.0) – The positive variance price l in the objective above.

Raises:
  • ImportError – When scipy is not installed; install the keeks[allocation] extra.

  • ValueError – If the mean, the covariance, or the risk aversion is invalid.

Examples

One option with expected return 2% and variance 4%: the unit risk-aversion optimum is mean / variance = 0.5, the rest cash:

>>> strategy = MeanVariance([0.02], [[0.04]], risk_aversion=1.0)
>>> [round(weight, 4) for weight in strategy.evaluate(1000.0)]
[0.5]
>>> strategy.evaluate(0.0)
(0.0,)

And keeks-native binary bets enter through the model door - the exact closed-form moments of a thin-edge even-money bet give the second-order Kelly stake, near Thorp’s f* = (2p - 1) / a with a the net win per unit staked (at odds 2.0, a = 1):

>>> from keeks.allocation.models import binary_bets_model
>>> model = binary_bets_model([(0.505, 2.0, 1.0)])
>>> kelly = MeanVariance.from_model(model)
>>> [round(weight, 4) for weight in kelly.evaluate(1000.0)]
[0.01]
evaluate(current_bankroll: float) → tuple[float, ...][source]

Return one long-only weight per option.

Parameters:

current_bankroll (float) – The current bankroll. Weights are scale-free fractions: the same vector at every positive bankroll, all zeros at zero or below.

Returns:

The validated weight tuple, one weight per option.

Return type:

tuple of float

optimize() → AllocationResult[source]

Return the allocation with its solver diagnostics.

objective is the maximized utility w'mu - (l / 2) w'Sigma w at the returned weights; expected_growth is the second-order estimate w'mu - w'Sigma w / 2 of E[log(1 + w'R)] - the same number at unit risk aversion, the Kelly bridge; volatility is the portfolio standard deviation sqrt(w'Sigma w).

Returns:

The optimal weights and the diagnostics above.

Return type:

AllocationResult

Examples

The unconstrained optimum of this two-option book sits inside the simplex (it holds about 68% cash), which is the budget constraint doing its job:

>>> result = MeanVariance(
...     [0.004, 0.005], [[0.02, 0.004], [0.004, 0.03]]
... ).optimize()
>>> result.converged
True
>>> [round(weight, 3) for weight in result.weights.tolist()]
[0.171, 0.144]
class keeks.allocation.moments.GlobalMinimumVariance(covariance: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes])[source]

Bases: _CovarianceOnlyModelInput, BaseAllocationStrategy

The long-only global minimum variance portfolio.

Minimizes w'Sigma w over the fully invested simplex {w : w >= 0, sum(w) = 1} - no expected returns, the one Markowitz corner that ignores mu entirely, which makes it the reference point when expected returns are untrustworthy. The equality is the formulation, not a restriction: under keeks’ budget contract a variance-only objective with a cash option available would simply hold all cash, so the classical fully invested anchor portfolio is the object worth naming. The unconstrained closed form w = Sigma^-1 1 / (1' Sigma^-1 1) solves the problem when it happens to land inside the simplex; the convex program handles the general long-only case.

Solved with scipy’s SLSQP behind the keeks[allocation] extra.

Weights are scale-free: they ignore the bankroll level and are all zeros for a nonpositive bankroll.

Parameters:

covariance (array-like) – The covariance matrix of the options’ simple returns.

Raises:
  • ImportError – When scipy is not installed; install the keeks[allocation] extra.

  • ValueError – If the covariance is invalid.

Examples

Two uncorrelated options with variances 4% and 1%: inverse-variance weights, one fifth and four fifths:

>>> result = GlobalMinimumVariance([[0.04, 0.0], [0.0, 0.01]]).optimize()
>>> [round(weight, 4) for weight in result.weights.tolist()]
[0.2, 0.8]
>>> round(result.objective, 6)
0.008
evaluate(current_bankroll: float) → tuple[float, ...][source]

Return one long-only weight per option.

Parameters:

current_bankroll (float) – The current bankroll. Weights are scale-free fractions: the same vector at every positive bankroll, all zeros at zero or below.

Returns:

The validated weight tuple, one weight per option.

Return type:

tuple of float

optimize() → AllocationResult[source]

Return the allocation with its solver diagnostics.

objective is the minimized portfolio variance w'Sigma w, and volatility its square root; there is no expected growth - the formulation never sees a mean.

Returns:

The optimal weights and the diagnostics above.

Return type:

AllocationResult

Examples

>>> result = GlobalMinimumVariance([[0.04, 0.0], [0.0, 0.01]]).optimize()
>>> round(result.volatility, 4)
0.0894
class keeks.allocation.moments.MaximumSharpe(mean: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], covariance: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], risk_free: float = 0.0)[source]

Bases: ModelInputMixin, BaseAllocationStrategy

The tangency portfolio: maximize the Sharpe ratio over the budget simplex.

The Sharpe ratio (w'mu - r) / sqrt(w'Sigma w) is invariant to scaling a portfolio (cash at the risk-free rate scales both numerator and denominator), so the budget constraint never binds and the answer is a direction. The Cornuejols-Tutuncu reformulation turns “maximize the ratio” into the convex quadratic min y'Sigma y subject to (mu - r)'y = 1 and y >= 0 through y = w / ((mu - r)'w), and the optimum reads back normalized to the fully invested representative w = y / sum(y). The reformulation needs at least one option with expected return above the risk-free rate - with none, cash dominates every option and no tangency exists.

Solved with scipy’s SLSQP behind the keeks[allocation] extra. Mean-variance methods assume finite second moments; see MeanVariance for the note.

Weights are scale-free: they ignore the bankroll level and are all zeros for a nonpositive bankroll.

Parameters:
  • mean (array-like) – The expected simple return of each option.

  • covariance (array-like) – The covariance matrix of the options’ simple returns, matching the mean’s length.

  • risk_free (float, default=0.0) – The per-period risk-free (cash) rate the excess return is measured against.

Raises:
  • ImportError – When scipy is not installed; install the keeks[allocation] extra.

  • ValueError – If the mean, the covariance, or the risk-free rate is invalid, or no option’s expected return is above the risk-free rate.

Examples

Two uncorrelated equal-variance options with returns 1% and 2%: the tangency holds them in proportion to their excess returns, one third and two thirds:

>>> result = MaximumSharpe(
...     [0.01, 0.02], [[0.01, 0.0], [0.0, 0.01]]
... ).optimize()
>>> [round(weight, 4) for weight in result.weights.tolist()]
[0.3333, 0.6667]
>>> round(result.objective, 4)
0.2236
evaluate(current_bankroll: float) → tuple[float, ...][source]

Return one long-only weight per option.

Parameters:

current_bankroll (float) – The current bankroll. Weights are scale-free fractions: the same vector at every positive bankroll, all zeros at zero or below.

Returns:

The validated weight tuple, one weight per option.

Return type:

tuple of float

optimize() → AllocationResult[source]

Return the allocation with its solver diagnostics.

objective is the maximized Sharpe ratio of the returned weights; expected_growth is the second-order estimate w'mu - w'Sigma w / 2 of E[log(1 + w'R)]; volatility is the portfolio standard deviation sqrt(w'Sigma w).

Returns:

The optimal weights and the diagnostics above.

Return type:

AllocationResult

Examples

>>> result = MaximumSharpe(
...     [0.01, 0.02], [[0.01, 0.0], [0.0, 0.01]]
... ).optimize()
>>> round(result.expected_growth, 4)
0.0139
class keeks.allocation.moments.MaximumDiversification(covariance: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes])[source]

Bases: _CovarianceOnlyModelInput, BaseAllocationStrategy

Maximize the diversification ratio w'sigma / sqrt(w'Sigma w).

Choueifaty-Coignard’s ratio: the weighted average volatility over the portfolio volatility. The ratio is scale-invariant and the numerator is positive for any nonzero long-only w (volatilities are), so the same Cornuejols-Tutuncu substitution MaximumSharpe uses applies with the volatilities in the direction’s place - and like the tangency, the formulation never sees a mean. The answer is the fully invested representative of the most diversified direction.

Solved with scipy’s SLSQP behind the keeks[allocation] extra.

Weights are scale-free: they ignore the bankroll level and are all zeros for a nonpositive bankroll.

Parameters:

covariance (array-like) – The covariance matrix of the options’ simple returns; strictly positive variances, since the ratio divides by them.

Raises:
  • ImportError – When scipy is not installed; install the keeks[allocation] extra.

  • ValueError – If the covariance is invalid or carries a zero variance.

Examples

Two correlated options with volatilities 20% and 10%: the most diversified long-only book holds a third and two thirds:

>>> result = MaximumDiversification(
...     [[0.04, 0.004], [0.004, 0.01]]
... ).optimize()
>>> [round(weight, 4) for weight in result.weights.tolist()]
[0.3333, 0.6667]
>>> round(result.objective, 3)
1.291
evaluate(current_bankroll: float) → tuple[float, ...][source]

Return one long-only weight per option.

Parameters:

current_bankroll (float) – The current bankroll. Weights are scale-free fractions: the same vector at every positive bankroll, all zeros at zero or below.

Returns:

The validated weight tuple, one weight per option.

Return type:

tuple of float

optimize() → AllocationResult[source]

Return the allocation with its solver diagnostics.

objective is the maximized diversification ratio of the returned weights and volatility the portfolio standard deviation sqrt(w'Sigma w); there is no expected growth - the formulation never sees a mean.

Returns:

The optimal weights and the diagnostics above.

Return type:

AllocationResult

Examples

>>> result = MaximumDiversification(
...     [[0.04, 0.004], [0.004, 0.01]]
... ).optimize()
>>> round(result.volatility, 4)
0.1033
class keeks.allocation.moments.RiskBudgeting(covariance: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], risk_budgets: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes] | None = None)[source]

Bases: _CovarianceOnlyModelInput, BaseAllocationStrategy

Spinu’s risk budgeting: give each option its targeted share of the risk.

Minimizes the convex objective 0.5 x'Sigma x - sum_i b_i log x_i over strictly positive x; the first-order condition x_i (Sigma x)_i = b_i ties every option’s risk contribution w_i (Sigma w)_i to its budget b_i. Normalized budgets are therefore the risk contribution shares, and equal budgets - the default - give the equal risk contribution portfolio. Budgets are scale-free: rescaling them rescales x but not the returned weights.

Solved by cyclical coordinate descent - one closed-form coordinate update at a time, numpy-only, no scipy, deterministic. The solve runs at construction and normalizes once into weights summing to one.

Weights are scale-free: they ignore the bankroll level and are all zeros for a nonpositive bankroll.

Parameters:
  • covariance (array-like) – The covariance matrix of the options’ simple returns; strictly positive variances, since the coordinate updates divide by them.

  • risk_budgets (array-like, optional) – The strictly positive risk budget of each option, scale-free. Defaults to equal budgets (the ERC portfolio).

Raises:
  • ValueError – If the covariance or the budgets are invalid.

  • RuntimeError – When the coordinate descent does not converge.

Examples

Two uncorrelated options with volatilities 20% and 10%: equal risk budgets weight the quieter option twice as heavy, one third and two thirds:

>>> result = RiskBudgeting([[0.04, 0.0], [0.0, 0.01]]).optimize()
>>> [round(weight, 4) for weight in result.weights.tolist()]
[0.3333, 0.6667]

And the budgets are the risk contribution shares - here 75/25:

>>> budgeted = RiskBudgeting(
...     [[0.04, 0.0], [0.0, 0.01]], risk_budgets=[0.75, 0.25]
... )
>>> [round(weight, 4) for weight in budgeted.weights.tolist()]
[0.4641, 0.5359]
evaluate(current_bankroll: float) → tuple[float, ...][source]

Return one long-only weight per option.

Parameters:

current_bankroll (float) – The current bankroll. Weights are scale-free fractions: the same vector at every positive bankroll, all zeros at zero or below.

Returns:

The validated weight tuple, one weight per option.

Return type:

tuple of float

optimize() → AllocationResult[source]

Return the allocation with its solver diagnostics.

objective is the minimized Spinu objective 0.5 x'Sigma x - sum_i b_i log x_i at the solver’s solution (before the single normalization into weights), converged and iterations the coordinate descent’s outcome and sweep count, and volatility the portfolio standard deviation sqrt(w'Sigma w). There is no expected growth - the formulation never sees a mean.

Returns:

The optimal weights and the diagnostics above.

Return type:

AllocationResult

Examples

>>> result = RiskBudgeting([[0.04, 0.0], [0.0, 0.01]]).optimize()
>>> result.converged, result.iterations > 0
(True, True)
class keeks.allocation.moments.RiskAversionScaling(inner: BaseAllocationStrategy, factor: float = 1.0)[source]

Bases: BaseAllocationStrategy

Shrink any allocator’s weights toward cash: w -> factor * w.

The fractional-Kelly dial for the whole layer: a factor of kappa stakes kappa of the wrapped allocator’s every weight and holds the rest as cash - the fractional-Kelly construction (a kappa fraction of the Kelly portfolio corresponds to CRRA risk aversion 1 / kappa), applicable to any allocator the layer can produce without a new solver. For an unconstrained interior mean-variance optimum, shrinking by kappa is exactly re-solving with kappa times the risk aversion; once the budget or bounds bind, the shrunk portfolio is the honest “same portfolio, less of it” answer.

The wrapped allocator stays in charge of its own state: settlements flow to it (an online allocator inside the wrapper keeps adapting), and evaluate is a pure read of the wrapped weights, scaled.

Parameters:
  • inner (BaseAllocationStrategy) – The allocator whose weights are scaled.

  • factor (float, default=1.0) – The scale kappa in [0, 1]: 1 leaves the allocator untouched, 0 is the all-cash portfolio.

Raises:

ValueError – If inner is not a BaseAllocationStrategy or the factor is not a finite number in [0, 1].

Examples

Half-Kelly over the unit-risk-aversion mean-variance stake:

>>> inner = MeanVariance([0.02], [[0.04]], risk_aversion=1.0)
>>> half_kelly = RiskAversionScaling(inner, factor=0.5)
>>> [round(weight, 4) for weight in half_kelly.evaluate(1000.0)]
[0.25]
>>> [round(weight, 4) for weight in half_kelly.optimize().weights.tolist()]
[0.25]
evaluate(current_bankroll: float) → tuple[float, ...][source]

Return one scaled long-only weight per option.

Parameters:

current_bankroll (float) – The current bankroll, passed through to the wrapped allocator. All zeros when it is nonpositive, like every keeks strategy.

Returns:

The validated weight tuple: the wrapped allocator’s weights, each scaled by the factor.

Return type:

tuple of float

optimize() → AllocationResult[source]

Return the shrunk allocation with the inner solver’s diagnostics.

The weights are the inner allocator’s optimum scaled by the factor; converged and iterations are the inner solve’s, and volatility scales exactly (sqrt((k w)'Sigma (k w)) == k sqrt(w'Sigma w)). objective and expected_growth describe the inner allocator’s unshrunk problem, so they stay None here. An inner allocator without an optimize - the online family, whose state is its settlement history - returns the shrunk current weights with no diagnostics.

Returns:

The scaled weights and whatever diagnostics transfer.

Return type:

AllocationResult

Scenario-Based Methods

Scenario methods consume joint-return draws directly — sample-CVaR is defined for any finite sample, so fat-tailed marginals and distributions without a finite variance size here, where the moment-based methods’ second-moment assumption would already be meaningless. Unit risk aversion is the v1 posture: the objective trades expected return against the tail’s expected loss one-for-one, so on daily-frequency market data — expected return far below the tail expectation — the optimum is typically full cash.

class keeks.allocation.scenarios.MeanCVaR(scenarios: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], tail_alpha: float = 0.05)[source]

Bases: BaseAllocationStrategy, ModelInputMixin

Mean-CVaR allocation over a scenario matrix.

Sizes the portfolio against a sample of joint simple-return vectors - empirical rows, model draws, anything shape (T, N) - by maximizing the unit-risk-aversion tradeoff between expected return and tail risk:

maximize  w' mu - CVaR_tail(w)

where mu is the scenarios’ mean vector and CVaR_tail the expected portfolio loss in the worst tail_alpha fraction of scenarios, losses read as negative simple returns. The scenarios are the whole story: sample-CVaR is defined for any finite sample, so fat-tailed marginals, mixture jumps, and distributions without a finite variance all size here - the territory where the moment-based methods’ second-moment assumption would already be meaningless. The price is the empirical estimate’s sampling noise: the scenarios are the distribution as far as this allocator knows.

Through the Rockafellar-Uryasev auxiliary-variable trick the tradeoff is exactly a linear program - one tail threshold alpha, one nonnegative surplus u_t per scenario, and the house long-only budget - solved with scipy.optimize.linprog under the HiGHS backend, which is why the class is gated on the keeks[allocation] optional extra. Unit risk aversion is the v1 posture: the objective trades expected return against CVaR one-for-one, and a risk-aversion dial is a documented follow-up, not a v1 parameter (spec decision D2).

The derivation is public and inspectable: scenarios (the validated descriptor, also the key the simulator’s descriptor-equality gate reads), tail_alpha, weights, cvar (the optimal tail expectation), objective (the minimized program value), converged, and iterations are exposed attributes.

Weights are long-only and sum to no more than one - the residual is cash at zero return - and are scale-free: all zeros for a nonpositive bankroll.

Parameters:
  • scenarios (array-like) – The (observations, options) matrix of joint simple returns. Validated like every scenario descriptor: non-empty, two-dimensional, finite.

  • tail_alpha (float, default=0.05) – The tail fraction, strictly between 0 and 1: 0.05 averages the worst one in twenty scenarios.

Raises:

ImportError – When scipy is not installed - the linear program is the method, so there is no numpy-only fallback. Install the optional solver backend with pip install keeks[allocation]; the first example below demonstrates the pointed error.

Examples

The scipy gate points at the optional extra, not a deep traceback:

>>> import builtins
>>> real_import = builtins.__import__
>>> def without_scipy(name, *args, **kwargs):
...     if name.startswith("scipy"):
...         raise ImportError(f"No module named {name!r}")
...     return real_import(name, *args, **kwargs)
>>> builtins.__import__ = without_scipy
>>> try:
...     MeanCVaR([[0.01], [-0.01]])
... except ImportError as error:
...     print("points at the extra:", "keeks[allocation]" in str(error))
... finally:
...     builtins.__import__ = real_import
points at the extra: True
>>> import numpy as np
>>> scenarios = np.array([
...     [0.03, 0.01],
...     [-0.01, 0.02],
...     [0.01, -0.01],
...     [-0.02, -0.02],
... ])
>>> strategy = MeanCVaR(scenarios)
>>> weights = strategy.evaluate(1000.0)
>>> len(weights)
2
>>> all(0.0 <= weight <= 1.0 for weight in weights)
True
>>> sum(weights) <= 1 + 1e-12
True
>>> strategy.evaluate(0.0)
(0.0, 0.0)

optimize() carries the solver’s diagnostics on the result object:

>>> result = strategy.optimize()
>>> result.converged
True
>>> result.iterations >= 0
True

Any joint-return model builds a scenario-bound allocator through from_model - keeks binary bets, fitted marginals, user samplers:

>>> from keeks.allocation.models import binary_bets_model
>>> model = binary_bets_model([(0.5, 2.0, 1.0), (0.25, 3.0, 1.0)])
>>> strategy = MeanCVaR.from_model(model, n_samples=256, seed=7)
>>> strategy.scenarios.shape
(256, 2)
evaluate(current_bankroll: float) → tuple[float, ...][source]

Return one long-only weight per option.

Parameters:

current_bankroll (float) – The current bankroll. Weights are scale-free fractions: the same vector at every positive bankroll, all zeros at zero or below.

Returns:

The validated weight tuple, one weight per option.

Return type:

tuple of float

Examples

>>> scenarios = [[0.03, 0.01], [-0.01, 0.02], [0.01, -0.01]]
>>> MeanCVaR(scenarios).evaluate(0.0)
(0.0, 0.0)
classmethod from_model(model: JointReturnModel, n_samples: int = 10000, seed: int | None = None, **kwargs) → MeanCVaR[source]

Build a scenario-bound allocator from a joint-return model’s draws.

The scenario-bound override of the moment-based mixin: draws, not moments, are this allocator’s descriptor, so from_model samples the model and binds the draw matrix - the model’s exact moments() hook, when it has one, is never consulted. A keeks.allocation.models.ScenarioModel carrying residual probability mass contributes all-cash zero rows proportionally, and the program reads them like any other scenario.

Parameters:
  • model (JointReturnModel) – The model to draw from. Any object with sample(n_samples, rng) works.

  • n_samples (int, default=10000) – The number of scenarios to draw; the program has one surplus variable per scenario, so very large counts cost solve time.

  • seed (int, optional) – Seed for the private sampling stream; omit it for fresh draws with no replay promised.

  • **kwargs – Extra constructor keyword arguments for cls (a tail fraction, …).

Returns:

The allocator bound to the model’s draws.

Return type:

MeanCVaR

Examples

>>> from keeks.allocation.models import scenario_model
>>> strategy = MeanCVaR.from_model(
...     scenario_model([[0.02], [-0.01], [0.01]]), n_samples=3, seed=1
... )
>>> strategy.scenarios.shape
(3, 1)
optimize() → AllocationResult[source]

Return the allocation with its solver diagnostics.

objective is the minimized program value (the tail expectation minus the scenarios’ expected return under the optimal weights) and cvar the tail expectation itself; expected_growth estimates E[log(1 + w'R)] under the scenarios - None when the optimal portfolio wipes out in some scenario, where the log is undefined - and volatility reads the weights against the scenarios’ empirical covariance from scenarios_to_moments().

When the program’s optimum is full cash - every weight effectively zero, as on daily-frequency market data under the unit-risk-aversion objective - all_cash_reason carries that explanation on the result, in the result object rather than only in example output.

Returns:

The weight vector with the program’s diagnostics.

Return type:

AllocationResult

Examples

>>> import numpy as np
>>> scenarios = np.array([
...     [0.03, 0.01],
...     [-0.01, 0.02],
...     [0.01, -0.01],
...     [-0.02, -0.02],
... ])
>>> result = MeanCVaR(scenarios).optimize()
>>> result.converged
True
>>> result.weights.shape
(2,)

Daily-frequency scenarios hold full cash, and the result says why:

>>> daily = np.array([
...     [0.0002, 0.0001],
...     [-0.0002, -0.0001],
...     [0.0001, -0.0001],
...     [-0.0001, 0.0002],
... ])
>>> MeanCVaR(daily).optimize().all_cash_reason is not None
True

Hierarchical Risk Parity

HRP builds the cluster tree from correlation distance, quasi-diagonalizes, and bisects recursively with inverse-variance splits — it never inverts the covariance matrix, so it sizes ill-conditioned books.

class keeks.allocation.hierarchical.HierarchicalRiskParity(covariance: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes])[source]

Bases: BaseAllocationStrategy

Hierarchical risk parity over a covariance matrix.

Sizes the portfolio from the covariance alone (Lopez de Prado, 2016): correlation distance clusters the options, the cluster tree is read as a quasi-diagonal leaf permutation, and a recursive bisection allocates each split in inverse-variance proportion. No expected returns, no solver, no matrix inversion - and no randomness, so repeated constructions from the same covariance replay bit-identically.

The derivation is public and inspectable: distance (the correlation-distance matrix), linkage (the agglomeration), and order (the quasi-diagonal permutation) are exposed attributes.

Weights are long-only and sum to one - the hierarchy fully invests the budget, leaving no cash residual - and are scale-free: all zeros for a nonpositive bankroll.

Parameters:

covariance (array-like) – The covariance matrix of the options’ simple returns. Validated like every allocation descriptor (square, finite, symmetric, positive semidefinite), and additionally required to have strictly positive variances: the bisection’s inverse-variance splits are undefined for a zero-variance option.

Examples

>>> covariance = np.array([[0.04, 0.004], [0.004, 0.04]])
>>> strategy = HierarchicalRiskParity(covariance)
>>> strategy.evaluate(1000.0)
(0.5, 0.5)
>>> strategy.evaluate(0.0)
(0.0, 0.0)

Two equally risky options split the budget evenly regardless of their correlation; an asymmetric covariance splits by inverse variance:

>>> risky = np.array([[0.04, 0.002], [0.002, 0.01]])
>>> result = HierarchicalRiskParity(risky).optimize()
>>> [round(weight, 4) for weight in result.weights.tolist()]
[0.2, 0.8]
evaluate(current_bankroll: float) → tuple[float, ...][source]

Return one long-only weight per option.

Parameters:

current_bankroll (float) – The current bankroll. Weights are scale-free fractions: the same vector at every positive bankroll, all zeros at zero or below.

Returns:

The validated weight tuple, one weight per option.

Return type:

tuple of float

Examples

>>> covariance = np.array([[0.04, 0.004], [0.004, 0.04]])
>>> HierarchicalRiskParity(covariance).evaluate(250.0)
(0.5, 0.5)
optimize() → AllocationResult[source]

Return the allocation with its diagnostics.

Hierarchical risk parity runs no solver, so objective, converged, iterations, and expected_growth stay None; volatility is sqrt(w' Sigma w) under the construction covariance.

Returns:

The weight vector and the portfolio volatility it implies.

Return type:

AllocationResult

Examples

>>> covariance = np.array([[0.04, 0.004], [0.004, 0.04]])
>>> result = HierarchicalRiskParity(covariance).optimize()
>>> result.weights.tolist()
[0.5, 0.5]
>>> round(result.volatility, 4)
0.1483

Estimators

Preprocessors that compose with any optimizer above: shrinkage fixes Σ, Black-Litterman fixes μ.

keeks.allocation.estimators.shrink_covariance(samples: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], alpha: float | None = None) → tuple[ndarray, float][source]

Ledoit-Wolf shrinkage estimate of a covariance matrix.

The estimate is a convex combination of the unbiased sample covariance S and the structured target F = m * I, where m is the average variance trace(S) / N (Ledoit & Wolf 2004):

shrunk = (1 - alpha) * S + alpha * F

alpha = 0 returns the sample covariance exactly and alpha = 1 the target exactly; in between, the blend trades the sample matrix’s noise for the target’s stability. When alpha is omitted, the intensity is estimated from the samples by the Ledoit-Wolf formula - the ratio of estimated estimation noise to the distance to the target, capped to [0, 1] - so the estimate adapts: trusted samples shrink barely, noisy ones shrink toward the average variance.

The samples are centered by their own column means first, so the estimate describes dispersion around the observed means. Only the covariance is touched - there is no mean output, and no μ is consumed.

Parameters:
  • samples (array-like) – The (observations, options) matrix of joint simple returns. At least two rows - a covariance needs them.

  • alpha (float, optional) – The shrinkage intensity in [0, 1]. When omitted, the Ledoit-Wolf intensity is estimated from the samples.

Returns:

The shrunk covariance as an (N, N) positive semidefinite matrix, and the intensity used (the estimated one when alpha is None).

Return type:

tuple of numpy.ndarray and float

Raises:

ValueError – If the samples are not a two-dimensional sequence of at least two rows of finite numbers, or alpha is given and is not a finite number in [0, 1].

Examples

>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> samples = rng.normal(scale=0.02, size=(128, 4))
>>> shrunk, alpha = shrink_covariance(samples)
>>> shrunk.shape
(4, 4)
>>> 0.0 <= alpha <= 1.0
True
>>> sample_only, _ = shrink_covariance(samples, alpha=0.0)
>>> bool(np.allclose(sample_only, np.cov(samples, rowvar=False)))
True
>>> fully_shrunk, _ = shrink_covariance(samples, alpha=1.0)
>>> target = np.eye(4) * np.trace(sample_only) / 4
>>> bool(np.allclose(fully_shrunk, target))
True
keeks.allocation.estimators.black_litterman_mean(covariance: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], market_weights: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], risk_aversion: float, views: tuple[_Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes]] | None, omega: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes] | None, tau: float) → ndarray[source]

Black-Litterman posterior mean: equilibrium returns blended with views.

The equilibrium (prior) mean is the one implied by the market weights through reverse optimization, prior = risk_aversion * covariance @ market_weights. Each view - a row P_k of the view matrix whose portfolio the holder expects to return q_k - is a noisy observation of that portfolio’s mean return, with uncertainty omega. The posterior precision-weights the prior against the views (Black & Litterman 1992):

mu = [(tau * covariance)^-1 + P' omega^-1 P]^-1
     [(tau * covariance)^-1 prior + P' omega^-1 q]

tau scales how much the prior is trusted relative to the views, and omega’s diagonal how noisy each view is: a confident view (small uncertainty) pulls the posterior toward q, an uninformative one leaves the prior standing. With no views the posterior is exactly the prior.

The result is a mean preprocessor, not an allocator: it fixes μ only, never Σ - feed the posterior to any optimizer that consumes a mean vector and keep the covariance it was built from.

Parameters:
  • covariance (array-like) – The (N, N) covariance of the options’ simple returns. Must be invertible - the posterior precision needs (tau * covariance)^-1.

  • market_weights (sequence of float) – One weight per option under the same long-only budget contract as strategy weights (each in [0, 1], summing to no more than one); the conventional fully-invested market portfolio passes with sum exactly one.

  • risk_aversion (float) – The market risk aversion delta turning the market weights into equilibrium returns. Positive.

  • views (tuple of array-like or None) – The (P, q) pair: the (K, N) view matrix and the (K,) view returns. None, or a pair whose matrix has zero rows (with omega an empty (0, 0) matrix), means no views.

  • omega (array-like or None) – The (K, K) uncertainty covariance of the view returns. Provided together with views - both None or both set.

  • tau (float) – The prior scaling. Positive.

Returns:

The posterior mean, one entry per option.

Return type:

numpy.ndarray

Raises:

ValueError – If the covariance, view inputs, or omega fail the shared numeric gates; if exactly one of views and omega is None; if either matrix is singular where an inverse is needed.

Examples

>>> import numpy as np
>>> covariance = np.array([[0.02, 0.004], [0.004, 0.03]])
>>> weights = [0.6, 0.4]
>>> prior = black_litterman_mean(
...     covariance, weights, risk_aversion=2.5, views=None, omega=None,
...     tau=1.0,
... )
>>> prior.shape
(2,)
>>> views = (np.array([[1.0, -1.0]]), np.array([0.01]))
>>> posterior = black_litterman_mean(
...     covariance, weights, risk_aversion=2.5, views=views,
...     omega=np.array([[0.001]]), tau=1.0,
... )
>>> posterior.shape
(2,)

Online Allocation

Online allocators add one stateful hook — record_settlement(realized_returns), called once per staked period with the realized joint simple-return vector — and adapt their weights from realized returns. FixedWeights is the constant rebalanced portfolio, the regret benchmark the adaptive methods compete against.

class keeks.allocation.online.FixedWeights(weights: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes])[source]

Bases: _OnlineAllocator

Constant rebalanced portfolio: the same weight vector every period.

The bound weights are staked every period forever - the best constant rebalanced portfolio that the adaptive online methods measure their regret against. It is also the family’s cash door: a bound vector summing to less than one holds the residual at zero return, while the adaptive members are always fully invested.

The weights bind at construction and are immutable thereafter, validated like every weight vector: finite, each in [0, 1], summing to no more than one. record_settlement validates the realized returns - the house hook contract - but updates nothing: the benchmark has no state.

Examples

>>> allocator = FixedWeights([0.25, 0.25, 0.5])
>>> allocator.evaluate(1000.0)
(0.25, 0.25, 0.5)
>>> allocator.evaluate(0.0)
(0.0, 0.0, 0.0)
record_settlement(won: Sequence[bool] | None, realized_returns: Sequence[float]) → None[source]

Validate the settlement; the benchmark holds no state.

Parameters:
  • won (sequence of bool or None) – The settled period’s per-option outcome vector; the benchmark updates from the returns alone and ignores it.

  • realized_returns (sequence of float) – The realized joint simple-return vector of the settled period.

Raises:

ValueError – If the vector is not a non-empty one-dimensional sequence of finite numbers matching the option count.

class keeks.allocation.online.ExponentialGradient(option_count: int, learning_rate: float = 0.05)[source]

Bases: _OnlineAllocator

Follow-the-loser multiplicative update on realized returns.

The exponentiated-gradient update: after each settled period with realized joint simple returns r, every weight is scaled by an exponential of its option’s realized return and the vector is renormalized to sum to one,

w_i <- w_i * exp(-learning_rate * r_i) , renormalized.

Under the follow-the-loser convention the realized return is a cost signal, so the minus sign sends weight toward the options that just underperformed - the profitable direction when returns mean-revert. The exponent is clipped at ±_MAX_LOG_TILT log-points in magnitude, so an extreme settlement concentrates the portfolio instead of overflowing.

The option count binds at construction and the portfolio starts uniform and fully invested. record_settlement is the only stateful channel: evaluate never mutates state. Once an option’s weight reaches zero it stays there - multiplicative updates cannot revive it - though the uniform start and the positive renormalizer keep every weight strictly positive under any finite settlement history.

Parameters:
  • option_count (int) – The number of options to size across; binds at construction.

  • learning_rate (float) – Positive adaptivity rate - larger values tilt harder toward the most recent losers. Defaults to 0.05.

Examples

>>> allocator = ExponentialGradient(option_count=2, learning_rate=0.5)
>>> allocator.evaluate(1000.0)
(0.5, 0.5)
>>> allocator.record_settlement((True, False), [0.2, -0.1])
>>> weights = allocator.evaluate(1000.0)
>>> weights[1] > weights[0]
True
>>> sum(weights) <= 1 + 1e-12
True
record_settlement(won: Sequence[bool] | None, realized_returns: Sequence[float]) → None[source]

Advance the weights with the settled joint simple returns.

Parameters:
  • won (sequence of bool or None) – The settled period’s per-option outcome vector; the gradient update reads the returns alone and ignores it.

  • realized_returns (sequence of float) – The realized joint simple-return vector of the settled period; one entry per option.

Raises:

ValueError – If the vector is not a non-empty one-dimensional sequence of finite numbers matching the option count.

class keeks.allocation.online.OnlineNewtonStep(option_count: int, learning_rate: float = 0.5, epsilon: float = 1e-06)[source]

Bases: _OnlineAllocator

Second-order follow-the-loser update with gradient outer-product state.

The online Newton step: the per-period follow-the-loser cost is the linear <w, r> whose gradient at any w is the realized return vector r itself. The allocator accumulates those gradients’ outer products into a regularized Gram matrix,

A = epsilon * I + sum_t r_t r_t'

and takes a Newton-style step on the cost before projecting back onto the simplex:

y = w - learning_rate * A^{-1} r , w <- project(y).

The inverse outer-product mass shrinks the step along directions the return stream has already explored and leaves it near full size along new ones, so adaptation concentrates where the stream still varies. The settlement that triggers the update contributes its outer product before the step, so early steps scale with the observed return magnitude instead of being amplified by 1 / epsilon.

Larger learning_rate values adapt faster; epsilon is the small ridge that keeps A invertible when the return stream never spans some direction. The raw step can overshoot the weight bounds - projecting onto the simplex keeps every weight long-only and fully invested - so early adaptation is aggressive on purpose: raise epsilon or lower learning_rate to temper it.

Parameters:
  • option_count (int) – The number of options to size across; binds at construction.

  • learning_rate (float) – Positive step size. Defaults to 0.5.

  • epsilon (float) – Positive ridge added to the Gram matrix’s diagonal. Defaults to 1e-6.

Examples

>>> allocator = OnlineNewtonStep(option_count=2)
>>> allocator.evaluate(1000.0)
(0.5, 0.5)
>>> allocator.record_settlement((True, False), [0.05, -0.05])
>>> weights = allocator.evaluate(1000.0)
>>> weights[1] > weights[0]
True
>>> sum(weights) <= 1 + 1e-12
True
record_settlement(won: Sequence[bool] | None, realized_returns: Sequence[float]) → None[source]

Advance the weights with the settled joint simple returns.

Parameters:
  • won (sequence of bool or None) – The settled period’s per-option outcome vector; the Newton step reads the returns alone and ignores it.

  • realized_returns (sequence of float) – The realized joint simple-return vector of the settled period; one entry per option.

Raises:

ValueError – If the vector is not a non-empty one-dimensional sequence of finite numbers matching the option count.

Simulator

The simulator replays an allocator over scenario realizations — batch-net settlement through the bankroll machinery, refuse-then-stop ruin policy, flat per-period costs, and the house single-seed-stream reproducibility contract. It is what makes the static/online distinction observable: the settlement hook feeds online allocators’ record_settlement.

class keeks.allocation.simulators.AllocationSimulator(model: JointReturnModel, probabilities: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes] | None = None, fee_per_bet: float = 0.0, trials: int = 1000, seed: int | None = None)[source]

Bases: object

Replay an allocator over scenario realizations through a bankroll.

Each trial draws one joint simple-return realization from the model, asks the allocation for its weights, and settles the portfolio through BankRoll with keeks’ batch-net convention: the option stakes are the weights times the bankroll’s bettable funds as they stood when the trial began, the option returns turn the stakes into signed amounts, and the amounts net into exactly one deposit or withdrawal - so bankruptcy and drawdown safeguards evaluate the period once, and the simulation is the single rounding site per period.

Residual probability mass is an all-cash period: with probabilities given, each trial draws one uniform and reads it against the cumulative bands, and a draw beyond the total mass is a period where nothing is realized - no transaction, no fee, and an all-None outcome vector with a zero return vector reported through the settlement hook.

Parameters:
  • model (JointReturnModel) – Source of joint simple-return realizations - anything exposing sample(n_samples, rng) -> (n_samples, N). Empirical scenario rows, keeks-native binary bets, parametric marginals, and user callables all enter through the same contract; a raw matrix wraps with scenario_model().

  • probabilities (sequence of float, optional) – Probability of each model state, validated like every keeks probability vector (finite, nonnegative, summing to no more than one within PROBABILITY_SUM_TOLERANCE). None (the default) makes the model’s draws the trial sequence: trial t settles the t-th realization row.

  • fee_per_bet (float) – Flat cost charged once per staked period, the house convention. Turnover-based charging (costs that scale with weight changes between periods) is deliberately deferred.

  • trials (int) – Maximum number of periods to replay.

  • seed (int, optional) – Seed for the simulator’s private random stream. See the reproducibility contract below.

Raises:

ValueError – If the model does not expose the sampling contract, the probabilities fail validation, the transaction costs are negative or non-finite, the trial count is not a nonnegative integer, or the seed is not a nonnegative integer or None.

Examples

>>> from keeks import BankRoll
>>> from keeks.allocation import AllocationSimulator, FixedWeights
>>> from keeks.allocation import scenario_model
>>> model = scenario_model(
...     [[0.03, 0.01], [-0.01, 0.02], [0.01, -0.005], [0.0, 0.0]]
... )
>>> simulator = AllocationSimulator(model, trials=200, seed=42)
>>> bankroll = BankRoll(initial_funds=1000.0, max_transaction_loss=None)
>>> simulator.evaluate_strategy(FixedWeights([0.4, 0.2]), bankroll)
>>> round(float(bankroll.total_funds), 2)
2583.92
>>> len(bankroll.history)
201

Notes

Reproducibility contract (public behavior, matching the multi-outcome simulators): with a seed, every draw the simulator makes comes from one private numpy.random.Generator on a spawned numpy.random.SeedSequence child - default_rng(SeedSequence(seed).spawn(1)[0]) - so a seeded run replays byte-identically: same realizations, same hook calls, same bankroll history. Seeded runs consume nothing from numpy’s global generator. Without a seed, a fresh generator drives each evaluate_strategy call and no replay is promised.

Realization semantics: the realizations are drawn from the model in one sampling call, lazily, the first time a trial needs one - a run whose every trial is skipped or rejected consumes no draws at all. With no probabilities, the drawn matrix is the trial sequence: trial t settles row t. This one-call, in-order shape is deliberate - it is what lets a binary-bets book (BinaryBetsModel) replay PortfolioSimulator’s outcome streams draw-for-draw under matched seeding, because the model keys each bet’s per-bet stream exactly as the portfolio simulator does and the simulator hands each bet its trials in order. With probabilities, the matrix holds one row per probability band and each staked trial reads one uniform against the cumulative bands.

Each staked trial then runs in a fixed order:

  1. Stop when the bankroll is depleted (total_funds <= 0).

  2. Fire the allocation’s update_bankroll hook when it has one.

  3. Validate the weight vector returned by evaluate: finite, one weight per option, each in [0, 1], sum at most 1 + PROBABILITY_SUM_TOLERANCE. An invalid vector aborts the run before any draw is consumed whenever the model’s option count is known without sampling (every built-in model); models that reveal it only by sampling get the length gate at the first settlement, but no settlement ever sees an invalid vector. A trial staking nothing (every weight zero) is skipped entirely: no draw is consumed, no fee is charged, and no settlement is reported.

  4. Draw one realization (see the realization semantics above). An all-cash period - residual probability mass - reports a zero vector through the settlement hook and moves to the next trial with the bankroll untouched.

  5. Settle the batch net: exactly one deposit (net gain) or withdrawal (net loss) of bankroll * w'R(omega) minus the flat transaction cost, written to the history once. When a bankroll safeguard refuses the settlement (RuinError - bankruptcy or the configured max_transaction_loss cap), the period leaves the bankroll unchanged and the simulation stops after it completes.

  6. Fire record_settlement(won, realized_returns) once with the period’s per-option outcome vector (True when the option’s realized return is positive, False otherwise) and the realized joint simple-return vector - the state channel for online allocators. A refused settlement still reports the market’s realization: the refusal blocks the bankroll transfer, not the market’s move.

A scenario-bound allocator - one carrying a scenarios descriptor, like MeanCVaR - must be sized with the scenarios the simulator’s model settles with: the simulator runs the same descriptor-equality gate the multi-outcome simulators run on odds (_validate_strategy_scenarios()), and a mismatch raises before any draw is consumed. Allocators bound to other descriptors pass untouched, as do models without scenario rows - their compatibility stays the caller’s responsibility.

evaluate_strategy(allocation: BaseAllocationStrategy, bankroll: BankRoll) → None[source]

Replay the allocation over the simulator’s realizations, in place.

Parameters:
  • allocation (BaseAllocationStrategy) – The allocator to replay. Online allocators are resolved for their update_bankroll and record_settlement hooks.

  • bankroll (BankRoll) – The bankroll the portfolio settles through.

Raises:

ValueError – If a scenario-bound allocation does not match the model’s scenarios, or a trial returns an invalid weight vector.