core.test_solver

Tests for Hedge Portfolio Optimization Solver

This module tests src/core/solver.py which provides the optimization engine for finding optimal hedge portfolio quantities that minimize the difference between hedge profits and impermanent loss across price scenarios.

MATHEMATICAL BACKGROUND

The hedge optimization problem seeks to find option quantities that offset impermanent loss (IL) across different price scenarios. This is a constrained nonlinear optimization problem.

OPTIMIZATION OBJECTIVE

The solver minimizes the sum of absolute differences between hedge portfolio profit and impermanent loss magnitude:

minimize: Σᵢ |portfolio_profit[i] - |IL[i]||

Expanded: minimize: Σᵢ |Σⱼ (qⱼ × profit_matrix[j,i]) - |IL[i]||

Where: i = scenario index (price variations) j = strike index qⱼ = quantity of options at strike j profit_matrix[j,i] = put option profit at strike j, scenario i IL[i] = impermanent loss at scenario i (always ≤ 0)

OBJECTIVE FUNCTION RATIONALE

Why L1 norm (sum of absolute differences)?

  1. Robustness: Less sensitive to outliers than L2 (squared differences)
  2. Interpretability: Direct dollar interpretation of hedge shortfall
  3. Sparsity: Tends to produce sparser solutions (fewer active strikes)

Alternative objectives considered:

  • Sum of squared differences: |portfolio_profit - |IL||² Pros: Differentiable, penalizes large mismatches more Cons: Over-penalizes tail scenarios, less interpretable
  • Worst-case minimization (minimax): max(|portfolio_profit[i] - |IL[i]||) Pros: Guarantees worst-case performance Cons: Ignores typical scenarios, harder to optimize

  • Expected value with probability weights: Σᵢ wᵢ|error[i]| Pros: Can incorporate market views Cons: Requires probability estimates

CONVERGENCE CRITERIA

The solver uses scipy.optimize.minimize with these convergence parameters:

Parameter Value Description
ftol 1e-9 Function value tolerance
gtol 1e-8 Gradient tolerance
maxiter 1000 Maximum iterations (configurable)

Convergence declared when:

  • |f(x_k) - f(x_{k-1})| < ftol × max(1, |f(x_k)|), OR
  • ||∇f(x_k)||∞ < gtol, OR
  • Iterations exceed maxiter (may indicate non-convergence)

SOLVER METHOD COMPARISON

Method Algorithm Bounds Constraints Speed Robustness
SLSQP SQP eq + ineq Medium Good
L-BFGS-B Quasi-Newton Fast Moderate
trust-constr Trust-region SQP eq + ineq Slow Excellent

Default Choice: SLSQP

  • Handles both equality and inequality constraints
  • Good balance of speed and robustness
  • Well-tested on financial optimization problems

EXCEL REPRODUCTION GUIDE

To verify calculations in Excel, follow these steps:

STEP 1: SET UP INPUT DATA

Create a spreadsheet with these named ranges:

Cell A1: "spot"         Value: 3050
Cell A2: "p_min"        Value: 2200
Cell A3: "p_max"        Value: 3650

STEP 2: GENERATE STRIKES (Column B)

Starting at B5, list strikes from p_min to spot in 100 increments:

B5: 2200
B6: 2300
B7: 2400
...
B13: 3000

Or use formula: =SEQUENCE(ROUNDUP((A1-A2)/100,0)+1, 1, A2, 100)

STEP 3: GENERATE VARIATIONS (Row 4)

Starting at C4, list variations from -0.30 to +0.20 in 0.05 increments:

C4: -0.30
D4: -0.25
E4: -0.20
...
M4: +0.20

Or use formula: =SEQUENCE(1, 11, -0.3, 0.05)

STEP 4: BUILD PROFIT MATRIX (C5:M13)

Each cell calculates put option profit as % of spot.

Formula for cell C5 (and drag to fill matrix): =MAX($B5 - $A$1*(1+C$4), 0) / $A$1

This computes: max(strike - future_price, 0) / spot Where: future_price = spot × (1 + variation)

