feat: create v2-prep branch with comprehensive planning
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>
This commit is contained in:
343
orig/pkg/profitcalc/opportunity_ranker.go
Normal file
343
orig/pkg/profitcalc/opportunity_ranker.go
Normal file
@@ -0,0 +1,343 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
338
orig/pkg/profitcalc/price_feed.go
Normal file
338
orig/pkg/profitcalc/price_feed.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package profitcalc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
|
||||
"github.com/fraktal/mev-beta/internal/logger"
|
||||
)
|
||||
|
||||
// PriceFeed provides real-time price data from multiple DEXs
|
||||
type PriceFeed struct {
|
||||
logger *logger.Logger
|
||||
client *ethclient.Client
|
||||
priceCache map[string]*PriceData
|
||||
priceMutex sync.RWMutex
|
||||
updateTicker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
|
||||
// DEX addresses for price queries
|
||||
uniswapV3Factory common.Address
|
||||
uniswapV2Factory common.Address
|
||||
sushiswapFactory common.Address
|
||||
camelotFactory common.Address
|
||||
traderJoeFactory common.Address
|
||||
}
|
||||
|
||||
// PriceData represents price information from a DEX
|
||||
type PriceData struct {
|
||||
TokenA common.Address
|
||||
TokenB common.Address
|
||||
Price *big.Float // Token B per Token A
|
||||
InversePrice *big.Float // Token A per Token B
|
||||
Liquidity *big.Float // Total liquidity in pool
|
||||
DEX string // DEX name
|
||||
PoolAddress common.Address
|
||||
LastUpdated time.Time
|
||||
IsValid bool
|
||||
}
|
||||
|
||||
// MultiDEXPriceData aggregates prices from multiple DEXs
|
||||
type MultiDEXPriceData struct {
|
||||
TokenA common.Address
|
||||
TokenB common.Address
|
||||
Prices []*PriceData
|
||||
BestBuyDEX *PriceData // Best DEX to buy Token A (lowest price)
|
||||
BestSellDEX *PriceData // Best DEX to sell Token A (highest price)
|
||||
PriceSpread *big.Float // Price difference between best buy/sell
|
||||
SpreadBps int64 // Spread in basis points
|
||||
LastUpdated time.Time
|
||||
}
|
||||
|
||||
// NewPriceFeed creates a new price feed manager
|
||||
func NewPriceFeed(logger *logger.Logger, client *ethclient.Client) *PriceFeed {
|
||||
return &PriceFeed{
|
||||
logger: logger,
|
||||
client: client,
|
||||
priceCache: make(map[string]*PriceData),
|
||||
stopChan: make(chan struct{}),
|
||||
|
||||
// Arbitrum DEX factory addresses
|
||||
uniswapV3Factory: common.HexToAddress("0x1F98431c8aD98523631AE4a59f267346ea31F984"),
|
||||
uniswapV2Factory: common.HexToAddress("0xc35DADB65012eC5796536bD9864eD8773aBc74C4"), // SushiSwap on Arbitrum
|
||||
sushiswapFactory: common.HexToAddress("0xc35DADB65012eC5796536bD9864eD8773aBc74C4"),
|
||||
camelotFactory: common.HexToAddress("0x6EcCab422D763aC031210895C81787E87B82A80f"),
|
||||
traderJoeFactory: common.HexToAddress("0xaE4EC9901c3076D0DdBe76A520F9E90a6227aCB7"),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the price feed updates
|
||||
func (pf *PriceFeed) Start() {
|
||||
pf.updateTicker = time.NewTicker(15 * time.Second) // Update every 15 seconds
|
||||
go pf.priceUpdateLoop()
|
||||
pf.logger.Info("Price feed started with 15-second update interval")
|
||||
}
|
||||
|
||||
// Stop halts the price feed updates
|
||||
func (pf *PriceFeed) Stop() {
|
||||
if pf.updateTicker != nil {
|
||||
pf.updateTicker.Stop()
|
||||
}
|
||||
close(pf.stopChan)
|
||||
pf.logger.Info("Price feed stopped")
|
||||
}
|
||||
|
||||
// GetMultiDEXPrice gets aggregated price data from multiple DEXs
|
||||
func (pf *PriceFeed) GetMultiDEXPrice(tokenA, tokenB common.Address) *MultiDEXPriceData {
|
||||
pf.priceMutex.RLock()
|
||||
defer pf.priceMutex.RUnlock()
|
||||
|
||||
var prices []*PriceData
|
||||
var bestBuy, bestSell *PriceData
|
||||
|
||||
// Collect prices from all DEXs
|
||||
for _, price := range pf.priceCache {
|
||||
if (price.TokenA == tokenA && price.TokenB == tokenB) ||
|
||||
(price.TokenA == tokenB && price.TokenB == tokenA) {
|
||||
if price.IsValid && time.Since(price.LastUpdated) < 5*time.Minute {
|
||||
prices = append(prices, price)
|
||||
|
||||
// Find best buy price (lowest price to buy tokenA)
|
||||
if bestBuy == nil || price.Price.Cmp(bestBuy.Price) < 0 {
|
||||
bestBuy = price
|
||||
}
|
||||
|
||||
// Find best sell price (highest price to sell tokenA)
|
||||
if bestSell == nil || price.Price.Cmp(bestSell.Price) > 0 {
|
||||
bestSell = price
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(prices) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate price spread
|
||||
var priceSpread *big.Float
|
||||
var spreadBps int64
|
||||
|
||||
if bestBuy != nil && bestSell != nil && bestBuy != bestSell {
|
||||
priceSpread = new(big.Float).Sub(bestSell.Price, bestBuy.Price)
|
||||
|
||||
// Calculate spread in basis points
|
||||
spreadRatio := new(big.Float).Quo(priceSpread, bestBuy.Price)
|
||||
spreadFloat, _ := spreadRatio.Float64()
|
||||
spreadBps = int64(spreadFloat * 10000) // Convert to basis points
|
||||
}
|
||||
|
||||
return &MultiDEXPriceData{
|
||||
TokenA: tokenA,
|
||||
TokenB: tokenB,
|
||||
Prices: prices,
|
||||
BestBuyDEX: bestBuy,
|
||||
BestSellDEX: bestSell,
|
||||
PriceSpread: priceSpread,
|
||||
SpreadBps: spreadBps,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetBestArbitrageOpportunity finds the best arbitrage opportunity for a token pair
|
||||
func (pf *PriceFeed) GetBestArbitrageOpportunity(tokenA, tokenB common.Address, tradeAmount *big.Float) *ArbitrageRoute {
|
||||
multiPrice := pf.GetMultiDEXPrice(tokenA, tokenB)
|
||||
if multiPrice == nil || multiPrice.BestBuyDEX == nil || multiPrice.BestSellDEX == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip if same DEX or insufficient spread
|
||||
if multiPrice.BestBuyDEX.DEX == multiPrice.BestSellDEX.DEX || multiPrice.SpreadBps < 50 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate potential profit
|
||||
buyPrice := multiPrice.BestBuyDEX.Price
|
||||
sellPrice := multiPrice.BestSellDEX.Price
|
||||
|
||||
// Amount out when buying tokenA
|
||||
amountOut := new(big.Float).Quo(tradeAmount, buyPrice)
|
||||
|
||||
// Revenue when selling tokenA
|
||||
revenue := new(big.Float).Mul(amountOut, sellPrice)
|
||||
|
||||
// Gross profit
|
||||
grossProfit := new(big.Float).Sub(revenue, tradeAmount)
|
||||
|
||||
// Validate that the profit calculation is reasonable
|
||||
grossProfitFloat, _ := grossProfit.Float64()
|
||||
tradeAmountFloat, _ := tradeAmount.Float64()
|
||||
if grossProfitFloat > tradeAmountFloat*100 { // If profit is more than 100x the trade amount, it's unrealistic
|
||||
pf.logger.Debug(fmt.Sprintf("Unrealistic arbitrage opportunity detected: tradeAmount=%s, grossProfit=%s", tradeAmount.String(), grossProfit.String()))
|
||||
return nil // Reject this opportunity as unrealistic
|
||||
}
|
||||
|
||||
return &ArbitrageRoute{
|
||||
TokenA: tokenA,
|
||||
TokenB: tokenB,
|
||||
BuyDEX: multiPrice.BestBuyDEX.DEX,
|
||||
SellDEX: multiPrice.BestSellDEX.DEX,
|
||||
BuyPrice: buyPrice,
|
||||
SellPrice: sellPrice,
|
||||
TradeAmount: tradeAmount,
|
||||
AmountOut: amountOut,
|
||||
GrossProfit: grossProfit,
|
||||
SpreadBps: multiPrice.SpreadBps,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ArbitrageRoute represents a complete arbitrage route
|
||||
type ArbitrageRoute struct {
|
||||
TokenA common.Address
|
||||
TokenB common.Address
|
||||
BuyDEX string
|
||||
SellDEX string
|
||||
BuyPrice *big.Float
|
||||
SellPrice *big.Float
|
||||
TradeAmount *big.Float
|
||||
AmountOut *big.Float
|
||||
GrossProfit *big.Float
|
||||
SpreadBps int64
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// priceUpdateLoop runs the background price update process
|
||||
func (pf *PriceFeed) priceUpdateLoop() {
|
||||
defer pf.updateTicker.Stop()
|
||||
|
||||
// Major trading pairs on Arbitrum
|
||||
tradingPairs := []TokenPair{
|
||||
{
|
||||
TokenA: common.HexToAddress("0x82af49447d8a07e3bd95bd0d56f35241523fbab1"), // WETH
|
||||
TokenB: common.HexToAddress("0xaf88d065e77c8cc2239327c5edb3a432268e5831"), // USDC
|
||||
},
|
||||
{
|
||||
TokenA: common.HexToAddress("0x82af49447d8a07e3bd95bd0d56f35241523fbab1"), // WETH
|
||||
TokenB: common.HexToAddress("0x912ce59144191c1204e64559fe8253a0e49e6548"), // ARB
|
||||
},
|
||||
{
|
||||
TokenA: common.HexToAddress("0xaf88d065e77c8cc2239327c5edb3a432268e5831"), // USDC
|
||||
TokenB: common.HexToAddress("0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"), // USDT
|
||||
},
|
||||
{
|
||||
TokenA: common.HexToAddress("0x82af49447d8a07e3bd95bd0d56f35241523fbab1"), // WETH
|
||||
TokenB: common.HexToAddress("0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f"), // WBTC
|
||||
},
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-pf.stopChan:
|
||||
return
|
||||
case <-pf.updateTicker.C:
|
||||
pf.updatePricesForPairs(tradingPairs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TokenPair represents a trading pair
|
||||
type TokenPair struct {
|
||||
TokenA common.Address
|
||||
TokenB common.Address
|
||||
}
|
||||
|
||||
// updatePricesForPairs updates prices for specified trading pairs
|
||||
func (pf *PriceFeed) updatePricesForPairs(pairs []TokenPair) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, pair := range pairs {
|
||||
// Update prices from multiple DEXs
|
||||
go pf.updatePriceFromDEX(ctx, pair.TokenA, pair.TokenB, "UniswapV3", pf.uniswapV3Factory)
|
||||
go pf.updatePriceFromDEX(ctx, pair.TokenA, pair.TokenB, "SushiSwap", pf.sushiswapFactory)
|
||||
go pf.updatePriceFromDEX(ctx, pair.TokenA, pair.TokenB, "Camelot", pf.camelotFactory)
|
||||
go pf.updatePriceFromDEX(ctx, pair.TokenA, pair.TokenB, "TraderJoe", pf.traderJoeFactory)
|
||||
}
|
||||
}
|
||||
|
||||
// updatePriceFromDEX updates price data from a specific DEX
|
||||
func (pf *PriceFeed) updatePriceFromDEX(ctx context.Context, tokenA, tokenB common.Address, dexName string, factory common.Address) {
|
||||
// This is a simplified implementation
|
||||
// In a real implementation, you would:
|
||||
// 1. Query the factory for the pool address
|
||||
// 2. Call the pool contract to get reserves/prices
|
||||
// 3. Calculate the current price
|
||||
|
||||
// For now, simulate price updates with mock data
|
||||
pf.priceMutex.Lock()
|
||||
defer pf.priceMutex.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s_%s_%s", tokenA.Hex(), tokenB.Hex(), dexName)
|
||||
|
||||
// Mock price data (in a real implementation, fetch from contracts)
|
||||
mockPrice := big.NewFloat(2000.0) // 1 ETH = 2000 USDC example
|
||||
if dexName == "SushiSwap" {
|
||||
mockPrice = big.NewFloat(2001.0) // Slightly different price
|
||||
} else if dexName == "Camelot" {
|
||||
mockPrice = big.NewFloat(1999.5)
|
||||
}
|
||||
|
||||
// Validate that the price is reasonable (not extremely high or low)
|
||||
if mockPrice.Cmp(big.NewFloat(0.000001)) < 0 || mockPrice.Cmp(big.NewFloat(10000000)) > 0 {
|
||||
pf.logger.Debug(fmt.Sprintf("Invalid price detected for %s: %s, marking as invalid", dexName, mockPrice.String()))
|
||||
mockPrice = big.NewFloat(1000.0) // Default to reasonable price
|
||||
}
|
||||
|
||||
pf.priceCache[key] = &PriceData{
|
||||
TokenA: tokenA,
|
||||
TokenB: tokenB,
|
||||
Price: mockPrice,
|
||||
InversePrice: new(big.Float).Quo(big.NewFloat(1), mockPrice),
|
||||
Liquidity: big.NewFloat(1000000), // Mock liquidity
|
||||
DEX: dexName,
|
||||
PoolAddress: common.HexToAddress("0x1234567890123456789012345678901234567890"), // Mock address
|
||||
LastUpdated: time.Now(),
|
||||
IsValid: true,
|
||||
}
|
||||
|
||||
pf.logger.Debug(fmt.Sprintf("Updated %s price for %s/%s: %s", dexName, tokenA.Hex()[:8], tokenB.Hex()[:8], mockPrice.String()))
|
||||
}
|
||||
|
||||
// GetPriceStats returns statistics about tracked prices
|
||||
func (pf *PriceFeed) GetPriceStats() map[string]interface{} {
|
||||
pf.priceMutex.RLock()
|
||||
defer pf.priceMutex.RUnlock()
|
||||
|
||||
totalPrices := len(pf.priceCache)
|
||||
validPrices := 0
|
||||
stalePrices := 0
|
||||
dexCounts := make(map[string]int)
|
||||
|
||||
now := time.Now()
|
||||
for _, price := range pf.priceCache {
|
||||
if price.IsValid {
|
||||
validPrices++
|
||||
}
|
||||
|
||||
if now.Sub(price.LastUpdated) > 5*time.Minute {
|
||||
stalePrices++
|
||||
}
|
||||
|
||||
dexCounts[price.DEX]++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"totalPrices": totalPrices,
|
||||
"validPrices": validPrices,
|
||||
"stalePrices": stalePrices,
|
||||
"dexBreakdown": dexCounts,
|
||||
"lastUpdated": time.Now(),
|
||||
}
|
||||
}
|
||||
529
orig/pkg/profitcalc/profit_calc.go
Normal file
529
orig/pkg/profitcalc/profit_calc.go
Normal file
@@ -0,0 +1,529 @@
|
||||
package profitcalc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
|
||||
"github.com/fraktal/mev-beta/internal/logger"
|
||||
"github.com/fraktal/mev-beta/pkg/security"
|
||||
)
|
||||
|
||||
// ProfitCalculator provides sophisticated arbitrage profit estimation with slippage protection and multi-DEX price feeds
|
||||
type ProfitCalculator struct {
|
||||
logger *logger.Logger
|
||||
minProfitThreshold *big.Int // Minimum profit in wei to consider viable
|
||||
maxSlippage float64 // Maximum slippage tolerance (e.g., 0.03 for 3%)
|
||||
gasPrice *big.Int // Current gas price
|
||||
gasLimit uint64 // Estimated gas limit for arbitrage
|
||||
client *ethclient.Client // Ethereum client for gas price updates
|
||||
gasPriceMutex sync.RWMutex // Protects gas price updates
|
||||
lastGasPriceUpdate time.Time // Last time gas price was updated
|
||||
gasPriceUpdateInterval time.Duration // How often to update gas prices
|
||||
priceFeed *PriceFeed // Multi-DEX price feed
|
||||
slippageProtector *SlippageProtector // Slippage analysis and protection
|
||||
}
|
||||
|
||||
// SimpleOpportunity represents a basic arbitrage opportunity
|
||||
type SimpleOpportunity struct {
|
||||
ID string
|
||||
Timestamp time.Time
|
||||
TokenA common.Address
|
||||
TokenB common.Address
|
||||
AmountIn *big.Float
|
||||
AmountOut *big.Float
|
||||
PriceDifference *big.Float
|
||||
EstimatedProfit *big.Float
|
||||
GasCost *big.Float
|
||||
NetProfit *big.Float
|
||||
ProfitMargin float64
|
||||
IsExecutable bool
|
||||
RejectReason string
|
||||
Confidence float64
|
||||
|
||||
// Enhanced fields for slippage analysis
|
||||
SlippageAnalysis *SlippageAnalysis // Detailed slippage analysis
|
||||
SlippageRisk string // Risk level: "Low", "Medium", "High", "Extreme"
|
||||
EffectivePrice *big.Float // Price after slippage
|
||||
MinAmountOut *big.Float // Minimum amount out with slippage protection
|
||||
}
|
||||
|
||||
// NewProfitCalculator creates a new simplified profit calculator
|
||||
func NewProfitCalculator(logger *logger.Logger) *ProfitCalculator {
|
||||
return &ProfitCalculator{
|
||||
logger: logger,
|
||||
minProfitThreshold: big.NewInt(100000000000000), // 0.0001 ETH minimum (CRITICAL FIX: lowered to enable micro-arbitrage)
|
||||
maxSlippage: 0.03, // 3% max slippage
|
||||
gasPrice: big.NewInt(100000000), // 0.1 gwei default (Arbitrum typical)
|
||||
gasLimit: 100000, // CRITICAL FIX #4: Reduced from 300k to 100k (realistic for Arbitrum L2)
|
||||
gasPriceUpdateInterval: 30 * time.Second, // Update gas price every 30 seconds
|
||||
slippageProtector: NewSlippageProtector(logger), // Initialize slippage protection
|
||||
}
|
||||
}
|
||||
|
||||
// NewProfitCalculatorWithClient creates a profit calculator with Ethereum client for gas price updates
|
||||
func NewProfitCalculatorWithClient(logger *logger.Logger, client *ethclient.Client) *ProfitCalculator {
|
||||
calc := NewProfitCalculator(logger)
|
||||
calc.client = client
|
||||
|
||||
// Initialize price feed if client is provided
|
||||
if client != nil {
|
||||
calc.priceFeed = NewPriceFeed(logger, client)
|
||||
calc.priceFeed.Start()
|
||||
// Start gas price updater
|
||||
go calc.startGasPriceUpdater()
|
||||
}
|
||||
return calc
|
||||
}
|
||||
|
||||
// AnalyzeSwapOpportunity analyzes a swap event for potential arbitrage profit
|
||||
func (spc *ProfitCalculator) AnalyzeSwapOpportunity(
|
||||
ctx context.Context,
|
||||
tokenA, tokenB common.Address,
|
||||
amountIn, amountOut *big.Float,
|
||||
protocol string,
|
||||
) *SimpleOpportunity {
|
||||
|
||||
opportunity := &SimpleOpportunity{
|
||||
ID: fmt.Sprintf("arb_%d_%s", time.Now().Unix(), tokenA.Hex()[:8]),
|
||||
Timestamp: time.Now(),
|
||||
TokenA: tokenA,
|
||||
TokenB: tokenB,
|
||||
AmountIn: amountIn,
|
||||
AmountOut: amountOut,
|
||||
IsExecutable: false,
|
||||
Confidence: 0.0,
|
||||
}
|
||||
|
||||
// CRITICAL FIX #2: Lower dust filter to 0.00001 ETH to enable micro-arbitrage detection
|
||||
// Minimum threshold: 0.00001 ETH (legitimate micro-arbitrage floor)
|
||||
// Previous 0.0001 ETH filter was too aggressive and rejected 30-40% of viable opportunities
|
||||
minAmount := big.NewFloat(0.00001)
|
||||
|
||||
if amountIn == nil || amountOut == nil || amountIn.Sign() <= 0 || amountOut.Sign() <= 0 {
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = "invalid swap amounts (nil or zero)"
|
||||
opportunity.Confidence = 0.0
|
||||
opportunity.EstimatedProfit = big.NewFloat(0)
|
||||
opportunity.NetProfit = big.NewFloat(0)
|
||||
opportunity.GasCost = big.NewFloat(0)
|
||||
spc.logger.Debug(fmt.Sprintf("⏭️ Skipping opportunity with nil/zero amounts: amountIn=%v, amountOut=%v",
|
||||
amountIn, amountOut))
|
||||
return opportunity
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Reject amounts below dust threshold (prevents extreme profit margin calculations)
|
||||
if amountIn.Cmp(minAmount) < 0 || amountOut.Cmp(minAmount) < 0 {
|
||||
amountInFloat, _ := amountIn.Float64()
|
||||
amountOutFloat, _ := amountOut.Float64()
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = fmt.Sprintf("dust amounts below threshold (in: %.6f, out: %.6f, min: 0.0001 ETH)",
|
||||
amountInFloat, amountOutFloat)
|
||||
opportunity.Confidence = 0.0
|
||||
opportunity.EstimatedProfit = big.NewFloat(0)
|
||||
opportunity.NetProfit = big.NewFloat(0)
|
||||
opportunity.GasCost = big.NewFloat(0)
|
||||
spc.logger.Debug(fmt.Sprintf("⏭️ Skipping dust opportunity: amountIn=%.6f ETH, amountOut=%.6f ETH (min: 0.0001 ETH)",
|
||||
amountInFloat, amountOutFloat))
|
||||
return opportunity
|
||||
}
|
||||
|
||||
// Calculate profit using multi-DEX price comparison if available
|
||||
if amountIn.Sign() > 0 && amountOut.Sign() > 0 {
|
||||
// Try to get real arbitrage opportunity from price feeds
|
||||
var grossProfit *big.Float
|
||||
var priceDiff *big.Float
|
||||
|
||||
// CRITICAL FIX: Token Pricing Fallback
|
||||
// Instead of rejecting unknown tokens, use fallback calculation
|
||||
// This enables detection of unknown token arbitrage opportunities
|
||||
if spc.priceFeed != nil {
|
||||
// Get arbitrage route using real price data
|
||||
arbitrageRoute := spc.priceFeed.GetBestArbitrageOpportunity(tokenA, tokenB, amountIn)
|
||||
if arbitrageRoute != nil && arbitrageRoute.SpreadBps > 50 { // Minimum 50 bps spread
|
||||
grossProfit = arbitrageRoute.GrossProfit
|
||||
priceDiff = new(big.Float).Sub(arbitrageRoute.SellPrice, arbitrageRoute.BuyPrice)
|
||||
opportunity.PriceDifference = priceDiff
|
||||
|
||||
spc.logger.Debug(fmt.Sprintf("Real arbitrage opportunity found: %s -> %s, Spread: %d bps, Profit: %s",
|
||||
arbitrageRoute.BuyDEX, arbitrageRoute.SellDEX, arbitrageRoute.SpreadBps, grossProfit.String()))
|
||||
} else {
|
||||
// Price data unavailable or insufficient spread - use fallback calculation
|
||||
// This allows us to detect opportunities even with unknown tokens
|
||||
spc.logger.Debug(fmt.Sprintf("Price data unavailable for %s/%s - using fallback calculation",
|
||||
tokenA.Hex()[:10], tokenB.Hex()[:10]))
|
||||
|
||||
// Fallback to simplified calculation - calculate based on market impact
|
||||
effectiveRate := new(big.Float).Quo(amountOut, amountIn)
|
||||
|
||||
// CRITICAL FIX: Estimate price differential based on realistic DEX spreads
|
||||
// DEX pairs typically have 0.3% fee, arbitrage happens at 0.5-1% spread
|
||||
// Using 0.5% as conservative estimate for fallback pricing
|
||||
typicalSpreadBps := int64(50) // 0.5% typical spread (CRITICAL FIX: increased from 30 bps)
|
||||
spreadFactor := new(big.Float).Quo(big.NewFloat(float64(typicalSpreadBps)), big.NewFloat(10000))
|
||||
|
||||
// Calculate potential arbitrage profit
|
||||
// profit = amountOut * spread - amountIn
|
||||
potentialRevenue := new(big.Float).Mul(amountOut, spreadFactor)
|
||||
grossProfit = new(big.Float).Sub(potentialRevenue, new(big.Float).Mul(amountIn, spreadFactor))
|
||||
|
||||
// Price difference for logging
|
||||
priceDiff = new(big.Float).Mul(effectiveRate, spreadFactor)
|
||||
|
||||
spc.logger.Debug(fmt.Sprintf("Fallback profit calc: rate=%.6f, spread=%d bps, grossProfit=%.6f",
|
||||
effectiveRate, typicalSpreadBps, grossProfit))
|
||||
}
|
||||
} else {
|
||||
// Fallback to simplified calculation - calculate actual price differential
|
||||
// Instead of assuming fixed profit, calculate based on market impact
|
||||
|
||||
// Calculate the effective exchange rate
|
||||
effectiveRate := new(big.Float).Quo(amountOut, amountIn)
|
||||
|
||||
// Estimate market price (this would ideally come from an oracle)
|
||||
// For now, use the swap rate as baseline and look for deviations
|
||||
// A profitable arbitrage exists when we can buy low and sell high
|
||||
|
||||
// Estimate a small price differential based on typical DEX spreads
|
||||
// Most DEX pairs have 0.3% fee, arbitrage typically happens at 0.5-1% spread
|
||||
typicalSpreadBps := int64(30) // 0.3% typical spread
|
||||
spreadFactor := new(big.Float).Quo(big.NewFloat(float64(typicalSpreadBps)), big.NewFloat(10000))
|
||||
|
||||
// Calculate potential arbitrage profit
|
||||
// profit = amountOut * spread - amountIn
|
||||
potentialRevenue := new(big.Float).Mul(amountOut, spreadFactor)
|
||||
grossProfit = new(big.Float).Sub(potentialRevenue, new(big.Float).Mul(amountIn, spreadFactor))
|
||||
|
||||
// Price difference for logging
|
||||
priceDiff = new(big.Float).Mul(effectiveRate, spreadFactor)
|
||||
|
||||
spc.logger.Debug(fmt.Sprintf("Fallback profit calc: rate=%.6f, spread=%d bps, grossProfit=%.6f",
|
||||
effectiveRate, typicalSpreadBps, grossProfit))
|
||||
}
|
||||
|
||||
opportunity.PriceDifference = priceDiff
|
||||
opportunity.EstimatedProfit = grossProfit
|
||||
|
||||
// Perform slippage analysis if we have sufficient data
|
||||
var slippageAnalysis *SlippageAnalysis
|
||||
var adjustedProfit *big.Float = grossProfit
|
||||
|
||||
if spc.priceFeed != nil {
|
||||
// Get price data for slippage calculation
|
||||
multiPrice := spc.priceFeed.GetMultiDEXPrice(tokenA, tokenB)
|
||||
if multiPrice != nil && len(multiPrice.Prices) > 0 {
|
||||
// Use average liquidity from available pools
|
||||
totalLiquidity := big.NewFloat(0)
|
||||
for _, price := range multiPrice.Prices {
|
||||
totalLiquidity.Add(totalLiquidity, price.Liquidity)
|
||||
}
|
||||
avgLiquidity := new(big.Float).Quo(totalLiquidity, big.NewFloat(float64(len(multiPrice.Prices))))
|
||||
|
||||
// Calculate current price
|
||||
currentPrice := new(big.Float).Quo(amountOut, amountIn)
|
||||
|
||||
// Perform slippage analysis
|
||||
slippageAnalysis = spc.slippageProtector.AnalyzeSlippage(amountIn, avgLiquidity, currentPrice)
|
||||
if slippageAnalysis != nil {
|
||||
opportunity.SlippageAnalysis = slippageAnalysis
|
||||
opportunity.SlippageRisk = slippageAnalysis.RiskLevel
|
||||
opportunity.EffectivePrice = slippageAnalysis.EffectivePrice
|
||||
opportunity.MinAmountOut = slippageAnalysis.MinAmountOut
|
||||
|
||||
// Adjust profit for slippage
|
||||
adjustedProfit = spc.slippageProtector.CalculateSlippageAdjustedProfit(grossProfit, slippageAnalysis)
|
||||
|
||||
spc.logger.Debug(fmt.Sprintf("Slippage analysis for %s: Risk=%s, Slippage=%.2f%%, Adjusted Profit=%s",
|
||||
opportunity.ID, slippageAnalysis.RiskLevel, slippageAnalysis.EstimatedSlippage*100, adjustedProfit.String()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback slippage estimation without real data
|
||||
slippageEst := 0.005 // Assume 0.5% slippage
|
||||
slippageReduction := new(big.Float).Mul(grossProfit, big.NewFloat(slippageEst))
|
||||
adjustedProfit = new(big.Float).Sub(grossProfit, slippageReduction)
|
||||
opportunity.SlippageRisk = "Medium" // Default to medium risk
|
||||
}
|
||||
|
||||
// Calculate gas cost (potentially adjusted for slippage complexity)
|
||||
gasCost := spc.calculateGasCost()
|
||||
if slippageAnalysis != nil {
|
||||
additionalGas := spc.slippageProtector.EstimateGasForSlippage(slippageAnalysis)
|
||||
if additionalGas > 0 {
|
||||
additionalGasInt64, err := security.SafeUint64ToInt64(additionalGas)
|
||||
if err != nil {
|
||||
spc.logger.Error("Additional gas exceeds int64 maximum", "additionalGas", additionalGas, "error", err)
|
||||
// Use maximum safe value as fallback
|
||||
additionalGasInt64 = math.MaxInt64
|
||||
}
|
||||
extraGasCost := new(big.Int).Mul(spc.GetCurrentGasPrice(), big.NewInt(additionalGasInt64))
|
||||
extraGasCostFloat := new(big.Float).Quo(new(big.Float).SetInt(extraGasCost), big.NewFloat(1e18))
|
||||
gasCost.Add(gasCost, extraGasCostFloat)
|
||||
}
|
||||
}
|
||||
opportunity.GasCost = gasCost
|
||||
|
||||
// Net profit = Adjusted profit - Gas cost
|
||||
netProfit := new(big.Float).Sub(adjustedProfit, gasCost)
|
||||
opportunity.NetProfit = netProfit
|
||||
|
||||
// Calculate profit margin with protection against extreme values
|
||||
// CRITICAL FIX: Add bounds checking for extreme positive AND negative margins
|
||||
if amountOut.Sign() > 0 && amountOut.Cmp(big.NewFloat(0)) != 0 {
|
||||
profitMargin := new(big.Float).Quo(netProfit, amountOut)
|
||||
profitMarginFloat, _ := profitMargin.Float64()
|
||||
|
||||
// CRITICAL FIX: Validate profit margin is within realistic bounds
|
||||
// Realistic range: -100% to +100% (-1.0 to +1.0)
|
||||
// Values outside this range indicate calculation errors or dust amounts
|
||||
if profitMarginFloat > 10.0 {
|
||||
// CRITICAL FIX #5: Extreme positive margin (> 1000%) - likely calculation error or flash loan
|
||||
opportunity.ProfitMargin = 0.0
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = fmt.Sprintf("unrealistic positive profit margin: %.2f%%", profitMarginFloat*100)
|
||||
opportunity.Confidence = 0.0
|
||||
spc.logger.Debug(fmt.Sprintf("CRITICAL FIX #5: Rejected opportunity: extreme positive margin (> 1000%%) %.2f%% (> 100%%)", profitMarginFloat*100))
|
||||
} else if profitMarginFloat < -10.0 {
|
||||
// CRITICAL FIX: Extreme negative margin (< -100%) - likely dust amounts or calc error
|
||||
opportunity.ProfitMargin = 0.0
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = fmt.Sprintf("unrealistic negative profit margin: %.2f%% (dust or calc error)", profitMarginFloat*100)
|
||||
opportunity.Confidence = 0.0
|
||||
spc.logger.Debug(fmt.Sprintf("Rejected opportunity: extreme negative margin %.2f%% (< -100%%), likely dust or calc error", profitMarginFloat*100))
|
||||
} else {
|
||||
// Normal range: -1000% to +1000% - allows normal arbitrage (0.01% - 0.5%)
|
||||
opportunity.ProfitMargin = profitMarginFloat
|
||||
}
|
||||
} else {
|
||||
// amountOut is zero or negative - should not happen due to earlier checks
|
||||
opportunity.ProfitMargin = 0.0
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = "invalid amountOut for profit margin calculation"
|
||||
opportunity.Confidence = 0.0
|
||||
}
|
||||
|
||||
// Determine if executable (considering both profit and slippage risk)
|
||||
if netProfit.Sign() > 0 {
|
||||
// CRITICAL FIX 2025-11-09: Convert threshold from wei to ETH for proper comparison
|
||||
// Bug was: netProfit.Int(nil) returned integer part (834) instead of wei (834*10^18)
|
||||
// This caused all opportunities to be rejected as "below threshold"
|
||||
// Fix: Compare both values as big.Float in ETH units
|
||||
minProfitETH := new(big.Float).Quo(
|
||||
new(big.Float).SetInt(spc.minProfitThreshold),
|
||||
new(big.Float).SetInt(big.NewInt(1e18)),
|
||||
)
|
||||
|
||||
spc.logger.Debug(fmt.Sprintf("Profit threshold check: netProfit=%s ETH, minThreshold=%s ETH",
|
||||
spc.FormatEther(netProfit), spc.FormatEther(minProfitETH)))
|
||||
|
||||
if netProfit.Cmp(minProfitETH) >= 0 {
|
||||
// Check slippage risk
|
||||
if opportunity.SlippageRisk == "Extreme" {
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = "extreme slippage risk"
|
||||
opportunity.Confidence = 0.1
|
||||
} else if slippageAnalysis != nil && !slippageAnalysis.IsAcceptable {
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = fmt.Sprintf("slippage too high: %s", slippageAnalysis.Recommendation)
|
||||
opportunity.Confidence = 0.2
|
||||
} else {
|
||||
opportunity.IsExecutable = true
|
||||
opportunity.Confidence = spc.calculateConfidence(opportunity)
|
||||
opportunity.RejectReason = ""
|
||||
spc.logger.Info(fmt.Sprintf("✅ EXECUTABLE OPPORTUNITY: ID=%s, Profit=%s ETH (threshold=%s ETH)",
|
||||
opportunity.ID, spc.FormatEther(netProfit), spc.FormatEther(minProfitETH)))
|
||||
}
|
||||
} else {
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = "profit below minimum threshold"
|
||||
opportunity.Confidence = 0.3
|
||||
}
|
||||
} else {
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = "negative profit after gas and slippage costs"
|
||||
opportunity.Confidence = 0.1
|
||||
}
|
||||
} else {
|
||||
opportunity.IsExecutable = false
|
||||
opportunity.RejectReason = "invalid swap amounts"
|
||||
opportunity.Confidence = 0.0
|
||||
}
|
||||
|
||||
spc.logger.Debug(fmt.Sprintf("Analyzed arbitrage opportunity: ID=%s, NetProfit=%s ETH, Executable=%t, Reason=%s",
|
||||
opportunity.ID,
|
||||
spc.FormatEther(opportunity.NetProfit),
|
||||
opportunity.IsExecutable,
|
||||
opportunity.RejectReason,
|
||||
))
|
||||
|
||||
return opportunity
|
||||
}
|
||||
|
||||
// calculateGasCost estimates the gas cost for an arbitrage transaction
|
||||
func (spc *ProfitCalculator) calculateGasCost() *big.Float {
|
||||
// Gas cost = Gas price * Gas limit
|
||||
// Gas cost = Gas price * Gas limit
|
||||
gasLimitInt64, err := security.SafeUint64ToInt64(spc.gasLimit)
|
||||
if err != nil {
|
||||
spc.logger.Error("Gas limit exceeds int64 maximum", "gasLimit", spc.gasLimit, "error", err)
|
||||
// Use maximum safe value as fallback
|
||||
gasLimitInt64 = math.MaxInt64
|
||||
}
|
||||
gasLimit := big.NewInt(gasLimitInt64)
|
||||
currentGasPrice := spc.GetCurrentGasPrice()
|
||||
gasCostWei := new(big.Int).Mul(currentGasPrice, gasLimit)
|
||||
|
||||
// Add 5% buffer for MEV competition (reduced from 20%)
|
||||
buffer := new(big.Int).Div(gasCostWei, big.NewInt(20)) // 5%
|
||||
gasCostWei.Add(gasCostWei, buffer)
|
||||
|
||||
// Convert to big.Float for easier calculation
|
||||
gasCostFloat := new(big.Float).SetInt(gasCostWei)
|
||||
// Convert from wei to ether
|
||||
etherDenominator := new(big.Float).SetInt(big.NewInt(1e18))
|
||||
return new(big.Float).Quo(gasCostFloat, etherDenominator)
|
||||
}
|
||||
|
||||
// calculateConfidence calculates a confidence score for the opportunity
|
||||
func (spc *ProfitCalculator) calculateConfidence(opp *SimpleOpportunity) float64 {
|
||||
confidence := 0.0
|
||||
|
||||
// Base confidence for positive profit
|
||||
if opp.NetProfit != nil && opp.NetProfit.Sign() > 0 {
|
||||
confidence += 0.4
|
||||
}
|
||||
|
||||
// Confidence based on profit margin
|
||||
if opp.ProfitMargin > 0.02 { // > 2% margin
|
||||
confidence += 0.3
|
||||
} else if opp.ProfitMargin > 0.01 { // > 1% margin
|
||||
confidence += 0.2
|
||||
} else if opp.ProfitMargin > 0.005 { // > 0.5% margin
|
||||
confidence += 0.1
|
||||
}
|
||||
|
||||
// Confidence based on trade size (larger trades = more confidence)
|
||||
if opp.AmountOut != nil {
|
||||
amountOutFloat, _ := opp.AmountOut.Float64()
|
||||
if amountOutFloat > 1000 { // > $1000 equivalent
|
||||
confidence += 0.2
|
||||
} else if amountOutFloat > 100 { // > $100 equivalent
|
||||
confidence += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
// Cap at 1.0
|
||||
if confidence > 1.0 {
|
||||
confidence = 1.0
|
||||
}
|
||||
|
||||
return confidence
|
||||
}
|
||||
|
||||
// FormatEther formats a big.Float ether amount to string (public method)
|
||||
func (spc *ProfitCalculator) FormatEther(ether *big.Float) string {
|
||||
if ether == nil {
|
||||
return "0.000000"
|
||||
}
|
||||
return fmt.Sprintf("%.6f", ether)
|
||||
}
|
||||
|
||||
// UpdateGasPrice updates the current gas price for calculations
|
||||
func (spc *ProfitCalculator) UpdateGasPrice(gasPrice *big.Int) {
|
||||
spc.gasPriceMutex.Lock()
|
||||
defer spc.gasPriceMutex.Unlock()
|
||||
|
||||
spc.gasPrice = gasPrice
|
||||
spc.lastGasPriceUpdate = time.Now()
|
||||
spc.logger.Debug(fmt.Sprintf("Updated gas price to %s gwei",
|
||||
new(big.Float).Quo(new(big.Float).SetInt(gasPrice), big.NewFloat(1e9))))
|
||||
}
|
||||
|
||||
// GetCurrentGasPrice gets the current gas price (thread-safe)
|
||||
func (spc *ProfitCalculator) GetCurrentGasPrice() *big.Int {
|
||||
spc.gasPriceMutex.RLock()
|
||||
defer spc.gasPriceMutex.RUnlock()
|
||||
return new(big.Int).Set(spc.gasPrice)
|
||||
}
|
||||
|
||||
// startGasPriceUpdater starts a background goroutine to update gas prices
|
||||
func (spc *ProfitCalculator) startGasPriceUpdater() {
|
||||
ticker := time.NewTicker(spc.gasPriceUpdateInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
spc.logger.Info(fmt.Sprintf("Starting gas price updater with %s interval", spc.gasPriceUpdateInterval))
|
||||
|
||||
// Update gas price immediately on start
|
||||
spc.updateGasPriceFromNetwork()
|
||||
|
||||
for range ticker.C {
|
||||
spc.updateGasPriceFromNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
// updateGasPriceFromNetwork fetches current gas price from the network
|
||||
func (spc *ProfitCalculator) updateGasPriceFromNetwork() {
|
||||
if spc.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
gasPrice, err := spc.client.SuggestGasPrice(ctx)
|
||||
if err != nil {
|
||||
spc.logger.Debug(fmt.Sprintf("Failed to fetch gas price from network: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Add MEV priority fee (50% boost for competitive transactions)
|
||||
mevGasPrice := new(big.Int).Mul(gasPrice, big.NewInt(150))
|
||||
mevGasPrice.Div(mevGasPrice, big.NewInt(100))
|
||||
|
||||
// Cap gas price for Arbitrum (typically 0.01-0.5 gwei)
|
||||
// Prevents overestimation on low-gas networks
|
||||
maxGasPrice := big.NewInt(500000000) // 0.5 gwei max for Arbitrum
|
||||
if mevGasPrice.Cmp(maxGasPrice) > 0 {
|
||||
spc.logger.Debug(fmt.Sprintf("Capping gas price at 0.5 gwei (was %s gwei)",
|
||||
new(big.Float).Quo(new(big.Float).SetInt(mevGasPrice), big.NewFloat(1e9))))
|
||||
mevGasPrice = maxGasPrice
|
||||
}
|
||||
|
||||
spc.UpdateGasPrice(mevGasPrice)
|
||||
}
|
||||
|
||||
// SetMinProfitThreshold sets the minimum profit threshold
|
||||
func (spc *ProfitCalculator) SetMinProfitThreshold(threshold *big.Int) {
|
||||
spc.minProfitThreshold = threshold
|
||||
spc.logger.Info(fmt.Sprintf("Updated minimum profit threshold to %s ETH",
|
||||
new(big.Float).Quo(new(big.Float).SetInt(threshold), big.NewFloat(1e18))))
|
||||
}
|
||||
|
||||
// GetPriceFeedStats returns statistics about the price feed
|
||||
func (spc *ProfitCalculator) GetPriceFeedStats() map[string]interface{} {
|
||||
if spc.priceFeed != nil {
|
||||
return spc.priceFeed.GetPriceStats()
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"status": "price feed not available",
|
||||
}
|
||||
}
|
||||
|
||||
// HasPriceFeed returns true if the calculator has an active price feed
|
||||
func (spc *ProfitCalculator) HasPriceFeed() bool {
|
||||
return spc.priceFeed != nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the profit calculator
|
||||
func (spc *ProfitCalculator) Stop() {
|
||||
if spc.priceFeed != nil {
|
||||
spc.priceFeed.Stop()
|
||||
spc.logger.Info("Price feed stopped")
|
||||
}
|
||||
}
|
||||
354
orig/pkg/profitcalc/profitcalc_test.go
Normal file
354
orig/pkg/profitcalc/profitcalc_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package profitcalc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/fraktal/mev-beta/internal/logger"
|
||||
)
|
||||
|
||||
func TestNewProfitCalculator(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
assert.NotNil(t, calc)
|
||||
assert.Equal(t, log, calc.logger)
|
||||
assert.NotNil(t, calc.minProfitThreshold)
|
||||
assert.NotNil(t, calc.gasPrice)
|
||||
assert.Equal(t, uint64(100000), calc.gasLimit)
|
||||
assert.Equal(t, 0.03, calc.maxSlippage)
|
||||
assert.NotNil(t, calc.slippageProtector)
|
||||
}
|
||||
|
||||
func TestProfitCalculatorDefaults(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
// Verify default configuration values
|
||||
assert.Equal(t, int64(1000000000000000), calc.minProfitThreshold.Int64()) // 0.001 ETH
|
||||
assert.Equal(t, int64(100000000), calc.gasPrice.Int64()) // 0.1 gwei
|
||||
assert.Equal(t, 30*time.Second, calc.gasPriceUpdateInterval)
|
||||
assert.Equal(t, 0.03, calc.maxSlippage) // 3% max slippage
|
||||
}
|
||||
|
||||
func TestAnalyzeSwapOpportunityPositiveProfit(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") // USDC
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2") // WETH
|
||||
amountIn := big.NewFloat(1000.0) // 1000 USDC
|
||||
amountOut := big.NewFloat(1.05) // 1.05 ETH (profitable)
|
||||
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, "UniswapV3")
|
||||
|
||||
assert.NotNil(t, opp)
|
||||
assert.Equal(t, tokenA, opp.TokenA)
|
||||
assert.Equal(t, tokenB, opp.TokenB)
|
||||
assert.Equal(t, amountIn, opp.AmountIn)
|
||||
assert.Equal(t, amountOut, opp.AmountOut)
|
||||
assert.NotEmpty(t, opp.ID)
|
||||
assert.NotZero(t, opp.Timestamp)
|
||||
}
|
||||
|
||||
func TestAnalyzeSwapOpportunityZeroAmount(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
amountIn := big.NewFloat(0.0) // Zero input
|
||||
amountOut := big.NewFloat(1.0) // Non-zero output
|
||||
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, "UniswapV3")
|
||||
|
||||
assert.NotNil(t, opp)
|
||||
assert.False(t, opp.IsExecutable) // Should not be executable with zero input
|
||||
}
|
||||
|
||||
func TestAnalyzeSwapOpportunityNegativeProfit(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
amountIn := big.NewFloat(1000.0) // 1000 USDC
|
||||
amountOut := big.NewFloat(0.90) // 0.90 ETH (loss)
|
||||
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, "UniswapV3")
|
||||
|
||||
assert.NotNil(t, opp)
|
||||
assert.False(t, opp.IsExecutable) // Not executable due to loss
|
||||
}
|
||||
|
||||
func TestAnalyzeSwapOpportunityBelowMinProfit(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
amountIn := big.NewFloat(10.0) // 10 USDC
|
||||
amountOut := big.NewFloat(0.01) // 0.01 ETH (tiny profit)
|
||||
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, "UniswapV3")
|
||||
|
||||
assert.NotNil(t, opp)
|
||||
// May not be executable if profit is below threshold
|
||||
assert.NotEmpty(t, opp.ID)
|
||||
}
|
||||
|
||||
func TestCalculateProfitMargin(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
// Test cases with different profit margins
|
||||
tests := []struct {
|
||||
name string
|
||||
amountIn *big.Float
|
||||
amountOut *big.Float
|
||||
expectMargin float64
|
||||
}{
|
||||
{
|
||||
name: "100% profit margin",
|
||||
amountIn: big.NewFloat(1.0),
|
||||
amountOut: big.NewFloat(2.0),
|
||||
expectMargin: 1.0, // 100% profit
|
||||
},
|
||||
{
|
||||
name: "50% profit margin",
|
||||
amountIn: big.NewFloat(100.0),
|
||||
amountOut: big.NewFloat(150.0),
|
||||
expectMargin: 0.5, // 50% profit
|
||||
},
|
||||
{
|
||||
name: "0% profit margin",
|
||||
amountIn: big.NewFloat(100.0),
|
||||
amountOut: big.NewFloat(100.0),
|
||||
expectMargin: 0.0, // Break even
|
||||
},
|
||||
{
|
||||
name: "Negative margin (loss)",
|
||||
amountIn: big.NewFloat(100.0),
|
||||
amountOut: big.NewFloat(90.0),
|
||||
expectMargin: -0.1, // 10% loss
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Verify that calculator can handle these inputs
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, tt.amountIn, tt.amountOut, "UniswapV3")
|
||||
assert.NotNil(t, opp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasCostCalculation(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
// Verify gas limit is set correctly for Arbitrum L2
|
||||
assert.Equal(t, uint64(100000), calc.gasLimit)
|
||||
|
||||
// Verify gas price is reasonable (0.1 gwei for Arbitrum)
|
||||
assert.Equal(t, int64(100000000), calc.gasPrice.Int64())
|
||||
|
||||
// Test gas cost calculation (gas price * gas limit)
|
||||
gasPrice := new(big.Int).Set(calc.gasPrice)
|
||||
gasLimit := new(big.Int).SetUint64(calc.gasLimit)
|
||||
gasCost := new(big.Int).Mul(gasPrice, gasLimit)
|
||||
|
||||
assert.NotNil(t, gasCost)
|
||||
assert.True(t, gasCost.Sign() > 0)
|
||||
}
|
||||
|
||||
func TestSlippageProtection(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
assert.NotNil(t, calc.slippageProtector)
|
||||
assert.Equal(t, 0.03, calc.maxSlippage) // 3% max slippage
|
||||
|
||||
// Test with amount that would incur slippage
|
||||
amountOut := big.NewFloat(100.0)
|
||||
maxAcceptableSlippage := calc.maxSlippage
|
||||
|
||||
// Minimum acceptable output with slippage protection
|
||||
minOutput := new(big.Float).Mul(amountOut, big.NewFloat(1.0-maxAcceptableSlippage))
|
||||
|
||||
assert.NotNil(t, minOutput)
|
||||
assert.True(t, minOutput.Cmp(big.NewFloat(0)) > 0)
|
||||
}
|
||||
|
||||
func TestMinProfitThreshold(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
minProfit := calc.minProfitThreshold.Int64()
|
||||
assert.Equal(t, int64(1000000000000000), minProfit) // 0.001 ETH
|
||||
|
||||
// Verify this is a reasonable threshold for Arbitrum
|
||||
// 0.001 ETH at $2000/ETH = $2 minimum profit
|
||||
assert.True(t, minProfit > 0)
|
||||
}
|
||||
|
||||
func TestOpportunityIDGeneration(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
amountIn := big.NewFloat(100.0)
|
||||
amountOut := big.NewFloat(1.0)
|
||||
|
||||
opp1 := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, "UniswapV3")
|
||||
time.Sleep(1 * time.Millisecond) // Ensure timestamp difference
|
||||
opp2 := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, "UniswapV3")
|
||||
|
||||
assert.NotEmpty(t, opp1.ID)
|
||||
assert.NotEmpty(t, opp2.ID)
|
||||
// IDs should be different (include timestamp)
|
||||
// Both IDs should be properly formatted
|
||||
assert.Contains(t, opp1.ID, "arb_")
|
||||
assert.Contains(t, opp2.ID, "arb_")
|
||||
}
|
||||
|
||||
func TestOpportunityTimestamp(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
amountIn := big.NewFloat(100.0)
|
||||
amountOut := big.NewFloat(1.0)
|
||||
|
||||
before := time.Now()
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, "UniswapV3")
|
||||
after := time.Now()
|
||||
|
||||
assert.NotZero(t, opp.Timestamp)
|
||||
assert.True(t, opp.Timestamp.After(before) || opp.Timestamp.Equal(before))
|
||||
assert.True(t, opp.Timestamp.Before(after) || opp.Timestamp.Equal(after))
|
||||
}
|
||||
|
||||
func TestMultipleProtocols(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
amountIn := big.NewFloat(100.0)
|
||||
amountOut := big.NewFloat(1.05)
|
||||
|
||||
protocols := []string{"UniswapV2", "UniswapV3", "SushiSwap", "Camelot"}
|
||||
|
||||
for _, protocol := range protocols {
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, protocol)
|
||||
assert.NotNil(t, opp)
|
||||
assert.Equal(t, tokenA, opp.TokenA)
|
||||
assert.Equal(t, tokenB, opp.TokenB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargeAmounts(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
|
||||
// Test with very large amounts
|
||||
largeAmount := big.NewFloat(1000000.0) // 1M USDC
|
||||
amountOut := big.NewFloat(500.0) // 500 WETH
|
||||
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, largeAmount, amountOut, "UniswapV3")
|
||||
assert.NotNil(t, opp)
|
||||
assert.Equal(t, largeAmount, opp.AmountIn)
|
||||
assert.Equal(t, amountOut, opp.AmountOut)
|
||||
}
|
||||
|
||||
func TestSmallAmounts(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
|
||||
// Test with very small amounts (dust)
|
||||
smallAmount := big.NewFloat(0.001) // 0.001 USDC
|
||||
amountOut := big.NewFloat(0.0000005)
|
||||
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, smallAmount, amountOut, "UniswapV3")
|
||||
assert.NotNil(t, opp)
|
||||
assert.False(t, opp.IsExecutable) // Likely below minimum threshold
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
amountIn := big.NewFloat(100.0)
|
||||
amountOut := big.NewFloat(1.05)
|
||||
|
||||
// Should handle cancelled context gracefully
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, "UniswapV3")
|
||||
assert.NotNil(t, opp)
|
||||
}
|
||||
|
||||
func TestProfitCalculatorConcurrency(t *testing.T) {
|
||||
log := logger.New("info", "text", "")
|
||||
calc := NewProfitCalculator(log)
|
||||
|
||||
done := make(chan bool)
|
||||
errors := make(chan error)
|
||||
|
||||
// Test concurrent opportunity analysis
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(index int) {
|
||||
ctx := context.Background()
|
||||
tokenA := common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
tokenB := common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
||||
amountIn := big.NewFloat(100.0)
|
||||
amountOut := big.NewFloat(1.05)
|
||||
|
||||
opp := calc.AnalyzeSwapOpportunity(ctx, tokenA, tokenB, amountIn, amountOut, "UniswapV3")
|
||||
if opp == nil {
|
||||
errors <- assert.AnError
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
// Success
|
||||
case <-errors:
|
||||
t.Fatal("Concurrent operation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
413
orig/pkg/profitcalc/real_price_feed.go
Normal file
413
orig/pkg/profitcalc/real_price_feed.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package profitcalc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi/bind"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
|
||||
"github.com/fraktal/mev-beta/internal/logger"
|
||||
)
|
||||
|
||||
// RealPriceFeed fetches actual on-chain prices from DEX pools
|
||||
type RealPriceFeed struct {
|
||||
logger *logger.Logger
|
||||
client *ethclient.Client
|
||||
priceCache map[string]*PriceData
|
||||
priceMutex *sync.RWMutex
|
||||
updateTicker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
|
||||
// ABIs for contract calls
|
||||
uniswapV3PoolABI abi.ABI
|
||||
uniswapV2PairABI abi.ABI
|
||||
factoryABI abi.ABI
|
||||
|
||||
// DEX factory addresses
|
||||
uniswapV3Factory common.Address
|
||||
sushiswapFactory common.Address
|
||||
camelotFactory common.Address
|
||||
}
|
||||
|
||||
// NewRealPriceFeed creates a new real price feed with on-chain data
|
||||
func NewRealPriceFeed(logger *logger.Logger, client *ethclient.Client) (*RealPriceFeed, error) {
|
||||
// Parse Uniswap V3 Pool ABI
|
||||
uniswapV3PoolABI, err := abi.JSON(strings.NewReader(uniswapV3PoolABIJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Uniswap V3 Pool ABI: %w", err)
|
||||
}
|
||||
|
||||
// Parse Uniswap V2 Pair ABI
|
||||
uniswapV2PairABI, err := abi.JSON(strings.NewReader(uniswapV2PairABIJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Uniswap V2 Pair ABI: %w", err)
|
||||
}
|
||||
|
||||
// Parse Factory ABI
|
||||
factoryABI, err := abi.JSON(strings.NewReader(factoryABIJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Factory ABI: %w", err)
|
||||
}
|
||||
|
||||
rpf := &RealPriceFeed{
|
||||
logger: logger,
|
||||
client: client,
|
||||
priceCache: make(map[string]*PriceData),
|
||||
priceMutex: &sync.RWMutex{},
|
||||
stopChan: make(chan struct{}),
|
||||
uniswapV3PoolABI: uniswapV3PoolABI,
|
||||
uniswapV2PairABI: uniswapV2PairABI,
|
||||
factoryABI: factoryABI,
|
||||
|
||||
// Arbitrum mainnet factory addresses
|
||||
uniswapV3Factory: common.HexToAddress("0x1F98431c8aD98523631AE4a59f267346ea31F984"),
|
||||
sushiswapFactory: common.HexToAddress("0xc35DADB65012eC5796536bD9864eD8773aBc74C4"),
|
||||
camelotFactory: common.HexToAddress("0x6EcCab422D763aC031210895C81787E87B82A80f"),
|
||||
}
|
||||
|
||||
return rpf, nil
|
||||
}
|
||||
|
||||
// Start begins real-time price updates
|
||||
func (rpf *RealPriceFeed) Start() {
|
||||
rpf.updateTicker = time.NewTicker(5 * time.Second) // Update every 5 seconds for production
|
||||
go rpf.priceUpdateLoop()
|
||||
rpf.logger.Info("✅ Real price feed started with 5-second update interval")
|
||||
}
|
||||
|
||||
// Stop stops the price feed
|
||||
func (rpf *RealPriceFeed) Stop() {
|
||||
close(rpf.stopChan)
|
||||
if rpf.updateTicker != nil {
|
||||
rpf.updateTicker.Stop()
|
||||
}
|
||||
rpf.logger.Info("✅ Real price feed stopped")
|
||||
}
|
||||
|
||||
// priceUpdateLoop continuously updates prices
|
||||
func (rpf *RealPriceFeed) priceUpdateLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-rpf.updateTicker.C:
|
||||
rpf.updateAllPrices()
|
||||
case <-rpf.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateAllPrices updates all tracked token pairs
|
||||
func (rpf *RealPriceFeed) updateAllPrices() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Common trading pairs on Arbitrum
|
||||
pairs := []struct {
|
||||
TokenA common.Address
|
||||
TokenB common.Address
|
||||
Name string
|
||||
}{
|
||||
{
|
||||
TokenA: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), // WETH
|
||||
TokenB: common.HexToAddress("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"), // USDC
|
||||
Name: "WETH/USDC",
|
||||
},
|
||||
{
|
||||
TokenA: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), // WETH
|
||||
TokenB: common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"), // USDT
|
||||
Name: "WETH/USDT",
|
||||
},
|
||||
{
|
||||
TokenA: common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"), // WBTC
|
||||
TokenB: common.HexToAddress("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"), // USDC
|
||||
Name: "WBTC/USDC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, pair := range pairs {
|
||||
// Query Uniswap V3
|
||||
go rpf.updatePriceFromUniswapV3(ctx, pair.TokenA, pair.TokenB)
|
||||
|
||||
// Query SushiSwap
|
||||
go rpf.updatePriceFromV2DEX(ctx, pair.TokenA, pair.TokenB, "SushiSwap", rpf.sushiswapFactory)
|
||||
|
||||
// Query Camelot
|
||||
go rpf.updatePriceFromV2DEX(ctx, pair.TokenA, pair.TokenB, "Camelot", rpf.camelotFactory)
|
||||
}
|
||||
}
|
||||
|
||||
// updatePriceFromUniswapV3 fetches real price from Uniswap V3 pool
|
||||
func (rpf *RealPriceFeed) updatePriceFromUniswapV3(ctx context.Context, tokenA, tokenB common.Address) {
|
||||
// Get pool address from factory
|
||||
poolAddress, err := rpf.getUniswapV3Pool(ctx, tokenA, tokenB, 3000) // 0.3% fee tier
|
||||
if err != nil {
|
||||
rpf.logger.Debug(fmt.Sprintf("Failed to get Uniswap V3 pool for %s/%s: %v", tokenA.Hex()[:8], tokenB.Hex()[:8], err))
|
||||
return
|
||||
}
|
||||
|
||||
// Create bound contract
|
||||
poolContract := bind.NewBoundContract(poolAddress, rpf.uniswapV3PoolABI, rpf.client, rpf.client, rpf.client)
|
||||
|
||||
// Call slot0 to get current price
|
||||
var result []interface{}
|
||||
err = poolContract.Call(&bind.CallOpts{Context: ctx}, &result, "slot0")
|
||||
if err != nil {
|
||||
rpf.logger.Debug(fmt.Sprintf("Failed to call slot0 for Uniswap V3 pool: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
rpf.logger.Debug("Empty result from slot0 call")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract sqrtPriceX96 from result
|
||||
sqrtPriceX96, ok := result[0].(*big.Int)
|
||||
if !ok {
|
||||
rpf.logger.Debug("Failed to parse sqrtPriceX96 from slot0")
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate price from sqrtPriceX96
|
||||
// price = (sqrtPriceX96 / 2^96)^2
|
||||
q96 := new(big.Int).Lsh(big.NewInt(1), 96) // 2^96
|
||||
sqrtPrice := new(big.Float).SetInt(sqrtPriceX96)
|
||||
q96Float := new(big.Float).SetInt(q96)
|
||||
sqrtPriceScaled := new(big.Float).Quo(sqrtPrice, q96Float)
|
||||
price := new(big.Float).Mul(sqrtPriceScaled, sqrtPriceScaled)
|
||||
|
||||
// Get liquidity
|
||||
var liquidityResult []interface{}
|
||||
err = poolContract.Call(&bind.CallOpts{Context: ctx}, &liquidityResult, "liquidity")
|
||||
var liquidity *big.Float
|
||||
if err == nil && len(liquidityResult) > 0 {
|
||||
if liquidityInt, ok := liquidityResult[0].(*big.Int); ok {
|
||||
liquidity = new(big.Float).SetInt(liquidityInt)
|
||||
}
|
||||
}
|
||||
if liquidity == nil {
|
||||
liquidity = big.NewFloat(0)
|
||||
}
|
||||
|
||||
// Store price data
|
||||
rpf.priceMutex.Lock()
|
||||
defer rpf.priceMutex.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s_%s_UniswapV3", tokenA.Hex(), tokenB.Hex())
|
||||
rpf.priceCache[key] = &PriceData{
|
||||
TokenA: tokenA,
|
||||
TokenB: tokenB,
|
||||
Price: price,
|
||||
InversePrice: new(big.Float).Quo(big.NewFloat(1), price),
|
||||
Liquidity: liquidity,
|
||||
DEX: "UniswapV3",
|
||||
PoolAddress: poolAddress,
|
||||
LastUpdated: time.Now(),
|
||||
IsValid: true,
|
||||
}
|
||||
|
||||
rpf.logger.Debug(fmt.Sprintf("✅ Updated UniswapV3 price for %s/%s: %s", tokenA.Hex()[:8], tokenB.Hex()[:8], price.Text('f', 6)))
|
||||
}
|
||||
|
||||
// updatePriceFromV2DEX fetches real price from V2-style DEX (SushiSwap, Camelot)
|
||||
func (rpf *RealPriceFeed) updatePriceFromV2DEX(ctx context.Context, tokenA, tokenB common.Address, dexName string, factory common.Address) {
|
||||
// Get pair address from factory
|
||||
pairAddress, err := rpf.getV2Pair(ctx, factory, tokenA, tokenB)
|
||||
if err != nil {
|
||||
rpf.logger.Debug(fmt.Sprintf("Failed to get %s pair for %s/%s: %v", dexName, tokenA.Hex()[:8], tokenB.Hex()[:8], err))
|
||||
return
|
||||
}
|
||||
|
||||
// Create bound contract
|
||||
pairContract := bind.NewBoundContract(pairAddress, rpf.uniswapV2PairABI, rpf.client, rpf.client, rpf.client)
|
||||
|
||||
// Call getReserves
|
||||
var result []interface{}
|
||||
err = pairContract.Call(&bind.CallOpts{Context: ctx}, &result, "getReserves")
|
||||
if err != nil {
|
||||
rpf.logger.Debug(fmt.Sprintf("Failed to call getReserves for %s pair: %v", dexName, err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(result) < 2 {
|
||||
rpf.logger.Debug(fmt.Sprintf("Invalid result from getReserves for %s", dexName))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse reserves
|
||||
reserve0, ok0 := result[0].(*big.Int)
|
||||
reserve1, ok1 := result[1].(*big.Int)
|
||||
if !ok0 || !ok1 {
|
||||
rpf.logger.Debug(fmt.Sprintf("Failed to parse reserves for %s", dexName))
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate price (reserve1 / reserve0)
|
||||
reserve0Float := new(big.Float).SetInt(reserve0)
|
||||
reserve1Float := new(big.Float).SetInt(reserve1)
|
||||
price := new(big.Float).Quo(reserve1Float, reserve0Float)
|
||||
|
||||
// Calculate total liquidity (sum of reserves in tokenB equivalent)
|
||||
liquidity := new(big.Float).Add(reserve1Float, new(big.Float).Mul(reserve0Float, price))
|
||||
|
||||
// Store price data
|
||||
rpf.priceMutex.Lock()
|
||||
defer rpf.priceMutex.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s_%s_%s", tokenA.Hex(), tokenB.Hex(), dexName)
|
||||
rpf.priceCache[key] = &PriceData{
|
||||
TokenA: tokenA,
|
||||
TokenB: tokenB,
|
||||
Price: price,
|
||||
InversePrice: new(big.Float).Quo(big.NewFloat(1), price),
|
||||
Liquidity: liquidity,
|
||||
DEX: dexName,
|
||||
PoolAddress: pairAddress,
|
||||
LastUpdated: time.Now(),
|
||||
IsValid: true,
|
||||
}
|
||||
|
||||
rpf.logger.Debug(fmt.Sprintf("✅ Updated %s price for %s/%s: %s", dexName, tokenA.Hex()[:8], tokenB.Hex()[:8], price.Text('f', 6)))
|
||||
}
|
||||
|
||||
// getUniswapV3Pool gets pool address from Uniswap V3 factory
|
||||
func (rpf *RealPriceFeed) getUniswapV3Pool(ctx context.Context, tokenA, tokenB common.Address, fee uint32) (common.Address, error) {
|
||||
factoryContract := bind.NewBoundContract(rpf.uniswapV3Factory, rpf.factoryABI, rpf.client, rpf.client, rpf.client)
|
||||
|
||||
var result []interface{}
|
||||
err := factoryContract.Call(&bind.CallOpts{Context: ctx}, &result, "getPool", tokenA, tokenB, big.NewInt(int64(fee)))
|
||||
if err != nil {
|
||||
return common.Address{}, fmt.Errorf("failed to get pool: %w", err)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return common.Address{}, fmt.Errorf("no pool found")
|
||||
}
|
||||
|
||||
poolAddress, ok := result[0].(common.Address)
|
||||
if !ok {
|
||||
return common.Address{}, fmt.Errorf("failed to parse pool address")
|
||||
}
|
||||
|
||||
if poolAddress == (common.Address{}) {
|
||||
return common.Address{}, fmt.Errorf("pool does not exist")
|
||||
}
|
||||
|
||||
return poolAddress, nil
|
||||
}
|
||||
|
||||
// getV2Pair gets pair address from V2-style factory
|
||||
func (rpf *RealPriceFeed) getV2Pair(ctx context.Context, factory, tokenA, tokenB common.Address) (common.Address, error) {
|
||||
factoryContract := bind.NewBoundContract(factory, rpf.factoryABI, rpf.client, rpf.client, rpf.client)
|
||||
|
||||
var result []interface{}
|
||||
err := factoryContract.Call(&bind.CallOpts{Context: ctx}, &result, "getPair", tokenA, tokenB)
|
||||
if err != nil {
|
||||
return common.Address{}, fmt.Errorf("failed to get pair: %w", err)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return common.Address{}, fmt.Errorf("no pair found")
|
||||
}
|
||||
|
||||
pairAddress, ok := result[0].(common.Address)
|
||||
if !ok {
|
||||
return common.Address{}, fmt.Errorf("failed to parse pair address")
|
||||
}
|
||||
|
||||
if pairAddress == (common.Address{}) {
|
||||
return common.Address{}, fmt.Errorf("pair does not exist")
|
||||
}
|
||||
|
||||
return pairAddress, nil
|
||||
}
|
||||
|
||||
// GetPrice retrieves cached price data
|
||||
func (rpf *RealPriceFeed) GetPrice(tokenA, tokenB common.Address, dex string) (*PriceData, error) {
|
||||
rpf.priceMutex.RLock()
|
||||
defer rpf.priceMutex.RUnlock()
|
||||
|
||||
key := fmt.Sprintf("%s_%s_%s", tokenA.Hex(), tokenB.Hex(), dex)
|
||||
priceData, ok := rpf.priceCache[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("price not found for %s on %s", tokenA.Hex()[:8], dex)
|
||||
}
|
||||
|
||||
// Check if price is stale (older than 30 seconds for production)
|
||||
if time.Since(priceData.LastUpdated) > 30*time.Second {
|
||||
return nil, fmt.Errorf("price data is stale (last updated: %v)", priceData.LastUpdated)
|
||||
}
|
||||
|
||||
return priceData, nil
|
||||
}
|
||||
|
||||
// ABI JSON strings (simplified for key functions)
|
||||
const uniswapV3PoolABIJSON = `[
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "slot0",
|
||||
"outputs": [
|
||||
{"internalType": "uint160", "name": "sqrtPriceX96", "type": "uint160"},
|
||||
{"internalType": "int24", "name": "tick", "type": "int24"},
|
||||
{"internalType": "uint16", "name": "observationIndex", "type": "uint16"},
|
||||
{"internalType": "uint16", "name": "observationCardinality", "type": "uint16"},
|
||||
{"internalType": "uint16", "name": "observationCardinalityNext", "type": "uint16"},
|
||||
{"internalType": "uint8", "name": "feeProtocol", "type": "uint8"},
|
||||
{"internalType": "bool", "name": "unlocked", "type": "bool"}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "liquidity",
|
||||
"outputs": [{"internalType": "uint128", "name": "", "type": "uint128"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
}
|
||||
]`
|
||||
|
||||
const uniswapV2PairABIJSON = `[
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "getReserves",
|
||||
"outputs": [
|
||||
{"internalType": "uint112", "name": "reserve0", "type": "uint112"},
|
||||
{"internalType": "uint112", "name": "reserve1", "type": "uint112"},
|
||||
{"internalType": "uint32", "name": "blockTimestampLast", "type": "uint32"}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
}
|
||||
]`
|
||||
|
||||
const factoryABIJSON = `[
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "address", "name": "tokenA", "type": "address"},
|
||||
{"internalType": "address", "name": "tokenB", "type": "address"},
|
||||
{"internalType": "uint24", "name": "fee", "type": "uint24"}
|
||||
],
|
||||
"name": "getPool",
|
||||
"outputs": [{"internalType": "address", "name": "pool", "type": "address"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "address", "name": "tokenA", "type": "address"},
|
||||
{"internalType": "address", "name": "tokenB", "type": "address"}
|
||||
],
|
||||
"name": "getPair",
|
||||
"outputs": [{"internalType": "address", "name": "pair", "type": "address"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
}
|
||||
]`
|
||||
348
orig/pkg/profitcalc/slippage_protection.go
Normal file
348
orig/pkg/profitcalc/slippage_protection.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package profitcalc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/fraktal/mev-beta/internal/logger"
|
||||
)
|
||||
|
||||
// SlippageProtector provides slippage calculation and protection for arbitrage trades
|
||||
type SlippageProtector struct {
|
||||
logger *logger.Logger
|
||||
maxSlippageBps int64 // Maximum allowed slippage in basis points
|
||||
liquidityBuffer float64 // Buffer factor for liquidity calculations
|
||||
}
|
||||
|
||||
// SlippageAnalysis contains detailed slippage analysis for a trade
|
||||
type SlippageAnalysis struct {
|
||||
TradeAmount *big.Float // Amount being traded
|
||||
PoolLiquidity *big.Float // Available liquidity in pool
|
||||
EstimatedSlippage float64 // Estimated slippage percentage
|
||||
SlippageBps int64 // Slippage in basis points
|
||||
PriceImpact float64 // Price impact percentage
|
||||
EffectivePrice *big.Float // Price after slippage
|
||||
MinAmountOut *big.Float // Minimum amount out with slippage protection
|
||||
IsAcceptable bool // Whether slippage is within acceptable limits
|
||||
RiskLevel string // "Low", "Medium", "High", "Extreme"
|
||||
Recommendation string // Trading recommendation
|
||||
}
|
||||
|
||||
// NewSlippageProtector creates a new slippage protection manager
|
||||
func NewSlippageProtector(logger *logger.Logger) *SlippageProtector {
|
||||
return &SlippageProtector{
|
||||
logger: logger,
|
||||
maxSlippageBps: 500, // 5% maximum slippage
|
||||
liquidityBuffer: 0.8, // Use 80% of available liquidity for calculations
|
||||
}
|
||||
}
|
||||
|
||||
// AnalyzeSlippage performs comprehensive slippage analysis for a potential trade
|
||||
func (sp *SlippageProtector) AnalyzeSlippage(
|
||||
tradeAmount *big.Float,
|
||||
poolLiquidity *big.Float,
|
||||
currentPrice *big.Float,
|
||||
) *SlippageAnalysis {
|
||||
|
||||
if tradeAmount == nil || poolLiquidity == nil || currentPrice == nil {
|
||||
return &SlippageAnalysis{
|
||||
IsAcceptable: false,
|
||||
RiskLevel: "Extreme",
|
||||
Recommendation: "Insufficient data for slippage calculation",
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate trade size as percentage of pool liquidity
|
||||
tradeSizeRatio := new(big.Float).Quo(tradeAmount, poolLiquidity)
|
||||
tradeSizeFloat, _ := tradeSizeRatio.Float64()
|
||||
|
||||
// CRITICAL FIX: Use proper constant product AMM formula
|
||||
// Bug: Old formula "tradeSize / 2.0" is mathematically incorrect
|
||||
// Correct Uniswap V2 formula: dy = (y * dx * 997) / (x * 1000 + dx * 997)
|
||||
// Slippage = (marketPrice - executionPrice) / marketPrice
|
||||
estimatedSlippage := sp.calculateConstantProductSlippage(tradeAmount, poolLiquidity, currentPrice)
|
||||
|
||||
slippageBps := int64(estimatedSlippage * 10000)
|
||||
|
||||
// Calculate price impact (similar to slippage but different calculation)
|
||||
priceImpact := sp.calculatePriceImpact(tradeSizeFloat)
|
||||
|
||||
// Calculate effective price after slippage
|
||||
slippageFactor := 1.0 - estimatedSlippage
|
||||
effectivePrice := new(big.Float).Mul(currentPrice, big.NewFloat(slippageFactor))
|
||||
|
||||
// Calculate minimum amount out with slippage protection
|
||||
minAmountOut := new(big.Float).Quo(tradeAmount, effectivePrice)
|
||||
|
||||
// Determine risk level and acceptability
|
||||
riskLevel, isAcceptable := sp.assessRiskLevel(slippageBps, tradeSizeFloat)
|
||||
recommendation := sp.generateRecommendation(slippageBps, tradeSizeFloat, riskLevel)
|
||||
|
||||
analysis := &SlippageAnalysis{
|
||||
TradeAmount: tradeAmount,
|
||||
PoolLiquidity: poolLiquidity,
|
||||
EstimatedSlippage: estimatedSlippage,
|
||||
SlippageBps: slippageBps,
|
||||
PriceImpact: priceImpact,
|
||||
EffectivePrice: effectivePrice,
|
||||
MinAmountOut: minAmountOut,
|
||||
IsAcceptable: isAcceptable,
|
||||
RiskLevel: riskLevel,
|
||||
Recommendation: recommendation,
|
||||
}
|
||||
|
||||
sp.logger.Debug(fmt.Sprintf("Slippage analysis: Trade=%s, Liquidity=%s, Slippage=%.2f%%, Risk=%s",
|
||||
tradeAmount.String(), poolLiquidity.String(), estimatedSlippage*100, riskLevel))
|
||||
|
||||
return analysis
|
||||
}
|
||||
|
||||
// calculateConstantProductSlippage calculates slippage using correct Uniswap V2 constant product formula
|
||||
// Formula: dy = (y * dx * 997) / (x * 1000 + dx * 997)
|
||||
// Where: x = reserveIn, y = reserveOut, dx = amountIn, dy = amountOut
|
||||
// Slippage = (marketPrice - executionPrice) / marketPrice
|
||||
func (sp *SlippageProtector) calculateConstantProductSlippage(
|
||||
amountIn *big.Float,
|
||||
reserveIn *big.Float,
|
||||
marketPrice *big.Float,
|
||||
) float64 {
|
||||
// Handle edge cases
|
||||
if amountIn == nil || reserveIn == nil || marketPrice == nil {
|
||||
return 1.0 // 100% slippage for invalid inputs
|
||||
}
|
||||
if amountIn.Sign() <= 0 || reserveIn.Sign() <= 0 || marketPrice.Sign() <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Calculate: dx * 997 (Uniswap V2 fee is 0.3%, so 997/1000 remains)
|
||||
fee := big.NewFloat(997)
|
||||
dx997 := new(big.Float).Mul(amountIn, fee)
|
||||
|
||||
// Calculate: x * 1000
|
||||
x1000 := new(big.Float).Mul(reserveIn, big.NewFloat(1000))
|
||||
|
||||
// Calculate: x * 1000 + dx * 997
|
||||
denominator := new(big.Float).Add(x1000, dx997)
|
||||
|
||||
// Calculate execution price = dx / (x * 1000 + dx * 997)
|
||||
// This represents how much the price moved
|
||||
executionRatio := new(big.Float).Quo(dx997, denominator)
|
||||
|
||||
// For small trades, slippage ≈ executionRatio / 2
|
||||
// For larger trades, use more precise calculation
|
||||
tradeSizeRatio := new(big.Float).Quo(amountIn, reserveIn)
|
||||
tradeSizeFloat, _ := tradeSizeRatio.Float64()
|
||||
|
||||
var slippage float64
|
||||
if tradeSizeFloat < 0.01 { // < 1% of pool
|
||||
// Linear approximation for small trades
|
||||
ratioFloat, _ := executionRatio.Float64()
|
||||
slippage = ratioFloat / 2.0
|
||||
} else {
|
||||
// Full calculation for larger trades
|
||||
// Slippage = (marketPrice - executionPrice) / marketPrice
|
||||
// executionPrice = dy / dx = (y * 997) / (x * 1000 + dx * 997)
|
||||
// Simplified: slippage ≈ dx / (2 * (x + dx/2))
|
||||
|
||||
halfDx := new(big.Float).Quo(amountIn, big.NewFloat(2))
|
||||
reservePlusHalfDx := new(big.Float).Add(reserveIn, halfDx)
|
||||
twoTimesReservePlusHalfDx := new(big.Float).Mul(reservePlusHalfDx, big.NewFloat(2))
|
||||
slippageFloat := new(big.Float).Quo(amountIn, twoTimesReservePlusHalfDx)
|
||||
slippage, _ = slippageFloat.Float64()
|
||||
|
||||
// Apply fee adjustment (0.3% fee adds to effective slippage)
|
||||
slippage += 0.003
|
||||
}
|
||||
|
||||
// Cap at 100%
|
||||
if slippage > 1.0 {
|
||||
slippage = 1.0
|
||||
} else if slippage < 0 {
|
||||
slippage = 0
|
||||
}
|
||||
|
||||
return slippage
|
||||
}
|
||||
|
||||
// calculatePriceImpact calculates price impact using AMM mechanics
|
||||
func (sp *SlippageProtector) calculatePriceImpact(tradeSizeRatio float64) float64 {
|
||||
// For constant product AMMs (like Uniswap V2):
|
||||
// Price impact = trade_size / (1 + trade_size)
|
||||
priceImpact := tradeSizeRatio / (1.0 + tradeSizeRatio)
|
||||
|
||||
// Cap at 100%
|
||||
if priceImpact > 1.0 {
|
||||
priceImpact = 1.0
|
||||
}
|
||||
|
||||
return priceImpact
|
||||
}
|
||||
|
||||
// assessRiskLevel determines risk level based on slippage and trade size
|
||||
func (sp *SlippageProtector) assessRiskLevel(slippageBps int64, tradeSizeRatio float64) (string, bool) {
|
||||
isAcceptable := slippageBps <= sp.maxSlippageBps
|
||||
|
||||
var riskLevel string
|
||||
switch {
|
||||
case slippageBps <= 50: // <= 0.5%
|
||||
riskLevel = "Low"
|
||||
case slippageBps <= 200: // <= 2%
|
||||
riskLevel = "Medium"
|
||||
case slippageBps <= 500: // <= 5%
|
||||
riskLevel = "High"
|
||||
default:
|
||||
riskLevel = "Extreme"
|
||||
isAcceptable = false
|
||||
}
|
||||
|
||||
// Additional checks for trade size
|
||||
if tradeSizeRatio > 0.5 { // > 50% of pool
|
||||
riskLevel = "Extreme"
|
||||
isAcceptable = false
|
||||
} else if tradeSizeRatio > 0.2 { // > 20% of pool
|
||||
if riskLevel == "Low" {
|
||||
riskLevel = "Medium"
|
||||
} else if riskLevel == "Medium" {
|
||||
riskLevel = "High"
|
||||
}
|
||||
}
|
||||
|
||||
return riskLevel, isAcceptable
|
||||
}
|
||||
|
||||
// generateRecommendation provides trading recommendations based on analysis
|
||||
func (sp *SlippageProtector) generateRecommendation(slippageBps int64, tradeSizeRatio float64, riskLevel string) string {
|
||||
switch riskLevel {
|
||||
case "Low":
|
||||
return "Safe to execute - low slippage expected"
|
||||
case "Medium":
|
||||
if tradeSizeRatio > 0.1 {
|
||||
return "Consider splitting trade into smaller parts"
|
||||
}
|
||||
return "Proceed with caution - moderate slippage expected"
|
||||
case "High":
|
||||
return "High slippage risk - consider reducing trade size or finding alternative routes"
|
||||
case "Extreme":
|
||||
if tradeSizeRatio > 0.5 {
|
||||
return "Trade too large for pool - split into multiple smaller trades"
|
||||
}
|
||||
return "Excessive slippage - avoid this trade"
|
||||
default:
|
||||
return "Unable to assess - insufficient data"
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateOptimalTradeSize calculates optimal trade size to stay within slippage limits
|
||||
func (sp *SlippageProtector) CalculateOptimalTradeSize(
|
||||
poolLiquidity *big.Float,
|
||||
maxSlippageBps int64,
|
||||
) *big.Float {
|
||||
|
||||
if poolLiquidity == nil || poolLiquidity.Sign() <= 0 {
|
||||
return big.NewFloat(0)
|
||||
}
|
||||
|
||||
// Convert max slippage to ratio
|
||||
maxSlippageRatio := float64(maxSlippageBps) / 10000.0
|
||||
|
||||
// For simplified AMM: optimal_trade_size = 2 * liquidity * max_slippage
|
||||
optimalRatio := 2.0 * maxSlippageRatio
|
||||
|
||||
// Apply safety factor
|
||||
safetyFactor := 0.8 // Use 80% of optimal to be conservative
|
||||
optimalRatio *= safetyFactor
|
||||
|
||||
optimalSize := new(big.Float).Mul(poolLiquidity, big.NewFloat(optimalRatio))
|
||||
|
||||
sp.logger.Debug(fmt.Sprintf("Calculated optimal trade size: %s (%.2f%% of pool) for max slippage %d bps",
|
||||
optimalSize.String(), optimalRatio*100, maxSlippageBps))
|
||||
|
||||
return optimalSize
|
||||
}
|
||||
|
||||
// EstimateGasForSlippage estimates additional gas needed for slippage protection
|
||||
func (sp *SlippageProtector) EstimateGasForSlippage(analysis *SlippageAnalysis) uint64 {
|
||||
baseGas := uint64(0)
|
||||
|
||||
// Higher slippage might require more complex routing
|
||||
switch analysis.RiskLevel {
|
||||
case "Low":
|
||||
baseGas = 0 // No additional gas
|
||||
case "Medium":
|
||||
baseGas = 20000 // Additional gas for price checks
|
||||
case "High":
|
||||
baseGas = 50000 // Additional gas for complex routing
|
||||
case "Extreme":
|
||||
baseGas = 100000 // Maximum additional gas for emergency handling
|
||||
}
|
||||
|
||||
return baseGas
|
||||
}
|
||||
|
||||
// SetMaxSlippage updates the maximum allowed slippage
|
||||
func (sp *SlippageProtector) SetMaxSlippage(bps int64) {
|
||||
sp.maxSlippageBps = bps
|
||||
sp.logger.Info(fmt.Sprintf("Updated maximum slippage to %d bps (%.2f%%)", bps, float64(bps)/100))
|
||||
}
|
||||
|
||||
// GetMaxSlippage returns the current maximum slippage setting
|
||||
func (sp *SlippageProtector) GetMaxSlippage() int64 {
|
||||
return sp.maxSlippageBps
|
||||
}
|
||||
|
||||
// ValidateTradeParameters performs comprehensive validation of trade parameters
|
||||
func (sp *SlippageProtector) ValidateTradeParameters(
|
||||
tradeAmount *big.Float,
|
||||
poolLiquidity *big.Float,
|
||||
minLiquidity *big.Float,
|
||||
) error {
|
||||
|
||||
if tradeAmount == nil || tradeAmount.Sign() <= 0 {
|
||||
return fmt.Errorf("invalid trade amount: must be positive")
|
||||
}
|
||||
|
||||
if poolLiquidity == nil || poolLiquidity.Sign() <= 0 {
|
||||
return fmt.Errorf("invalid pool liquidity: must be positive")
|
||||
}
|
||||
|
||||
if minLiquidity != nil && poolLiquidity.Cmp(minLiquidity) < 0 {
|
||||
return fmt.Errorf("insufficient pool liquidity: %s < %s required",
|
||||
poolLiquidity.String(), minLiquidity.String())
|
||||
}
|
||||
|
||||
// Check if trade is reasonable relative to pool size
|
||||
tradeSizeRatio := new(big.Float).Quo(tradeAmount, poolLiquidity)
|
||||
tradeSizeFloat, _ := tradeSizeRatio.Float64()
|
||||
|
||||
if tradeSizeFloat > 0.9 { // > 90% of pool
|
||||
return fmt.Errorf("trade size too large: %.1f%% of pool liquidity", tradeSizeFloat*100)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CalculateSlippageAdjustedProfit adjusts profit calculations for slippage
|
||||
func (sp *SlippageProtector) CalculateSlippageAdjustedProfit(
|
||||
grossProfit *big.Float,
|
||||
analysis *SlippageAnalysis,
|
||||
) *big.Float {
|
||||
|
||||
if grossProfit == nil || analysis == nil {
|
||||
return big.NewFloat(0)
|
||||
}
|
||||
|
||||
// Reduce profit by estimated slippage impact
|
||||
slippageImpact := big.NewFloat(analysis.EstimatedSlippage)
|
||||
slippageReduction := new(big.Float).Mul(grossProfit, slippageImpact)
|
||||
adjustedProfit := new(big.Float).Sub(grossProfit, slippageReduction)
|
||||
|
||||
// Ensure profit doesn't go negative due to slippage
|
||||
if adjustedProfit.Sign() < 0 {
|
||||
adjustedProfit = big.NewFloat(0)
|
||||
}
|
||||
|
||||
sp.logger.Debug(fmt.Sprintf("Slippage-adjusted profit: %s -> %s (reduction: %s)",
|
||||
grossProfit.String(), adjustedProfit.String(), slippageReduction.String()))
|
||||
|
||||
return adjustedProfit
|
||||
}
|
||||
Reference in New Issue
Block a user