Completed clean root directory structure: - Root now contains only: .git, .env, docs/, orig/ - Moved all remaining files and directories to orig/: - Config files (.claude, .dockerignore, .drone.yml, etc.) - All .env variants (except active .env) - Git config (.gitconfig, .github, .gitignore, etc.) - Tool configs (.golangci.yml, .revive.toml, etc.) - Documentation (*.md files, @prompts) - Build files (Dockerfiles, Makefile, go.mod, go.sum) - Docker compose files - All source directories (scripts, tests, tools, etc.) - Runtime directories (logs, monitoring, reports) - Dependency files (node_modules, lib, cache) - Special files (--delete) - Removed empty runtime directories (bin/, data/) V2 structure is now clean: - docs/planning/ - V2 planning documents - orig/ - Complete V1 codebase preserved - .env - Active environment config (not in git) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
263 lines
7.9 KiB
Go
263 lines
7.9 KiB
Go
//go:build math_property
|
|
// +build math_property
|
|
|
|
package property
|
|
|
|
import (
|
|
"math"
|
|
"math/big"
|
|
"math/rand"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/fraktal/mev-beta/pkg/uniswap"
|
|
)
|
|
|
|
// Property-based testing for Uniswap V3 pricing functions
|
|
// These tests verify mathematical properties that should hold for all valid inputs
|
|
|
|
func init() {
|
|
rand.Seed(time.Now().UnixNano())
|
|
}
|
|
|
|
// generateRandomPrice generates a random price within realistic bounds
|
|
func generateRandomPrice() float64 {
|
|
// Generate prices between 0.000001 and 1000000 (6 orders of magnitude)
|
|
exponent := rand.Float64()*12 - 6 // -6 to 6
|
|
return math.Pow(10, exponent)
|
|
}
|
|
|
|
// generateRandomTick generates a random tick within valid bounds
|
|
func generateRandomTick() int {
|
|
// Uniswap V3 tick range is approximately -887272 to 887272
|
|
return rand.Intn(1774544) - 887272
|
|
}
|
|
|
|
// TestPriceConversionRoundTrip verifies that price->sqrt->price conversions are consistent
|
|
func TestPriceConversionRoundTrip(t *testing.T) {
|
|
const numTests = 1000
|
|
const tolerance = 0.001 // 0.1% tolerance for floating point precision
|
|
|
|
for i := 0; i < numTests; i++ {
|
|
originalPrice := generateRandomPrice()
|
|
if originalPrice <= 0 || originalPrice > 1e10 { // Skip extreme values
|
|
continue
|
|
}
|
|
|
|
// Convert price to sqrtPriceX96 and back
|
|
priceBigFloat := new(big.Float).SetFloat64(originalPrice)
|
|
sqrtPriceX96 := uniswap.PriceToSqrtPriceX96(priceBigFloat)
|
|
convertedPrice := uniswap.SqrtPriceX96ToPrice(sqrtPriceX96)
|
|
convertedPriceFloat, _ := convertedPrice.Float64()
|
|
|
|
// Verify round-trip consistency within tolerance
|
|
relativeError := abs(originalPrice-convertedPriceFloat) / originalPrice
|
|
assert.True(t, relativeError < tolerance,
|
|
"Round-trip conversion failed: original=%.6f, converted=%.6f, error=%.6f",
|
|
originalPrice, convertedPriceFloat, relativeError)
|
|
}
|
|
}
|
|
|
|
// TestTickConversionRoundTrip verifies that tick->sqrt->tick conversions are consistent
|
|
func TestTickConversionRoundTrip(t *testing.T) {
|
|
const numTests = 1000
|
|
|
|
for i := 0; i < numTests; i++ {
|
|
originalTick := generateRandomTick()
|
|
|
|
// Convert tick to sqrtPriceX96 and back
|
|
sqrtPriceX96 := uniswap.TickToSqrtPriceX96(originalTick)
|
|
convertedTick := uniswap.SqrtPriceX96ToTick(sqrtPriceX96)
|
|
|
|
// For tick conversions, we expect exact equality or at most 1 tick difference
|
|
// due to rounding in the logarithmic calculations
|
|
tickDifference := abs64(int64(originalTick) - int64(convertedTick))
|
|
assert.True(t, tickDifference <= 1,
|
|
"Tick round-trip failed: original=%d, converted=%d, difference=%d",
|
|
originalTick, convertedTick, tickDifference)
|
|
}
|
|
}
|
|
|
|
// TestPriceMonotonicity verifies that price increases monotonically with tick
|
|
func TestPriceMonotonicity(t *testing.T) {
|
|
const numTests = 100
|
|
const tickStep = 1000
|
|
|
|
for i := 0; i < numTests; i++ {
|
|
baseTick := generateRandomTick()
|
|
if baseTick > 800000 { // Ensure we don't overflow
|
|
baseTick = 800000
|
|
}
|
|
|
|
tick1 := baseTick
|
|
tick2 := baseTick + tickStep
|
|
|
|
sqrtPrice1 := uniswap.TickToSqrtPriceX96(tick1)
|
|
sqrtPrice2 := uniswap.TickToSqrtPriceX96(tick2)
|
|
|
|
price1 := uniswap.SqrtPriceX96ToPrice(sqrtPrice1)
|
|
price2 := uniswap.SqrtPriceX96ToPrice(sqrtPrice2)
|
|
|
|
price1Float, _ := price1.Float64()
|
|
price2Float, _ := price2.Float64()
|
|
|
|
// Higher tick should result in higher price
|
|
assert.True(t, price2Float > price1Float,
|
|
"Price monotonicity violated: tick1=%d, price1=%.6f, tick2=%d, price2=%.6f",
|
|
tick1, price1Float, tick2, price2Float)
|
|
}
|
|
}
|
|
|
|
// TestSqrtPriceX96Bounds verifies that sqrtPriceX96 values are within expected bounds
|
|
func TestSqrtPriceX96Bounds(t *testing.T) {
|
|
const numTests = 1000
|
|
|
|
// Define reasonable bounds for sqrtPriceX96
|
|
minBound := new(big.Int)
|
|
minBound.SetString("4295128739", 10) // Very small price
|
|
maxBound := new(big.Int)
|
|
maxBound.SetString("1461446703485210103287273052203988822378723970341", 10) // Very large price
|
|
|
|
for i := 0; i < numTests; i++ {
|
|
tick := generateRandomTick()
|
|
sqrtPriceX96 := uniswap.TickToSqrtPriceX96(tick)
|
|
|
|
// Verify bounds
|
|
assert.True(t, sqrtPriceX96.Cmp(minBound) >= 0,
|
|
"sqrtPriceX96 below minimum bound: tick=%d, sqrtPriceX96=%s",
|
|
tick, sqrtPriceX96.String())
|
|
|
|
assert.True(t, sqrtPriceX96.Cmp(maxBound) <= 0,
|
|
"sqrtPriceX96 above maximum bound: tick=%d, sqrtPriceX96=%s",
|
|
tick, sqrtPriceX96.String())
|
|
}
|
|
}
|
|
|
|
// TestPriceSymmetry verifies that inverse prices work correctly
|
|
func TestPriceSymmetry(t *testing.T) {
|
|
const numTests = 100
|
|
const tolerance = 0.001
|
|
|
|
for i := 0; i < numTests; i++ {
|
|
originalPrice := generateRandomPrice()
|
|
if originalPrice <= 0 || originalPrice > 1e6 {
|
|
continue
|
|
}
|
|
|
|
// Calculate inverse price
|
|
inversePrice := 1.0 / originalPrice
|
|
|
|
// Convert both to sqrtPriceX96
|
|
priceBigFloat := new(big.Float).SetFloat64(originalPrice)
|
|
inverseBigFloat := new(big.Float).SetFloat64(inversePrice)
|
|
|
|
sqrtPrice := uniswap.PriceToSqrtPriceX96(priceBigFloat)
|
|
sqrtInverse := uniswap.PriceToSqrtPriceX96(inverseBigFloat)
|
|
|
|
// Convert back to prices
|
|
convertedPrice := uniswap.SqrtPriceX96ToPrice(sqrtPrice)
|
|
convertedInverse := uniswap.SqrtPriceX96ToPrice(sqrtInverse)
|
|
|
|
convertedPriceFloat, _ := convertedPrice.Float64()
|
|
convertedInverseFloat, _ := convertedInverse.Float64()
|
|
|
|
// Verify that price * inverse ≈ 1
|
|
product := convertedPriceFloat * convertedInverseFloat
|
|
assert.InDelta(t, 1.0, product, tolerance,
|
|
"Price symmetry failed: price=%.6f, inverse=%.6f, product=%.6f",
|
|
convertedPriceFloat, convertedInverseFloat, product)
|
|
}
|
|
}
|
|
|
|
// TestEdgeCases tests edge cases and boundary conditions
|
|
func TestEdgeCases(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
tick int
|
|
shouldPass bool
|
|
description string
|
|
}{
|
|
{"MinTick", -887272, true, "Minimum valid tick"},
|
|
{"MaxTick", 887272, true, "Maximum valid tick"},
|
|
{"ZeroTick", 0, true, "Zero tick (price = 1)"},
|
|
{"NegativeTick", -100000, true, "Negative tick"},
|
|
{"PositiveTick", 100000, true, "Positive tick"},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.shouldPass {
|
|
// Test tick to sqrtPrice conversion
|
|
sqrtPriceX96 := uniswap.TickToSqrtPriceX96(tc.tick)
|
|
require.NotNil(t, sqrtPriceX96, "sqrtPriceX96 should not be nil for %s", tc.description)
|
|
|
|
// Test sqrtPrice to price conversion
|
|
price := uniswap.SqrtPriceX96ToPrice(sqrtPriceX96)
|
|
require.NotNil(t, price, "price should not be nil for %s", tc.description)
|
|
|
|
// Test round-trip: tick -> sqrt -> tick
|
|
convertedTick := uniswap.SqrtPriceX96ToTick(sqrtPriceX96)
|
|
tickDiff := abs64(int64(tc.tick) - int64(convertedTick))
|
|
assert.True(t, tickDiff <= 1, "Round-trip tick conversion failed for %s: original=%d, converted=%d",
|
|
tc.description, tc.tick, convertedTick)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPricePrecision verifies precision of price calculations
|
|
func TestPricePrecision(t *testing.T) {
|
|
knownCases := []struct {
|
|
name string
|
|
sqrtPriceX96 string
|
|
expectedPrice float64
|
|
tolerance float64
|
|
}{
|
|
{
|
|
name: "Price_1_ETH_USDC",
|
|
sqrtPriceX96: "79228162514264337593543950336", // 2^96, price = 1
|
|
expectedPrice: 1.0,
|
|
tolerance: 0.0001,
|
|
},
|
|
{
|
|
name: "Price_4_ETH_USDC",
|
|
sqrtPriceX96: "158456325028528675187087900672", // 2 * 2^96, price = 4
|
|
expectedPrice: 4.0,
|
|
tolerance: 0.01,
|
|
},
|
|
}
|
|
|
|
for _, tc := range knownCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
sqrtPriceX96 := new(big.Int)
|
|
_, ok := sqrtPriceX96.SetString(tc.sqrtPriceX96, 10)
|
|
require.True(t, ok, "Failed to parse sqrtPriceX96")
|
|
|
|
price := uniswap.SqrtPriceX96ToPrice(sqrtPriceX96)
|
|
priceFloat, accuracy := price.Float64()
|
|
require.Equal(t, big.Exact, accuracy, "Price conversion should be exact")
|
|
|
|
assert.InDelta(t, tc.expectedPrice, priceFloat, tc.tolerance,
|
|
"Price precision test failed for %s", tc.name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
func abs(x float64) float64 {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|
|
|
|
func abs64(x int64) int64 {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|