Example values at variation = -0.30 (column C): C5 (strike 2200): =MAX(2200 - 3050*0.70, 0) / 3050 = 0.0213 C6 (strike 2300): =MAX(2300 - 2135, 0) / 3050 = 0.0541 ...

STEP 5: INPUT IL VALUES (Row 14)

Below the profit matrix, enter IL values for each variation:

C14: -0.22   (IL at -30% variation)
D14: -0.18   (IL at -25% variation)
E14: -0.12   (IL at -20% variation)
...
M14: 0       (IL at +20% variation)

Note: IL values come from Uniswap IL calculations (see test_uniswap.py)

STEP 6: ADD QUANTITY CELLS (Column A)

Add decision variables (quantities) next to each strike:

A5: 0    (quantity for strike 2200)
A6: 0    (quantity for strike 2300)
...
A13: 0   (quantity for strike 3000)

STEP 7: CALCULATE PORTFOLIO PROFIT (Row 15)

For each variation, sum quantity × profit across all strikes:

C15: =SUMPRODUCT($A$5:$A$13, C5:C13)

This computes: Σⱼ (qⱼ × profit_matrix[j,i])

STEP 8: CALCULATE ABSOLUTE DIFFERENCES (Row 16)

Compute |portfolio_profit - |IL|| for each variation:

C16: =ABS(C15 - ABS(C14))

STEP 9: CALCULATE OBJECTIVE (Single Cell)

Sum all absolute differences:

O16: =SUM(C16:M16)

This is the value to minimize.

