From 63741de94b53ab1120eb0fbacf2ef302f1dfde6a Mon Sep 17 00:00:00 2001 From: timwang Date: Thu, 5 Feb 2026 18:19:35 +0800 Subject: [PATCH] Add coverage test and calculation test, add tick bounds limits and fix some mockdata --- internal/agent/agent.go | 13 +- internal/agent/deviation_test.go | 328 +++++++++++++++++++++ internal/coverage/convert_test.go | 309 ++++++++++++++++++++ internal/coverage/greedy_test.go | 341 ++++++++++++++++++++++ internal/strategy/service/service.go | 22 +- internal/strategy/service/service_test.go | 175 +++++++++++ internal/strategy/strategy.go | 10 +- 7 files changed, 1181 insertions(+), 17 deletions(-) create mode 100644 internal/agent/deviation_test.go create mode 100644 internal/coverage/convert_test.go create mode 100644 internal/coverage/greedy_test.go create mode 100644 internal/strategy/service/service_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 250255d..f38d6cc 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -130,11 +130,16 @@ func (s *Service) processVault(ctx context.Context, vaultAddr common.Address) Re Hooks: state.PoolKey.Hooks.Hex(), } + tickSpacing := int32(state.PoolKey.TickSpacing.Int64()) //nolint:gosec // tickSpacing fits in int24 + tickRange := state.AllowedTickUpper - state.AllowedTickLower + computeParams := &strategy.ComputeParams{ - PoolKey: liqPoolKey, - BinSizeTicks: 200, // TODO: Configurable - TickRange: 1000, // TODO: Configurable - AlgoConfig: coverage.DefaultConfig(), + PoolKey: liqPoolKey, + BinSizeTicks: tickSpacing, + TickRange: tickRange, + AlgoConfig: coverage.DefaultConfig(), + AllowedTickLower: state.AllowedTickLower, + AllowedTickUpper: state.AllowedTickUpper, } targetResult, err := s.strategySvc.ComputeTargetPositions(ctx, computeParams) diff --git a/internal/agent/deviation_test.go b/internal/agent/deviation_test.go new file mode 100644 index 0000000..e87a4f5 --- /dev/null +++ b/internal/agent/deviation_test.go @@ -0,0 +1,328 @@ +package agent + +import ( + "math" + "math/big" + "testing" + + "remora/internal/coverage" + "remora/internal/strategy" + "remora/internal/vault" +) + +func almostEqual(a, b, eps float64) bool { + return math.Abs(a-b) < eps +} + +func newService() *Service { + return &Service{} +} + +// ─── Edge cases ───────────────────────────────────────────────────────────── + +func TestDeviation_NoBinsNoPositions(t *testing.T) { + s := newService() + d := s.calculateDeviation(nil, &strategy.ComputeResult{}) + + if d != 0.0 { + t.Errorf("expected 0.0 for empty bins + empty positions, got %f", d) + } +} + +func TestDeviation_NoBinsWithPositions(t *testing.T) { + s := newService() + positions := []vault.Position{ + {TickLower: 0, TickUpper: 100, Liquidity: big.NewInt(1000)}, + } + d := s.calculateDeviation(positions, &strategy.ComputeResult{}) + + if d != 1.0 { + t.Errorf("expected 1.0 for empty bins + existing positions, got %f", d) + } +} + +func TestDeviation_HasBinsNoPositions(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + }, + Segments: []coverage.Segment{ + {TickLower: 0, TickUpper: 100, LiquidityAdded: big.NewInt(500)}, + }, + } + + d := s.calculateDeviation(nil, target) + + // Target has liquidity but current has none → 1.0 + if d != 1.0 { + t.Errorf("expected 1.0 for target with liquidity + no positions, got %f", d) + } +} + +func TestDeviation_BothEmpty_WithBins(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + }, + Segments: nil, // no target segments + } + + d := s.calculateDeviation(nil, target) + + // sumTarget=0, sumCurrent=0 → 0.0 + if d != 0.0 { + t.Errorf("expected 0.0 for both empty distributions, got %f", d) + } +} + +// ─── Identical distributions ──────────────────────────────────────────────── + +func TestDeviation_IdenticalSingleSegment(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + {TickLower: 100, TickUpper: 200}, + }, + Segments: []coverage.Segment{ + {TickLower: 0, TickUpper: 200, LiquidityAdded: big.NewInt(1000)}, + }, + } + // Current positions exactly match target: one position covering [0,200) with same liquidity + positions := []vault.Position{ + {TickLower: 0, TickUpper: 200, Liquidity: big.NewInt(1000)}, + } + + d := s.calculateDeviation(positions, target) + + if !almostEqual(d, 0.0, 0.01) { + t.Errorf("expected ~0.0 for identical distributions, got %f", d) + } +} + +func TestDeviation_IdenticalProportional(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + {TickLower: 100, TickUpper: 200}, + }, + Segments: []coverage.Segment{ + {TickLower: 0, TickUpper: 200, LiquidityAdded: big.NewInt(500)}, + }, + } + // Current has different absolute amount but same weight distribution → deviation = 0 + positions := []vault.Position{ + {TickLower: 0, TickUpper: 200, Liquidity: big.NewInt(2000)}, + } + + d := s.calculateDeviation(positions, target) + + if !almostEqual(d, 0.0, 0.01) { + t.Errorf("expected ~0.0 for proportional distributions, got %f", d) + } +} + +// ─── Completely different distributions ───────────────────────────────────── + +func TestDeviation_CompletelyDifferent(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + {TickLower: 100, TickUpper: 200}, + }, + Segments: []coverage.Segment{ + // Target only covers first bin + {TickLower: 0, TickUpper: 100, LiquidityAdded: big.NewInt(1000)}, + }, + } + // Current only covers second bin + positions := []vault.Position{ + {TickLower: 100, TickUpper: 200, Liquidity: big.NewInt(1000)}, + } + + d := s.calculateDeviation(positions, target) + + // Target weights: [1.0, 0.0], Current weights: [0.0, 1.0] + // L1 = |1-0| + |0-1| = 2, deviation = 2/2 = 1.0 + if !almostEqual(d, 1.0, 0.01) { + t.Errorf("expected 1.0 for completely different distributions, got %f", d) + } +} + +// ─── Partial overlap ──────────────────────────────────────────────────────── + +func TestDeviation_PartialOverlap(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + {TickLower: 100, TickUpper: 200}, + }, + Segments: []coverage.Segment{ + {TickLower: 0, TickUpper: 200, LiquidityAdded: big.NewInt(1000)}, + }, + } + // Current only covers first bin + positions := []vault.Position{ + {TickLower: 0, TickUpper: 100, Liquidity: big.NewInt(1000)}, + } + + d := s.calculateDeviation(positions, target) + + // Target weights: [0.5, 0.5], Current weights: [1.0, 0.0] + // L1 = |1-0.5| + |0-0.5| = 1.0, deviation = 1.0/2 = 0.5 + if !almostEqual(d, 0.5, 0.01) { + t.Errorf("expected 0.5, got %f", d) + } +} + +// ─── Multiple segments and positions ──────────────────────────────────────── + +func TestDeviation_MultipleSegments(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + {TickLower: 100, TickUpper: 200}, + {TickLower: 200, TickUpper: 300}, + {TickLower: 300, TickUpper: 400}, + }, + Segments: []coverage.Segment{ + {TickLower: 0, TickUpper: 200, LiquidityAdded: big.NewInt(100)}, + {TickLower: 200, TickUpper: 400, LiquidityAdded: big.NewInt(100)}, + }, + } + // Current matches exactly + positions := []vault.Position{ + {TickLower: 0, TickUpper: 200, Liquidity: big.NewInt(100)}, + {TickLower: 200, TickUpper: 400, Liquidity: big.NewInt(100)}, + } + + d := s.calculateDeviation(positions, target) + + if !almostEqual(d, 0.0, 0.01) { + t.Errorf("expected ~0.0, got %f", d) + } +} + +// ─── Null/zero liquidity positions ────────────────────────────────────────── + +func TestDeviation_NilLiquidityPosition(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + }, + Segments: []coverage.Segment{ + {TickLower: 0, TickUpper: 100, LiquidityAdded: big.NewInt(1000)}, + }, + } + // Position with nil liquidity should be skipped + positions := []vault.Position{ + {TickLower: 0, TickUpper: 100, Liquidity: nil}, + } + + d := s.calculateDeviation(positions, target) + + // Current sums to 0 → 1.0 + if d != 1.0 { + t.Errorf("expected 1.0 for nil liquidity position, got %f", d) + } +} + +func TestDeviation_ZeroLiquidityPosition(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + }, + Segments: []coverage.Segment{ + {TickLower: 0, TickUpper: 100, LiquidityAdded: big.NewInt(1000)}, + }, + } + positions := []vault.Position{ + {TickLower: 0, TickUpper: 100, Liquidity: big.NewInt(0)}, + } + + d := s.calculateDeviation(positions, target) + + if d != 1.0 { + t.Errorf("expected 1.0 for zero liquidity position, got %f", d) + } +} + +// ─── Range check ──────────────────────────────────────────────────────────── + +func TestDeviation_AlwaysBetweenZeroAndOne(t *testing.T) { + s := newService() + + cases := []struct { + name string + positions []vault.Position + segments []coverage.Segment + }{ + { + name: "shifted", + positions: []vault.Position{{TickLower: 100, TickUpper: 300, Liquidity: big.NewInt(500)}}, + segments: []coverage.Segment{{TickLower: 0, TickUpper: 200, LiquidityAdded: big.NewInt(1000)}}, + }, + { + name: "uneven", + positions: []vault.Position{{TickLower: 0, TickUpper: 400, Liquidity: big.NewInt(1)}}, + segments: []coverage.Segment{ + {TickLower: 0, TickUpper: 200, LiquidityAdded: big.NewInt(9999)}, + {TickLower: 200, TickUpper: 400, LiquidityAdded: big.NewInt(1)}, + }, + }, + } + + bins := []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + {TickLower: 100, TickUpper: 200}, + {TickLower: 200, TickUpper: 300}, + {TickLower: 300, TickUpper: 400}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + target := &strategy.ComputeResult{ + Bins: bins, + Segments: tc.segments, + } + + d := s.calculateDeviation(tc.positions, target) + + if d < 0.0 || d > 1.0 { + t.Errorf("deviation %f out of [0,1] range", d) + } + }) + } +} + +// ─── Nil segment LiquidityAdded ───────────────────────────────────────────── + +func TestDeviation_NilSegmentLiquidity(t *testing.T) { + s := newService() + target := &strategy.ComputeResult{ + Bins: []coverage.Bin{ + {TickLower: 0, TickUpper: 100}, + }, + Segments: []coverage.Segment{ + {TickLower: 0, TickUpper: 100, LiquidityAdded: nil}, + }, + } + positions := []vault.Position{ + {TickLower: 0, TickUpper: 100, Liquidity: big.NewInt(1000)}, + } + + d := s.calculateDeviation(positions, target) + + // Target sums to 0, current > 0 → 1.0 + if d != 1.0 { + t.Errorf("expected 1.0 for nil segment liquidity, got %f", d) + } +} diff --git a/internal/coverage/convert_test.go b/internal/coverage/convert_test.go new file mode 100644 index 0000000..0745154 --- /dev/null +++ b/internal/coverage/convert_test.go @@ -0,0 +1,309 @@ +package coverage + +import ( + "context" + "math" + "math/big" + "testing" +) + +// ─── toInternalBins ───────────────────────────────────────────────────────── + +func TestToInternalBins_Empty(t *testing.T) { + result := toInternalBins(nil) + if len(result) != 0 { + t.Errorf("expected empty, got %d", len(result)) + } +} + +func TestToInternalBins_NilLiquidity(t *testing.T) { + bins := []Bin{ + {TickLower: 0, TickUpper: 100, Liquidity: nil, IsCurrent: true}, + } + result := toInternalBins(bins) + + if len(result) != 1 { + t.Fatalf("expected 1, got %d", len(result)) + } + if result[0].liquidity != 0 { + t.Errorf("expected 0 for nil liquidity, got %f", result[0].liquidity) + } + if !result[0].isCurrent { + t.Error("expected isCurrent=true") + } +} + +func TestToInternalBins_ConvertsBigInt(t *testing.T) { + bins := []Bin{ + { + TickLower: -100, + TickUpper: 200, + PriceLower: 1.5, + PriceUpper: 2.5, + Liquidity: big.NewInt(999), + IsCurrent: false, + }, + } + result := toInternalBins(bins) + + if result[0].tickLower != -100 { + t.Errorf("tickLower: expected -100, got %d", result[0].tickLower) + } + if result[0].tickUpper != 200 { + t.Errorf("tickUpper: expected 200, got %d", result[0].tickUpper) + } + if result[0].priceLower != 1.5 { + t.Errorf("priceLower: expected 1.5, got %f", result[0].priceLower) + } + if result[0].priceUpper != 2.5 { + t.Errorf("priceUpper: expected 2.5, got %f", result[0].priceUpper) + } + if !almostEqual(result[0].liquidity, 999, 0.01) { + t.Errorf("liquidity: expected 999, got %f", result[0].liquidity) + } +} + +func TestToInternalBins_MultipleBins(t *testing.T) { + bins := []Bin{ + {TickLower: 0, TickUpper: 100, Liquidity: big.NewInt(100), IsCurrent: true}, + {TickLower: 100, TickUpper: 200, Liquidity: big.NewInt(200), IsCurrent: false}, + {TickLower: 200, TickUpper: 300, Liquidity: big.NewInt(300), IsCurrent: false}, + } + result := toInternalBins(bins) + + if len(result) != 3 { + t.Fatalf("expected 3 bins, got %d", len(result)) + } + if !result[0].isCurrent { + t.Error("bin 0 should be current") + } + if result[1].isCurrent || result[2].isCurrent { + t.Error("bins 1,2 should not be current") + } +} + +// ─── toSegments ───────────────────────────────────────────────────────────── + +func TestToSegments_Empty(t *testing.T) { + bins := []internalBin{{tickLower: 0, tickUpper: 100}} + result := toSegments(bins, nil, []float64{100}) + + if len(result.Segments) != 0 { + t.Errorf("expected 0 segments, got %d", len(result.Segments)) + } +} + +func TestToSegments_SingleSegment(t *testing.T) { + bins := []internalBin{ + {tickLower: 0, tickUpper: 100, priceLower: 1.0, priceUpper: 1.5}, + {tickLower: 100, tickUpper: 200, priceLower: 1.5, priceUpper: 2.0}, + {tickLower: 200, tickUpper: 300, priceLower: 2.0, priceUpper: 2.5}, + } + segments := []internalSegment{ + {l: 0, r: 2, h: 50, liquidityAdded: 50}, + } + target := []float64{100, 100, 100} + + result := toSegments(bins, segments, target) + + if len(result.Segments) != 1 { + t.Fatalf("expected 1 segment, got %d", len(result.Segments)) + } + + seg := result.Segments[0] + if seg.TickLower != 0 { + t.Errorf("TickLower: expected 0, got %d", seg.TickLower) + } + if seg.TickUpper != 300 { + t.Errorf("TickUpper: expected 300, got %d", seg.TickUpper) + } + if seg.PriceLower != 1.0 { + t.Errorf("PriceLower: expected 1.0, got %f", seg.PriceLower) + } + if seg.PriceUpper != 2.5 { + t.Errorf("PriceUpper: expected 2.5, got %f", seg.PriceUpper) + } + if seg.LiquidityAdded.Cmp(big.NewInt(50)) != 0 { + t.Errorf("LiquidityAdded: expected 50, got %s", seg.LiquidityAdded) + } +} + +func TestToSegments_MultipleSegments(t *testing.T) { + bins := []internalBin{ + {tickLower: 0, tickUpper: 100, priceLower: 1.0, priceUpper: 1.1}, + {tickLower: 100, tickUpper: 200, priceLower: 1.1, priceUpper: 1.2}, + {tickLower: 200, tickUpper: 300, priceLower: 1.2, priceUpper: 1.3}, + {tickLower: 300, tickUpper: 400, priceLower: 1.3, priceUpper: 1.4}, + } + segments := []internalSegment{ + {l: 0, r: 1, h: 80, liquidityAdded: 80}, + {l: 2, r: 3, h: 60, liquidityAdded: 60}, + } + target := []float64{100, 100, 100, 100} + + result := toSegments(bins, segments, target) + + if len(result.Segments) != 2 { + t.Fatalf("expected 2 segments, got %d", len(result.Segments)) + } + + // First segment: bins 0-1 + if result.Segments[0].TickLower != 0 || result.Segments[0].TickUpper != 200 { + t.Errorf("seg0 ticks: expected [0,200], got [%d,%d]", + result.Segments[0].TickLower, result.Segments[0].TickUpper) + } + // Second segment: bins 2-3 + if result.Segments[1].TickLower != 200 || result.Segments[1].TickUpper != 400 { + t.Errorf("seg1 ticks: expected [200,400], got [%d,%d]", + result.Segments[1].TickLower, result.Segments[1].TickUpper) + } +} + +// ─── calcMetrics ──────────────────────────────────────────────────────────── + +func TestCalcMetrics_PerfectCoverage(t *testing.T) { + target := []float64{100, 200, 300} + pred := []float64{100, 200, 300} + m := calcMetrics(target, pred) + + if !almostEqual(m.Covered, 600, 0.01) { + t.Errorf("Covered: expected 600, got %f", m.Covered) + } + if !almostEqual(m.Gap, 0, 0.01) { + t.Errorf("Gap: expected 0, got %f", m.Gap) + } + if !almostEqual(m.Over, 0, 0.01) { + t.Errorf("Over: expected 0, got %f", m.Over) + } +} + +func TestCalcMetrics_UnderCoverage(t *testing.T) { + target := []float64{100, 200} + pred := []float64{50, 100} + m := calcMetrics(target, pred) + + // covered = min(100,50) + min(200,100) = 50 + 100 = 150 + if !almostEqual(m.Covered, 150, 0.01) { + t.Errorf("Covered: expected 150, got %f", m.Covered) + } + // gap = (100-50) + (200-100) = 50 + 100 = 150 + if !almostEqual(m.Gap, 150, 0.01) { + t.Errorf("Gap: expected 150, got %f", m.Gap) + } + if !almostEqual(m.Over, 0, 0.01) { + t.Errorf("Over: expected 0, got %f", m.Over) + } +} + +func TestCalcMetrics_OverCoverage(t *testing.T) { + target := []float64{100, 200} + pred := []float64{150, 300} + m := calcMetrics(target, pred) + + // covered = 100 + 200 = 300 + if !almostEqual(m.Covered, 300, 0.01) { + t.Errorf("Covered: expected 300, got %f", m.Covered) + } + if !almostEqual(m.Gap, 0, 0.01) { + t.Errorf("Gap: expected 0, got %f", m.Gap) + } + // over = (150-100) + (300-200) = 50 + 100 = 150 + if !almostEqual(m.Over, 150, 0.01) { + t.Errorf("Over: expected 150, got %f", m.Over) + } +} + +func TestCalcMetrics_ZeroPrediction(t *testing.T) { + target := []float64{100, 200} + pred := []float64{0, 0} + m := calcMetrics(target, pred) + + if !almostEqual(m.Covered, 0, 0.01) { + t.Errorf("Covered: expected 0, got %f", m.Covered) + } + if !almostEqual(m.Gap, 300, 0.01) { + t.Errorf("Gap: expected 300, got %f", m.Gap) + } +} + +func TestCalcMetrics_Invariant(t *testing.T) { + // Invariant: covered + gap = sum(target) + target := []float64{73, 150, 220, 10} + pred := []float64{50, 200, 100, 0} + m := calcMetrics(target, pred) + + totalTarget := 0.0 + for _, v := range target { + totalTarget += v + } + + // covered + gap should always equal total target + if !almostEqual(m.Covered+m.Gap, totalTarget, 0.01) { + t.Errorf("invariant broken: Covered(%f) + Gap(%f) = %f, totalTarget = %f", + m.Covered, m.Gap, m.Covered+m.Gap, totalTarget) + } + + // Also: covered + over = sum(pred) + totalPred := 0.0 + for _, v := range pred { + totalPred += v + } + if !almostEqual(m.Covered+m.Over, totalPred, 0.01) { + t.Errorf("invariant broken: Covered(%f) + Over(%f) = %f, totalPred = %f", + m.Covered, m.Over, m.Covered+m.Over, totalPred) + } +} + +// ─── Integration: Run with various configs ────────────────────────────────── + +func TestRun_WithMinLiqEnabled(t *testing.T) { + // Big peak + tiny peak + liqs := []float64{1000, 1000, 0, 0, 1, 0} + bins := makeBins(liqs, 100, 0) + cfg := DefaultConfig() + cfg.N = 5 + cfg.EnableMinLiq = true + + result := Run(context.Background(), bins, cfg) + + // The tiny segment (liquidity=1) should be filtered out + for _, seg := range result.Segments { + if seg.LiquidityAdded.Cmp(big.NewInt(0)) <= 0 { + t.Errorf("found segment with non-positive liquidity: %s", seg.LiquidityAdded) + } + } +} + +func TestRun_LookAheadZero(t *testing.T) { + bins := makeBins([]float64{100, 200, 300}, 100, 1) + cfg := DefaultConfig() + cfg.LookAhead = 0 + + // Should still work, just without look-ahead expansion + result := Run(context.Background(), bins, cfg) + + if len(result.Segments) == 0 { + t.Fatal("expected at least 1 segment even with LookAhead=0") + } +} + +func TestRun_SegmentsHaveValidTickOrder(t *testing.T) { + liqs := []float64{50, 100, 200, 150, 80, 300, 250, 100} + bins := makeBins(liqs, 200, 3) + cfg := DefaultConfig() + cfg.N = 3 + + result := Run(context.Background(), bins, cfg) + + for i, seg := range result.Segments { + if seg.TickLower >= seg.TickUpper { + t.Errorf("segment %d: TickLower(%d) >= TickUpper(%d)", i, seg.TickLower, seg.TickUpper) + } + if seg.LiquidityAdded == nil || seg.LiquidityAdded.Sign() <= 0 { + t.Errorf("segment %d: invalid LiquidityAdded %v", i, seg.LiquidityAdded) + } + if math.IsNaN(seg.PriceLower) || math.IsNaN(seg.PriceUpper) { + t.Errorf("segment %d: NaN price values", i) + } + } +} diff --git a/internal/coverage/greedy_test.go b/internal/coverage/greedy_test.go new file mode 100644 index 0000000..8131093 --- /dev/null +++ b/internal/coverage/greedy_test.go @@ -0,0 +1,341 @@ +package coverage + +import ( + "context" + "math" + "math/big" + "testing" +) + +// ─── helpers ──────────────────────────────────────────────────────────────── + +func makeBins(liquidities []float64, tickWidth int32, currentIdx int) []Bin { + bins := make([]Bin, len(liquidities)) + for i, liq := range liquidities { + lower := int32(i) * tickWidth + upper := lower + tickWidth + bins[i] = Bin{ + TickLower: lower, + TickUpper: upper, + PriceLower: float64(lower), + PriceUpper: float64(upper), + Liquidity: big.NewInt(int64(liq)), + IsCurrent: i == currentIdx, + } + } + return bins +} + +func almostEqual(a, b, eps float64) bool { + return math.Abs(a-b) < eps +} + +// ─── Run ──────────────────────────────────────────────────────────────────── + +func TestRun_EmptyBins(t *testing.T) { + result := Run(context.Background(), nil, DefaultConfig()) + if len(result.Segments) != 0 { + t.Fatalf("expected 0 segments, got %d", len(result.Segments)) + } +} + +func TestRun_SingleBin(t *testing.T) { + bins := makeBins([]float64{1000}, 100, 0) + cfg := DefaultConfig() + cfg.N = 3 + + result := Run(context.Background(), bins, cfg) + + if len(result.Segments) != 1 { + t.Fatalf("expected 1 segment, got %d", len(result.Segments)) + } + + seg := result.Segments[0] + if seg.TickLower != 0 || seg.TickUpper != 100 { + t.Errorf("expected tick range [0,100], got [%d,%d]", seg.TickLower, seg.TickUpper) + } + if seg.LiquidityAdded.Cmp(big.NewInt(1000)) != 0 { + t.Errorf("expected liquidity 1000, got %s", seg.LiquidityAdded) + } +} + +func TestRun_UniformLiquidity(t *testing.T) { + // 5 bins all with same liquidity — should be covered by 1 wide segment + bins := makeBins([]float64{500, 500, 500, 500, 500}, 100, 2) + cfg := DefaultConfig() + cfg.N = 1 + + result := Run(context.Background(), bins, cfg) + + if len(result.Segments) == 0 { + t.Fatal("expected at least 1 segment") + } + + // With uniform liquidity and N=1, algorithm should produce a single segment + if len(result.Segments) != 1 { + t.Fatalf("expected 1 segment for uniform distribution with N=1, got %d", len(result.Segments)) + } + + seg := result.Segments[0] + if seg.TickLower != 0 || seg.TickUpper != 500 { + t.Errorf("expected full range [0,500], got [%d,%d]", seg.TickLower, seg.TickUpper) + } +} + +func TestRun_TwoPeaks(t *testing.T) { + // Two distinct peaks separated by a gap — should produce 2 segments + liqs := []float64{0, 1000, 1000, 0, 0, 0, 800, 800, 0, 0} + bins := makeBins(liqs, 100, 1) + cfg := DefaultConfig() + cfg.N = 5 + cfg.LookAhead = 2 + cfg.Lambda = 50 + + result := Run(context.Background(), bins, cfg) + + if len(result.Segments) < 2 { + t.Fatalf("expected at least 2 segments for two-peak distribution, got %d", len(result.Segments)) + } +} + +func TestRun_MetricsCoverage(t *testing.T) { + bins := makeBins([]float64{100, 200, 300}, 100, 1) + cfg := DefaultConfig() + cfg.N = 1 + + result := Run(context.Background(), bins, cfg) + + // Covered + Gap should equal total target liquidity + totalTarget := 100.0 + 200.0 + 300.0 + coveredPlusGap := result.Metrics.Covered + result.Metrics.Gap + if !almostEqual(coveredPlusGap, totalTarget, 1.0) { + t.Errorf("Covered(%.1f) + Gap(%.1f) = %.1f, expected %.1f", + result.Metrics.Covered, result.Metrics.Gap, coveredPlusGap, totalTarget) + } +} + +func TestRun_RespectsMaxSegments(t *testing.T) { + liqs := []float64{100, 200, 300, 400, 500, 600, 700, 800, 900, 1000} + bins := makeBins(liqs, 100, 5) + cfg := DefaultConfig() + cfg.N = 3 + + result := Run(context.Background(), bins, cfg) + + if len(result.Segments) > cfg.N { + t.Errorf("segments %d exceeds N=%d", len(result.Segments), cfg.N) + } +} + +func TestRun_ZeroLiquidity(t *testing.T) { + bins := makeBins([]float64{0, 0, 0}, 100, 1) + cfg := DefaultConfig() + + result := Run(context.Background(), bins, cfg) + + if len(result.Segments) != 0 { + t.Errorf("expected 0 segments for zero liquidity, got %d", len(result.Segments)) + } +} + +// ─── quantile ─────────────────────────────────────────────────────────────── + +func TestQuantile_Empty(t *testing.T) { + if q := quantile(nil, 0.5); q != 0 { + t.Errorf("expected 0, got %f", q) + } +} + +func TestQuantile_Single(t *testing.T) { + if q := quantile([]float64{42}, 0.5); q != 42 { + t.Errorf("expected 42, got %f", q) + } +} + +func TestQuantile_Boundaries(t *testing.T) { + data := []float64{10, 20, 30, 40, 50} + + if q := quantile(data, 0); q != 10 { + t.Errorf("q=0: expected 10, got %f", q) + } + if q := quantile(data, 1); q != 50 { + t.Errorf("q=1: expected 50, got %f", q) + } +} + +func TestQuantile_Median(t *testing.T) { + data := []float64{10, 20, 30, 40, 50} + q := quantile(data, 0.5) + if !almostEqual(q, 30, 0.01) { + t.Errorf("median: expected 30, got %f", q) + } +} + +func TestQuantile_Interpolation(t *testing.T) { + data := []float64{10, 20, 30, 40} + // q=0.5 → index=1.5 → interpolate between 20 and 30 + q := quantile(data, 0.5) + if !almostEqual(q, 25, 0.01) { + t.Errorf("expected 25, got %f", q) + } +} + +func TestQuantile_UnsortedInput(t *testing.T) { + data := []float64{50, 10, 40, 20, 30} + q := quantile(data, 0.5) + if !almostEqual(q, 30, 0.01) { + t.Errorf("expected 30 (median of sorted), got %f", q) + } +} + +// ─── calcH ────────────────────────────────────────────────────────────────── + +func TestCalcH_AllZero(t *testing.T) { + gaps := []float64{0, 0, 0} + h := calcH(gaps, 0, 2, 0.6) + if h != 0 { + t.Errorf("expected 0 for all-zero gaps, got %f", h) + } +} + +func TestCalcH_SingleBin(t *testing.T) { + gaps := []float64{100} + h := calcH(gaps, 0, 0, 0.6) + if !almostEqual(h, 100, 0.01) { + t.Errorf("expected 100, got %f", h) + } +} + +func TestCalcH_Subset(t *testing.T) { + gaps := []float64{10, 20, 30, 40, 50} + // Only use indices 1..3 → {20, 30, 40} + h := calcH(gaps, 1, 3, 0.5) + expected := quantile([]float64{20, 30, 40}, 0.5) + if !almostEqual(h, expected, 0.01) { + t.Errorf("expected %f, got %f", expected, h) + } +} + +func TestCalcH_SkipsZeros(t *testing.T) { + gaps := []float64{0, 100, 0} + h := calcH(gaps, 0, 2, 0.5) + // Only non-zero is 100 + if !almostEqual(h, 100, 0.01) { + t.Errorf("expected 100, got %f", h) + } +} + +// ─── calcNetScore ─────────────────────────────────────────────────────────── + +func TestCalcNetScore_PerfectCoverage(t *testing.T) { + gaps := []float64{100, 100, 100} + bins := []internalBin{ + {isCurrent: false}, + {isCurrent: false}, + {isCurrent: false}, + } + // h=100 perfectly covers all gaps + score := calcNetScore(gaps, bins, 0, 2, 100, 0.5, 50, 0, 3, 1) + if score <= 0 { + t.Errorf("expected positive score for perfect coverage, got %f", score) + } +} + +func TestCalcNetScore_CurrentBonus(t *testing.T) { + gaps := []float64{100} + binsNoCurrent := []internalBin{{isCurrent: false}} + binsCurrent := []internalBin{{isCurrent: true}} + + scoreNoCurrent := calcNetScore(gaps, binsNoCurrent, 0, 0, 100, 0.5, 0, 0.2, 1, 1) + scoreCurrent := calcNetScore(gaps, binsCurrent, 0, 0, 100, 0.5, 0, 0.2, 1, 1) + + if scoreCurrent <= scoreNoCurrent { + t.Errorf("current bonus should increase score: current=%f, noCurrent=%f", scoreCurrent, scoreNoCurrent) + } +} + +func TestCalcNetScore_WidthPenalty(t *testing.T) { + gaps := []float64{100, 100, 100, 100, 100} + bins := make([]internalBin, 5) + + // Wider segment with same h should get penalized + scoreNarrow := calcNetScore(gaps, bins, 0, 0, 100, 0.5, 50, 0, 5, 5) + scoreWide := calcNetScore(gaps, bins, 0, 4, 100, 0.5, 50, 0, 5, 5) + + // Narrow segment covers 1 bin with ideal width=1, no penalty + // Wide segment covers 5 bins with ideal width=1, heavy penalty + if scoreWide >= scoreNarrow*5 { + t.Errorf("width penalty should limit wide segment advantage: narrow=%f, wide=%f", scoreNarrow, scoreWide) + } +} + +// ─── enforceMinLiquidity ──────────────────────────────────────────────────── + +func TestEnforceMinLiquidity_Empty(t *testing.T) { + result := enforceMinLiquidity(nil, 3) + if len(result) != 0 { + t.Errorf("expected empty, got %d", len(result)) + } +} + +func TestEnforceMinLiquidity_FiltersTinySegments(t *testing.T) { + segments := []internalSegment{ + {l: 0, r: 4, h: 1000, liquidityAdded: 1000}, // width=5, amount=5000 + {l: 6, r: 6, h: 1, liquidityAdded: 1}, // width=1, amount=1 (tiny) + } + + result := enforceMinLiquidity(segments, 3) + + // threshold = 5000 / (3*2) = 833.3 — second segment (amount=1) should be filtered + if len(result) != 1 { + t.Fatalf("expected 1 segment after filtering, got %d", len(result)) + } + if result[0].l != 0 || result[0].r != 4 { + t.Errorf("expected big segment to survive, got l=%d r=%d", result[0].l, result[0].r) + } +} + +func TestEnforceMinLiquidity_KeepsAll(t *testing.T) { + segments := []internalSegment{ + {l: 0, r: 4, h: 100, liquidityAdded: 100}, // amount=500 + {l: 6, r: 9, h: 80, liquidityAdded: 80}, // amount=320 + } + + result := enforceMinLiquidity(segments, 5) + + // threshold = 500 / (5*2) = 50 — both exceed + if len(result) != 2 { + t.Errorf("expected 2 segments kept, got %d", len(result)) + } +} + +// ─── DefaultConfig ────────────────────────────────────────────────────────── + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + if cfg.N != 5 { + t.Errorf("N: expected 5, got %d", cfg.N) + } + if cfg.MinWidth != 1 { + t.Errorf("MinWidth: expected 1, got %d", cfg.MinWidth) + } + if cfg.MaxWidth != 0 { + t.Errorf("MaxWidth: expected 0, got %d", cfg.MaxWidth) + } + if cfg.Lambda != 50.0 { + t.Errorf("Lambda: expected 50, got %f", cfg.Lambda) + } + if cfg.Beta != 0.5 { + t.Errorf("Beta: expected 0.5, got %f", cfg.Beta) + } + if cfg.WeightMode != "quantile" { + t.Errorf("WeightMode: expected quantile, got %s", cfg.WeightMode) + } + if cfg.Quantile != 0.6 { + t.Errorf("Quantile: expected 0.6, got %f", cfg.Quantile) + } + if cfg.LookAhead != 3 { + t.Errorf("LookAhead: expected 3, got %d", cfg.LookAhead) + } +} diff --git a/internal/strategy/service/service.go b/internal/strategy/service/service.go index bba54ef..c5029ac 100644 --- a/internal/strategy/service/service.go +++ b/internal/strategy/service/service.go @@ -38,8 +38,8 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C return nil, fmt.Errorf("get distribution: %w", err) } - // Step 2: Convert liquidity bins to allocation bins - allocationBins := toAllocationBins(dist.Bins, dist.CurrentTick) + // Step 2: Convert liquidity bins to allocation bins (filtered by vault's allowed tick range) + allocationBins := toAllocationBins(dist.Bins, dist.CurrentTick, params.AllowedTickLower, params.AllowedTickUpper) if len(allocationBins) == 0 { sqrtPriceX96 := new(big.Int) @@ -71,23 +71,27 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C }, nil } -// toAllocationBins converts liquidity.Bin slice to coverage.Bin slice. -func toAllocationBins(liqBins []liquidity.Bin, currentTick int32) []coverage.Bin { +// toAllocationBins converts liquidity.Bin slice to coverage.Bin slice, +// filtering out bins outside the vault's allowed tick range. +func toAllocationBins(liqBins []liquidity.Bin, currentTick int32, allowedLower, allowedUpper int32) []coverage.Bin { if len(liqBins) == 0 { return nil } - bins := make([]coverage.Bin, len(liqBins)) - for i, b := range liqBins { - // Determine if this bin contains the current tick + bins := make([]coverage.Bin, 0, len(liqBins)) + for _, b := range liqBins { + if b.TickLower < allowedLower || b.TickUpper > allowedUpper { + continue + } + isCurrent := currentTick >= b.TickLower && currentTick < b.TickUpper - bins[i] = coverage.Bin{ + bins = append(bins, coverage.Bin{ TickLower: b.TickLower, TickUpper: b.TickUpper, Liquidity: b.ActiveLiquidity, IsCurrent: isCurrent, - } + }) } return bins diff --git a/internal/strategy/service/service_test.go b/internal/strategy/service/service_test.go new file mode 100644 index 0000000..3562194 --- /dev/null +++ b/internal/strategy/service/service_test.go @@ -0,0 +1,175 @@ +package service + +import ( + "math/big" + "testing" + + "remora/internal/coverage" + "remora/internal/liquidity" +) + +func TestToAllocationBins_Empty(t *testing.T) { + result := toAllocationBins(nil, 0, -1000, 1000) + if len(result) != 0 { + t.Errorf("expected empty, got %d", len(result)) + } +} + +func TestToAllocationBins_AllWithinRange(t *testing.T) { + bins := []liquidity.Bin{ + {TickLower: 0, TickUpper: 100, ActiveLiquidity: big.NewInt(500)}, + {TickLower: 100, TickUpper: 200, ActiveLiquidity: big.NewInt(600)}, + {TickLower: 200, TickUpper: 300, ActiveLiquidity: big.NewInt(700)}, + } + + result := toAllocationBins(bins, 150, 0, 300) + + if len(result) != 3 { + t.Fatalf("expected 3 bins, got %d", len(result)) + } +} + +func TestToAllocationBins_FiltersLowerBound(t *testing.T) { + bins := []liquidity.Bin{ + {TickLower: -200, TickUpper: -100, ActiveLiquidity: big.NewInt(100)}, // below allowed + {TickLower: -100, TickUpper: 0, ActiveLiquidity: big.NewInt(200)}, + {TickLower: 0, TickUpper: 100, ActiveLiquidity: big.NewInt(300)}, + } + + result := toAllocationBins(bins, 50, -100, 100) + + if len(result) != 2 { + t.Fatalf("expected 2 bins after filtering lower bound, got %d", len(result)) + } + if result[0].TickLower != -100 { + t.Errorf("first bin TickLower: expected -100, got %d", result[0].TickLower) + } +} + +func TestToAllocationBins_FiltersUpperBound(t *testing.T) { + bins := []liquidity.Bin{ + {TickLower: 0, TickUpper: 100, ActiveLiquidity: big.NewInt(100)}, + {TickLower: 100, TickUpper: 200, ActiveLiquidity: big.NewInt(200)}, + {TickLower: 200, TickUpper: 300, ActiveLiquidity: big.NewInt(300)}, // above allowed + } + + result := toAllocationBins(bins, 50, 0, 200) + + if len(result) != 2 { + t.Fatalf("expected 2 bins after filtering upper bound, got %d", len(result)) + } + if result[1].TickUpper != 200 { + t.Errorf("last bin TickUpper: expected 200, got %d", result[1].TickUpper) + } +} + +func TestToAllocationBins_FiltersBothBounds(t *testing.T) { + bins := []liquidity.Bin{ + {TickLower: -500, TickUpper: -400, ActiveLiquidity: big.NewInt(10)}, // out + {TickLower: -100, TickUpper: 0, ActiveLiquidity: big.NewInt(100)}, // in + {TickLower: 0, TickUpper: 100, ActiveLiquidity: big.NewInt(200)}, // in + {TickLower: 100, TickUpper: 200, ActiveLiquidity: big.NewInt(300)}, // in + {TickLower: 400, TickUpper: 500, ActiveLiquidity: big.NewInt(10)}, // out + } + + result := toAllocationBins(bins, 50, -100, 200) + + if len(result) != 3 { + t.Fatalf("expected 3 bins, got %d", len(result)) + } + if result[0].TickLower != -100 || result[2].TickUpper != 200 { + t.Errorf("unexpected range: [%d, %d]", result[0].TickLower, result[2].TickUpper) + } +} + +func TestToAllocationBins_AllFiltered(t *testing.T) { + bins := []liquidity.Bin{ + {TickLower: -500, TickUpper: -400, ActiveLiquidity: big.NewInt(100)}, + {TickLower: 400, TickUpper: 500, ActiveLiquidity: big.NewInt(200)}, + } + + result := toAllocationBins(bins, 0, -100, 100) + + if len(result) != 0 { + t.Errorf("expected 0 bins when all are out of range, got %d", len(result)) + } +} + +func TestToAllocationBins_IsCurrent(t *testing.T) { + bins := []liquidity.Bin{ + {TickLower: 0, TickUpper: 100, ActiveLiquidity: big.NewInt(100)}, + {TickLower: 100, TickUpper: 200, ActiveLiquidity: big.NewInt(200)}, + {TickLower: 200, TickUpper: 300, ActiveLiquidity: big.NewInt(300)}, + } + + result := toAllocationBins(bins, 150, 0, 300) + + if result[0].IsCurrent { + t.Error("bin [0,100) should not be current for tick 150") + } + if !result[1].IsCurrent { + t.Error("bin [100,200) should be current for tick 150") + } + if result[2].IsCurrent { + t.Error("bin [200,300) should not be current for tick 150") + } +} + +func TestToAllocationBins_LiquidityPreserved(t *testing.T) { + bins := []liquidity.Bin{ + {TickLower: 0, TickUpper: 100, ActiveLiquidity: big.NewInt(12345)}, + } + + result := toAllocationBins(bins, 50, 0, 100) + + if result[0].Liquidity.Cmp(big.NewInt(12345)) != 0 { + t.Errorf("liquidity not preserved: expected 12345, got %s", result[0].Liquidity) + } +} + +func TestToAllocationBins_EdgeExactBoundary(t *testing.T) { + bins := []liquidity.Bin{ + {TickLower: -100, TickUpper: 0, ActiveLiquidity: big.NewInt(100)}, // exactly at lower bound + {TickLower: 0, TickUpper: 100, ActiveLiquidity: big.NewInt(200)}, // middle + {TickLower: 100, TickUpper: 200, ActiveLiquidity: big.NewInt(300)}, // exactly at upper bound + } + + result := toAllocationBins(bins, 50, -100, 200) + + // All bins are exactly within bounds + if len(result) != 3 { + t.Fatalf("expected 3 bins at exact boundaries, got %d", len(result)) + } +} + +func TestToAllocationBins_PartialOverlapFiltered(t *testing.T) { + // A bin that straddles the allowed lower bound should be filtered + bins := []liquidity.Bin{ + {TickLower: -150, TickUpper: -50, ActiveLiquidity: big.NewInt(100)}, // straddles: lower < allowedLower + {TickLower: -100, TickUpper: 0, ActiveLiquidity: big.NewInt(200)}, // fully inside + } + + result := toAllocationBins(bins, -50, -100, 100) + + if len(result) != 1 { + t.Fatalf("expected 1 bin (partial overlap should be filtered), got %d", len(result)) + } + if result[0].TickLower != -100 { + t.Errorf("expected bin at -100, got %d", result[0].TickLower) + } +} + +// Verify output type is correct coverage.Bin +func TestToAllocationBins_OutputType(t *testing.T) { + bins := []liquidity.Bin{ + {TickLower: 0, TickUpper: 100, ActiveLiquidity: big.NewInt(500)}, + } + + result := toAllocationBins(bins, 50, 0, 100) + + // Type assertion - compile-time check + var _ []coverage.Bin = result + if len(result) != 1 { + t.Fatal("expected 1 bin") + } +} diff --git a/internal/strategy/strategy.go b/internal/strategy/strategy.go index 8053f4b..17fcdcf 100644 --- a/internal/strategy/strategy.go +++ b/internal/strategy/strategy.go @@ -18,10 +18,12 @@ type Service interface { // ComputeParams contains parameters for computing target positions. type ComputeParams struct { - PoolKey poolid.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 coverage.Config // Algorithm configuration + PoolKey poolid.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 coverage.Config // Algorithm configuration + AllowedTickLower int32 // Vault's allowed lower tick bound + AllowedTickUpper int32 // Vault's allowed upper tick bound } // ComputeResult contains the computed target positions.