From b6a94a8d6e795fd1a62ec2392869fd242504ae1e Mon Sep 17 00:00:00 2001 From: timwang Date: Thu, 5 Feb 2026 13:34:12 +0800 Subject: [PATCH 1/8] move old allocation to coverage package --- internal/{allocation => coverage}/convert.go | 2 +- internal/{allocation => coverage}/greedy.go | 2 +- internal/{allocation => coverage}/types.go | 2 +- internal/strategy/service/service.go | 14 +++++++------- internal/strategy/strategy.go | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) rename internal/{allocation => coverage}/convert.go (99%) rename internal/{allocation => coverage}/greedy.go (99%) rename internal/{allocation => coverage}/types.go (99%) diff --git a/internal/allocation/convert.go b/internal/coverage/convert.go similarity index 99% rename from internal/allocation/convert.go rename to internal/coverage/convert.go index 0f7cfa2..1fb72d0 100644 --- a/internal/allocation/convert.go +++ b/internal/coverage/convert.go @@ -1,4 +1,4 @@ -package allocation +package coverage import ( "math" diff --git a/internal/allocation/greedy.go b/internal/coverage/greedy.go similarity index 99% rename from internal/allocation/greedy.go rename to internal/coverage/greedy.go index 7f74367..1de5be7 100644 --- a/internal/allocation/greedy.go +++ b/internal/coverage/greedy.go @@ -1,4 +1,4 @@ -package allocation +package coverage import ( "log/slog" diff --git a/internal/allocation/types.go b/internal/coverage/types.go similarity index 99% rename from internal/allocation/types.go rename to internal/coverage/types.go index ebc1806..b8489d2 100644 --- a/internal/allocation/types.go +++ b/internal/coverage/types.go @@ -1,4 +1,4 @@ -package allocation +package coverage import "math/big" diff --git a/internal/strategy/service/service.go b/internal/strategy/service/service.go index 65a8ee7..89a7e63 100644 --- a/internal/strategy/service/service.go +++ b/internal/strategy/service/service.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "remora/internal/allocation" + "remora/internal/coverage" "remora/internal/liquidity" "remora/internal/strategy" ) @@ -44,13 +44,13 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C return &strategy.ComputeResult{ CurrentTick: dist.CurrentTick, Segments: nil, - Metrics: allocation.Metrics{}, + Metrics: coverage.Metrics{}, ComputedAt: time.Now().UTC(), }, nil } // Step 3: Run coverage algorithm - result := allocation.Run(allocationBins, params.AlgoConfig) + result := coverage.Run(allocationBins, params.AlgoConfig) return &strategy.ComputeResult{ CurrentTick: dist.CurrentTick, @@ -60,18 +60,18 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C }, nil } -// toAllocationBins converts liquidity.Bin slice to allocation.Bin slice. -func toAllocationBins(liqBins []liquidity.Bin, currentTick int32) []allocation.Bin { +// toAllocationBins converts liquidity.Bin slice to coverage.Bin slice. +func toAllocationBins(liqBins []liquidity.Bin, currentTick int32) []coverage.Bin { if len(liqBins) == 0 { return nil } - bins := make([]allocation.Bin, len(liqBins)) + bins := make([]coverage.Bin, len(liqBins)) for i, b := range liqBins { // Determine if this bin contains the current tick isCurrent := currentTick >= b.TickLower && currentTick < b.TickUpper - bins[i] = allocation.Bin{ + bins[i] = coverage.Bin{ TickLower: b.TickLower, TickUpper: b.TickUpper, Liquidity: b.ActiveLiquidity, diff --git a/internal/strategy/strategy.go b/internal/strategy/strategy.go index 5ef2d91..ca4c565 100644 --- a/internal/strategy/strategy.go +++ b/internal/strategy/strategy.go @@ -7,7 +7,7 @@ import ( "math/big" "time" - "remora/internal/allocation" + "remora/internal/coverage" "remora/internal/liquidity" ) @@ -22,14 +22,14 @@ type ComputeParams struct { PoolKey liquidity.PoolKey // Uniswap v4 pool key BinSizeTicks int32 // Size of each bin in ticks TickRange int32 // Range of ticks to scan (±tickRange from current tick) - AlgoConfig allocation.Config // Algorithm configuration + AlgoConfig coverage.Config // Algorithm configuration } // ComputeResult contains the computed target positions. type ComputeResult struct { CurrentTick int32 // Current pool tick - Segments []allocation.Segment // Target LP segments - Metrics allocation.Metrics // Coverage metrics + Segments []coverage.Segment // Target LP segments + Metrics coverage.Metrics // Coverage metrics ComputedAt time.Time // Timestamp when computation was performed } From 337e79d8a5b3499b76110682e1da8a19b5f5d74f Mon Sep 17 00:00:00 2001 From: timwang Date: Thu, 5 Feb 2026 14:49:12 +0800 Subject: [PATCH 2/8] Add allocation call to transfer weight to trully allocatie positions - Add uniswap math - Add value calculation and swap calculation --- internal/allocation/allocation.go | 238 +++++++++++++++++++++ internal/allocation/allocation_test.go | 274 +++++++++++++++++++++++++ internal/allocation/types.go | 36 ++++ internal/allocation/unimath.go | 196 ++++++++++++++++++ internal/allocation/unimath_test.go | 118 +++++++++++ 5 files changed, 862 insertions(+) create mode 100644 internal/allocation/allocation.go create mode 100644 internal/allocation/allocation_test.go create mode 100644 internal/allocation/types.go create mode 100644 internal/allocation/unimath.go create mode 100644 internal/allocation/unimath_test.go diff --git a/internal/allocation/allocation.go b/internal/allocation/allocation.go new file mode 100644 index 0000000..d71c176 --- /dev/null +++ b/internal/allocation/allocation.go @@ -0,0 +1,238 @@ +package allocation + +import ( + "math/big" + + "remora/internal/coverage" +) + +// Allocate distributes user funds across segments based on weights +// Main entry point for allocation calculation +func Allocate(segments []coverage.Segment, funds UserFunds, pool PoolState) (*AllocationResult, error) { + if len(segments) == 0 { + return &AllocationResult{ + Positions: []PositionPlan{}, + TotalAmount0: big.NewInt(0), + TotalAmount1: big.NewInt(0), + SwapAmount: big.NewInt(0), + }, nil + } + + // 1. Calculate weights from LiquidityAdded + weights := normalizeWeights(segments) + + // 2. Calculate total value in token1 units + totalValue := calculateTotalValue(funds, pool) + + // 3. For each segment: allocate value and calculate position + positions := make([]PositionPlan, len(segments)) + totalAmount0 := big.NewInt(0) + totalAmount1 := big.NewInt(0) + + for i, seg := range segments { + // allocatedValue = totalValue * weight + // Use big.Float for weight multiplication + totalValueFloat := new(big.Float).SetInt(totalValue) + allocatedFloat := new(big.Float).Mul(totalValueFloat, big.NewFloat(weights[i])) + allocatedValue, _ := allocatedFloat.Int(nil) + + // seg.TickLower and seg.TickUpper are int32 in internal/coverage, convert to int + pos := calculatePosition(allocatedValue, int(seg.TickLower), int(seg.TickUpper), weights[i], pool) + positions[i] = *pos + + totalAmount0.Add(totalAmount0, pos.Amount0) + totalAmount1.Add(totalAmount1, pos.Amount1) + } + + // 4. Calculate swap needed + swapAmount, token0To1 := calculateSwapNeeded(totalAmount0, totalAmount1, funds, pool) + + return &AllocationResult{ + Positions: positions, + TotalAmount0: totalAmount0, + TotalAmount1: totalAmount1, + SwapAmount: swapAmount, + SwapToken0To1: token0To1, + }, nil +} + +// normalizeWeights converts LiquidityAdded to normalized weights (sum = 1) +func normalizeWeights(segments []coverage.Segment) []float64 { + var total float64 + liquidityFloats := make([]float64, len(segments)) + + for i, seg := range segments { + if seg.LiquidityAdded != nil { + f, _ := new(big.Float).SetInt(seg.LiquidityAdded).Float64() + liquidityFloats[i] = f + total += f + } + } + + weights := make([]float64, len(segments)) + if total == 0 { + // Equal weights if no liquidity info + for i := range weights { + weights[i] = 1.0 / float64(len(segments)) + } + return weights + } + + for i := range segments { + weights[i] = liquidityFloats[i] / total + } + return weights +} + +// calculateTotalValue computes total value in token1 units +// totalValue = amount0 * price + amount1 +func calculateTotalValue(funds UserFunds, pool PoolState) *big.Int { + // value = amount0 * price + amount1 + // price = sqrtPriceX96^2 / Q192 + // value = amount0 * sqrtPriceX96^2 / Q192 + amount1 + + sqrtPriceSquared := new(big.Int).Mul(pool.SqrtPriceX96, pool.SqrtPriceX96) + amount0Value := new(big.Int).Mul(funds.Amount0, sqrtPriceSquared) + amount0Value.Div(amount0Value, Q192) + + totalValue := new(big.Int).Add(amount0Value, funds.Amount1) + return totalValue +} + +// calculatePosition computes L, amount0, amount1 for a single position +// given allocated value and price range +func calculatePosition(allocatedValue *big.Int, tickLower, tickUpper int, weight float64, pool PoolState) *PositionPlan { + sqrtPriceAX96 := TickToSqrtPriceX96(tickLower) + sqrtPriceBX96 := TickToSqrtPriceX96(tickUpper) + + // Calculate L from allocated value + liquidity := calculateLiquidityFromValue( + allocatedValue, + pool.SqrtPriceX96, + sqrtPriceAX96, + sqrtPriceBX96, + pool, + ) + + // Calculate amount0, amount1 from L + amount0 := GetAmount0ForLiquidity(pool.SqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + amount1 := GetAmount1ForLiquidity(pool.SqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + + return &PositionPlan{ + TickLower: tickLower, + TickUpper: tickUpper, + Liquidity: liquidity, + Amount0: amount0, + Amount1: amount1, + Weight: weight, + } +} + +// calculateLiquidityFromValue solves for L given allocated value (in token1 units) +// value = amount0 * price + amount1, where amounts are functions of L +func calculateLiquidityFromValue(value *big.Int, sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96 *big.Int, pool PoolState) *big.Int { + // Ensure sqrtPriceA < sqrtPriceB + if sqrtPriceAX96.Cmp(sqrtPriceBX96) > 0 { + sqrtPriceAX96, sqrtPriceBX96 = sqrtPriceBX96, sqrtPriceAX96 + } + + if sqrtPriceX96.Cmp(sqrtPriceAX96) <= 0 { + // Case 1: price <= priceA (below range, only token0) + // L = value * sqrtA * sqrtB * Q96 / [price * (sqrtB - sqrtA)] + // where price = sqrtPriceX96^2 / Q192 + // + // Rearranged to avoid precision loss: + // L = value * sqrtA * sqrtB * Q96 * Q192 / [sqrtPriceX96^2 * (sqrtB - sqrtA)] + + diff := new(big.Int).Sub(sqrtPriceBX96, sqrtPriceAX96) + numerator := new(big.Int).Mul(value, sqrtPriceAX96) + numerator.Mul(numerator, sqrtPriceBX96) + numerator.Mul(numerator, Q96) + numerator.Mul(numerator, Q192) + + sqrtPriceSquared := new(big.Int).Mul(sqrtPriceX96, sqrtPriceX96) + denominator := new(big.Int).Mul(sqrtPriceSquared, diff) + + return new(big.Int).Div(numerator, denominator) + + } else if sqrtPriceX96.Cmp(sqrtPriceBX96) >= 0 { + // Case 2: price >= priceB (above range, only token1) + // L = value * Q96 / (sqrtB - sqrtA) + + diff := new(big.Int).Sub(sqrtPriceBX96, sqrtPriceAX96) + numerator := new(big.Int).Mul(value, Q96) + return new(big.Int).Div(numerator, diff) + + } else { + // Case 3: priceA < price < priceB (in range, both tokens) + // value = L * [(1/sqrtP - 1/sqrtB) * price + (sqrtP - sqrtA)] + // + // Let's compute the coefficient in Q96 format: + // coef = (1/sqrtP - 1/sqrtB) * price + (sqrtP - sqrtA) + // = (sqrtB - sqrtP) * price / (sqrtP * sqrtB) + (sqrtP - sqrtA) + + // Term 1: (sqrtB - sqrtP) * price / (sqrtP * sqrtB) + // In raw units, then we'll combine + diffBP := new(big.Int).Sub(sqrtPriceBX96, sqrtPriceX96) + sqrtPriceSquared := new(big.Int).Mul(sqrtPriceX96, sqrtPriceX96) + + term1Num := new(big.Int).Mul(diffBP, sqrtPriceSquared) + term1Denom := new(big.Int).Mul(sqrtPriceX96, sqrtPriceBX96) + term1 := new(big.Int).Div(term1Num, term1Denom) + + // Term 2: (sqrtP - sqrtA) - already in Q96 format + term2 := new(big.Int).Sub(sqrtPriceX96, sqrtPriceAX96) + + // coef = term1 + term2 (both in Q96-ish units) + coef := new(big.Int).Add(term1, term2) + + // L = value * Q96 / coef + numerator := new(big.Int).Mul(value, Q96) + return new(big.Int).Div(numerator, coef) + } +} + +// calculateSwapNeeded computes the swap amount and direction +// Returns: swapAmount (in source token units), token0To1 (swap direction) +func calculateSwapNeeded(totalNeeded0, totalNeeded1 *big.Int, funds UserFunds, pool PoolState) (swapAmount *big.Int, token0To1 bool) { + // deficit = needed - have + deficit0 := new(big.Int).Sub(totalNeeded0, funds.Amount0) + deficit1 := new(big.Int).Sub(totalNeeded1, funds.Amount1) + + // If deficit0 > 0: we need more token0, swap token1 → token0 + // If deficit1 > 0: we need more token1, swap token0 → token1 + // (Only one can be positive at a time in a valid allocation) + + if deficit0.Sign() > 0 { + // Need more token0, swap token1 → token0 + // swapAmount = how much token1 to swap + // deficit0 (token0 needed) * price = token1 amount to swap + // + // price = sqrtPriceX96^2 / Q192 + + sqrtPriceSquared := new(big.Int).Mul(pool.SqrtPriceX96, pool.SqrtPriceX96) + + // swapAmount (token1) = deficit0 * sqrtPriceX96^2 / Q192 + swapAmount = new(big.Int).Mul(deficit0, sqrtPriceSquared) + swapAmount.Div(swapAmount, Q192) + + return swapAmount, false // token1 → token0, so token0To1 = false + + } else if deficit1.Sign() > 0 { + // Need more token1, swap token0 → token1 + // swapAmount = how much token0 to swap + // deficit1 (token1 needed) / price = token0 amount to swap + // + // swapAmount (token0) = deficit1 * Q192 / sqrtPriceX96^2 + + sqrtPriceSquared := new(big.Int).Mul(pool.SqrtPriceX96, pool.SqrtPriceX96) + + swapAmount = new(big.Int).Mul(deficit1, Q192) + swapAmount.Div(swapAmount, sqrtPriceSquared) + + return swapAmount, true // token0 → token1, so token0To1 = true + } + + // No swap needed + return big.NewInt(0), false +} diff --git a/internal/allocation/allocation_test.go b/internal/allocation/allocation_test.go new file mode 100644 index 0000000..c1d13eb --- /dev/null +++ b/internal/allocation/allocation_test.go @@ -0,0 +1,274 @@ +package allocation + +import ( + "math/big" + "testing" + + "remora/internal/coverage" +) + +func TestNormalizeWeights(t *testing.T) { + segments := []coverage.Segment{ + {LiquidityAdded: big.NewInt(100)}, + {LiquidityAdded: big.NewInt(200)}, + {LiquidityAdded: big.NewInt(300)}, + } + + weights := normalizeWeights(segments) + + if len(weights) != 3 { + t.Fatalf("expected 3 weights, got %d", len(weights)) + } + + // Check sum = 1 + sum := weights[0] + weights[1] + weights[2] + if sum < 0.999 || sum > 1.001 { + t.Errorf("weights sum: expected 1, got %f", sum) + } + + // Check ratios + if weights[0] < 0.166 || weights[0] > 0.167 { // 100/600 + t.Errorf("weight[0]: expected ~0.167, got %f", weights[0]) + } + if weights[1] < 0.333 || weights[1] > 0.334 { // 200/600 + t.Errorf("weight[1]: expected ~0.333, got %f", weights[1]) + } + if weights[2] < 0.499 || weights[2] > 0.501 { // 300/600 + t.Errorf("weight[2]: expected ~0.5, got %f", weights[2]) + } +} + +func TestNormalizeWeights_ZeroTotal(t *testing.T) { + segments := []coverage.Segment{ + {LiquidityAdded: big.NewInt(0)}, + {LiquidityAdded: big.NewInt(0)}, + } + + weights := normalizeWeights(segments) + + // Should be equal weights + if weights[0] != 0.5 || weights[1] != 0.5 { + t.Errorf("zero total: expected equal weights [0.5, 0.5], got %v", weights) + } +} + +// Use simple setup: both tokens 18 decimals, price = 1 (tick 0) +func simplePool() PoolState { + return PoolState{ + SqrtPriceX96: TickToSqrtPriceX96(0), // price = 1 + CurrentTick: 0, + Token0Decimals: 18, + Token1Decimals: 18, + } +} + +func TestCalculateTotalValue_SimplePool(t *testing.T) { + pool := simplePool() + + funds := UserFunds{ + Amount0: big.NewInt(1e18), // 1 token0 + Amount1: big.NewInt(2e18), // 2 token1 + } + + totalValue := calculateTotalValue(funds, pool) + + // price = 1, so totalValue = 1 + 2 = 3 token1 = 3e18 + expected := big.NewInt(3e18) + + // Allow 1% tolerance + diff := new(big.Int).Sub(totalValue, expected) + tolerance := big.NewInt(3e16) // 1% + if diff.CmpAbs(tolerance) > 0 { + t.Errorf("totalValue: expected ~%s, got %s (diff=%s)", expected.String(), totalValue.String(), diff.String()) + } +} + +func TestCalculateSwapNeeded_NeedToken0(t *testing.T) { + pool := simplePool() + + funds := UserFunds{ + Amount0: big.NewInt(0), // no token0 + Amount1: big.NewInt(2e18), // 2 token1 + } + + totalNeeded0 := big.NewInt(1e18) // need 1 token0 + totalNeeded1 := big.NewInt(0) // need 0 token1 + + swapAmount, token0To1 := calculateSwapNeeded(totalNeeded0, totalNeeded1, funds, pool) + + // Should swap token1 → token0, so token0To1 = false + if token0To1 { + t.Error("expected token0To1=false (swap token1→token0)") + } + + // swapAmount should be ~1e18 (price = 1) + expected := big.NewInt(1e18) + tolerance := big.NewInt(1e16) // 1% + diff := new(big.Int).Sub(swapAmount, expected) + if diff.CmpAbs(tolerance) > 0 { + t.Errorf("swapAmount: expected ~%s, got %s", expected.String(), swapAmount.String()) + } +} + +func TestCalculateSwapNeeded_NeedToken1(t *testing.T) { + pool := simplePool() + + funds := UserFunds{ + Amount0: big.NewInt(2e18), // 2 token0 + Amount1: big.NewInt(0), // no token1 + } + + totalNeeded0 := big.NewInt(0) // need 0 token0 + totalNeeded1 := big.NewInt(1e18) // need 1 token1 + + swapAmount, token0To1 := calculateSwapNeeded(totalNeeded0, totalNeeded1, funds, pool) + + // Should swap token0 → token1, so token0To1 = true + if !token0To1 { + t.Error("expected token0To1=true (swap token0→token1)") + } + + // swapAmount should be ~1e18 (price = 1) + expected := big.NewInt(1e18) + tolerance := big.NewInt(1e16) // 1% + diff := new(big.Int).Sub(swapAmount, expected) + if diff.CmpAbs(tolerance) > 0 { + t.Errorf("swapAmount: expected ~%s, got %s", expected.String(), swapAmount.String()) + } +} + +func TestCalculateSwapNeeded_NoSwap(t *testing.T) { + pool := simplePool() + + funds := UserFunds{ + Amount0: big.NewInt(1e18), + Amount1: big.NewInt(1e18), + } + + totalNeeded0 := big.NewInt(1e18) + totalNeeded1 := big.NewInt(1e18) + + swapAmount, _ := calculateSwapNeeded(totalNeeded0, totalNeeded1, funds, pool) + + if swapAmount.Sign() != 0 { + t.Errorf("expected no swap, got %s", swapAmount.String()) + } +} + +func TestAllocate_Integration(t *testing.T) { + pool := simplePool() + + funds := UserFunds{ + Amount0: big.NewInt(1e18), // 1 token0 + Amount1: big.NewInt(1e18), // 1 token1 + } + + // Three segments around current price (tick 0) + segments := []coverage.Segment{ + {TickLower: -1000, TickUpper: -500, LiquidityAdded: big.NewInt(100)}, // below + {TickLower: -500, TickUpper: 500, LiquidityAdded: big.NewInt(200)}, // in range + {TickLower: 500, TickUpper: 1000, LiquidityAdded: big.NewInt(100)}, // above + } + + result, err := Allocate(segments, funds, pool) + if err != nil { + t.Fatalf("Allocate error: %v", err) + } + + // Should have 3 positions + if len(result.Positions) != 3 { + t.Fatalf("expected 3 positions, got %d", len(result.Positions)) + } + + // All positions should have positive liquidity + for i, pos := range result.Positions { + if pos.Liquidity.Sign() <= 0 { + t.Errorf("position %d: liquidity should be > 0, got %s", i, pos.Liquidity.String()) + } + t.Logf("position %d: L=%s, amt0=%s, amt1=%s, weight=%.3f", + i, pos.Liquidity.String(), pos.Amount0.String(), pos.Amount1.String(), pos.Weight) + } + + // Weights should match input ratios (100:200:100 = 0.25:0.5:0.25) + if result.Positions[0].Weight < 0.24 || result.Positions[0].Weight > 0.26 { + t.Errorf("position 0 weight: expected ~0.25, got %f", result.Positions[0].Weight) + } + if result.Positions[1].Weight < 0.49 || result.Positions[1].Weight > 0.51 { + t.Errorf("position 1 weight: expected ~0.5, got %f", result.Positions[1].Weight) + } + + t.Logf("TotalAmount0: %s", result.TotalAmount0.String()) + t.Logf("TotalAmount1: %s", result.TotalAmount1.String()) + t.Logf("SwapAmount: %s, Token0To1: %v", result.SwapAmount.String(), result.SwapToken0To1) +} + +func TestCalculateLiquidityFromValue_InRange_Verify(t *testing.T) { + // Setup pool at Tick 0 (Price = 1 raw) + sqrtP := TickToSqrtPriceX96(0) + + // Range [-887220, 887220] is max, let's use [-600, 600] + tickLower := -600 + tickUpper := 600 + sqrtA := TickToSqrtPriceX96(tickLower) + sqrtB := TickToSqrtPriceX96(tickUpper) + + testCases := []struct { + name string + d0 int + d1 int + val *big.Int // input value in token1 units + }{ + { + name: "Equal Decimals 18/18", + d0: 18, + d1: 18, + val: new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)), // 10 token1 + }, + { + name: "Diff Decimals 18/6 (ETH/USDC style)", + d0: 18, + d1: 6, + val: big.NewInt(10e6), // 10 USDC (small enough for int64) + }, + { + name: "Diff Decimals 6/18", + d0: 6, + d1: 18, + val: new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)), // 10 token1 + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pool := PoolState{ + SqrtPriceX96: sqrtP, + CurrentTick: 0, + Token0Decimals: tc.d0, + Token1Decimals: tc.d1, + } + + // Calculate L + L := calculateLiquidityFromValue(tc.val, sqrtP, sqrtA, sqrtB, pool) + + // Verify by calculating amounts back + a0 := GetAmount0ForLiquidity(sqrtP, sqrtA, sqrtB, L) + a1 := GetAmount1ForLiquidity(sqrtP, sqrtA, sqrtB, L) + + // Recalculate value = a0 * price + a1 + funds := UserFunds{Amount0: a0, Amount1: a1} + recalcVal := calculateTotalValue(funds, pool) + + // Check difference + diff := new(big.Int).Sub(recalcVal, tc.val) + tolerance := new(big.Int).Div(tc.val, big.NewInt(100)) // 1% tolerance (should be much tighter actually) + + if diff.CmpAbs(tolerance) > 0 { + t.Errorf("Value mismatch: Input: %s, Recalc: %s, Diff: %s", + tc.val.String(), recalcVal.String(), diff.String()) + t.Logf("L: %s", L.String()) + t.Logf("a0: %s", a0.String()) + t.Logf("a1: %s", a1.String()) + } + }) + } +} diff --git a/internal/allocation/types.go b/internal/allocation/types.go new file mode 100644 index 0000000..d2b448e --- /dev/null +++ b/internal/allocation/types.go @@ -0,0 +1,36 @@ +package allocation + +import "math/big" + +// UserFunds represents user's input funds +type UserFunds struct { + Amount0 *big.Int // token0 amount (e.g., ETH in wei, 18 decimals) + Amount1 *big.Int // token1 amount (e.g., USDC, 6 decimals) +} + +// PoolState represents the current state of the pool +type PoolState struct { + SqrtPriceX96 *big.Int + CurrentTick int + Token0Decimals int + Token1Decimals int +} + +// PositionPlan represents a planned LP position for modifyLiquidities +type PositionPlan struct { + TickLower int + TickUpper int + Liquidity *big.Int // calculated L value + Amount0 *big.Int // token0 needed (amount0Max) + Amount1 *big.Int // token1 needed (amount1Max) + Weight float64 // original weight +} + +// AllocationResult is the output of allocation calculation +type AllocationResult struct { + Positions []PositionPlan + TotalAmount0 *big.Int // sum of all positions' amount0 + TotalAmount1 *big.Int // sum of all positions' amount1 + SwapAmount *big.Int // amount to swap (in source token units) + SwapToken0To1 bool // true = swap token0 to token1, false = opposite +} diff --git a/internal/allocation/unimath.go b/internal/allocation/unimath.go new file mode 100644 index 0000000..a7fcc5b --- /dev/null +++ b/internal/allocation/unimath.go @@ -0,0 +1,196 @@ +package allocation + +import ( + "math" + "math/big" +) + +var ( + // Q96 = 2^96 + Q96 = new(big.Int).Exp(big.NewInt(2), big.NewInt(96), nil) + // Q192 = 2^192 + Q192 = new(big.Int).Exp(big.NewInt(2), big.NewInt(192), nil) +) + +// TickToSqrtPriceX96 converts a tick to sqrtPriceX96 +// sqrtPriceX96 = sqrt(1.0001^tick) * 2^96 +func TickToSqrtPriceX96(tick int) *big.Int { + // Use float64 for intermediate calculation, then convert to big.Int + // price = 1.0001^tick + // sqrtPrice = 1.0001^(tick/2) + sqrtPrice := math.Pow(1.0001, float64(tick)/2.0) + + // sqrtPriceX96 = sqrtPrice * 2^96 + sqrtPriceX96 := new(big.Float).SetFloat64(sqrtPrice) + q96Float := new(big.Float).SetInt(Q96) + sqrtPriceX96.Mul(sqrtPriceX96, q96Float) + + result := new(big.Int) + sqrtPriceX96.Int(result) + return result +} + +// SqrtPriceX96ToTick converts sqrtPriceX96 to tick +// tick = log(sqrtPriceX96^2 / 2^192) / log(1.0001) +func SqrtPriceX96ToTick(sqrtPriceX96 *big.Int) int { + // sqrtPrice = sqrtPriceX96 / 2^96 + sqrtPriceFloat := new(big.Float).SetInt(sqrtPriceX96) + q96Float := new(big.Float).SetInt(Q96) + sqrtPriceFloat.Quo(sqrtPriceFloat, q96Float) + + sqrtPrice, _ := sqrtPriceFloat.Float64() + // price = sqrtPrice^2 + // tick = log(price) / log(1.0001) + price := sqrtPrice * sqrtPrice + tick := math.Log(price) / math.Log(1.0001) + return int(math.Floor(tick)) +} + +// GetAmount0ForLiquidity calculates amount0 given liquidity and price range +// When current price is above the range: amount0 = 0 +// When current price is below the range: amount0 = L * (1/sqrtPriceA - 1/sqrtPriceB) +// When current price is in range: amount0 = L * (1/sqrtPrice - 1/sqrtPriceB) +func GetAmount0ForLiquidity(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity *big.Int) *big.Int { + // Ensure sqrtPriceA < sqrtPriceB + if sqrtPriceAX96.Cmp(sqrtPriceBX96) > 0 { + sqrtPriceAX96, sqrtPriceBX96 = sqrtPriceBX96, sqrtPriceAX96 + } + + if sqrtPriceX96.Cmp(sqrtPriceAX96) <= 0 { + // Current price below range + return calcAmount0(sqrtPriceAX96, sqrtPriceBX96, liquidity) + } else if sqrtPriceX96.Cmp(sqrtPriceBX96) >= 0 { + // Current price above range + return big.NewInt(0) + } else { + // Current price in range + return calcAmount0(sqrtPriceX96, sqrtPriceBX96, liquidity) + } +} + +// GetAmount1ForLiquidity calculates amount1 given liquidity and price range +// When current price is below the range: amount1 = 0 +// When current price is above the range: amount1 = L * (sqrtPriceB - sqrtPriceA) +// When current price is in range: amount1 = L * (sqrtPrice - sqrtPriceA) +func GetAmount1ForLiquidity(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity *big.Int) *big.Int { + // Ensure sqrtPriceA < sqrtPriceB + if sqrtPriceAX96.Cmp(sqrtPriceBX96) > 0 { + sqrtPriceAX96, sqrtPriceBX96 = sqrtPriceBX96, sqrtPriceAX96 + } + + if sqrtPriceX96.Cmp(sqrtPriceAX96) <= 0 { + // Current price below range + return big.NewInt(0) + } else if sqrtPriceX96.Cmp(sqrtPriceBX96) >= 0 { + // Current price above range + return calcAmount1(sqrtPriceAX96, sqrtPriceBX96, liquidity) + } else { + // Current price in range + return calcAmount1(sqrtPriceAX96, sqrtPriceX96, liquidity) + } +} + +// calcAmount0 calculates amount0 = L * Q96 * (sqrtPriceB - sqrtPriceA) / (sqrtPriceA * sqrtPriceB) +// Rearranged to avoid precision loss: amount0 = L * Q96 * (1/sqrtPriceA - 1/sqrtPriceB) +func calcAmount0(sqrtPriceAX96, sqrtPriceBX96, liquidity *big.Int) *big.Int { + // amount0 = L * (sqrtPriceB - sqrtPriceA) * Q96 / (sqrtPriceA * sqrtPriceB / Q96) + // Simplified: amount0 = L * (sqrtPriceB - sqrtPriceA) * Q96^2 / (sqrtPriceA * sqrtPriceB) + + diff := new(big.Int).Sub(sqrtPriceBX96, sqrtPriceAX96) + numerator := new(big.Int).Mul(liquidity, diff) + numerator.Mul(numerator, Q96) + + denominator := new(big.Int).Mul(sqrtPriceAX96, sqrtPriceBX96) + // denominator.Div(denominator, Q96) <-- Removed this line + + return new(big.Int).Div(numerator, denominator) +} + +// calcAmount1 calculates amount1 = L * (sqrtPriceB - sqrtPriceA) / Q96 +func calcAmount1(sqrtPriceAX96, sqrtPriceBX96, liquidity *big.Int) *big.Int { + diff := new(big.Int).Sub(sqrtPriceBX96, sqrtPriceAX96) + result := new(big.Int).Mul(liquidity, diff) + return result.Div(result, Q96) +} + +// GetLiquidityForAmounts calculates liquidity given amounts and price range +// Returns the maximum liquidity that can be minted with the given amounts +func GetLiquidityForAmounts(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, amount0, amount1 *big.Int) *big.Int { + // Ensure sqrtPriceA < sqrtPriceB + if sqrtPriceAX96.Cmp(sqrtPriceBX96) > 0 { + sqrtPriceAX96, sqrtPriceBX96 = sqrtPriceBX96, sqrtPriceAX96 + } + + if sqrtPriceX96.Cmp(sqrtPriceAX96) <= 0 { + // Current price below range: only amount0 matters + return getLiquidityForAmount0(sqrtPriceAX96, sqrtPriceBX96, amount0) + } else if sqrtPriceX96.Cmp(sqrtPriceBX96) >= 0 { + // Current price above range: only amount1 matters + return getLiquidityForAmount1(sqrtPriceAX96, sqrtPriceBX96, amount1) + } else { + // Current price in range: take the minimum of the two + l0 := getLiquidityForAmount0(sqrtPriceX96, sqrtPriceBX96, amount0) + l1 := getLiquidityForAmount1(sqrtPriceAX96, sqrtPriceX96, amount1) + if l0.Cmp(l1) < 0 { + return l0 + } + return l1 + } +} + +// getLiquidityForAmount0 calculates L given amount0 +// L = amount0 * sqrtPriceA * sqrtPriceB / (Q96 * (sqrtPriceB - sqrtPriceA)) +func getLiquidityForAmount0(sqrtPriceAX96, sqrtPriceBX96, amount0 *big.Int) *big.Int { + if amount0.Sign() == 0 { + return big.NewInt(0) + } + diff := new(big.Int).Sub(sqrtPriceBX96, sqrtPriceAX96) + if diff.Sign() == 0 { + return big.NewInt(0) + } + + // L = amount0 * sqrtPriceA * sqrtPriceB / (Q96 * (sqrtPriceB - sqrtPriceA)) + // Rearranged: L = amount0 * (sqrtPriceA * sqrtPriceB / Q96) / (sqrtPriceB - sqrtPriceA) + product := new(big.Int).Mul(sqrtPriceAX96, sqrtPriceBX96) + product.Div(product, Q96) + + numerator := new(big.Int).Mul(amount0, product) + return numerator.Div(numerator, diff) +} + +// getLiquidityForAmount1 calculates L given amount1 +// L = amount1 * Q96 / (sqrtPriceB - sqrtPriceA) +func getLiquidityForAmount1(sqrtPriceAX96, sqrtPriceBX96, amount1 *big.Int) *big.Int { + if amount1.Sign() == 0 { + return big.NewInt(0) + } + diff := new(big.Int).Sub(sqrtPriceBX96, sqrtPriceAX96) + if diff.Sign() == 0 { + return big.NewInt(0) + } + + numerator := new(big.Int).Mul(amount1, Q96) + return numerator.Div(numerator, diff) +} + +// SqrtPriceX96ToPrice converts sqrtPriceX96 to human-readable price (float64) +func SqrtPriceX96ToPrice(sqrtPriceX96 *big.Int) float64 { + sqrtPriceFloat := new(big.Float).SetInt(sqrtPriceX96) + q96Float := new(big.Float).SetInt(Q96) + sqrtPriceFloat.Quo(sqrtPriceFloat, q96Float) + + sqrtPrice, _ := sqrtPriceFloat.Float64() + return sqrtPrice * sqrtPrice +} + +// PriceToSqrtPriceX96 converts human-readable price to sqrtPriceX96 +func PriceToSqrtPriceX96(price float64) *big.Int { + sqrtPrice := math.Sqrt(price) + sqrtPriceX96 := new(big.Float).SetFloat64(sqrtPrice) + q96Float := new(big.Float).SetInt(Q96) + sqrtPriceX96.Mul(sqrtPriceX96, q96Float) + + result := new(big.Int) + sqrtPriceX96.Int(result) + return result +} diff --git a/internal/allocation/unimath_test.go b/internal/allocation/unimath_test.go new file mode 100644 index 0000000..5e4af7d --- /dev/null +++ b/internal/allocation/unimath_test.go @@ -0,0 +1,118 @@ +package allocation + +import ( + "math/big" + "testing" +) + +func TestTickToSqrtPriceX96_RoundTrip(t *testing.T) { + tests := []int{0, 100, -100, 1000, -1000, 50000, -50000} + + for _, tick := range tests { + sqrtPriceX96 := TickToSqrtPriceX96(tick) + gotTick := SqrtPriceX96ToTick(sqrtPriceX96) + + // Allow ±1 tick difference due to rounding + diff := gotTick - tick + if diff < -1 || diff > 1 { + t.Errorf("tick %d: got %d after round-trip, diff=%d", tick, gotTick, diff) + } + } +} + +func TestTickToSqrtPriceX96_KnownValues(t *testing.T) { + // tick 0 → price = 1 → sqrtPrice = 1 → sqrtPriceX96 = Q96 + sqrtPriceX96 := TickToSqrtPriceX96(0) + if sqrtPriceX96.Cmp(Q96) != 0 { + t.Errorf("tick 0: expected %s, got %s", Q96.String(), sqrtPriceX96.String()) + } +} + +func TestSqrtPriceX96ToPrice(t *testing.T) { + // tick 0 → price = 1 + sqrtPriceX96 := TickToSqrtPriceX96(0) + price := SqrtPriceX96ToPrice(sqrtPriceX96) + if price < 0.999 || price > 1.001 { + t.Errorf("tick 0: expected price ~1, got %f", price) + } + + // tick 10000 → price ≈ 2.718 (e^1 ≈ 1.0001^10000) + sqrtPriceX96 = TickToSqrtPriceX96(10000) + price = SqrtPriceX96ToPrice(sqrtPriceX96) + expected := 2.718 + if price < expected*0.99 || price > expected*1.01 { + t.Errorf("tick 10000: expected price ~%f, got %f", expected, price) + } +} + +func TestGetLiquidityForAmounts_RoundTrip(t *testing.T) { + // Setup: current price at tick 0, range from tick -1000 to 1000 + sqrtPriceX96 := TickToSqrtPriceX96(0) + sqrtPriceAX96 := TickToSqrtPriceX96(-1000) + sqrtPriceBX96 := TickToSqrtPriceX96(1000) + + // Given amounts + amount0 := big.NewInt(1e18) // 1 token0 + amount1 := big.NewInt(1000e6) // 1000 token1 + + // Calculate liquidity + liquidity := GetLiquidityForAmounts(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, amount0, amount1) + + // Recalculate amounts from liquidity + gotAmount0 := GetAmount0ForLiquidity(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + gotAmount1 := GetAmount1ForLiquidity(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + + // Should be <= original amounts (liquidity is limited by the smaller ratio) + if gotAmount0.Cmp(amount0) > 0 { + t.Errorf("amount0: got %s > original %s", gotAmount0.String(), amount0.String()) + } + if gotAmount1.Cmp(amount1) > 0 { + t.Errorf("amount1: got %s > original %s", gotAmount1.String(), amount1.String()) + } + + // At least one should be close to original + ratio0 := new(big.Float).Quo(new(big.Float).SetInt(gotAmount0), new(big.Float).SetInt(amount0)) + ratio1 := new(big.Float).Quo(new(big.Float).SetInt(gotAmount1), new(big.Float).SetInt(amount1)) + r0, _ := ratio0.Float64() + r1, _ := ratio1.Float64() + + if r0 < 0.99 && r1 < 0.99 { + t.Errorf("neither amount is close to original: ratio0=%f, ratio1=%f", r0, r1) + } +} + +func TestGetAmountForLiquidity_BelowRange(t *testing.T) { + // Current price below range: only token0 needed + sqrtPriceX96 := TickToSqrtPriceX96(-2000) + sqrtPriceAX96 := TickToSqrtPriceX96(-1000) + sqrtPriceBX96 := TickToSqrtPriceX96(1000) + liquidity := big.NewInt(1e18) + + amount0 := GetAmount0ForLiquidity(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + amount1 := GetAmount1ForLiquidity(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + + if amount0.Sign() <= 0 { + t.Errorf("below range: amount0 should be > 0, got %s", amount0.String()) + } + if amount1.Sign() != 0 { + t.Errorf("below range: amount1 should be 0, got %s", amount1.String()) + } +} + +func TestGetAmountForLiquidity_AboveRange(t *testing.T) { + // Current price above range: only token1 needed + sqrtPriceX96 := TickToSqrtPriceX96(2000) + sqrtPriceAX96 := TickToSqrtPriceX96(-1000) + sqrtPriceBX96 := TickToSqrtPriceX96(1000) + liquidity := big.NewInt(1e18) + + amount0 := GetAmount0ForLiquidity(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + amount1 := GetAmount1ForLiquidity(sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + + if amount0.Sign() != 0 { + t.Errorf("above range: amount0 should be 0, got %s", amount0.String()) + } + if amount1.Sign() <= 0 { + t.Errorf("above range: amount1 should be > 0, got %s", amount1.String()) + } +} From acbfa5a03032046c7b649234a3fba0539028940e Mon Sep 17 00:00:00 2001 From: timwang Date: Thu, 5 Feb 2026 15:10:34 +0800 Subject: [PATCH 3/8] Add allocation logic to complete agent/strategy --- internal/agent/agent.go | 156 ++++++++++++++++++++---- internal/liquidity/rebalance.go | 55 --------- internal/liquidity/rebalance_test.go | 172 --------------------------- internal/strategy/service/service.go | 24 ++-- internal/strategy/strategy.go | 9 +- internal/vault/vault.go | 2 + 6 files changed, 153 insertions(+), 265 deletions(-) delete mode 100644 internal/liquidity/rebalance.go delete mode 100644 internal/liquidity/rebalance_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d05e2a1..d82c4b6 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -3,10 +3,14 @@ package agent import ( "context" "log/slog" + "math/big" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" + "remora/internal/allocation" + "remora/internal/coverage" + "remora/internal/liquidity" "remora/internal/signer" "remora/internal/strategy" "remora/internal/vault" @@ -72,7 +76,7 @@ func (s *Service) Run(ctx context.Context) ([]RebalanceResult, error) { } // processVault handles rebalance logic for a single vault. -func (s *Service) processVault(_ context.Context, vaultAddr common.Address) RebalanceResult { +func (s *Service) processVault(ctx context.Context, vaultAddr common.Address) RebalanceResult { s.logger.Info("processing vault", slog.String("address", vaultAddr.Hex())) // Step 1: Create vault client @@ -87,40 +91,140 @@ func (s *Service) processVault(_ context.Context, vaultAddr common.Address) Reba } // Step 2: Get vault state and current positions - // TODO: state, err := vaultClient.GetState(ctx) - // TODO: currentPositions, err := vaultClient.GetPositions(ctx) - _ = vaultClient + state, err := vaultClient.GetState(ctx) + if err != nil { + s.logger.Error("failed to get vault state", slog.Any("error", err)) + return RebalanceResult{VaultAddress: vaultAddr, Reason: "get_state_error"} + } // Step 3: Compute target positions using strategy service - // TODO: targetResult, err := s.computeTargetPositions(ctx, state.PoolKey) + // Convert vault.PoolKey to liquidity.PoolKey + liqPoolKey := liquidity.PoolKey{ + Currency0: state.PoolKey.Currency0.Hex(), + Currency1: state.PoolKey.Currency1.Hex(), + Fee: uint32(state.PoolKey.Fee.Uint64()), //nolint:gosec // fee fits in uint24 + TickSpacing: int32(state.PoolKey.TickSpacing.Int64()), //nolint:gosec // tickSpacing fits in int24 + Hooks: state.PoolKey.Hooks.Hex(), + } + + computeParams := &strategy.ComputeParams{ + PoolKey: liqPoolKey, + BinSizeTicks: 200, // TODO: Configurable + TickRange: 1000, // TODO: Configurable + AlgoConfig: coverage.DefaultConfig(), + } + + targetResult, err := s.strategySvc.ComputeTargetPositions(ctx, computeParams) + if err != nil { + s.logger.Error("failed to compute target", slog.Any("error", err)) + return RebalanceResult{VaultAddress: vaultAddr, Reason: "strategy_error"} + } + + // Step 4: Calculate Total Assets (Idle + Invested) + // We need decimals and balances + token0 := state.PoolKey.Currency0 + token1 := state.PoolKey.Currency1 + + // TODO: Cache decimals + decimals0, err := s.getTokenDecimals(ctx, token0) + if err != nil { + s.logger.Error("failed to get token0 decimals", slog.Any("error", err)) + return RebalanceResult{VaultAddress: vaultAddr, Reason: "token_error"} + } + decimals1, err := s.getTokenDecimals(ctx, token1) + if err != nil { + s.logger.Error("failed to get token1 decimals", slog.Any("error", err)) + return RebalanceResult{VaultAddress: vaultAddr, Reason: "token_error"} + } - // Step 4: Calculate deviation between current and target - // TODO: deviation := s.calculateDeviation(currentPositions, targetResult) + // Get Idle Balances + idle0, err := s.getTokenBalance(ctx, token0, vaultAddr) + if err != nil { + s.logger.Error("failed to get token0 balance", slog.Any("error", err)) + return RebalanceResult{VaultAddress: vaultAddr, Reason: "balance_error"} + } + idle1, err := s.getTokenBalance(ctx, token1, vaultAddr) + if err != nil { + s.logger.Error("failed to get token1 balance", slog.Any("error", err)) + return RebalanceResult{VaultAddress: vaultAddr, Reason: "balance_error"} + } - // Step 5: Check if rebalance is needed - // TODO: if deviation < s.deviationThreshold { return skipped } + // Get Invested Balances (from current positions) + positions, err := vaultClient.GetPositions(ctx) + if err != nil { + s.logger.Error("failed to get positions", slog.Any("error", err)) + return RebalanceResult{VaultAddress: vaultAddr, Reason: "get_positions_error"} + } - // Step 6: Execute rebalance - // TODO: err := s.executeRebalance(ctx, vaultClient, targetResult) + invested0 := big.NewInt(0) + invested1 := big.NewInt(0) + + // We use the Strategy's SqrtPriceX96 to estimate current position value + // Note: accurate value requires getting the real positions info including uncollected fees, + // but here we just estimate principal from liquidity. + for _, pos := range positions { + if pos.Liquidity == nil || pos.Liquidity.Sign() == 0 { + continue + } + + // Calculate amounts for this position + // allocation.GetAmount0ForLiquidity needs sqrtPriceX96, sqrtPriceA, sqrtPriceB, liquidity + sqrtPriceAX96 := allocation.TickToSqrtPriceX96(int(pos.TickLower)) + sqrtPriceBX96 := allocation.TickToSqrtPriceX96(int(pos.TickUpper)) + + amt0 := allocation.GetAmount0ForLiquidity(targetResult.SqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, pos.Liquidity) + amt1 := allocation.GetAmount1ForLiquidity(targetResult.SqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, pos.Liquidity) + + invested0.Add(invested0, amt0) + invested1.Add(invested1, amt1) + } + + // Sum total + total0 := new(big.Int).Add(idle0, invested0) + total1 := new(big.Int).Add(idle1, invested1) + + // Step 5: Allocate + poolState := allocation.PoolState{ + SqrtPriceX96: targetResult.SqrtPriceX96, + CurrentTick: int(targetResult.CurrentTick), + Token0Decimals: int(decimals0), + Token1Decimals: int(decimals1), + } + + userFunds := allocation.UserFunds{ + Amount0: total0, + Amount1: total1, + } + + allocationResult, err := allocation.Allocate(targetResult.Segments, userFunds, poolState) + if err != nil { + s.logger.Error("failed to allocate", slog.Any("error", err)) + return RebalanceResult{VaultAddress: vaultAddr, Reason: "allocation_error"} + } + + s.logger.Info("allocation computed", + slog.String("swap_amount", allocationResult.SwapAmount.String()), + slog.Bool("zero_for_one", allocationResult.SwapToken0To1), + slog.Int("new_positions", len(allocationResult.Positions)), + ) + + // Step 6: Execute rebalance (TODO) + // err := s.executeRebalance(ctx, vaultClient, allocationResult) return RebalanceResult{ VaultAddress: vaultAddr, - Rebalanced: false, - Reason: "not_implemented", + Rebalanced: true, // Mark as processed for now + Reason: "allocation_computed", } } -// ============================================================================= -// Private methods to implement -// ============================================================================= - -// computeTargetPositions computes target positions for a vault. -// Flow: PoolKey -> liquidity.GetDistribution -> strategy.ComputeTargetPositions -// func (s *Service) computeTargetPositions(ctx context.Context, poolKey vault.PoolKey) (*strategy.ComputeResult, error) - -// calculateDeviation calculates deviation between current and target positions. -// func (s *Service) calculateDeviation(current []vault.Position, target *strategy.ComputeResult) float64 +// Helper stubs +func (s *Service) getTokenDecimals(ctx context.Context, token common.Address) (uint8, error) { + // TODO: Implement ERC20 call + return 18, nil +} -// executeRebalance executes rebalance transactions. -// Flow: 1. Burn all existing positions 2. Mint new positions -// func (s *Service) executeRebalance(ctx context.Context, client vault.Vault, target *strategy.ComputeResult) error +func (s *Service) getTokenBalance(ctx context.Context, token common.Address, owner common.Address) (*big.Int, error) { + // TODO: Implement ERC20 call + return big.NewInt(0), nil +} diff --git a/internal/liquidity/rebalance.go b/internal/liquidity/rebalance.go deleted file mode 100644 index b61fa91..0000000 --- a/internal/liquidity/rebalance.go +++ /dev/null @@ -1,55 +0,0 @@ -package liquidity - -import "github.com/shopspring/decimal" - -func BuildRebalanceAllocations( - ranges []TickRangeWeight, - totalAmount decimal.Decimal, -) ([]RebalanceAllocation, error) { - if len(ranges) == 0 { - return nil, ErrNoTickRanges - } - - if totalAmount.IsNegative() { - return nil, ErrInvalidTotalAmount - } - - sumWeight := decimal.Zero - for _, r := range ranges { - if r.TickLower >= r.TickUpper { - return nil, ErrInvalidTickRange - } - - if r.Weight.IsNegative() { - return nil, ErrInvalidWeight - } - - sumWeight = sumWeight.Add(r.Weight) - } - - if sumWeight.IsZero() { - return nil, ErrZeroTotalWeight - } - - allocations := make([]RebalanceAllocation, len(ranges)) - remaining := totalAmount - - for i, r := range ranges { - var amount decimal.Decimal - if i == len(ranges)-1 { - amount = remaining - } else { - amount = totalAmount.Mul(r.Weight).Div(sumWeight) - remaining = remaining.Sub(amount) - } - - allocations[i] = RebalanceAllocation{ - TickLower: r.TickLower, - TickUpper: r.TickUpper, - Weight: r.Weight, - Amount: amount, - } - } - - return allocations, nil -} diff --git a/internal/liquidity/rebalance_test.go b/internal/liquidity/rebalance_test.go deleted file mode 100644 index 94d41ff..0000000 --- a/internal/liquidity/rebalance_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package liquidity_test - -import ( - "errors" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/shopspring/decimal" - - "remora/internal/liquidity" -) - -func TestBuildRebalanceAllocations(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - ranges []liquidity.TickRangeWeight - amount decimal.Decimal - want []liquidity.RebalanceAllocation - wantErr bool - wantErrIs error - wantAmount decimal.Decimal - }{ - { - name: "success - weighted allocation", - ranges: []liquidity.TickRangeWeight{ - {TickLower: -100, TickUpper: 0, Weight: mustDecimal(t, "1")}, - {TickLower: 0, TickUpper: 100, Weight: mustDecimal(t, "3")}, - }, - amount: mustDecimal(t, "100"), - want: []liquidity.RebalanceAllocation{ - { - TickLower: -100, - TickUpper: 0, - Weight: mustDecimal(t, "1"), - Amount: mustDecimal(t, "25"), - }, - { - TickLower: 0, - TickUpper: 100, - Weight: mustDecimal(t, "3"), - Amount: mustDecimal(t, "75"), - }, - }, - wantAmount: mustDecimal(t, "100"), - }, - { - name: "success - zero weight keeps entry with zero amounts", - ranges: []liquidity.TickRangeWeight{ - {TickLower: -200, TickUpper: -100, Weight: mustDecimal(t, "0")}, - {TickLower: -100, TickUpper: 100, Weight: mustDecimal(t, "2")}, - }, - amount: mustDecimal(t, "10"), - want: []liquidity.RebalanceAllocation{ - { - TickLower: -200, - TickUpper: -100, - Weight: mustDecimal(t, "0"), - Amount: mustDecimal(t, "0"), - }, - { - TickLower: -100, - TickUpper: 100, - Weight: mustDecimal(t, "2"), - Amount: mustDecimal(t, "10"), - }, - }, - wantAmount: mustDecimal(t, "10"), - }, - { - name: "error - no ranges", - ranges: nil, - amount: mustDecimal(t, "1"), - wantErr: true, - wantErrIs: liquidity.ErrNoTickRanges, - wantAmount: mustDecimal(t, "0"), - }, - { - name: "error - invalid tick range", - ranges: []liquidity.TickRangeWeight{ - {TickLower: 100, TickUpper: 100, Weight: mustDecimal(t, "1")}, - }, - amount: mustDecimal(t, "1"), - wantErr: true, - wantErrIs: liquidity.ErrInvalidTickRange, - wantAmount: mustDecimal(t, "0"), - }, - { - name: "error - negative weight", - ranges: []liquidity.TickRangeWeight{ - {TickLower: 0, TickUpper: 10, Weight: mustDecimal(t, "-1")}, - }, - amount: mustDecimal(t, "1"), - wantErr: true, - wantErrIs: liquidity.ErrInvalidWeight, - wantAmount: mustDecimal(t, "0"), - }, - { - name: "error - negative total amount", - ranges: []liquidity.TickRangeWeight{ - {TickLower: 0, TickUpper: 10, Weight: mustDecimal(t, "1")}, - }, - amount: mustDecimal(t, "-1"), - wantErr: true, - wantErrIs: liquidity.ErrInvalidTotalAmount, - wantAmount: mustDecimal(t, "0"), - }, - { - name: "error - total weight zero", - ranges: []liquidity.TickRangeWeight{ - {TickLower: 0, TickUpper: 10, Weight: mustDecimal(t, "0")}, - {TickLower: 10, TickUpper: 20, Weight: mustDecimal(t, "0")}, - }, - amount: mustDecimal(t, "1"), - wantErr: true, - wantErrIs: liquidity.ErrZeroTotalWeight, - wantAmount: mustDecimal(t, "0"), - }, - } - - decimalComparer := cmp.Comparer(func(a, b decimal.Decimal) bool { - return a.Equal(b) - }) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got, err := liquidity.BuildRebalanceAllocations(tt.ranges, tt.amount) - if err != nil { - if !tt.wantErr { - t.Fatalf("BuildRebalanceAllocations() failed: %v", err) - } - - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Fatalf("BuildRebalanceAllocations() error = %v, want %v", err, tt.wantErrIs) - } - - return - } - - if tt.wantErr { - t.Fatalf("BuildRebalanceAllocations() expected error") - } - - if diff := cmp.Diff(tt.want, got, decimalComparer); diff != "" { - t.Fatalf("BuildRebalanceAllocations() mismatch (-want +got):\n%s", diff) - } - - sum := decimal.Zero - for _, allocation := range got { - sum = sum.Add(allocation.Amount) - } - - if !sum.Equal(tt.wantAmount) { - t.Fatalf("BuildRebalanceAllocations() amount sum = %s, want %s", sum, tt.wantAmount) - } - }) - } -} - -func mustDecimal(t *testing.T, value string) decimal.Decimal { - t.Helper() - - dec, err := decimal.NewFromString(value) - if err != nil { - t.Fatalf("NewDecimalFromString(%q) failed: %v", value, err) - } - - return dec -} diff --git a/internal/strategy/service/service.go b/internal/strategy/service/service.go index 89a7e63..e92ef0b 100644 --- a/internal/strategy/service/service.go +++ b/internal/strategy/service/service.go @@ -41,22 +41,30 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C allocationBins := toAllocationBins(dist.Bins, dist.CurrentTick) if len(allocationBins) == 0 { + sqrtPriceX96 := new(big.Int) + sqrtPriceX96.SetString(dist.SqrtPriceX96, 10) + return &strategy.ComputeResult{ - CurrentTick: dist.CurrentTick, - Segments: nil, - Metrics: coverage.Metrics{}, - ComputedAt: time.Now().UTC(), + CurrentTick: dist.CurrentTick, + SqrtPriceX96: sqrtPriceX96, + Segments: nil, + Metrics: coverage.Metrics{}, + ComputedAt: time.Now().UTC(), }, nil } // Step 3: Run coverage algorithm result := coverage.Run(allocationBins, params.AlgoConfig) + sqrtPriceX96 := new(big.Int) + sqrtPriceX96.SetString(dist.SqrtPriceX96, 10) + return &strategy.ComputeResult{ - CurrentTick: dist.CurrentTick, - Segments: result.Segments, - Metrics: result.Metrics, - ComputedAt: time.Now().UTC(), + CurrentTick: dist.CurrentTick, + SqrtPriceX96: sqrtPriceX96, + Segments: result.Segments, + Metrics: result.Metrics, + ComputedAt: time.Now().UTC(), }, nil } diff --git a/internal/strategy/strategy.go b/internal/strategy/strategy.go index ca4c565..8119df5 100644 --- a/internal/strategy/strategy.go +++ b/internal/strategy/strategy.go @@ -27,10 +27,11 @@ type ComputeParams struct { // ComputeResult contains the computed target positions. type ComputeResult struct { - CurrentTick int32 // Current pool tick - Segments []coverage.Segment // Target LP segments - Metrics coverage.Metrics // Coverage metrics - ComputedAt time.Time // Timestamp when computation was performed + CurrentTick int32 // Current pool tick + SqrtPriceX96 *big.Int // Current pool sqrtPriceX96 + Segments []coverage.Segment // Target LP segments + Metrics coverage.Metrics // Coverage metrics + ComputedAt time.Time // Timestamp when computation was performed } // Position represents an existing LP position (for future use with gap calculation). diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 39da4e5..62719f9 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -16,6 +16,7 @@ type Position struct { TokenID *big.Int TickLower int32 TickUpper int32 + Liquidity *big.Int } // VaultState represents the current state of a vault. @@ -169,6 +170,7 @@ func (c *Client) GetPositions(ctx context.Context) ([]Position, error) { TokenID: tokenID, TickLower: int32(tickLower.Int64()), //nolint:gosec // Uniswap tick is int24, fits in int32 TickUpper: int32(tickUpper.Int64()), //nolint:gosec // Uniswap tick is int24, fits in int32 + Liquidity: big.NewInt(0), // TODO: Fetch liquidity from PositionManager (posm) }) } From e2e9c37a01d345d9e4546c477a87ad6c112ca0eb Mon Sep 17 00:00:00 2001 From: timwang Date: Thu, 5 Feb 2026 15:30:05 +0800 Subject: [PATCH 4/8] Add state view to get user vault balance and check balance --- cmd/rebalance/main.go | 2 + internal/agent/agent.go | 34 +++++++------ internal/agent/erc20.go | 101 ++++++++++++++++++++++++++++++++++++ internal/agent/posm.go | 110 ++++++++++++++++++++++++++++++++++++++++ internal/vault/vault.go | 7 +++ 5 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 internal/agent/erc20.go create mode 100644 internal/agent/posm.go diff --git a/cmd/rebalance/main.go b/cmd/rebalance/main.go index 99e617a..a0f90e0 100644 --- a/cmd/rebalance/main.go +++ b/cmd/rebalance/main.go @@ -57,12 +57,14 @@ func main() { }) // Initialize agent service + stateViewAddr := common.HexToAddress(os.Getenv("STATEVIEW_CONTRACT_ADDR")) agentSvc := agent.New( vaultSource, nil, // TODO: strategySvc sgn, ethClient, logger, + stateViewAddr, ) ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d82c4b6..8d54c4d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -30,11 +30,12 @@ type RebalanceResult struct { // Service is the main agent orchestrator. type Service struct { - vaultSource VaultSource - strategySvc strategy.Service - signer *signer.Signer - ethClient *ethclient.Client - logger *slog.Logger + vaultSource VaultSource + strategySvc strategy.Service + signer *signer.Signer + ethClient *ethclient.Client + logger *slog.Logger + stateViewAddr common.Address deviationThreshold float64 } @@ -46,6 +47,7 @@ func New( signer *signer.Signer, ethClient *ethclient.Client, logger *slog.Logger, + stateViewAddr common.Address, ) *Service { return &Service{ vaultSource: vaultSource, @@ -53,6 +55,7 @@ func New( signer: signer, ethClient: ethClient, logger: logger, + stateViewAddr: stateViewAddr, deviationThreshold: 0.1, } } @@ -163,6 +166,16 @@ func (s *Service) processVault(ctx context.Context, vaultAddr common.Address) Re // Note: accurate value requires getting the real positions info including uncollected fees, // but here we just estimate principal from liquidity. for _, pos := range positions { + // Fetch real liquidity from POSM/StateView + liquidity, err := s.getPositionLiquidity(ctx, state.Posm, state.PoolID, pos.TokenID) + if err != nil { + s.logger.Warn("failed to get position liquidity", + slog.String("tokenID", pos.TokenID.String()), + slog.Any("error", err)) + continue + } + pos.Liquidity = liquidity + if pos.Liquidity == nil || pos.Liquidity.Sign() == 0 { continue } @@ -217,14 +230,3 @@ func (s *Service) processVault(ctx context.Context, vaultAddr common.Address) Re Reason: "allocation_computed", } } - -// Helper stubs -func (s *Service) getTokenDecimals(ctx context.Context, token common.Address) (uint8, error) { - // TODO: Implement ERC20 call - return 18, nil -} - -func (s *Service) getTokenBalance(ctx context.Context, token common.Address, owner common.Address) (*big.Int, error) { - // TODO: Implement ERC20 call - return big.NewInt(0), nil -} diff --git a/internal/agent/erc20.go b/internal/agent/erc20.go new file mode 100644 index 0000000..b065956 --- /dev/null +++ b/internal/agent/erc20.go @@ -0,0 +1,101 @@ +package agent + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" +) + +// ERC20 ABI (minimal) for balanceOf and decimals +const erc20ABIJSON = `[ + {"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"type":"function"}, + {"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"type":"function"} +]` + +var erc20ABI abi.ABI + +func init() { + var err error + erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse ERC20 ABI: %v", err)) + } +} + +// getTokenDecimals fetches the decimals for a given token address. +func (s *Service) getTokenDecimals(ctx context.Context, token common.Address) (uint8, error) { + // Pack the input for the "decimals" call + data, err := erc20ABI.Pack("decimals") + if err != nil { + return 0, fmt.Errorf("pack decimals: %w", err) + } + + // Create the call message + msg := ethereum.CallMsg{ + To: &token, + Data: data, + } + + // Execute the call + output, err := s.ethClient.CallContract(ctx, msg, nil) + if err != nil { + return 0, fmt.Errorf("call decimals: %w", err) + } + + // Unpack the output + var decimals uint8 + results, err := erc20ABI.Unpack("decimals", output) + if err != nil { + return 0, fmt.Errorf("unpack decimals: %w", err) + } + + // Handle flexible unpacking (sometimes returns []interface{}) + if len(results) > 0 { + decimals = results[0].(uint8) + } else { + return 0, fmt.Errorf("unexpected empty result from decimals") + } + + return decimals, nil +} + +// getTokenBalance fetches the balance of a token for a specific owner. +func (s *Service) getTokenBalance(ctx context.Context, token common.Address, owner common.Address) (*big.Int, error) { + // Pack the input for the "balanceOf" call + data, err := erc20ABI.Pack("balanceOf", owner) + if err != nil { + return nil, fmt.Errorf("pack balanceOf: %w", err) + } + + // Create the call message + msg := ethereum.CallMsg{ + To: &token, + Data: data, + } + + // Execute the call + output, err := s.ethClient.CallContract(ctx, msg, nil) + if err != nil { + return nil, fmt.Errorf("call balanceOf: %w", err) + } + + // Unpack the output + var balance *big.Int + results, err := erc20ABI.Unpack("balanceOf", output) + if err != nil { + return nil, fmt.Errorf("unpack balanceOf: %w", err) + } + + if len(results) > 0 { + balance = results[0].(*big.Int) + } else { + return nil, fmt.Errorf("unexpected empty result from balanceOf") + } + + return balance, nil +} diff --git a/internal/agent/posm.go b/internal/agent/posm.go new file mode 100644 index 0000000..319b893 --- /dev/null +++ b/internal/agent/posm.go @@ -0,0 +1,110 @@ +package agent + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" +) + +// Minimal ABIs for POSM and StateView +const ( + posmABIJSON = `[ + {"constant":true,"inputs":[{"name":"tokenId","type":"uint256"}],"name":"positions","outputs":[ + {"name":"poolKey","type":"tuple","components":[ + {"name":"currency0","type":"address"}, + {"name":"currency1","type":"address"}, + {"name":"fee","type":"uint24"}, + {"name":"tickSpacing","type":"int24"}, + {"name":"hooks","type":"address"} + ]}, + {"name":"tickLower","type":"int24"}, + {"name":"tickUpper","type":"int24"}, + {"name":"salt","type":"uint256"} + ],"type":"function"} + ]` + + stateViewABIJSON = `[ + {"constant":true,"inputs":[{"name":"poolId","type":"bytes32"},{"name":"owner","type":"address"},{"name":"tickLower","type":"int24"},{"name":"tickUpper","type":"int24"},{"name":"salt","type":"bytes32"}],"name":"getPositionInfo","outputs":[ + {"name":"liquidity","type":"uint128"}, + {"name":"feeGrowthInside0LastX128","type":"uint256"}, + {"name":"feeGrowthInside1LastX128","type":"uint256"} + ],"type":"function"} + ]` +) + +var ( + posmABI abi.ABI + stateViewABI abi.ABI +) + +func init() { + var err error + posmABI, err = abi.JSON(strings.NewReader(posmABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse POSM ABI: %v", err)) + } + stateViewABI, err = abi.JSON(strings.NewReader(stateViewABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse StateView ABI: %v", err)) + } +} + +// getPositionLiquidity fetches the liquidity of a position using POSM and StateView. +func (s *Service) getPositionLiquidity(ctx context.Context, posmAddr common.Address, poolId [32]byte, tokenID *big.Int) (*big.Int, error) { + // 1. Get salt and tick range from POSM + data, err := posmABI.Pack("positions", tokenID) + if err != nil { + return nil, fmt.Errorf("pack positions: %w", err) + } + + msg := ethereum.CallMsg{To: &posmAddr, Data: data} + output, err := s.ethClient.CallContract(ctx, msg, nil) + if err != nil { + return nil, fmt.Errorf("call positions: %w", err) + } + + results, err := posmABI.Unpack("positions", output) + if err != nil { + return nil, fmt.Errorf("unpack positions: %w", err) + } + + // results: [PoolKey, tickLower, tickUpper, salt] + // tickLower is results[1], tickUpper is results[2], salt is results[3] + tickLower := results[1].(*big.Int) + tickUpper := results[2].(*big.Int) + salt := results[3].(*big.Int) + + // 2. Convert salt to [32]byte for StateView + saltBytes := [32]byte{} + salt.FillBytes(saltBytes[:]) + + // 3. Get liquidity from StateView + // owner is the POSM address + if s.stateViewAddr == (common.Address{}) { + return nil, fmt.Errorf("stateView address not configured") + } + + svData, err := stateViewABI.Pack("getPositionInfo", poolId, posmAddr, tickLower, tickUpper, saltBytes) + if err != nil { + return nil, fmt.Errorf("pack getPositionInfo: %w", err) + } + + svMsg := ethereum.CallMsg{To: &s.stateViewAddr, Data: svData} + svOutput, err := s.ethClient.CallContract(ctx, svMsg, nil) + if err != nil { + return nil, fmt.Errorf("call getPositionInfo: %w", err) + } + + svResults, err := stateViewABI.Unpack("getPositionInfo", svOutput) + if err != nil { + return nil, fmt.Errorf("unpack getPositionInfo: %w", err) + } + + // svResults[0] is liquidity (uint128) + return svResults[0].(*big.Int), nil +} \ No newline at end of file diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 62719f9..e5ce85a 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -29,6 +29,7 @@ type VaultState struct { MaxPositionsK *big.Int PoolKey PoolKey PoolID [32]byte + Posm common.Address PositionsLength *big.Int } @@ -121,6 +122,11 @@ func (c *Client) GetState(ctx context.Context) (*VaultState, error) { return nil, err } + posm, err := c.contract.Posm(opts) + if err != nil { + return nil, err + } + positionsLength, err := c.contract.PositionsLength(opts) if err != nil { return nil, err @@ -135,6 +141,7 @@ func (c *Client) GetState(ctx context.Context) (*VaultState, error) { MaxPositionsK: maxPositionsK, PoolKey: poolKey, PoolID: poolID, + Posm: posm, PositionsLength: positionsLength, }, nil } From 3b238d4b0af28512be6acb06d7420ba22d1491d3 Mon Sep 17 00:00:00 2001 From: timwang Date: Thu, 5 Feb 2026 15:58:45 +0800 Subject: [PATCH 5/8] Add executor to handle invest by vault and our calculated result --- internal/agent/agent.go | 12 ++++-- internal/agent/executor.go | 81 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 internal/agent/executor.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 8d54c4d..edcd9a9 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -221,12 +221,16 @@ func (s *Service) processVault(ctx context.Context, vaultAddr common.Address) Re slog.Int("new_positions", len(allocationResult.Positions)), ) - // Step 6: Execute rebalance (TODO) - // err := s.executeRebalance(ctx, vaultClient, allocationResult) + // Step 6: Execute rebalance + err = s.executeRebalance(ctx, vaultClient, positions, allocationResult) + if err != nil { + s.logger.Error("failed to execute rebalance", slog.Any("error", err)) + return RebalanceResult{VaultAddress: vaultAddr, Reason: "execution_error"} + } return RebalanceResult{ VaultAddress: vaultAddr, - Rebalanced: true, // Mark as processed for now - Reason: "allocation_computed", + Rebalanced: true, + Reason: "success", } } diff --git a/internal/agent/executor.go b/internal/agent/executor.go new file mode 100644 index 0000000..b6d3ed3 --- /dev/null +++ b/internal/agent/executor.go @@ -0,0 +1,81 @@ +package agent + +import ( + "context" + "fmt" + "log/slog" + "math/big" + "time" + + "remora/internal/allocation" + "remora/internal/vault" +) + +// executeRebalance orchestrates the execution of a rebalance plan. +// Flow: Burn old positions -> Swap tokens -> Mint new positions. +func (s *Service) executeRebalance( + ctx context.Context, + vaultClient vault.Vault, + oldPositions []vault.Position, + result *allocation.AllocationResult, +) error { + deadline := big.NewInt(time.Now().Add(20 * time.Minute).Unix()) // 20 min deadline + + // 1. Burn All Old Positions + // This collects all liquidity and fees back into the vault. + for _, pos := range oldPositions { + s.logger.Info("burning position", slog.String("tokenId", pos.TokenID.String())) + + // For prototype, we set minAmounts to 0. + // TODO: Implement slippage protection using current price. + tx, err := vaultClient.BurnPosition(ctx, pos.TokenID, big.NewInt(0), big.NewInt(0), deadline) + if err != nil { + return fmt.Errorf("burn position %s: %w", pos.TokenID.String(), err) + } + + s.logger.Info("burn transaction sent", slog.String("tx", tx.Hash().Hex())) + // In a real environment, we might want to wait for the tx to be mined. + // For now, we assume sequential execution for simplicity or that POSM handles nonces. + } + + // 2. Execute Swap if needed + if result.SwapAmount != nil && result.SwapAmount.Sign() > 0 { + s.logger.Info("executing swap", + slog.String("amount", result.SwapAmount.String()), + slog.Bool("zeroForOne", result.SwapToken0To1)) + + // TODO: Calculate minAmountOut with slippage protection. + tx, err := vaultClient.Swap(ctx, result.SwapToken0To1, result.SwapAmount, big.NewInt(0), deadline) + if err != nil { + return fmt.Errorf("swap: %w", err) + } + s.logger.Info("swap transaction sent", slog.String("tx", tx.Hash().Hex())) + } + + // 3. Mint New Positions + for i, posPlan := range result.Positions { + s.logger.Info("minting new position", + slog.Int("index", i), + slog.Int("tickLower", posPlan.TickLower), + slog.Int("tickUpper", posPlan.TickUpper), + slog.String("liquidity", posPlan.Liquidity.String())) + + // amount0Max and amount1Max are the calculated needs from allocation. + // We add a small buffer (e.g., 5%) to avoid "Insufficient balance" errors due to price movements. + tx, err := vaultClient.MintPosition( + ctx, + int32(posPlan.TickLower), + int32(posPlan.TickUpper), + posPlan.Liquidity, + posPlan.Amount0, + posPlan.Amount1, + deadline, + ) + if err != nil { + return fmt.Errorf("mint position %d: %w", i, err) + } + s.logger.Info("mint transaction sent", slog.String("tx", tx.Hash().Hex())) + } + + return nil +} From c21c976b104978df562f77d04b139ee096efce01 Mon Sep 17 00:00:00 2001 From: timwang Date: Thu, 5 Feb 2026 16:08:51 +0800 Subject: [PATCH 6/8] Consider about swap allowed === false to handle the actually L calculation --- internal/agent/agent.go | 2 +- internal/allocation/allocation.go | 63 +++++++++++++++++++++++--- internal/allocation/allocation_test.go | 2 +- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index edcd9a9..a7ff89b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -209,7 +209,7 @@ func (s *Service) processVault(ctx context.Context, vaultAddr common.Address) Re Amount1: total1, } - allocationResult, err := allocation.Allocate(targetResult.Segments, userFunds, poolState) + allocationResult, err := allocation.Allocate(targetResult.Segments, userFunds, poolState, state.SwapAllowed) if err != nil { s.logger.Error("failed to allocate", slog.Any("error", err)) return RebalanceResult{VaultAddress: vaultAddr, Reason: "allocation_error"} diff --git a/internal/allocation/allocation.go b/internal/allocation/allocation.go index d71c176..77a953b 100644 --- a/internal/allocation/allocation.go +++ b/internal/allocation/allocation.go @@ -8,7 +8,7 @@ import ( // Allocate distributes user funds across segments based on weights // Main entry point for allocation calculation -func Allocate(segments []coverage.Segment, funds UserFunds, pool PoolState) (*AllocationResult, error) { +func Allocate(segments []coverage.Segment, funds UserFunds, pool PoolState, swapAllowed bool) (*AllocationResult, error) { if len(segments) == 0 { return &AllocationResult{ Positions: []PositionPlan{}, @@ -21,22 +21,26 @@ func Allocate(segments []coverage.Segment, funds UserFunds, pool PoolState) (*Al // 1. Calculate weights from LiquidityAdded weights := normalizeWeights(segments) - // 2. Calculate total value in token1 units + if swapAllowed { + return allocateWithSwap(segments, weights, funds, pool) + } + + return allocateWithoutSwap(segments, weights, funds, pool) +} + +// allocateWithSwap implements the original logic: total value -> weights -> calculate swap +func allocateWithSwap(segments []coverage.Segment, weights []float64, funds UserFunds, pool PoolState) (*AllocationResult, error) { totalValue := calculateTotalValue(funds, pool) - // 3. For each segment: allocate value and calculate position positions := make([]PositionPlan, len(segments)) totalAmount0 := big.NewInt(0) totalAmount1 := big.NewInt(0) for i, seg := range segments { - // allocatedValue = totalValue * weight - // Use big.Float for weight multiplication totalValueFloat := new(big.Float).SetInt(totalValue) allocatedFloat := new(big.Float).Mul(totalValueFloat, big.NewFloat(weights[i])) allocatedValue, _ := allocatedFloat.Int(nil) - // seg.TickLower and seg.TickUpper are int32 in internal/coverage, convert to int pos := calculatePosition(allocatedValue, int(seg.TickLower), int(seg.TickUpper), weights[i], pool) positions[i] = *pos @@ -44,7 +48,6 @@ func Allocate(segments []coverage.Segment, funds UserFunds, pool PoolState) (*Al totalAmount1.Add(totalAmount1, pos.Amount1) } - // 4. Calculate swap needed swapAmount, token0To1 := calculateSwapNeeded(totalAmount0, totalAmount1, funds, pool) return &AllocationResult{ @@ -56,6 +59,52 @@ func Allocate(segments []coverage.Segment, funds UserFunds, pool PoolState) (*Al }, nil } +// allocateWithoutSwap implements "Fit-to-Balance" logic: distribute existing tokens by weight +func allocateWithoutSwap(segments []coverage.Segment, weights []float64, funds UserFunds, pool PoolState) (*AllocationResult, error) { + positions := make([]PositionPlan, len(segments)) + totalAmount0 := big.NewInt(0) + totalAmount1 := big.NewInt(0) + + for i, seg := range segments { + // Distribute current balances by weight + budget0Float := new(big.Float).Mul(new(big.Float).SetInt(funds.Amount0), big.NewFloat(weights[i])) + budget1Float := new(big.Float).Mul(new(big.Float).SetInt(funds.Amount1), big.NewFloat(weights[i])) + + budget0, _ := budget0Float.Int(nil) + budget1, _ := budget1Float.Int(nil) + + sqrtPriceAX96 := TickToSqrtPriceX96(int(seg.TickLower)) + sqrtPriceBX96 := TickToSqrtPriceX96(int(seg.TickUpper)) + + // Calculate max liquidity L that fits into both budgets + liquidity := GetLiquidityForAmounts(pool.SqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, budget0, budget1) + + // Calculate actual amounts needed for this L + amt0 := GetAmount0ForLiquidity(pool.SqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + amt1 := GetAmount1ForLiquidity(pool.SqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, liquidity) + + positions[i] = PositionPlan{ + TickLower: int(seg.TickLower), + TickUpper: int(seg.TickUpper), + Liquidity: liquidity, + Amount0: amt0, + Amount1: amt1, + Weight: weights[i], + } + + totalAmount0.Add(totalAmount0, amt0) + totalAmount1.Add(totalAmount1, amt1) + } + + return &AllocationResult{ + Positions: positions, + TotalAmount0: totalAmount0, + TotalAmount1: totalAmount1, + SwapAmount: big.NewInt(0), // No swap allowed + SwapToken0To1: false, + }, nil +} + // normalizeWeights converts LiquidityAdded to normalized weights (sum = 1) func normalizeWeights(segments []coverage.Segment) []float64 { var total float64 diff --git a/internal/allocation/allocation_test.go b/internal/allocation/allocation_test.go index c1d13eb..eb267a0 100644 --- a/internal/allocation/allocation_test.go +++ b/internal/allocation/allocation_test.go @@ -170,7 +170,7 @@ func TestAllocate_Integration(t *testing.T) { {TickLower: 500, TickUpper: 1000, LiquidityAdded: big.NewInt(100)}, // above } - result, err := Allocate(segments, funds, pool) + result, err := Allocate(segments, funds, pool, true) if err != nil { t.Fatalf("Allocate error: %v", err) } From 00d2f3057deb9a0a37c12ebaae15a2e5cedb0d93 Mon Sep 17 00:00:00 2001 From: timwang Date: Thu, 5 Feb 2026 17:22:23 +0800 Subject: [PATCH 7/8] Implement rebalance orchestration with allocation and safety protections -Add weight-based deviation check to optimize rebalance frequency -Implement execution flow (Burn-Swap-Mint) with slippage and gas protection --- .env.sample | 21 +++++++ cmd/rebalance/main.go | 30 ++++++++++ internal/agent/agent.go | 30 +++++++++- internal/agent/deviation.go | 82 ++++++++++++++++++++++++++++ internal/agent/executor.go | 57 ++++++++++++++++--- internal/config/agent/agent.go | 22 ++++++++ internal/strategy/service/service.go | 3 + internal/strategy/strategy.go | 1 + 8 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 internal/agent/deviation.go create mode 100644 internal/config/agent/agent.go diff --git a/.env.sample b/.env.sample index f67aa1f..3314008 100644 --- a/.env.sample +++ b/.env.sample @@ -37,3 +37,24 @@ AGENT_PRIVATE_KEY=your_private_key_here_without_0x_prefix # */30 * * * * - every 30 minutes REBALANCE_SCHEDULE=*/5 * * * * +# ============================================================================= +# Protection & Safety +# ============================================================================= + +# Maximum gas price allowed for transactions (in Gwei) +# For L2s like Base, 0.1 to 1.0 is common. +MAX_GAS_PRICE_GWEI=1.0 + +# Swap slippage tolerance in basis points (1 bps = 0.01%) +# 50 = 0.5%, 100 = 1.0% +SWAP_SLIPPAGE_BPS=50 + +# Rebalance threshold (0.1 = 10% deviation) +# Rebalance only triggers if the current portfolio distribution deviates from target by more than this. +DEVIATION_THRESHOLD=0.1 + +# StateView contract address (used to fetch position liquidity) +# Mainnet: 0x7ffe42c4a5deea5b0fec41c94c136cf115597227 +# Sepolia: 0xe1dd9c3fa50edb962e442f60dfbc432e24537e4c +# Base Sepolia: 0x571291b572ed32ce6751a2cb2486ebee8defb9b4 +STATEVIEW_CONTRACT_ADDR=0x7ffe42c4a5deea5b0fec41c94c136cf115597227 diff --git a/cmd/rebalance/main.go b/cmd/rebalance/main.go index a0f90e0..08b3ec0 100644 --- a/cmd/rebalance/main.go +++ b/cmd/rebalance/main.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" "os/signal" + "strconv" "syscall" "github.com/ethereum/go-ethereum/common" @@ -67,6 +68,35 @@ func main() { stateViewAddr, ) + // Load protection settings from env + swapSlippage := os.Getenv("SWAP_SLIPPAGE_BPS") + maxGasPrice := os.Getenv("MAX_GAS_PRICE_GWEI") + devThreshold := os.Getenv("DEVIATION_THRESHOLD") + + sSlippage := int64(50) // default 0.5% + if swapSlippage != "" { + if val, err := strconv.ParseInt(swapSlippage, 10, 64); err == nil { + sSlippage = val + } + } + + mGasPrice := 1.0 // default 1.0 Gwei + if maxGasPrice != "" { + if val, err := strconv.ParseFloat(maxGasPrice, 64); err == nil { + mGasPrice = val + } + } + + dThreshold := 0.1 // default 10% + if devThreshold != "" { + if val, err := strconv.ParseFloat(devThreshold, 64); err == nil { + dThreshold = val + } + } + + agentSvc.SetProtectionSettings(sSlippage, mGasPrice) + agentSvc.SetDeviationThreshold(dThreshold) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/internal/agent/agent.go b/internal/agent/agent.go index a7ff89b..39e3ce4 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -38,6 +38,8 @@ type Service struct { stateViewAddr common.Address deviationThreshold float64 + swapSlippageBps int64 + maxGasPriceGwei float64 } // New creates a new agent service. @@ -57,9 +59,22 @@ func New( logger: logger, stateViewAddr: stateViewAddr, deviationThreshold: 0.1, + swapSlippageBps: 50, // default: 0.5% + maxGasPriceGwei: 1.0, // default: 1.0 Gwei (suitable for many L2s) } } +// SetProtectionSettings updates the protection settings for the service. +func (s *Service) SetProtectionSettings(swapSlippageBps int64, maxGasPriceGwei float64) { + s.swapSlippageBps = swapSlippageBps + s.maxGasPriceGwei = maxGasPriceGwei +} + +// SetDeviationThreshold updates the threshold for rebalance decision. +func (s *Service) SetDeviationThreshold(threshold float64) { + s.deviationThreshold = threshold +} + // Run executes one round of rebalance check for all vaults. func (s *Service) Run(ctx context.Context) ([]RebalanceResult, error) { addresses, err := s.vaultSource.GetVaultAddresses(ctx) @@ -192,6 +207,19 @@ func (s *Service) processVault(ctx context.Context, vaultAddr common.Address) Re invested1.Add(invested1, amt1) } + // Step 4.5: Deviation Check + // Decide if we really need to rebalance + deviation := s.calculateDeviation(positions, targetResult) + s.logger.Info("deviation calculated", slog.Float64("deviation", deviation), slog.Float64("threshold", s.deviationThreshold)) + + if deviation < s.deviationThreshold { + return RebalanceResult{ + VaultAddress: vaultAddr, + Rebalanced: false, + Reason: "deviation_below_threshold", + } + } + // Sum total total0 := new(big.Int).Add(idle0, invested0) total1 := new(big.Int).Add(idle1, invested1) @@ -222,7 +250,7 @@ func (s *Service) processVault(ctx context.Context, vaultAddr common.Address) Re ) // Step 6: Execute rebalance - err = s.executeRebalance(ctx, vaultClient, positions, allocationResult) + err = s.executeRebalance(ctx, vaultClient, positions, allocationResult, targetResult.SqrtPriceX96) if err != nil { s.logger.Error("failed to execute rebalance", slog.Any("error", err)) return RebalanceResult{VaultAddress: vaultAddr, Reason: "execution_error"} diff --git a/internal/agent/deviation.go b/internal/agent/deviation.go new file mode 100644 index 0000000..1b4efaf --- /dev/null +++ b/internal/agent/deviation.go @@ -0,0 +1,82 @@ +package agent + +import ( + "math/big" + + "remora/internal/strategy" + "remora/internal/vault" +) + +// calculateDeviation computes the distance between current positions and target segments. +// Returns a value between 0.0 (identical) and 1.0 (completely different). +// Uses Weight Distribution Distance: Σ |W_curr - W_target| / 2 +func (s *Service) calculateDeviation(current []vault.Position, target *strategy.ComputeResult) float64 { + if len(target.Bins) == 0 { + if len(current) == 0 { + return 0.0 + } + return 1.0 + } + + // 1. Project distributions onto strategy bins + n := len(target.Bins) + targetL := make([]float64, n) + currentL := make([]float64, n) + + // Target distribution (from strategy segments) + for i, bin := range target.Bins { + for _, seg := range target.Segments { + // If bin is within segment range + if bin.TickLower >= seg.TickLower && bin.TickUpper <= seg.TickUpper { + if seg.LiquidityAdded != nil { + f, _ := new(big.Float).SetInt(seg.LiquidityAdded).Float64() + targetL[i] += f + } + } + } + } + + // Current distribution (from old positions) + for i, bin := range target.Bins { + for _, pos := range current { + if pos.Liquidity == nil || pos.Liquidity.Sign() == 0 { + continue + } + // If bin is within position range + if bin.TickLower >= pos.TickLower && bin.TickUpper <= pos.TickUpper { + f, _ := new(big.Float).SetInt(pos.Liquidity).Float64() + currentL[i] += f + } + } + } + + // 2. Normalize to weights + var sumTarget, sumCurrent float64 + for i := 0; i < n; i++ { + sumTarget += targetL[i] + sumCurrent += currentL[i] + } + + // If one side is empty, they are completely different + if sumTarget == 0 || sumCurrent == 0 { + if sumTarget == 0 && sumCurrent == 0 { + return 0.0 + } + return 1.0 + } + + // 3. Calculate L1 Distance + var l1Dist float64 + for i := 0; i < n; i++ { + wTarget := targetL[i] / sumTarget + wCurrent := currentL[i] / sumCurrent + + diff := wCurrent - wTarget + if diff < 0 { + diff = -diff + } + l1Dist += diff + } + + return l1Dist / 2.0 +} diff --git a/internal/agent/executor.go b/internal/agent/executor.go index b6d3ed3..018249b 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -18,7 +18,26 @@ func (s *Service) executeRebalance( vaultClient vault.Vault, oldPositions []vault.Position, result *allocation.AllocationResult, + currentSqrtPriceX96 *big.Int, ) error { + // 0. Gas Price Check + gasPrice, err := s.ethClient.SuggestGasPrice(ctx) + if err != nil { + return fmt.Errorf("suggest gas price: %w", err) + } + + // Convert maxGasPriceGwei to wei + // 1 Gwei = 10^9 wei + maxGasPriceWei := new(big.Float).Mul(big.NewFloat(s.maxGasPriceGwei), big.NewFloat(1e9)) + maxGasPriceWeiInt, _ := maxGasPriceWei.Int(nil) + + if gasPrice.Cmp(maxGasPriceWeiInt) > 0 { + s.logger.Warn("gas price too high, skipping rebalance", + slog.String("current", gasPrice.String()), + slog.String("limit", maxGasPriceWeiInt.String())) + return fmt.Errorf("gas price too high: %s > %s", gasPrice.String(), maxGasPriceWeiInt.String()) + } + deadline := big.NewInt(time.Now().Add(20 * time.Minute).Unix()) // 20 min deadline // 1. Burn All Old Positions @@ -26,26 +45,47 @@ func (s *Service) executeRebalance( for _, pos := range oldPositions { s.logger.Info("burning position", slog.String("tokenId", pos.TokenID.String())) - // For prototype, we set minAmounts to 0. - // TODO: Implement slippage protection using current price. + // For burn, we use 0 min amounts for now (collecting all available) tx, err := vaultClient.BurnPosition(ctx, pos.TokenID, big.NewInt(0), big.NewInt(0), deadline) if err != nil { return fmt.Errorf("burn position %s: %w", pos.TokenID.String(), err) } s.logger.Info("burn transaction sent", slog.String("tx", tx.Hash().Hex())) - // In a real environment, we might want to wait for the tx to be mined. - // For now, we assume sequential execution for simplicity or that POSM handles nonces. } // 2. Execute Swap if needed if result.SwapAmount != nil && result.SwapAmount.Sign() > 0 { + // Calculate minAmountOut with slippage protection + // Expected Out = amountIn * price (if zeroForOne) or amountIn / price (if oneForZero) + // price = sqrtPriceX96^2 / Q192 + + sqrtPriceSquared := new(big.Int).Mul(currentSqrtPriceX96, currentSqrtPriceX96) + var expectedOut *big.Int + + if result.SwapToken0To1 { + // Token0 -> Token1 + // out = in * sqrtP^2 / Q192 + expectedOut = new(big.Int).Mul(result.SwapAmount, sqrtPriceSquared) + expectedOut.Div(expectedOut, allocation.Q192) + } else { + // Token1 -> Token0 + // out = in * Q192 / sqrtP^2 + expectedOut = new(big.Int).Mul(result.SwapAmount, allocation.Q192) + expectedOut.Div(expectedOut, sqrtPriceSquared) + } + + // minAmountOut = expectedOut * (10000 - slippageBps) / 10000 + multiplier := big.NewInt(10000 - s.swapSlippageBps) + minAmountOut := new(big.Int).Mul(expectedOut, multiplier) + minAmountOut.Div(minAmountOut, big.NewInt(10000)) + s.logger.Info("executing swap", - slog.String("amount", result.SwapAmount.String()), + slog.String("amountIn", result.SwapAmount.String()), + slog.String("minAmountOut", minAmountOut.String()), slog.Bool("zeroForOne", result.SwapToken0To1)) - // TODO: Calculate minAmountOut with slippage protection. - tx, err := vaultClient.Swap(ctx, result.SwapToken0To1, result.SwapAmount, big.NewInt(0), deadline) + tx, err := vaultClient.Swap(ctx, result.SwapToken0To1, result.SwapAmount, minAmountOut, deadline) if err != nil { return fmt.Errorf("swap: %w", err) } @@ -60,8 +100,7 @@ func (s *Service) executeRebalance( slog.Int("tickUpper", posPlan.TickUpper), slog.String("liquidity", posPlan.Liquidity.String())) - // amount0Max and amount1Max are the calculated needs from allocation. - // We add a small buffer (e.g., 5%) to avoid "Insufficient balance" errors due to price movements. + // amount0Max and amount1Max are used as hard caps (no slippage buffer added as requested) tx, err := vaultClient.MintPosition( ctx, int32(posPlan.TickLower), diff --git a/internal/config/agent/agent.go b/internal/config/agent/agent.go new file mode 100644 index 0000000..5b19732 --- /dev/null +++ b/internal/config/agent/agent.go @@ -0,0 +1,22 @@ +package agent + +import ( + "remora/internal/config/api" +) + +type Config struct { + Name string `mapstructure:"name" structs:"name"` + Ethereum api.Ethereum `mapstructure:"ethereum" structs:"ethereum"` + Agent AgentConfig `mapstructure:"agent" structs:"agent"` +} + +type AgentConfig struct { + // RebalanceSchedule is the cron schedule for the rebalance agent + RebalanceSchedule string `mapstructure:"rebalance_schedule" structs:"rebalance_schedule"` + + // MaxGasPriceGwei is the maximum gas price in Gwei allowed for transactions + MaxGasPriceGwei float64 `mapstructure:"max_gas_price_gwei" structs:"max_gas_price_gwei"` + + // SwapSlippageBps is the slippage tolerance for swaps in basis points (1 bps = 0.01%) + SwapSlippageBps int64 `mapstructure:"swap_slippage_bps" structs:"swap_slippage_bps"` +} diff --git a/internal/strategy/service/service.go b/internal/strategy/service/service.go index e92ef0b..5d80df6 100644 --- a/internal/strategy/service/service.go +++ b/internal/strategy/service/service.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "math/big" "time" "remora/internal/coverage" @@ -48,6 +49,7 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C CurrentTick: dist.CurrentTick, SqrtPriceX96: sqrtPriceX96, Segments: nil, + Bins: nil, Metrics: coverage.Metrics{}, ComputedAt: time.Now().UTC(), }, nil @@ -63,6 +65,7 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C CurrentTick: dist.CurrentTick, SqrtPriceX96: sqrtPriceX96, Segments: result.Segments, + Bins: allocationBins, Metrics: result.Metrics, ComputedAt: time.Now().UTC(), }, nil diff --git a/internal/strategy/strategy.go b/internal/strategy/strategy.go index 8119df5..974b78e 100644 --- a/internal/strategy/strategy.go +++ b/internal/strategy/strategy.go @@ -30,6 +30,7 @@ type ComputeResult struct { CurrentTick int32 // Current pool tick SqrtPriceX96 *big.Int // Current pool sqrtPriceX96 Segments []coverage.Segment // Target LP segments + Bins []coverage.Bin // Original market liquidity bins Metrics coverage.Metrics // Coverage metrics ComputedAt time.Time // Timestamp when computation was performed } From 45a2e44acc496821501d13684b17463c116a822d Mon Sep 17 00:00:00 2001 From: timwang Date: Thu, 5 Feb 2026 17:47:30 +0800 Subject: [PATCH 8/8] Fix test issue --- internal/strategy/service/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/strategy/service/service.go b/internal/strategy/service/service.go index 56abae7..bba54ef 100644 --- a/internal/strategy/service/service.go +++ b/internal/strategy/service/service.go @@ -56,7 +56,7 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C } // Step 3: Run coverage algorithm - result := coverage.Run(allocationBins, params.AlgoConfig) + result := coverage.Run(ctx, allocationBins, params.AlgoConfig) sqrtPriceX96 := new(big.Int) sqrtPriceX96.SetString(dist.SqrtPriceX96, 10)