Skip to main content

Solver Deep Dive

The current optimizer is implemented in solver.ts as DeterministicSolver. It is intentionally simple, deterministic, and auditable.

Design choice

The solver is not using:
  • machine learning
  • reinforcement learning
  • black-box optimization
  • probabilistic routing
Instead, it uses a policy-constrained weighted allocation algorithm with explicit penalties.

Inputs

The solver constructor receives:
  • strategies
  • policy
  • manager
  • adapters
Each plan build receives:
  • snapshots
  • navUsd
  • generatedAt

Output

The solver returns a RebalancePlan with:
  • reserveBps
  • expectedBlendedApyBps
  • riskBudgetUsageBps
  • targetWeights
  • strategyScores
  • plannedSteps
  • planHash
  • targetWeightsHash
  • txBundleHash
  • noOp

Objective function

For each strategy, the solver computes:
score =
  expected_net_apy
  - risk_haircut
  - liquidity_haircut
  - fee_haircut
  - concentration_haircut
  - operational_haircut
This is a single-score ranking model, not a mean-variance optimizer.

Mathematical notation

For documentation purposes, the current V0 solver can be written as:
For each strategy i:

S_i = E_i - R_i - L_i - F_i - C_i - O_i
Where:
  • S_i is the final score of strategy i
  • E_i is expected net APY
  • R_i is risk haircut
  • L_i is liquidity haircut
  • F_i is fee haircut
  • C_i is concentration haircut
  • O_i is operational haircut
The allocation problem is then:
maximize     sum_i (w_i * S_i)

subject to:
  sum_i w_i <= 10_000 - reserveBps
  0 <= w_i <= strategyCap_i
  sum_{i in protocol p} w_i <= protocolCap_p
  sum_{i in exotic} w_i <= exoticCap
  sum_{i in marginfi} w_i <= marginFiCap
Important nuance:
  • this is not solved with linear programming or quadratic optimization
  • the current implementation approximates this objective with deterministic score-ranked proportional allocation under caps

Exact components

1. Expected net APY

The solver delegates this to the adapter:
const expectedNetApyBps = await adapter.getExpectedNetApy(snapshot);
For static-style adapters, this is effectively:
grossApyBps - feeBps - borrowCostBps - slippageBps

2. Risk haircut

riskHaircutBps = round(riskScoreBps * 0.35)

3. Liquidity haircut

Liquidity is penalized by profile plus withdrawal delay:
Liquidity profileBase penalty
instant25 bps
same_day60 bps
batched135 bps
term220 bps
Then:
liquidityHaircutBps = profilePenalty + round(withdrawalDelayHours / 2)

4. Fee haircut

feeHaircutBps = feeBps + borrowCostBps + slippageBps

5. Concentration haircut

concentrationHaircutBps = round(protocolConcentrationBps * 0.2)
This makes large existing concentrations progressively less attractive.

6. Operational haircut

Starts from:
operationalComplexityBps
Then adds:
ConditionExtra penalty
canary sleeve+120 bps
oracle unhealthy+400 bps
protocol unhealthy+600 bps
withdrawals unhealthy+300 bps

Reserve logic

The reserve is dynamic but simple. The solver counts how many strategies have:
operationalHaircutBps >= 500
Then computes:
reserveBps = clamp(minLiquidReserveBps + unhealthyCount * 100, minLiquidReserveBps, 3000)
So every sufficiently unhealthy sleeve raises reserve by 100 bps, capped at 30%. In notation:
reserveBps = min(
  3000,
  max(
    minLiquidReserveBps,
    minLiquidReserveBps + 100 * unhealthyCount
  )
)

Candidate filter

A strategy is considered allocatable only if all of these are true:
  • snapshot exists
  • score exists
  • strategy id is in manager.allowed_strategies
  • strategy status is active
  • oracleHealthy === true
  • protocolHealthy === true
  • scoreBps > 0
Important nuance:
  • withdrawalsHealthy is currently a penalty, not a hard filter
That means the V0 solver may still allocate to a strategy with degraded withdrawal health if the final score stays positive.

Allocation algorithm

After reserve is set, the solver allocates the remaining basis points in rounds.

Step 1. Initialize remaining capacity

