Per-Asset Weight Bounds

In addition to the uniform minimum_weight guardrail that applies equally to all assets, quantammsim supports per-asset minimum and maximum weight constraints. This allows you to specify different allocation limits for each asset in the pool.

Use Cases

Per-asset bounds are useful when you want to:

  • Ensure a stablecoin maintains a minimum allocation (e.g., USDC always >= 10%)

  • Cap exposure to volatile assets (e.g., meme coins <= 30%)

  • Implement risk management constraints specific to each asset

  • Model regulatory or mandate-driven allocation limits

Usage

To use per-asset bounds, create a pool with the bounded__ prefix:

from quantammsim.pools.creator import create_pool

# Create a bounded momentum pool
pool = create_pool("bounded__momentum")

# Also works with other strategies
pool = create_pool("bounded__mean_reversion_channel")

Then specify the bounds in your parameters:

params = {
    # ... other strategy parameters ...
    "min_weights_per_asset": jnp.array([0.25, 0.20, 0.10]),  # BTC, ETH, USDC
    "max_weights_per_asset": jnp.array([0.60, 0.55, 0.30]),
}

Algorithm

The per-asset bounds use a clip-and-redistribute algorithm:

Step 1: Initial Clip

\[w'_i = \text{clip}(w_i, \min_i, \max_i)\]

Step 2: Calculate Slack

\[ \begin{align}\begin{aligned}\text{slack\_up}_i = \max_i - w'_i \quad \text{(room to grow)}\\\text{slack\_down}_i = w'_i - \min_i \quad \text{(room to shrink)}\end{aligned}\end{align} \]

Step 3: Redistribute Proportionally

If \(\sum w'_i < 1\) (deficit):

\[\text{adjustment}_i = \frac{(1 - \sum w'_j) \cdot \text{slack\_up}_i}{\sum \text{slack\_up}_j}\]

If \(\sum w'_i > 1\) (surplus):

\[\text{adjustment}_i = -\frac{(\sum w'_j - 1) \cdot \text{slack\_down}_i}{\sum \text{slack\_down}_j}\]

Step 4: Final Normalisation

\[ \begin{align}\begin{aligned}w''_i = \text{clip}(w'_i + \text{adjustment}_i, \min_i, \max_i)\\w_{\text{final},i} = \frac{w''_i}{\sum w''_j}\end{aligned}\end{align} \]

Restrictions on Bounds

For a feasible solution to exist, the bounds must satisfy these constraints:

Constraint

Formula

Meaning

Sum of minimums

\(\sum \min_i \leq 1\)

Must be possible to satisfy all minimums

Sum of maximums

\(\sum \max_i \geq 1\)

Must be possible to reach total weight of 1

Per-asset ordering

\(\min_i < \max_i \; \forall i\)

Each asset must have a valid range

Non-negative

\(\min_i \geq 0 \; \forall i\)

Weights cannot be negative

Upper bound

\(\max_i \leq 1 \; \forall i\)

No single asset can exceed 100%

Example: Valid vs Invalid Bounds

For a 3-asset pool:

  • Valid: min = [0.2, 0.2, 0.2], max = [0.5, 0.5, 0.5]

    • \(\sum \min = 0.6 \leq 1\)

    • \(\sum \max = 1.5 \geq 1\)

  • Invalid: min = [0.4, 0.4, 0.4]

    • \(\sum \min = 1.2 > 1\) ✗ (impossible to satisfy all minimums)

  • Invalid: max = [0.3, 0.3, 0.3]

    • \(\sum \max = 0.9 < 1\) ✗ (impossible to reach total weight of 1)

Key Properties

  1. Guaranteed feasibility: If the bounds satisfy the constraints above, a valid weight vector always exists.

  2. Proportional redistribution: Slack is redistributed proportionally, so assets with more room to adjust absorb more of the deficit/surplus.

  3. Preserves relative ordering: Assets closer to their bounds move less than those with more slack.

  4. Layered on existing guardrails: Per-asset bounds are applied BEFORE the uniform minimum_weight guardrail, so both constraints must be satisfied.

Integration with Run Fingerprints

When using per-asset bounds in a simulation, specify the rule with the bounded__ prefix:

run_fingerprint = {
    "tokens": ["BTC", "ETH", "USDC"],
    "rule": "bounded__momentum",
    "minimum_weight": 0.05,  # Uniform guardrail (still applies)
    # ... other settings ...
}

params = {
    "min_weights_per_asset": jnp.array([0.25, 0.20, 0.10]),
    "max_weights_per_asset": jnp.array([0.60, 0.55, 0.30]),
    # ... other strategy parameters ...
}

result = do_run_on_historic_data(run_fingerprint, params)