From b8b5ee7b78a55ac0bb921d2ffcf2388da00f1d33 Mon Sep 17 00:00:00 2001 From: timwang Date: Wed, 4 Feb 2026 14:05:33 +0800 Subject: [PATCH 1/2] Add algorithm to calc lp allocation -Add alocation types and adapter with blockchain-native types and strategy orchestrator - Add internal type conversion layer for float64-based algorithm calculations - Create strategy package with orchestrator to integrate liquidity distribution with coverage algorithm - Add greedy algorithm as baseline --- internal/allocation/convert.go | 94 ++++++++++ internal/allocation/greedy.go | 254 +++++++++++++++++++++++++++ internal/allocation/types.go | 67 +++++++ internal/strategy/service/service.go | 79 +++++++++ internal/strategy/strategy.go | 39 ++++ 5 files changed, 533 insertions(+) create mode 100644 internal/allocation/convert.go create mode 100644 internal/allocation/greedy.go create mode 100644 internal/allocation/types.go create mode 100644 internal/strategy/service/service.go create mode 100644 internal/strategy/strategy.go diff --git a/internal/allocation/convert.go b/internal/allocation/convert.go new file mode 100644 index 0000000..0f7cfa2 --- /dev/null +++ b/internal/allocation/convert.go @@ -0,0 +1,94 @@ +package allocation + +import ( + "math" + "math/big" +) + +// internalBin is used for float64-based algorithm calculations. +type internalBin struct { + tickLower int + tickUpper int + priceLower float64 + priceUpper float64 + liquidity float64 + isCurrent bool +} + +// internalSegment holds segment data during algorithm execution. +type internalSegment struct { + l, r int + h float64 + liquidityAdded float64 +} + +// toInternalBins converts public Bin slice to internal format for algorithm processing. +func toInternalBins(bins []Bin) []internalBin { + result := make([]internalBin, len(bins)) + for i, b := range bins { + var liq float64 + if b.Liquidity != nil { + liq, _ = new(big.Float).SetInt(b.Liquidity).Float64() + } + result[i] = internalBin{ + tickLower: int(b.TickLower), + tickUpper: int(b.TickUpper), + priceLower: b.PriceLower, + priceUpper: b.PriceUpper, + liquidity: liq, + isCurrent: b.IsCurrent, + } + } + return result +} + +// toSegments converts internal segments to public Segment format. +func toSegments(bins []internalBin, segments []internalSegment, target []float64) Result { + outputSegments := make([]Segment, 0, len(segments)) + pred := make([]float64, len(bins)) + + for _, seg := range segments { + // Convert float64 liquidity to big.Int + // We use Floor to avoid fractional liquidity + liqFloat := big.NewFloat(seg.h) + liqInt, _ := liqFloat.Int(nil) + + outputSeg := Segment{ + TickLower: int32(bins[seg.l].tickLower), //nolint:gosec // safe conversion + TickUpper: int32(bins[seg.r].tickUpper), //nolint:gosec // safe conversion + PriceLower: bins[seg.l].priceLower, + PriceUpper: bins[seg.r].priceUpper, + LiquidityAdded: liqInt, + } + outputSegments = append(outputSegments, outputSeg) + + // Update pred for metrics calculation + for i := seg.l; i <= seg.r; i++ { + pred[i] += seg.h + } + } + + metrics := calcMetrics(target, pred) + + return Result{ + Segments: outputSegments, + Metrics: metrics, + } +} + +// calcMetrics calculates coverage evaluation metrics. +func calcMetrics(target, pred []float64) Metrics { + var covered, gap, over float64 + + for i := range target { + covered += math.Min(target[i], pred[i]) + gap += math.Max(0, target[i]-pred[i]) + over += math.Max(0, pred[i]-target[i]) + } + + return Metrics{ + Covered: covered, + Gap: gap, + Over: over, + } +} diff --git a/internal/allocation/greedy.go b/internal/allocation/greedy.go new file mode 100644 index 0000000..7f74367 --- /dev/null +++ b/internal/allocation/greedy.go @@ -0,0 +1,254 @@ +package allocation + +import ( + "log/slog" + "math" + "sort" +) + +// Run executes the greedy coverage algorithm (default). +func Run(bins []Bin, cfg Config) Result { + if len(bins) == 0 { + return Result{} + } + + // Convert to internal format + internalBins := toInternalBins(bins) + + // Run LookAhead greedy algorithm + return runLookAhead(internalBins, cfg) +} + +// runLookAhead implements the new look-ahead expansion algorithm. +// +//nolint:cyclop // algorithm complexity +func runLookAhead(bins []internalBin, cfg Config) Result { + n := len(bins) + target := make([]float64, n) + + // Initialize target array + for i, bin := range bins { + target[i] = bin.liquidity + } + + // Phase 1: Greedy expansion with look-ahead + remainingGaps := make([]float64, n) + copy(remainingGaps, target) + + var segments []internalSegment + + for len(segments) < cfg.N { + // Find seed: bin with max remaining gap + seed := -1 + maxGap := 0.0 + for i, g := range remainingGaps { + if g > maxGap { + maxGap = g + seed = i + } + } + + if cfg.Debug { + slog.Debug("[DEBUG] Round", slog.Int("round", len(segments)+1), slog.Int("seed", seed), slog.Float64("maxGap", maxGap)) + } + + if seed < 0 || maxGap <= 0 { + break // No more gaps + } + + // Expand from seed using look-ahead + seg := expandWithLookAhead(remainingGaps, bins, seed, cfg, n) + + if cfg.Debug { + slog.Debug("[DEBUG] Segment", slog.Int("l", seg.l), slog.Int("r", seg.r), slog.Float64("h", seg.h), slog.Float64("liq", seg.liquidityAdded)) + } + + if seg.h <= 0 { + break + } + + segments = append(segments, seg) + + // Update remaining gaps + for i := seg.l; i <= seg.r; i++ { + remainingGaps[i] = math.Max(0, remainingGaps[i]-seg.h) + } + } + + // Phase 2: Iterative convergence (min_liquidity constraint) + // threshold = max_liquidity / 2^N + if cfg.EnableMinLiq { + segments = enforceMinLiquidity(segments, cfg.N) + } + + // Convert to output format + return toSegments(bins, segments, target) +} + +// expandWithLookAhead expands a segment from seed using look-ahead strategy. +func expandWithLookAhead(gaps []float64, bins []internalBin, seed int, cfg Config, totalBins int) internalSegment { + n := len(gaps) + l, r := seed, seed + + // Calculate initial net score + h := calcH(gaps, l, r, cfg.Quantile) + currentScore := calcNetScore(gaps, bins, l, r, h, cfg.Beta, cfg.Lambda, cfg.CurrentBonus, totalBins, cfg.N) + + for { + bestNewL, bestNewR := l, r + bestScore := currentScore + + // Try expanding left with look-ahead + for steps := 1; steps <= cfg.LookAhead && l-steps >= 0; steps++ { + newL := l - steps + newH := calcH(gaps, newL, r, cfg.Quantile) + newScore := calcNetScore(gaps, bins, newL, r, newH, cfg.Beta, cfg.Lambda, cfg.CurrentBonus, totalBins, cfg.N) + if newScore > bestScore { + bestScore = newScore + bestNewL = newL + bestNewR = r + } + } + + // Try expanding right with look-ahead + for steps := 1; steps <= cfg.LookAhead && r+steps < n; steps++ { + newR := r + steps + newH := calcH(gaps, l, newR, cfg.Quantile) + newScore := calcNetScore(gaps, bins, l, newR, newH, cfg.Beta, cfg.Lambda, cfg.CurrentBonus, totalBins, cfg.N) + if newScore > bestScore { + bestScore = newScore + bestNewL = l + bestNewR = newR + } + } + + // If no improvement, stop + if bestNewL == l && bestNewR == r { + break + } + + // Apply best expansion + l, r = bestNewL, bestNewR + currentScore = bestScore + } + + finalH := calcH(gaps, l, r, cfg.Quantile) + // internalSegment.liquidityAdded should store Height (h) to be consistent with enforceMinLiquidity + return internalSegment{l: l, r: r, h: finalH, liquidityAdded: finalH} +} + +// calcH calculates the height (h) for a segment using quantile. +func calcH(gaps []float64, l, r int, q float64) float64 { + segmentGaps := make([]float64, 0, r-l+1) + for i := l; i <= r; i++ { + if gaps[i] > 0 { + segmentGaps = append(segmentGaps, gaps[i]) + } + } + if len(segmentGaps) == 0 { + return 0 + } + return quantile(segmentGaps, q) +} + +// calcNetScore calculates the net score for a segment. +// score = Σ min(gap[i], h) (captured gap) +// loss = Σ max(0, gap[i]-h) + β×Σmax(0, h-gap[i]) + λ×widthPenalty +// widthPenalty = max(0, ratio-1) × avgGap, where ratio = numBins / idealWidth +// net_score = score - loss, with currentBonus if segment contains current price. +func calcNetScore(gaps []float64, bins []internalBin, l, r int, h, beta, lambda, currentBonus float64, totalBins, k int) float64 { + var captured, underCover, waste, sumGap float64 + numBins := float64(r - l + 1) + containsCurrent := false + + for i := l; i <= r; i++ { + captured += math.Min(gaps[i], h) // gap captured by this segment + underCover += math.Max(0, gaps[i]-h) // gap not covered + waste += math.Max(0, h-gaps[i]) // liquidity wasted + sumGap += gaps[i] + if bins[i].isCurrent { + containsCurrent = true + } + } + + // Width penalty: only penalize if exceeding ideal width + idealWidth := float64(totalBins) / float64(k) + ratio := numBins / idealWidth + excess := math.Max(0, ratio-1) + avgGap := sumGap / numBins + widthPenalty := lambda * excess * avgGap + + // Apply current price bonus to captured only + if containsCurrent && currentBonus > 0 { + captured *= (1 + currentBonus) + } + + loss := underCover + beta*waste + widthPenalty + return captured - loss +} + +// enforceMinLiquidity applies the min_liquidity constraint. +// threshold = max_total_liquidity / (N×2). +// Total Liquidity = h * width. +func enforceMinLiquidity(segments []internalSegment, n int) []internalSegment { + if len(segments) == 0 { + return segments + } + + // Find max total liquidity (amount) + maxAmount := 0.0 + for _, seg := range segments { + width := float64(seg.r - seg.l + 1) + amount := seg.liquidityAdded * width + if amount > maxAmount { + maxAmount = amount + } + } + + // threshold = maxAmount / (N×2) + threshold := maxAmount / float64(n*2) //nolint:mnd // threshold factor + + // Filter segments below threshold + var validSegments []internalSegment + + for _, seg := range segments { + width := float64(seg.r - seg.l + 1) + amount := seg.liquidityAdded * width + + if amount >= threshold { + validSegments = append(validSegments, seg) + } + } + + return validSegments +} + +// quantile calculates the q-th quantile of a slice. +func quantile(data []float64, q float64) float64 { + if len(data) == 0 { + return 0 + } + + sorted := make([]float64, len(data)) + copy(sorted, data) + sort.Float64s(sorted) + + if q <= 0 { + return sorted[0] + } + if q >= 1 { + return sorted[len(sorted)-1] + } + + index := q * float64(len(sorted)-1) + lower := int(math.Floor(index)) + upper := int(math.Ceil(index)) + + if lower == upper { + return sorted[lower] + } + + // Linear interpolation + weight := index - float64(lower) + return sorted[lower]*(1-weight) + sorted[upper]*weight +} diff --git a/internal/allocation/types.go b/internal/allocation/types.go new file mode 100644 index 0000000..ebc1806 --- /dev/null +++ b/internal/allocation/types.go @@ -0,0 +1,67 @@ +package allocation + +import "math/big" + +// Bin represents a single tick bin with liquidity data. +// Uses blockchain-native types for seamless integration with Uniswap v4. +type Bin struct { + TickLower int32 `json:"tickLower"` + TickUpper int32 `json:"tickUpper"` + PriceLower float64 `json:"priceLower"` + PriceUpper float64 `json:"priceUpper"` + Liquidity *big.Int `json:"liquidity"` + IsCurrent bool `json:"isCurrent"` +} + +// Segment represents an LP position segment. +// Uses blockchain-native types for seamless integration with Uniswap v4. +type Segment struct { + TickLower int32 `json:"tickLower"` + TickUpper int32 `json:"tickUpper"` + PriceLower float64 `json:"priceLower"` + PriceUpper float64 `json:"priceUpper"` + LiquidityAdded *big.Int `json:"liquidityAdded"` +} + +// Config holds the algorithm configuration. +type Config struct { + Algo string // algorithm: greedy, dp + N int // max number of segments (positions) + MinWidth int // minimum width in number of bins + MaxWidth int // maximum width in number of bins (0 = unlimited) + Lambda float64 // width penalty coefficient + Beta float64 // waste penalty coefficient (h > gap) + CurrentBonus float64 // score bonus for segments containing current price (e.g., 0.2 = +20%) + EnableMinLiq bool // enable min liquidity filter (threshold = max / 2^N) + WeightMode string // "avg" or "quantile" + Quantile float64 // quantile value (used when WeightMode = "quantile") + LookAhead int // look-ahead steps for expansion (0 = use old algorithm) + Debug bool // enable debug output +} + +// DefaultConfig returns a default configuration. +func DefaultConfig() Config { + return Config{ + N: 5, //nolint:mnd // default segment count + MinWidth: 1, + MaxWidth: 0, + Lambda: 50.0, //nolint:mnd // default width penalty coefficient + Beta: 0.5, //nolint:mnd // default waste penalty coefficient + WeightMode: "quantile", + Quantile: 0.6, //nolint:mnd // default quantile value + LookAhead: 3, //nolint:mnd // default look-ahead steps + } +} + +// Result holds the algorithm output and metrics. +type Result struct { + Segments []Segment + Metrics Metrics +} + +// Metrics holds coverage evaluation metrics. +type Metrics struct { + Covered float64 // Σ min(target, pred) + Gap float64 // Σ max(0, target - pred) + Over float64 // Σ max(0, pred - target) +} diff --git a/internal/strategy/service/service.go b/internal/strategy/service/service.go new file mode 100644 index 0000000..c433db6 --- /dev/null +++ b/internal/strategy/service/service.go @@ -0,0 +1,79 @@ +package service + +import ( + "context" + "fmt" + + "remora/internal/allocation" + "remora/internal/liquidity" + "remora/internal/strategy" +) + +// Service implements strategy.Service. +type Service struct { + liquiditySvc liquidity.Service +} + +// New creates a new strategy service. +func New(liquiditySvc liquidity.Service) *Service { + return &Service{ + liquiditySvc: liquiditySvc, + } +} + +// Ensure Service implements strategy.Service. +var _ strategy.Service = (*Service)(nil) + +// ComputeTargetPositions computes optimal LP positions based on market liquidity. +func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.ComputeParams) (*strategy.ComputeResult, error) { + // Step 1: Get market liquidity distribution + dist, err := s.liquiditySvc.GetDistribution(ctx, &liquidity.DistributionParams{ + PoolKey: params.PoolKey, + BinSizeTicks: params.BinSizeTicks, + TickRange: params.TickRange, + }) + if err != nil { + return nil, fmt.Errorf("get distribution: %w", err) + } + + // Step 2: Convert liquidity bins to allocation bins + allocationBins := toAllocationBins(dist.Bins, dist.CurrentTick) + + if len(allocationBins) == 0 { + return &strategy.ComputeResult{ + CurrentTick: dist.CurrentTick, + Segments: nil, + Metrics: allocation.Metrics{}, + }, nil + } + + // Step 3: Run coverage algorithm + result := allocation.Run(allocationBins, params.AlgoConfig) + + return &strategy.ComputeResult{ + CurrentTick: dist.CurrentTick, + Segments: result.Segments, + Metrics: result.Metrics, + }, nil +} + +// toAllocationBins converts liquidity.Bin slice to allocation.Bin slice. +func toAllocationBins(liqBins []liquidity.Bin, currentTick int32) []allocation.Bin { + if len(liqBins) == 0 { + return nil + } + + bins := make([]allocation.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{ + TickLower: b.TickLower, + TickUpper: b.TickUpper, + Liquidity: b.ActiveLiquidity, + IsCurrent: isCurrent, + } + } + return bins +} diff --git a/internal/strategy/strategy.go b/internal/strategy/strategy.go new file mode 100644 index 0000000..ff5ad8f --- /dev/null +++ b/internal/strategy/strategy.go @@ -0,0 +1,39 @@ +package strategy + +//go:generate mockgen -destination=mocks/mock_service.go -package=mocks . Service + +import ( + "context" + "math/big" + + "remora/internal/allocation" + "remora/internal/liquidity" +) + +// Service defines the strategy orchestration use cases. +type Service interface { + // ComputeTargetPositions computes optimal LP positions based on market liquidity. + ComputeTargetPositions(ctx context.Context, params *ComputeParams) (*ComputeResult, error) +} + +// ComputeParams contains parameters for computing target positions. +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 +} + +// 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 +} + +// Position represents an existing LP position (for future use with gap calculation). +type Position struct { + TickLower int32 + TickUpper int32 + Liquidity *big.Int +} From 579a00b4d13ddb5660356c387717c5008ad2b8db Mon Sep 17 00:00:00 2001 From: timwang Date: Wed, 4 Feb 2026 15:46:11 +0800 Subject: [PATCH 2/2] Add timestamp for computed result --- internal/strategy/service/service.go | 3 +++ internal/strategy/strategy.go | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/strategy/service/service.go b/internal/strategy/service/service.go index c433db6..65a8ee7 100644 --- a/internal/strategy/service/service.go +++ b/internal/strategy/service/service.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "time" "remora/internal/allocation" "remora/internal/liquidity" @@ -44,6 +45,7 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C CurrentTick: dist.CurrentTick, Segments: nil, Metrics: allocation.Metrics{}, + ComputedAt: time.Now().UTC(), }, nil } @@ -54,6 +56,7 @@ func (s *Service) ComputeTargetPositions(ctx context.Context, params *strategy.C CurrentTick: dist.CurrentTick, Segments: result.Segments, Metrics: result.Metrics, + ComputedAt: time.Now().UTC(), }, nil } diff --git a/internal/strategy/strategy.go b/internal/strategy/strategy.go index ff5ad8f..5ef2d91 100644 --- a/internal/strategy/strategy.go +++ b/internal/strategy/strategy.go @@ -5,6 +5,7 @@ package strategy import ( "context" "math/big" + "time" "remora/internal/allocation" "remora/internal/liquidity" @@ -26,9 +27,10 @@ type ComputeParams struct { // ComputeResult contains the computed target positions. type ComputeResult struct { - CurrentTick int32 // Current pool tick + CurrentTick int32 // Current pool tick Segments []allocation.Segment // Target LP segments Metrics allocation.Metrics // Coverage metrics + ComputedAt time.Time // Timestamp when computation was performed } // Position represents an existing LP position (for future use with gap calculation).