Restructured project for V2 refactor: **Structure Changes:** - Moved all V1 code to orig/ folder (preserved with git mv) - Created docs/planning/ directory - Added orig/README_V1.md explaining V1 preservation **Planning Documents:** - 00_V2_MASTER_PLAN.md: Complete architecture overview - Executive summary of critical V1 issues - High-level component architecture diagrams - 5-phase implementation roadmap - Success metrics and risk mitigation - 07_TASK_BREAKDOWN.md: Atomic task breakdown - 99+ hours of detailed tasks - Every task < 2 hours (atomic) - Clear dependencies and success criteria - Organized by implementation phase **V2 Key Improvements:** - Per-exchange parsers (factory pattern) - Multi-layer strict validation - Multi-index pool cache - Background validation pipeline - Comprehensive observability **Critical Issues Addressed:** - Zero address tokens (strict validation + cache enrichment) - Parsing accuracy (protocol-specific parsers) - No audit trail (background validation channel) - Inefficient lookups (multi-index cache) - Stats disconnection (event-driven metrics) Next Steps: 1. Review planning documents 2. Begin Phase 1: Foundation (P1-001 through P1-010) 3. Implement parsers in Phase 2 4. Build cache system in Phase 3 5. Add validation pipeline in Phase 4 6. Migrate and test in Phase 5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
344 lines
11 KiB
Go
344 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", opp.ID, "Confidence", opp.Confidence, "ProfitMargin", 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", opp.ID, "UpdateCount", 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", opp.ID, "ProfitMargin", opp.ProfitMargin, "Confidence", 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", opp.ID, "Age", 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(),
|
|
}
|
|
}
|