Files
mev-beta/pkg/profitcalc/opportunity_ranker.go
Krypto Kajun fac8a64092 feat: Implement comprehensive Market Manager with database and logging
- Add complete Market Manager package with in-memory storage and CRUD operations
- Implement arbitrage detection with profit calculations and thresholds
- Add database adapter with PostgreSQL schema for persistence
- Create comprehensive logging system with specialized log files
- Add detailed documentation and implementation plans
- Include example application and comprehensive test suite
- Update Makefile with market manager build targets
- Add check-implementations command for verification
2025-09-18 03:52:33 -05:00

347 lines
11 KiB
Go

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(),
}
}