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,ABCAbstract 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
evaluateis wrapped so its returned weight vector is validated through_validate_weights()before the caller sees it - a subclass returning contract-violating weights fails its ownevaluate()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 implementrecord_settlement(won, realized_returns)- called once per staked period with the period’s per-option outcome vector (Truefor a win,Falsefor a loss,Nonewhen 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], andsum(result) <= 1 + PROBABILITY_SUM_TOLERANCE. The shortfall below one is cash held at zero return. All zeros whencurrent_bankroll <= 0: there is nothing left to allocate.- Return type:
- 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
weightsattribute, which keeps its name and remains the primary accessor. Wrappers that recompute from a wrapped allocator instead of holding their own solve (RiskAversionScaling) raiseAttributeError: read the wrapped allocator’sweights_, or this wrapper’sevaluate.
- 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:
objectDiagnostics from one allocation optimization.
optimize()returns one of these alongside the simulator-facingevaluatetuple, so solver diagnostics are first-class rather than a side dict. Every field exceptweightsis optional: an allocator fills the fields its formulation produces and leaves the restNone.Comparison is identity-based (
eq=False):weightsis anumpy.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:
- 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.
Nonewhen the portfolio takes risk or the allocator offers no explanation.- Type:
str, optional
- class keeks.allocation.models.ModelInputMixin[source]¶
Bases:
objectMixin giving moment-based allocators a
from_modelconstructor.Allocators consume
(mean, covariance)or scenario descriptors bound at construction; this mixin builds those descriptors from anyJointReturnModel- keeks binary bets, fitted marginals, user samplers - so every allocator accepts every input through one door. The classmethod prefers the model’s exactmoments()and falls back toestimate_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 toclseither 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
clsfrom 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_samplesandseedare only consumed on the estimation fallback.- Parameters:
model (JointReturnModel) – The model to build inputs from. Any object with
sample(n_samples, rng)(and optionallymoments()) 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:
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:
ABCSource 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_modelskip estimation entirely. Models without closed-form moments - Student-t marginals withnu <= 2(infinite variance), Gaussian-copula dependence (no closed-form Pearson covariance for non-Gaussian marginals), user samplers - returnNoneand are estimated from draws instead.- Returns:
The exact
(mean, covariance)- a shape(N,)mean vector and an(N, N)covariance matrix - orNonewhen 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:
- 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:
JointReturnModelEmpirical 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:
- 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:
- 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 asscenariosbecomes 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:
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:
JointReturnModelKeeks-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_rateon a win,-(loss + transaction_cost_rate)on a loss. This is the arithmetickeeks.multi_outcome.PortfolioSimulatorsettles with - a win adds(payoff - 1) * staketo the bankroll (payoffis decimal-odds gross including the returned stake) minus the fee - so allocation weights over these options replay portfolio stakes. Unlike here, thekeeks.binary_strategiesstrategies readpayoffas the net win per unit staked (decimal odds minus one): aKellyCriterion(payoff=2.0)bet is this model’s(p, 3.0, loss).transaction_cost_ratehere 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:
- 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:
- 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:
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:
JointReturnModelPer-option parametric marginals joined by a configurable dependence.
Each option carries one built-in marginal family -
normal,student_t,laplace,lognormal, orbinary- 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 behindkeeks[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.
Nonemeans independent.
- Raises:
ImportError – When
dependenceis 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 returnsNone. 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 returnNonetoo.- Returns:
The exact
(mean, covariance), orNonewhen 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:
- 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.
Nonemeans independent.
- Returns:
The marginals model.
- Return type:
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 aMarginalModel. The thin-tailed families are moment-matched to the column’s mean and (population) standard deviation; the Student-t is fitted by maximum likelihood throughscipy.stats, which gates on thekeeks[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.
Nonemeans independent.
- Returns:
The fitted model.
- Return type:
- Raises:
ImportError – When
familyis"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()isNone: 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:
- Raises:
ValueError – If
n_samplesis not an integer of at least two, the seed is not a nonnegative integer orNone, 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 throughkeeks.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:
- 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,BaseAllocationStrategyLong-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 aversionlprices variance against expected return; atl = 1the 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 asexpected_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 - soRiskAversionScalingis 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
lin 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) / awithathe 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.
- optimize() AllocationResult[source]¶
Return the allocation with its solver diagnostics.
objectiveis the maximized utilityw'mu - (l / 2) w'Sigma wat the returned weights;expected_growthis the second-order estimatew'mu - w'Sigma w / 2ofE[log(1 + w'R)]- the same number at unit risk aversion, the Kelly bridge;volatilityis the portfolio standard deviationsqrt(w'Sigma w).- Returns:
The optimal weights and the diagnostics above.
- Return type:
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,BaseAllocationStrategyThe long-only global minimum variance portfolio.
Minimizes
w'Sigma wover the fully invested simplex{w : w >= 0, sum(w) = 1}- no expected returns, the one Markowitz corner that ignoresmuentirely, 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 formw = 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.
- optimize() AllocationResult[source]¶
Return the allocation with its solver diagnostics.
objectiveis the minimized portfolio variancew'Sigma w, andvolatilityits square root; there is no expected growth - the formulation never sees a mean.- Returns:
The optimal weights and the diagnostics above.
- Return type:
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,BaseAllocationStrategyThe 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 quadraticmin y'Sigma ysubject to(mu - r)'y = 1andy >= 0throughy = w / ((mu - r)'w), and the optimum reads back normalized to the fully invested representativew = 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; seeMeanVariancefor 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.
- optimize() AllocationResult[source]¶
Return the allocation with its solver diagnostics.
objectiveis the maximized Sharpe ratio of the returned weights;expected_growthis the second-order estimatew'mu - w'Sigma w / 2ofE[log(1 + w'R)];volatilityis the portfolio standard deviationsqrt(w'Sigma w).- Returns:
The optimal weights and the diagnostics above.
- Return type:
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,BaseAllocationStrategyMaximize 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 substitutionMaximumSharpeuses 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.
- optimize() AllocationResult[source]¶
Return the allocation with its solver diagnostics.
objectiveis the maximized diversification ratio of the returned weights andvolatilitythe portfolio standard deviationsqrt(w'Sigma w); there is no expected growth - the formulation never sees a mean.- Returns:
The optimal weights and the diagnostics above.
- Return type:
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,BaseAllocationStrategySpinu’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_iover strictly positivex; the first-order conditionx_i (Sigma x)_i = b_ities every option’s risk contributionw_i (Sigma w)_ito its budgetb_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 rescalesxbut 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.
- optimize() AllocationResult[source]¶
Return the allocation with its solver diagnostics.
objectiveis the minimized Spinu objective0.5 x'Sigma x - sum_i b_i log x_iat the solver’s solution (before the single normalization into weights),convergedanditerationsthe coordinate descent’s outcome and sweep count, andvolatilitythe portfolio standard deviationsqrt(w'Sigma w). There is no expected growth - the formulation never sees a mean.- Returns:
The optimal weights and the diagnostics above.
- Return type:
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:
BaseAllocationStrategyShrink any allocator’s weights toward cash:
w -> factor * w.The fractional-Kelly dial for the whole layer: a
factorofkappastakeskappaof the wrapped allocator’s every weight and holds the rest as cash - the fractional-Kelly construction (akappafraction of the Kelly portfolio corresponds to CRRA risk aversion1 / kappa), applicable to any allocator the layer can produce without a new solver. For an unconstrained interior mean-variance optimum, shrinking bykappais exactly re-solving withkappatimes 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
evaluateis a pure read of the wrapped weights, scaled.- Parameters:
inner (BaseAllocationStrategy) – The allocator whose weights are scaled.
factor (float, default=1.0) – The scale
kappain[0, 1]: 1 leaves the allocator untouched, 0 is the all-cash portfolio.
- Raises:
ValueError – If
inneris not aBaseAllocationStrategyor 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.
- 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;
convergedanditerationsare the inner solve’s, andvolatilityscales exactly (sqrt((k w)'Sigma (k w)) == k sqrt(w'Sigma w)).objectiveandexpected_growthdescribe the inner allocator’s unshrunk problem, so they stayNonehere. An inner allocator without anoptimize- 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:
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,ModelInputMixinMean-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
muis the scenarios’ mean vector andCVaR_tailthe expected portfolio loss in the worsttail_alphafraction 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 surplusu_tper scenario, and the house long-only budget - solved withscipy.optimize.linprogunder the HiGHS backend, which is why the class is gated on thekeeks[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, anditerationsare 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.05averages 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:
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_modelsamples the model and binds the draw matrix - the model’s exactmoments()hook, when it has one, is never consulted. Akeeks.allocation.models.ScenarioModelcarrying 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:
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.
objectiveis the minimized program value (the tail expectation minus the scenarios’ expected return under the optimal weights) andcvarthe tail expectation itself;expected_growthestimatesE[log(1 + w'R)]under the scenarios -Nonewhen the optimal portfolio wipes out in some scenario, where the log is undefined - andvolatilityreads the weights against the scenarios’ empirical covariance fromscenarios_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_reasoncarries 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:
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:
BaseAllocationStrategyHierarchical 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), andorder(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:
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, andexpected_growthstayNone;volatilityissqrt(w' Sigma w)under the construction covariance.- Returns:
The weight vector and the portfolio volatility it implies.
- Return type:
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
Sand the structured targetF = m * I, wheremis the average variancetrace(S) / N(Ledoit & Wolf 2004):shrunk = (1 - alpha) * S + alpha * F
alpha = 0returns the sample covariance exactly andalpha = 1the target exactly; in between, the blend trades the sample matrix’s noise for the target’s stability. Whenalphais 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 whenalphaisNone).- 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
alphais 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 rowP_kof the view matrix whose portfolio the holder expects to returnq_k- is a noisy observation of that portfolio’s mean return, with uncertaintyomega. 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]
tauscales how much the prior is trusted relative to the views, andomega’s diagonal how noisy each view is: a confident view (small uncertainty) pulls the posterior towardq, 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
deltaturning 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 (withomegaan empty(0, 0)matrix), means no views.omega (array-like or None) – The
(K, K)uncertainty covariance of the view returns. Provided together withviews- bothNoneor both set.tau (float) – The prior scaling. Positive.
- Returns:
The posterior mean, one entry per option.
- Return type:
- Raises:
ValueError – If the covariance, view inputs, or omega fail the shared numeric gates; if exactly one of
viewsandomegaisNone; 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:
_OnlineAllocatorConstant 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_settlementvalidates 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)
- class keeks.allocation.online.ExponentialGradient(option_count: int, learning_rate: float = 0.05)[source]¶
Bases:
_OnlineAllocatorFollow-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_TILTlog-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_settlementis the only stateful channel:evaluatenever 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:
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
- class keeks.allocation.online.OnlineNewtonStep(option_count: int, learning_rate: float = 0.5, epsilon: float = 1e-06)[source]¶
Bases:
_OnlineAllocatorSecond-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 anywis the realized return vectorritself. 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_ratevalues adapt faster;epsilonis the small ridge that keepsAinvertible 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: raiseepsilonor lowerlearning_rateto temper it.- Parameters:
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
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:
objectReplay 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
BankRollwith 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
probabilitiesgiven, 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-Noneoutcome 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 withscenario_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: trialtsettles thet-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 privatenumpy.random.Generatoron a spawnednumpy.random.SeedSequencechild -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 eachevaluate_strategycall 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: trialtsettles rowt. This one-call, in-order shape is deliberate - it is what lets a binary-bets book (BinaryBetsModel) replayPortfolioSimulator’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. Withprobabilities, 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:
Stop when the bankroll is depleted (
total_funds <= 0).Fire the allocation’s
update_bankrollhook when it has one.Validate the weight vector returned by
evaluate: finite, one weight per option, each in[0, 1], sum at most1 + 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.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.
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 configuredmax_transaction_losscap), the period leaves the bankroll unchanged and the simulation stops after it completes.Fire
record_settlement(won, realized_returns)once with the period’s per-option outcome vector (Truewhen the option’s realized return is positive,Falseotherwise) 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
scenariosdescriptor, likeMeanCVaR- 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_bankrollandrecord_settlementhooks.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.