package profitcalc import ( "math" "math/big" "sort" "time" "github.com/fraktal/mev-beta/internal/logger" ) // OpportunityRanker handles filtering, ranking, and prioritization of arbitrage opportunities type OpportunityRanker struct { logger *logger.Logger maxOpportunities int // Maximum number of opportunities to track minConfidence float64 // Minimum confidence score to consider minProfitMargin float64 // Minimum profit margin to consider opportunityTTL time.Duration // How long opportunities are valid recentOpportunities []*RankedOpportunity } // RankedOpportunity wraps SimpleOpportunity with ranking data type RankedOpportunity struct { *SimpleOpportunity Score float64 // Composite ranking score Rank int // Current rank (1 = best) FirstSeen time.Time // When this opportunity was first detected LastUpdated time.Time // When this opportunity was last updated UpdateCount int // How many times we've seen this opportunity IsStale bool // Whether this opportunity is too old CompetitionRisk float64 // Risk due to MEV competition } // RankingWeights defines weights for different ranking factors type RankingWeights struct { ProfitMargin float64 // Weight for profit margin (0-1) NetProfit float64 // Weight for absolute profit (0-1) Confidence float64 // Weight for confidence score (0-1) TradeSize float64 // Weight for trade size (0-1) Freshness float64 // Weight for opportunity freshness (0-1) Competition float64 // Weight for competition risk (0-1, negative) GasEfficiency float64 // Weight for gas efficiency (0-1) } // DefaultRankingWeights provides sensible default weights var DefaultRankingWeights = RankingWeights{ ProfitMargin: 0.3, // 30% - profit margin is very important NetProfit: 0.25, // 25% - absolute profit matters Confidence: 0.2, // 20% - confidence in the opportunity TradeSize: 0.1, // 10% - larger trades are preferred Freshness: 0.1, // 10% - fresher opportunities are better Competition: 0.05, // 5% - competition risk (negative) GasEfficiency: 0.1, // 10% - gas efficiency } // NewOpportunityRanker creates a new opportunity ranker func NewOpportunityRanker(logger *logger.Logger) *OpportunityRanker { return &OpportunityRanker{ logger: logger, maxOpportunities: 50, // Track top 50 opportunities minConfidence: 0.3, // Minimum 30% confidence minProfitMargin: 0.001, // Minimum 0.1% profit margin opportunityTTL: 5 * time.Minute, // Opportunities valid for 5 minutes recentOpportunities: make([]*RankedOpportunity, 0, 50), } } // AddOpportunity adds a new opportunity to the ranking system func (or *OpportunityRanker) AddOpportunity(opp *SimpleOpportunity) *RankedOpportunity { if opp == nil { return nil } // Filter out opportunities that don't meet minimum criteria if !or.passesFilters(opp) { or.logger.Debug("Opportunity filtered out: ID=%s, Confidence=%.2f, ProfitMargin=%.4f", opp.ID, opp.Confidence, opp.ProfitMargin) return nil } // Check if we already have this opportunity (based on token pair and similar amounts) existingOpp := or.findSimilarOpportunity(opp) if existingOpp != nil { // Update existing opportunity existingOpp.SimpleOpportunity = opp existingOpp.LastUpdated = time.Now() existingOpp.UpdateCount++ or.logger.Debug("Updated existing opportunity: ID=%s, UpdateCount=%d", opp.ID, existingOpp.UpdateCount) } else { // Create new ranked opportunity rankedOpp := &RankedOpportunity{ SimpleOpportunity: opp, FirstSeen: time.Now(), LastUpdated: time.Now(), UpdateCount: 1, IsStale: false, CompetitionRisk: or.estimateCompetitionRisk(opp), } or.recentOpportunities = append(or.recentOpportunities, rankedOpp) or.logger.Debug("Added new opportunity: ID=%s, ProfitMargin=%.4f, Confidence=%.2f", opp.ID, opp.ProfitMargin, opp.Confidence) } // Cleanup stale opportunities and re-rank or.cleanupStaleOpportunities() or.rankOpportunities() return or.findRankedOpportunity(opp.ID) } // GetTopOpportunities returns the top N ranked opportunities func (or *OpportunityRanker) GetTopOpportunities(limit int) []*RankedOpportunity { if limit <= 0 || limit > len(or.recentOpportunities) { limit = len(or.recentOpportunities) } // Ensure opportunities are ranked or.rankOpportunities() result := make([]*RankedOpportunity, limit) copy(result, or.recentOpportunities[:limit]) return result } // GetExecutableOpportunities returns only opportunities marked as executable func (or *OpportunityRanker) GetExecutableOpportunities(limit int) []*RankedOpportunity { executable := make([]*RankedOpportunity, 0) for _, opp := range or.recentOpportunities { if opp.IsExecutable && !opp.IsStale { executable = append(executable, opp) } } if limit > 0 && len(executable) > limit { executable = executable[:limit] } return executable } // passesFilters checks if an opportunity meets minimum criteria func (or *OpportunityRanker) passesFilters(opp *SimpleOpportunity) bool { // Check confidence threshold if opp.Confidence < or.minConfidence { return false } // Check profit margin threshold if opp.ProfitMargin < or.minProfitMargin { return false } // Check that net profit is positive if opp.NetProfit == nil || opp.NetProfit.Sign() <= 0 { return false } return true } // findSimilarOpportunity finds an existing opportunity with same token pair func (or *OpportunityRanker) findSimilarOpportunity(newOpp *SimpleOpportunity) *RankedOpportunity { for _, existing := range or.recentOpportunities { if existing.TokenA == newOpp.TokenA && existing.TokenB == newOpp.TokenB { // Consider similar if within 10% of amount if existing.AmountIn != nil && newOpp.AmountIn != nil { ratio := new(big.Float).Quo(existing.AmountIn, newOpp.AmountIn) ratioFloat, _ := ratio.Float64() if ratioFloat > 0.9 && ratioFloat < 1.1 { return existing } } } } return nil } // findRankedOpportunity finds a ranked opportunity by ID func (or *OpportunityRanker) findRankedOpportunity(id string) *RankedOpportunity { for _, opp := range or.recentOpportunities { if opp.ID == id { return opp } } return nil } // estimateCompetitionRisk estimates MEV competition risk for an opportunity func (or *OpportunityRanker) estimateCompetitionRisk(opp *SimpleOpportunity) float64 { risk := 0.0 // Higher profit margins attract more competition if opp.ProfitMargin > 0.05 { // > 5% risk += 0.8 } else if opp.ProfitMargin > 0.02 { // > 2% risk += 0.5 } else if opp.ProfitMargin > 0.01 { // > 1% risk += 0.3 } else { risk += 0.1 } // Larger trades attract more competition if opp.AmountOut != nil { amountFloat, _ := opp.AmountOut.Float64() if amountFloat > 10000 { // > $10k risk += 0.3 } else if amountFloat > 1000 { // > $1k risk += 0.1 } } // Cap risk at 1.0 if risk > 1.0 { risk = 1.0 } return risk } // rankOpportunities ranks all opportunities and assigns scores func (or *OpportunityRanker) rankOpportunities() { weights := DefaultRankingWeights for _, opp := range or.recentOpportunities { opp.Score = or.calculateOpportunityScore(opp, weights) } // Sort by score (highest first) sort.Slice(or.recentOpportunities, func(i, j int) bool { return or.recentOpportunities[i].Score > or.recentOpportunities[j].Score }) // Assign ranks for i, opp := range or.recentOpportunities { opp.Rank = i + 1 } } // calculateOpportunityScore calculates a composite score for an opportunity func (or *OpportunityRanker) calculateOpportunityScore(opp *RankedOpportunity, weights RankingWeights) float64 { score := 0.0 // Profit margin component (0-1) profitMarginScore := math.Min(opp.ProfitMargin/0.1, 1.0) // Cap at 10% margin = 1.0 score += profitMarginScore * weights.ProfitMargin // Net profit component (0-1, normalized by 0.1 ETH = 1.0) netProfitScore := 0.0 if opp.NetProfit != nil { netProfitFloat, _ := opp.NetProfit.Float64() netProfitScore = math.Min(netProfitFloat/0.1, 1.0) } score += netProfitScore * weights.NetProfit // Confidence component (already 0-1) score += opp.Confidence * weights.Confidence // Trade size component (0-1, normalized by $10k = 1.0) tradeSizeScore := 0.0 if opp.AmountOut != nil { amountFloat, _ := opp.AmountOut.Float64() tradeSizeScore = math.Min(amountFloat/10000, 1.0) } score += tradeSizeScore * weights.TradeSize // Freshness component (1.0 for new, decays over time) age := time.Since(opp.FirstSeen) freshnessScore := math.Max(0, 1.0-age.Seconds()/300) // Decays to 0 over 5 minutes score += freshnessScore * weights.Freshness // Competition risk component (negative) score -= opp.CompetitionRisk * weights.Competition // Gas efficiency component (profit per gas unit) gasEfficiencyScore := 0.0 if opp.GasCost != nil && opp.GasCost.Sign() > 0 && opp.NetProfit != nil { gasCostFloat, _ := opp.GasCost.Float64() netProfitFloat, _ := opp.NetProfit.Float64() gasEfficiencyScore = math.Min(netProfitFloat/gasCostFloat/10, 1.0) // Profit 10x gas cost = 1.0 } score += gasEfficiencyScore * weights.GasEfficiency return math.Max(0, score) // Ensure score is non-negative } // cleanupStaleOpportunities removes old opportunities func (or *OpportunityRanker) cleanupStaleOpportunities() { now := time.Now() validOpportunities := make([]*RankedOpportunity, 0, len(or.recentOpportunities)) for _, opp := range or.recentOpportunities { age := now.Sub(opp.FirstSeen) if age <= or.opportunityTTL { validOpportunities = append(validOpportunities, opp) } else { opp.IsStale = true or.logger.Debug("Marked opportunity as stale: ID=%s, Age=%s", opp.ID, age) } } // Keep only valid opportunities, but respect max limit if len(validOpportunities) > or.maxOpportunities { // Sort by score to keep the best ones sort.Slice(validOpportunities, func(i, j int) bool { return validOpportunities[i].Score > validOpportunities[j].Score }) validOpportunities = validOpportunities[:or.maxOpportunities] } or.recentOpportunities = validOpportunities } // GetStats returns statistics about tracked opportunities func (or *OpportunityRanker) GetStats() map[string]interface{} { executableCount := 0 totalScore := 0.0 avgConfidence := 0.0 for _, opp := range or.recentOpportunities { if opp.IsExecutable { executableCount++ } totalScore += opp.Score avgConfidence += opp.Confidence } count := len(or.recentOpportunities) if count > 0 { avgConfidence /= float64(count) } return map[string]interface{}{ "totalOpportunities": count, "executableOpportunities": executableCount, "averageScore": totalScore / float64(count), "averageConfidence": avgConfidence, "maxOpportunities": or.maxOpportunities, "minConfidence": or.minConfidence, "minProfitMargin": or.minProfitMargin, "opportunityTTL": or.opportunityTTL.String(), } }