remainingBps = 10_000 - reserveBps
It also tracks:
  • per-protocol usage
  • exotic usage
  • MarginFi usage

Step 2. Sort candidates by score

Candidates are sorted descending by scoreBps.

Step 3. Allocate proportionally by score

For each round:
theoretical = max(100, round((remainingBps * candidateScore) / totalScore))
That means the solver has a minimum target granularity of 100 bps when a candidate is still eligible.

Step 4. Respect caps

Actual allocation is clipped by:
  • strategy cap
  • protocol cap
  • exotic cap
  • MarginFi cap
  • remaining unallocated bps
Formally:
availableCapacity = min(
  strategyCap - currentWeight,
  protocolCap - protocolUsed,
  specialCapRemaining,
  remainingBps
)

allocation = min(theoretical, availableCapacity)
In more compact notation:
w_i^(round) = min(
  max(100, round(remainingBps * S_i / sum_j S_j)),
  capStrategy_i - currentWeight_i,
  capProtocol_p - protocolUsage_p,
  capSpecial_i,
  remainingBps
)

Step 5. Repeat until no progress

The loop stops when:
  • remainingBps == 0, or
  • no candidate can receive additional weight in the round

Important behavior: residual cash can remain unallocated

The solver does not force all non-reserve capital into strategies. If all strategy caps are reached before all remainingBps are consumed, the difference stays implicitly in cash. That matters in the MVP because:
  • reserve is explicit
  • strategy weights are explicit
  • residual idle capital is implicit
This creates safety, but it can also create cash drag. In portfolio notation:
cashIdleBps = 10_000 - reserveBps - sum_i w_i
That idle capital is not a separate sleeve in the current target weights output, but financially it behaves like zero-yield or low-yield residual cash.

Planned steps generation

After weights are computed, the solver asks each adapter to build execution steps:
adapter.buildExecutionSteps(snapshot, targetNotionalUsd)
Then it:
  • removes zero-notional steps except hold
  • chunks oversized static steps with chunkExecutionSteps

Blended metrics

After target weights are computed, the solver calculates portfolio-level summary metrics.

Expected blended APY

expectedBlendedApyBps =
  sum_i (w_i * expectedNetApy_i) / sum_i w_i
In code, this is a weighted average over target weights.

Risk budget usage

riskBudgetUsageBps =
  sum_i (w_i * riskScore_i) / sum_i w_i
This is not a VaR model or volatility estimator. It is a weighted average of the strategy risk scores already present in the snapshots.

Rebalance trigger

The plan is marked noOp when the total intended change is too small:
totalDeltaBps = sum_i |targetWeight_i - currentWeight_i|

noOp = totalDeltaBps < minRebalanceDeltaBps
This avoids paying execution cost for immaterial reallocations.

Hashes and execution integrity

The solver also emits deterministic hashes:
targetWeightsHash = H(target weights)
txBundleHash = H(planned steps)
planHash = H(plan summary)
These hashes matter because the executor and on-chain proof flow can reject a rebalance if the approved plan and the executed bundle diverge.
  • preserves SDK or manifest bundles as single units when envelopes are already present
This last point is important:
  • live protocol bundles are not arbitrarily chunked if instructionEnvelopes already exist

Blended metrics

The plan includes:

Expected blended APY

A weighted average of expectedNetApyBps using targetWeightBps.

Risk budget usage

A weighted average of riskScoreBps using targetWeightBps.

Rebalance gating

The solver computes:
totalDeltaBps = sum(abs(targetWeightBps - currentWeightBps))
Then:
noOp = totalDeltaBps < minRebalanceDeltaBps
So even a mathematically valid plan may be marked noOp if the net change is too small.

Hashes and proofs

The solver creates:
  • targetWeightsHash
  • txBundleHash
  • planHash
These are later used by Ranger and the Anchor program to bind execution to the approved plan.

Current limitations

This V0 solver does not yet include:
  • covariance between strategies
  • explicit drawdown modeling
  • dynamic transaction cost estimation from chain state
  • probabilistic slippage distributions
  • volatility targeting
  • regime detection

Why this is still a good V0

It is:
  • explainable
  • deterministic
  • easy to audit
  • easy to backtest
  • easy to constrain by policy
That is exactly what you want in an MVP for treasury automation.