STEP 10: RUN EXCEL SOLVER

  1. Go to Data → Solver (install Solver Add-in if needed)

  2. Configure Solver:

    • Set Objective: $O$16
    • To: Min
    • By Changing Variable Cells: $A$5:$A$13
    • Subject to Constraints:
      • $A$5:$A$13 >= -100 (lower bound)
      • $A$5:$A$13 <= 100 (upper bound)
  3. Select Solving Method: "GRG Nonlinear" (This is similar to scipy's L-BFGS-B)

  4. Click "Solve"

EXPECTED EXCEL LAYOUT

     A          B        C         D         E        ...   M
1   spot       3050
2   p_min      2200
3   p_max      3650
4              Strike   -30%      -25%      -20%      ...   +20%
5   [q1]       2200     0.0213    0.0049    0.0000    ...   0.0000
6   [q2]       2300     0.0541    0.0213    0.0000    ...   0.0000
7   [q3]       2400     0.0869    0.0541    0.0213    ...   0.0000
...
13  [q9]       3000     0.2836    0.2508    0.2180    ...   0.0000
14  IL                  -0.22     -0.18     -0.12     ...   0.00
15  Profit              [sum]     [sum]     [sum]     ...   [sum]
16  |Diff|              [abs]     [abs]     [abs]     ...   [abs]   → OBJECTIVE

VERIFICATION CHECKLIST

After running Excel Solver, verify:

  1. Objective value matches Python output (within tolerance ~0.01)
  2. Quantities have same sign and similar magnitude
  3. Portfolio profit at each scenario is close to |IL|

Common Differences:

  • Excel GRG may find different local minimum than scipy SLSQP
  • Floating point differences (~1e-6) are expected
  • Different starting points may yield different solutions

ADVANCED: USING EXCEL FOR SENSITIVITY ANALYSIS

  1. Add Data Table (What-If Analysis):

    • Vary spot price and observe optimal quantities
  2. Add Scenario Manager:

    • Save multiple solutions for different market conditions
  3. Create charts:

    • Plot portfolio_profit vs |IL| across variations
    • Visualize hedge effectiveness

Test Categories

  1. Dynamic Strike Generation

    • Tests for generate_dynamic_strikes() which creates strike prices
    • Verifies Pmin inclusion, sorting, and strike step configuration
  2. Dynamic Variation Generation

    • Tests for generate_dynamic_variations() which creates price scenarios
    • Verifies range extension (Pmin-10% to Pmax+10%) and 5% increments
  3. Profit Calculations

    • Tests for calc_strike_profit_percent() and calc_strike_distance_percent()
    • Verifies ITM/OTM put payoff calculations
  4. Profit Matrix

    • Tests for calc_strike_profit_matrix() which builds the optimization input
    • Verifies matrix dimensions and profit relationships
  5. Optimizer

    • Tests for optimize_hedge_quantities() with all three solver methods
    • Verifies convergence, bounds, masks, and output structure
  6. Solver Comparison

    • Integration tests comparing L-BFGS-B, SLSQP, and trust-constr
    • Verifies solvers reduce objective function vs initial values

Standard Test Parameters

SPOT = 3050    (Current ETH price in USDT)
P_MIN = 2200   (Lower price bound for LP position)
P_MAX = 3650   (Upper price bound for LP position)

Run with: uv run pytest tests/test_solver.py -v

SPOT = Decimal('3050')
P_MIN = Decimal('2200')
P_MAX = Decimal('3650')

Tests for generate_dynamic_strikes()

This function generates strike prices for the hedge portfolio based on:

  • Current spot price
  • Lower price bound (Pmin) from the LP position
  • Strike step size (default 100)
  • Number of additional strikes below Pmin

NUMERICAL EXAMPLE

Input: spot = 3050 p_min = 2200 strike_step = 100 num_additional = 3

Output: [2000, 2100, 2200, 2300, 2400, 2500, 2600, 2700, 2800, 2900, 3000]

The strikes include:

  • Pmin (2200) always included
  • Strikes from Pmin to spot in 100-point increments
  • 3 additional strikes below Pmin (2100, 2000, 1900)
def test_strikes_includes_pmin():

Pmin should always be included in strikes.

def test_strikes_below_or_at_spot():

All strikes should be at or below spot price.

def test_strikes_sorted():

Strikes should be sorted ascending.

def test_custom_strike_step():

Custom strike step should be respected.

def test_includes_additional_strikes():

Should include strikes above Pmin.

def test_variations_includes_zero():

Zero variation (current price) should always be included.

def test_variations_includes_pmin_variation():

Pmin variation should be included.

def test_variations_includes_pmax_variation():

Pmax variation should be included.

def test_variations_sorted():

Variations should be sorted ascending.

def test_variations_extends_beyond_range():

Variations should extend 10% beyond Pmin/Pmax.

def test_profit_itm_put():

ITM put should have positive profit.

def test_profit_otm_put():

OTM put should have zero profit.

def test_profit_deep_itm():

Deep ITM put should have large profit.

def test_strike_distance():

Strike distance should be percentage from spot.

def test_matrix_shape():

Matrix should be [num_strikes × num_variations].

def test_matrix_values_positive_or_zero():

All profits should be non-negative (puts can't have negative payoff).

def test_higher_strike_more_profit():

Higher strike should have more profit for same downside move.

@pytest.fixture
def sample_problem():

Create a sample optimization problem.

@pytest.mark.filterwarnings('ignore:delta_grad == 0.0')
def test_slsqp_converges(sample_problem):

SLSQP solver should converge.

@pytest.mark.filterwarnings('ignore:delta_grad == 0.0')
def test_lbfgsb_converges(sample_problem):

L-BFGS-B solver should converge.

@pytest.mark.filterwarnings('ignore:delta_grad == 0.0')
def test_trust_constr_converges(sample_problem):

trust-constr solver should converge.

def test_quantities_length_matches_strikes(sample_problem):

Output quantities should match number of strikes.

def test_il_differences_computed(sample_problem):

IL differences should be computed for all variations.

def test_changeable_mask_respected(sample_problem):

Fixed strikes should not change from initial value.

def test_include_mask_filters_objective(sample_problem):

Include mask should filter which variations affect objective.

def test_bounds_respected(sample_problem):

Quantity bounds should be respected.

@pytest.fixture
def realistic_problem():

Create a more realistic optimization problem.

@pytest.mark.filterwarnings('ignore:delta_grad == 0.0')
def test_all_solvers_produce_valid_output(realistic_problem):

All solvers should produce valid OptimizedHedge output.

@pytest.mark.filterwarnings('ignore:delta_grad == 0.0')
def test_solvers_reduce_objective(realistic_problem):

Optimized quantities should have lower objective than zeros.