diff --git a/src/main/java/com/fanitas/evelyn/math/BigDecimalUtils.java b/src/main/java/com/fanitas/evelyn/math/BigDecimalUtils.java new file mode 100644 index 0000000..3de98b3 --- /dev/null +++ b/src/main/java/com/fanitas/evelyn/math/BigDecimalUtils.java @@ -0,0 +1,61 @@ +package com.fanitas.evelyn.math; + +import java.math.BigDecimal; +import java.math.MathContext; + +import static java.math.BigDecimal.ZERO; + +public class BigDecimalUtils { + public static BigDecimal min(BigDecimal a, BigDecimal b) { + return a.compareTo(b) <= 0 ? a : b; + } + + public static BigDecimal max(BigDecimal a, BigDecimal b) { + return a.compareTo(b) >= 0 ? a : b; + } + + public static BigDecimal sqrt(BigDecimal value, MathContext mc) { + if (value.compareTo(ZERO) < 0) { + throw new IllegalArgumentException("sqrt af negativ værdi"); + } + if (value.compareTo(ZERO) == 0) { + return ZERO; + } + + BigDecimal x = new BigDecimal(Math.sqrt(value.doubleValue()), mc); + + for (int i = 0; i < 20; i++) { + x = x.add(value.divide(x, mc), mc).divide(TWO, mc); + } + + return x; + } + + public static double erf(BigDecimal x) { + double value = x.doubleValue(); + double sign = value < 0 ? -1.0 : 1.0; + value = Math.abs(value); + + double t = 1.0 / (1.0 + 0.5 * value); + + double tau = t * Math.exp( + -value * value + - 1.26551223 + + t * (1.00002368 + + t * (0.37409196 + + t * (0.09678418 + + t * (-0.18628806 + + t * (0.27886807 + + t * (-1.13520398 + + t * (1.48851587 + + t * (-0.82215223 + + t * 0.17087277)))))))) + ); + + double result = sign * (1.0 - tau); + return result; + } + + public static final BigDecimal TWO = new BigDecimal("2"); + public static final BigDecimal THREE = new BigDecimal("3"); +} diff --git a/src/main/tjava/com/fanitas/evelyn/core/DesiredPositionCalculator.tjava b/src/main/tjava/com/fanitas/evelyn/core/DesiredPositionCalculator.tjava new file mode 100644 index 0000000..a586ec8 --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/core/DesiredPositionCalculator.tjava @@ -0,0 +1,27 @@ +package com.fanitas.evelyn.core; + +import com.r35157.libs.valuetypes.basic.MoneyAmount; + +import java.math.BigDecimal; +import java.util.HashMap; + +public interface DesiredPositionCalculator { + + HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, MoneyAmount> calculateRebalancingProposal( + ΩPriceΩ currentPrice, + MoneyAmount totalReadyAmountMintA, + MoneyAmount totalReadyAmountMintB + ); + + Pair calculateTotalDistributedSums(); + + Pair calculateLockedSums(ΩPriceΩ currentPrice); + + Pair calculateRedistributableSums( + ΩPriceΩ currentPrice, + MoneyAmount inactiveInAccountMintA, + MoneyAmount inactiveInAccountMintB, + MoneyAmount reservedForBurnMintA, + MoneyAmount reservedForBurnMintB + ); +} diff --git a/src/main/tjava/com/fanitas/evelyn/core/Evelyn.tjava b/src/main/tjava/com/fanitas/evelyn/core/Evelyn.tjava new file mode 100644 index 0000000..22dfbdd --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/core/Evelyn.tjava @@ -0,0 +1,5 @@ +package com.fanitas.evelyn.core; + +public interface Evelyn { + void executeService() throws Exception; +} diff --git a/src/main/tjava/com/fanitas/evelyn/core/Pair.tjava b/src/main/tjava/com/fanitas/evelyn/core/Pair.tjava new file mode 100644 index 0000000..ab6db44 --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/core/Pair.tjava @@ -0,0 +1,9 @@ +package com.fanitas.evelyn.core; + +import java.math.BigDecimal; + +public record Pair( + ΩAmountΩ amountA, + ΩAmountΩ amountB +) { +} \ No newline at end of file diff --git a/src/main/tjava/com/fanitas/evelyn/core/State.tjava b/src/main/tjava/com/fanitas/evelyn/core/State.tjava new file mode 100644 index 0000000..454c053 --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/core/State.tjava @@ -0,0 +1,19 @@ +package com.fanitas.evelyn.core; + +import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated; +import com.r35157.libs.valuetypes.basic.MoneyAmount; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Map; + +public interface State { + void update() throws IOException, InterruptedException; + ΩmilliSecondsΩ getIterationInterval(); + ΩSolanaAmountΩ getSyrupOwnedByEvelyn(); + ΩSolanaAddressΩ getSolanaAddressForEvelynIOU(); + ΩSolanaAddressΩ getSolanaAddressForEvelynIOUBurner(); + ΩRaydiumLiquidityPoolIdΩ getRaydiumPoolId(); + ΩCurveWidthΩ getCurveWidth(); + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> getLiquidityPositions(); +} diff --git a/src/main/tjava/com/fanitas/evelyn/core/Triblet.tjava b/src/main/tjava/com/fanitas/evelyn/core/Triblet.tjava new file mode 100644 index 0000000..af75bfb --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/core/Triblet.tjava @@ -0,0 +1,10 @@ +package com.fanitas.evelyn.core; + +import java.math.BigDecimal; + +public record Triblet( + ΩAmountΩ currentAmount, + ΩAmountΩ suggestedAmount, + ΩAmountΩ diff +) { +} \ No newline at end of file diff --git a/src/main/tjava/com/fanitas/evelyn/core/impl/ref/DesiredPositionCalculatorImpl.tjava b/src/main/tjava/com/fanitas/evelyn/core/impl/ref/DesiredPositionCalculatorImpl.tjava new file mode 100644 index 0000000..5f30ecc --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/core/impl/ref/DesiredPositionCalculatorImpl.tjava @@ -0,0 +1,336 @@ +package com.fanitas.evelyn.core.impl.ref; + +import com.fanitas.evelyn.core.Pair; +import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated; +import com.fanitas.evelyn.core.DesiredPositionCalculator; + +import com.r35157.libs.valuetypes.basic.CurrencyType; +import com.r35157.libs.valuetypes.basic.MoneyAmount; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.util.*; + +import static com.fanitas.evelyn.math.BigDecimalUtils.*; +import static java.math.BigDecimal.ONE; +import static java.math.BigDecimal.ZERO; + +public class DesiredPositionCalculatorImpl implements DesiredPositionCalculator { + + public DesiredPositionCalculatorImpl( + ΩCurveWidthΩ curveWidth, + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositions + ) { + this.curveWidth = curveWidth; + this.liquidityProviderPositions = liquidityProviderPositions; + } + + @Override + public HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, MoneyAmount> calculateRebalancingProposal( + ΩPriceΩ currentPrice, + MoneyAmount totalReadyAmountMintA, + MoneyAmount totalReadyAmountMintB + ) { + List<ΩPriceΩ> intervalStarts = getSortedPositionIntervalFromValues(liquidityProviderPositions); + List<ΩPriceΩ> intervalEnds = getSortedPositionIntervalToValues(liquidityProviderPositions); + + int indexOfPositionWithAnEndPriceBeforePositionWithCurrentPrice = lookupIndexOfLastLessThanOrEqual( + intervalStarts, + currentPrice + ); + + ΩPriceΩ lastLessThanOrEqual = intervalEnds.get(indexOfPositionWithAnEndPriceBeforePositionWithCurrentPrice); + HashMap<ΩRaydiumLiquidityPoolPositionIdΩ, MoneyAmount> desiredPositions = new HashMap<>(); + + boolean beforeNonZeroPos = true; + + for (int i = 0; i < intervalStarts.size(); i++) { + ΩPriceΩ iStart = intervalStarts.get(i); + ΩPriceΩ iEnd = intervalEnds.get(i); + + ΩAmountΩ dp = calculateDesiredPositionSizeForInterval( + currentPrice, + totalReadyAmountMintB.amount(), + iStart, + iEnd, + lastLessThanOrEqual + ); + + int desiredPositionSign = dp.compareTo(ZERO); + + if (desiredPositionSign > 0) { + beforeNonZeroPos = false; + } else if (!beforeNonZeroPos && desiredPositionSign == 0) { + break; + } + + MoneyAmount desiredPositionSize = new MoneyAmount( + dp, + totalReadyAmountMintB.currencyType() + ); + + RaydiumLiquidityPoolPositionConcentrated position = getPositionByStartPriceA(iStart); + ΩRaydiumLiquidityPoolPositionNftIdΩ poolPositionId = position.nftId(); + desiredPositions.put(poolPositionId, desiredPositionSize); + } + + return desiredPositions; + } + + @Override + public Pair calculateTotalDistributedSums() { + Collection c = liquidityProviderPositions.values(); + + if(c.isEmpty()) { + // TODO: I do not like this - I prefer 0 - but without any positions, do I then know the currencyType? + throw new IllegalStateException("No positions in pool!"); + } + + // TODO: This smells - setting to null. Oh dear! + CurrencyType ct = null; + ΩAmountΩ amountB = ZERO; + for (RaydiumLiquidityPoolPositionConcentrated position : liquidityProviderPositions.values()) { + ct = position.amountMintB().currencyType(); // TODO: Too redundant - please rethink + amountB = amountB.add(position.accountingInfo().addedMintB()); + } + + MoneyAmount ma = new MoneyAmount(amountB, ct); + return new Pair(ZERO, ma); + } + + @Override + public Pair calculateLockedSums(ΩPriceΩ currentPrice) { + List<ΩPriceΩ> ascendingPrices = getSortedPositionIntervalFromValues(liquidityProviderPositions); + ΩAmountΩ sum = ZERO; + + // TODO: This smells - setting to null. Oh dear! + CurrencyType ct = null; + + int index = lookupIndexOfFirstGreaterThanOrEqual(ascendingPrices, currentPrice); + if(index >= 0) { + ΩPriceΩ startPriceOfStartPos = ascendingPrices.get(index); + + for (RaydiumLiquidityPoolPositionConcentrated position : liquidityProviderPositions.values()) { + if (position.priceRange().from().compareTo(startPriceOfStartPos) >= 0) { + ct = position.amountMintA().currencyType(); + sum = sum.add(position.amountMintB().amount()); + } + } + } + + MoneyAmount ma = new MoneyAmount(sum, ct); + return new Pair(ZERO, ma); + } + + @Override + public Pair calculateRedistributableSums( + ΩPriceΩ currentPrice, + MoneyAmount inactiveInAccountMintA, + MoneyAmount inactiveInAccountMintB, + MoneyAmount reservedForBurnMintA, + MoneyAmount reservedForBurnMintB + ) { + List<ΩPriceΩ> ascendingValues = getSortedPositionIntervalFromValues(liquidityProviderPositions); + ΩAmountΩ redistSum = ZERO; + CurrencyType ct = null; + + int index = lookupIndexOfLastLessThan(ascendingValues, currentPrice); + if(index >= 0) { + ΩPriceΩ startPriceOfStartPos = ascendingValues.get(index); + + for (RaydiumLiquidityPoolPositionConcentrated position : liquidityProviderPositions.values()) { + if (position.priceRange().from().compareTo(startPriceOfStartPos) <= 0) { + ct = position.amountMintB().currencyType(); + redistSum = redistSum.add(position.amountMintB().amount()); + } + } + } + + redistSum = add(inactiveInAccountMintB, redistSum); + redistSum = subtract(redistSum, reservedForBurnMintB); + MoneyAmount ma = new MoneyAmount(redistSum, ct); + return new Pair(ZERO, ma); + } + + + // TODO: Can this be deleted? + private MoneyAmount add(ΩAmountΩ a, ΩAmountΩ b, CurrencyType ct) { + MoneyAmount ma = new MoneyAmount(a.add(b), ct); + return ma; + } + + private MoneyAmount add(MoneyAmount a, MoneyAmount b) { + MoneyAmount ma = new MoneyAmount(a.amount().add(b.amount()), a.currencyType()); + return ma; + } + + private ΩAmountΩ add(MoneyAmount a, ΩAmountΩ b) { + return a.amount().add(b); + } + + private ΩAmountΩ subtract(ΩAmountΩ a, MoneyAmount b) { + return a.subtract(b.amount()); + } + + private RaydiumLiquidityPoolPositionConcentrated getPositionByStartPriceA(ΩPriceΩ startPriceA) { + for (RaydiumLiquidityPoolPositionConcentrated candidate : liquidityProviderPositions.values()) { + if (candidate.priceRange().from().compareTo(startPriceA) == 0) { + return candidate; + } + } + + return null; + } + + private ΩAmountΩ calculateDesiredPositionSizeForInterval( + ΩPriceΩ currentPrice, + ΩAmountΩ totalSyrupToDistribution, + ΩPriceΩ intervalPriceFrom, + ΩPriceΩ intervalPriceTo, + ΩPriceΩ lookupPrice + ) { + ΩPriceΩ curveStartPrice = currentPrice.multiply( + ONE.subtract(curveWidth, MC), + MC + ); + + boolean intervalIsCompletelyBeforeCurve = + intervalPriceTo.compareTo(curveStartPrice) <= 0; + + boolean intervalIsCompletelyAfterLookupLimit = + intervalPriceFrom.compareTo(lookupPrice) >= 0; + + if (intervalIsCompletelyBeforeCurve || intervalIsCompletelyAfterLookupLimit) { + return ZERO; + } + + ΩPriceΩ effectiveIntervalFrom = max(intervalPriceFrom, curveStartPrice); + ΩPriceΩ effectiveIntervalTo = min(intervalPriceTo, lookupPrice); + + BigDecimal sigma = currentPrice.multiply(curveWidth, MC) + .divide(THREE, MC); + + BigDecimal sqrtTwo = sqrt(TWO, MC); + BigDecimal denominator = sigma.multiply(sqrtTwo, MC); + + ΩPriceΩ normalizedEffectiveTo = effectiveIntervalTo.subtract(currentPrice, MC) + .divide(denominator, MC); + + ΩPriceΩ normalizedEffectiveFrom = effectiveIntervalFrom.subtract(currentPrice, MC) + .divide(denominator, MC); + + ΩPriceΩ normalizedLookupPrice = lookupPrice.subtract(currentPrice, MC) + .divide(denominator, MC); + + ΩPriceΩ normalizedCurveStart = curveStartPrice.subtract(currentPrice, MC) + .divide(denominator, MC); + + BigDecimal intervalWeight = new BigDecimal(erf(normalizedEffectiveTo) - erf(normalizedEffectiveFrom)); + BigDecimal totalWeightInActiveCurve = new BigDecimal(erf(normalizedLookupPrice) - erf(normalizedCurveStart)); + + ΩAmountΩ dps = totalSyrupToDistribution.multiply(intervalWeight, MC) + .divide(totalWeightInActiveCurve, MC); + + return dps; + } + + private static int lookupIndexOfFirstGreaterThan(List<ΩPriceΩ> ascendingPrices, ΩPriceΩ searchPrice) { + int index = -1; + + for (int i = ascendingPrices.size() - 1; i >= 0; i--) { + BigDecimal p = ascendingPrices.get(i); + if (p.compareTo(searchPrice) <= 0) { + break; + } else { + index = i; + } + } + + if (index == -1) { + throw new IllegalArgumentException( + "No position has a greater start price than a position containing '" + searchPrice + "'" + ); + } + + return index; + } + + private static int lookupIndexOfFirstGreaterThanOrEqual(List<ΩPriceΩ> ascendingPrices, ΩPriceΩ searchPrice) { + int index = -1; + + for (int i = ascendingPrices.size() - 1; i >= 0; i--) { + BigDecimal p = ascendingPrices.get(i); + if (p.compareTo(searchPrice) <= 0) { + break; + } else { + index = i; + } + } + + if (index == -1) { + throw new IllegalArgumentException( + "No position has a greater start price than a position containing '" + searchPrice + "'" + ); + } + + return index; + } + + private static int lookupIndexOfLastLessThanOrEqual(List<ΩPriceΩ> ascendingPrices, ΩPriceΩ searchPrice) { + int index = -1; + + for (int i = 0; i < ascendingPrices.size() - 1; i++) { + ΩPriceΩ p = ascendingPrices.get(i); + if (p.compareTo(searchPrice) < 0) { + index = i - 1; + } else { + break; + } + } + + return index; + } + + private static int lookupIndexOfLastLessThan(List<ΩPriceΩ> ascendingPrices, ΩPriceΩ searchPrice) { + int index = -1; + + for (int i = 0; i < ascendingPrices.size() - 1; i++) { + ΩPriceΩ p = ascendingPrices.get(i); + if (p.compareTo(searchPrice) < 0) { + index = i - 1; + } else { + break; + } + } + + return index; + } + + private static List<ΩPriceΩ> getSortedPositionIntervalFromValues( + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityPositions + ) { + List<ΩPriceΩ> result = new ArrayList<>(liquidityPositions.size()); + for (RaydiumLiquidityPoolPositionConcentrated position : liquidityPositions.values()) { + result.add(position.priceRange().from()); + } + result.sort(ΩPriceΩ::compareTo); + return result; + } + + private static List<ΩPriceΩ> getSortedPositionIntervalToValues( + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityPositions + ) { + List<ΩPriceΩ> result = new ArrayList<>(liquidityPositions.size()); + for (RaydiumLiquidityPoolPositionConcentrated position : liquidityPositions.values()) { + result.add(position.priceRange().to()); + } + result.sort(ΩPriceΩ::compareTo); + return result; + } + + private static final MathContext MC = new MathContext(20, RoundingMode.HALF_UP); + + private final ΩCurveWidthΩ curveWidth; + private final Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositions; +} diff --git a/src/main/tjava/com/fanitas/evelyn/core/impl/ref/EvelynImpl.tjava b/src/main/tjava/com/fanitas/evelyn/core/impl/ref/EvelynImpl.tjava new file mode 100644 index 0000000..8fca835 --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/core/impl/ref/EvelynImpl.tjava @@ -0,0 +1,226 @@ +package com.fanitas.evelyn.core.impl.ref; + +import com.fanitas.evelyn.core.*; +import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated; + +import com.r35157.libs.raydium.Raydium; +import com.r35157.libs.raydium.RaydiumLiquidityPoolPrice; +import com.r35157.libs.solana.SPLTokenHolding; +import com.r35157.libs.solana.SolanaBlockChain; +import com.r35157.libs.solana.SolanaConstants; +import com.r35157.libs.valuetypes.basic.MoneyAmount; + +import java.math.BigDecimal; +import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM; +import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.TOKEN_2022_PROGRAM; + +public class EvelynImpl implements Evelyn { + + public EvelynImpl( + State state, + DesiredPositionCalculator desiredPositionCalculator, + Raydium raydium, + SolanaBlockChain solanaChain + ) { + this.state = state; + this.desiredPositionCalculator = desiredPositionCalculator; + this.raydium = raydium; + this.solanaChain = solanaChain; + } + + @Override + public void executeService() throws Exception { + handleRebalancingProposal(); + } + + private SPLTokenHolding getSPLHolding(ΩSolanaAddressΩ ownerAddress, ΩSPLMintAddressΩ splMintAddress) throws Exception { + Map<ΩSPLMintAddressΩ, SPLTokenHolding> holdings = new HashMap<>(); + + holdings.putAll(solanaChain.getSPLTokenHoldings(ownerAddress, SPL_TOKEN_PROGRAM)); + holdings.putAll(solanaChain.getSPLTokenHoldings(ownerAddress, TOKEN_2022_PROGRAM)); + + SPLTokenHolding result = holdings.get(splMintAddress); + return result; + } + + private MoneyAmount add(MoneyAmount a, ΩAmountΩ b) { + MoneyAmount ma = new MoneyAmount(a.amount().add(b), a.currencyType()); + return ma; + } + + private MoneyAmount subtract(MoneyAmount a, MoneyAmount b) { + if(a.currencyType() != b.currencyType()) { + String errTxt = "Cannot subtract " + b.currencyType().name() + + " from " + a.currencyType().name(); + throw new IllegalArgumentException(errTxt); + } + + MoneyAmount ma = new MoneyAmount(a.amount().subtract(b.amount()), a.currencyType()); + return ma; + } + + private MoneyAmount subtract(MoneyAmount a, ΩAmountΩ b) { + MoneyAmount ma = new MoneyAmount(a.amount().subtract(b), a.currencyType()); + return ma; + } + + private ΩAmountΩ subtract(ΩAmountΩ a, ΩAmountΩ b) { + return a.subtract(b); + } + + private ΩAmountΩ subtract(ΩAmountΩ a, MoneyAmount b) { + return a.subtract(b.amount()); + } + + private void handleRebalancingProposal() { + HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, MoneyAmount> rebalancingProposal; + + try { + ΩSolanaAddressΩ solanaAddressForEvelynIOU = state.getSolanaAddressForEvelynIOU(); + ΩSPLMintAddressΩ syrupUSDCMintAddr = SolanaConstants.SPL_TOKEN_SYRUPUSDC; + + SPLTokenHolding syrupHolding = getSPLHolding(solanaAddressForEvelynIOU, syrupUSDCMintAddr); + ΩAmountΩ inactiveInAccountSyrup = syrupHolding.uiAmount(); + ΩPriceΩ currentPriceFromRaydium; + MoneyAmount currentPriceFromChain; + + while(true) { + state.update(); + ΩmilliSecondsΩ iterationStartTime = System.currentTimeMillis(); + + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + RaydiumLiquidityPoolPrice concentratedResult; + Future cFuture = executor.submit( + () -> new RaydiumLiquidityPoolPrice( + state.getRaydiumPoolId(), + raydium.fetchPoolPrice(state.getRaydiumPoolId()) + ) + ); + + concentratedResult = cFuture.get(); + currentPriceFromRaydium = concentratedResult.amount().amount(); + } + currentPriceFromChain = raydium.fetchPoolPrice(state.getRaydiumPoolId()); + + System.out.println("Iteration interval: " + (state.getIterationInterval() / 1000) + " secs"); + + System.out.println("Solana balances:"); + ΩSolanaAmountΩ solBalanceEvelyn = solanaChain.getBalanceInSolana(state.getSolanaAddressForEvelynIOU()); + System.out.println(" Evelyn: " + solBalanceEvelyn.amount()); + ΩSolanaAmountΩ solBalanceBurn = solanaChain.getBalanceInSolana(state.getSolanaAddressForEvelynIOUBurner()); + System.out.println(" Burn: " + solBalanceBurn.amount()); + System.out.println("SYRUP owned by Evelyn: " + state.getSyrupOwnedByEvelyn().amount()); + System.out.println("SYRUP inactive on Evelyn account: " + inactiveInAccountSyrup); + + System.out.println("Pool price: " + currentPriceFromRaydium + "/" + currentPriceFromChain.amount()); + + Pair totalDistributedSums = desiredPositionCalculator.calculateTotalDistributedSums(); + ΩSolanaAmountΩ totalDistributedSumSolana = totalDistributedSums.amountA(); + ΩSyrupAmountΩ totalDistributedSumSyrup = totalDistributedSums.amountB(); + System.out.println("Total amount currently distributed: " + totalDistributedSumSolana + + " / " + totalDistributedSumSyrup); + totalDistributedSum + Pair amountsLocked = desiredPositionCalculator.calculateLockedSums(currentPriceFromChain.amount()); + ΩSolanaAmountΩ amountLockedSolana = amountsLocked.amountA(); + ΩSyrupAmountΩ amountLockedSyrup = amountsLocked.amountB(); + System.out.println("Total amount locked due to HIGH price: " + amountLockedSolana); + System.out.println("Total amount locked due to LOW price: " + amountLockedSyrup); + + ΩSyrupAmountΩ totalAmountSyrup = add(totalDistributedSumSyrup, inactiveInAccountSyrup); + ΩSyrupAmountΩ reservedForBurnSyrup = subtract(totalAmountSyrup, state.getSyrupOwnedByEvelyn()); + ΩSolanaAmountΩ readyForBurnSolana = subtract(solBalanceEvelyn, SOFT_LOW_LIMIT_SOLANA_BALANCE); + System.out.println("Amount reserved for burn: Syrup:" + reservedForBurnSyrup.amount() + + " + Solana: " + readyForBurnSolana.amount()); + + Pair/*ΩSyrupAmountΩ*/ syrupTotalReadyAmount = desiredPositionCalculator.calculateRedistributableSums( + currentPriceFromChain.amount(), + new ΩSyrupAmountΩ(inactiveInAccountSyrup, readyForBurnSolana.currencyType()), // TODO: Wow! This is not pretty! Stealing cyrrenctType from another object. Oh dear! + reservedForBurnSyrup + ); + System.out.println("Total amount of Syrup ready for distribution: " + syrupTotalReadyAmount.amount()); + + rebalancingProposal = desiredPositionCalculator.calculateRebalancingProposal( + currentPriceFromChain.amount(), + syrupTotalReadyAmount + ); + + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityPositions = state.getLiquidityPositions(); + + Map diffs = calculateDiffs( + rebalancingProposal, + liquidityPositions + ); + + String minKey = diffs.entrySet().stream() + .min(Comparator.comparing(entry -> entry.getValue().diff())) + .map(Map.Entry::getKey) + .orElseThrow(); + + String maxKey = diffs.entrySet().stream() + .max(Comparator.comparing(entry -> entry.getValue().diff())) + .map(Map.Entry::getKey) + .orElseThrow(); + + System.out.println("Move value from (" + maxKey + "): "); + + RaydiumLiquidityPoolPositionConcentrated maxPos = liquidityPositions.get(maxKey); + Triblet maxDiff = diffs.get(maxKey); + System.out.println(" " + maxPos.priceRange().from() + ".." + + maxPos.priceRange().to() + " - Current: " + + maxDiff.currentAmount() + ", Suggested: " + maxDiff.suggestedAmount() + + ", Diff: " + maxDiff.diff()); + + System.out.println(" --> "); + + RaydiumLiquidityPoolPositionConcentrated minPos = liquidityPositions.get(minKey); + Triblet minDiff = diffs.get(minKey); + System.out.println(" " + minPos.priceRange().from() + ".." + + minPos.priceRange().to() + " - Current: " + + minDiff.currentAmount() + ", Suggested: " + minDiff.suggestedAmount() + + ", Diff: " + minDiff.diff()); + + ΩmilliSecondsΩ iterationExecutionTime = (System.currentTimeMillis() - iterationStartTime); + + for(ΩmilliSecondsΩ sleepTime = state.getIterationInterval() - iterationExecutionTime; sleepTime > 0; sleepTime -= 1000) { + System.out.print("\rSleeping for " + (sleepTime / 1000) + " secs... "); + System.out.flush(); + Thread.sleep(1000L); + } + System.out.println(); + System.out.println("-------------------------------------------------------------"); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + private HashMap calculateDiffs( + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, ΩSyrupAmountΩ> rebalancingProposal, + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositionMap + ) { + HashMap diffs = new HashMap<>(rebalancingProposal.size()); + + for(String nftId : rebalancingProposal.keySet()) { + RaydiumLiquidityPoolPositionConcentrated pos = liquidityProviderPositionMap.get(nftId); + ΩAmountΩ currentlyAmountAdded = pos.amountMintB().amount(); + ΩSyrupAmountΩ suggestedAmount = rebalancingProposal.get(nftId); + ΩAmountΩ diff = subtract(currentlyAmountAdded, suggestedAmount); + Triblet t = new Triblet(currentlyAmountAdded, suggestedAmount.amount(), diff); + diffs.put(nftId, t); + } + + return diffs; + } + + private final ΩAmountΩ SOFT_LOW_LIMIT_SOLANA_BALANCE = new BigDecimal("0.1"); + + private final State state; + private final DesiredPositionCalculator desiredPositionCalculator; + private final Raydium raydium; + private final SolanaBlockChain solanaChain; +} diff --git a/src/main/tjava/com/fanitas/evelyn/core/impl/ref/Main.tjava b/src/main/tjava/com/fanitas/evelyn/core/impl/ref/Main.tjava new file mode 100644 index 0000000..ea7e42b --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/core/impl/ref/Main.tjava @@ -0,0 +1,51 @@ +package com.fanitas.evelyn.core.impl.ref; + +import com.fanitas.evelyn.core.Evelyn; +import com.fanitas.evelyn.core.DesiredPositionCalculator; +import com.fanitas.evelyn.core.State; +import com.r35157.libs.raydium.*; +import com.r35157.libs.raydium.impl.ref.RaydiumImpl; +import com.r35157.libs.solana.SPLTokenHolding; +import com.r35157.libs.solana.SPLTokenSupply; +import com.r35157.libs.solana.SolanaAccountInfo; +import com.r35157.libs.solana.SolanaBlockChain; +import com.r35157.libs.solana.impl.ref.SolanaBlockChainImpl; +import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress; +import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram; +import com.r35157.libs.valuetypes.basic.Range; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; + +public class Main { + + static void main() throws Exception { + log.info("Initializing Evelyn..."); + SolanaBlockChain solanaChain = new SolanaBlockChainImpl(); + Raydium raydium = new RaydiumImpl(solanaChain); + System.out.println(" Done."); + + State state = new StateImpl(raydium); + DesiredPositionCalculator desiredPositionCalculator = new DesiredPositionCalculatorImpl( + state.getCurveWidth(), + state.getLiquidityPositions() + ); + + Evelyn evelyn = new EvelynImpl( + state, + desiredPositionCalculator, + raydium, + solanaChain + ); + + evelyn.executeService(); + } + + private static final Logger log = LoggerFactory.getLogger(Main.class); +} \ No newline at end of file diff --git a/src/main/tjava/com/fanitas/evelyn/core/impl/ref/StateImpl.tjava b/src/main/tjava/com/fanitas/evelyn/core/impl/ref/StateImpl.tjava new file mode 100644 index 0000000..fec2b24 --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/core/impl/ref/StateImpl.tjava @@ -0,0 +1,309 @@ +package com.fanitas.evelyn.core.impl.ref; + +import com.fanitas.evelyn.core.State; +import com.fanitas.evelyn.raydium.PriceRange; +import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionAccounting; +import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated; +import com.r35157.libs.raydium.*; +import com.r35157.libs.solana.valuetypes.WellKnownCurrencyTypes; +import com.r35157.libs.valuetypes.basic.*; + +import java.io.BufferedReader; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +public class StateImpl implements State { + + public StateImpl(Raydium raydium) throws IOException, InterruptedException { + this.raydium = raydium; + update(); + } + + @Override + public ΩmilliSecondsΩ getIterationInterval() { + return iterationInterval; + } + + @Override + public ΩSyrupAmountΩ getSyrupOwnedByEvelyn() { + return syrupOwnedByEvelyn; + } + + @Override + public ΩSolanaAddressΩ getSolanaAddressForEvelynIOU() { + return solanaAddressForEvelynIOU; + } + + @Override + public ΩSolanaAddressΩ getSolanaAddressForEvelynIOUBurner() { + return solanaAddressForEvelynIOUBurner; + } + + @Override + public ΩRaydiumLiquidityPoolIdΩ getRaydiumPoolId() { + return raydiumPoolId; + } + + @Override + public BigDecimal getCurveWidth() { + return curveWidth; + } + + @Override + public Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> getLiquidityPositions() { + return liquidityPositions; + } + + public void update() throws IOException, InterruptedException { + System.out.print("Updating from config file..."); + updateStateFromPersistence(); + System.out.println(" Done."); + System.out.print("Updating from Raydium..."); + updateStateFromRaydium(); + System.out.println(" Done."); + System.out.print("Updating from positions file..."); + updateStateFromPositionsFile(); + System.out.println(" Done."); + } + + private void updateStateFromPersistence() throws IOException { + try (BufferedReader reader = Files.newBufferedReader(PATH_EVELYN_STATE, StandardCharsets.UTF_8)) { + iterationInterval = readIterationInterval(reader); + syrupOwnedByEvelyn = readSyrupOwnedByEvelyn(reader); + solanaAddressForEvelynIOU = readSolanaAddress(reader); + solanaAddressForEvelynIOUBurner = readSolanaAddress(reader); + raydiumPoolId = readRaydiumPoolId(reader); + curveWidth = readCurveWidth(reader); + } + } + + private void updateStateFromRaydium() throws IOException, InterruptedException { + Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> positionNftIds = + raydium.fetchConcentratedPositionNftIds(solanaAddressForEvelynIOU); + + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> positions = + new HashMap<>(); + + System.out.println("Detected " + positionNftIds.size() + " position(s)..."); + for (ΩRaydiumLiquidityPoolPositionNftIdΩ positionNftId : positionNftIds) { + RaydiumConcentratedPositionState positionState = + raydium.fetchConcentratedPositionState(positionNftId); + + RaydiumConcentratedPoolInfo poolInfo = + raydium.fetchConcentratedPoolInfo(positionState.poolId()); + + RaydiumConcentratedPoolState poolState = + raydium.fetchConcentratedPoolState(positionState.poolId()); + + CurrencyType ctSol = WellKnownCurrencyTypes.SOLANA.getCurrencyType(); + CurrencyType ctSyrup = WellKnownCurrencyTypes.SYRUPUSDC.getCurrencyType(); + + int mintBDecimals = poolInfo.mintBDecimals(); + Range<ΩPriceΩ> raydiumPriceRange = raydium.calculateConcentratedPositionPriceRange( + positionState, + poolInfo, + mintBDecimals + ); + + PriceRange priceRange = new PriceRange( + ctSyrup, + raydiumPriceRange + ); + + RaydiumLiquidityPoolTokenAmounts tokenAmounts = + raydium.calculateConcentratedPositionTokenAmounts( + positionState, + poolInfo, + poolState + ); + + MoneyAmount solAmount = new MoneyAmount(tokenAmounts.amountA(), ctSol); + MoneyAmount syrupAmount = new MoneyAmount(tokenAmounts.amountB(), ctSyrup); + RaydiumLiquidityPoolPositionConcentrated position = + new RaydiumLiquidityPoolPositionConcentrated( + positionState.poolId(), + positionNftId, + priceRange, + solAmount, + syrupAmount, + null // The accounting info will be added from 'conf/positions.conf' later. + ); + + positions.put(positionNftId, position); + System.out.println(" Added '" + position.nftId() + "': " + + "Range:" + position.priceRange() + + ", Liquidity:" + solAmount + "," + syrupAmount + ); + } + + liquidityPositions = Map.copyOf(positions); + } + + private void updateStateFromPositionsFile() throws IOException { + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionAccounting> accountingEntries = + readPositionAccounting(); + + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> updatedEntries = + new HashMap<>(liquidityPositions); + + for (RaydiumLiquidityPoolPositionAccounting accountingEntry : accountingEntries.values()) { + String nftId = accountingEntry.nftId(); + RaydiumLiquidityPoolPositionConcentrated position = updatedEntries.get(nftId); + + if (position == null) { + System.out.println( + "WARNING: File 'conf/positions.conf' contains accounting for an NFT '" + nftId + + "' that was not discovered in the wallet!"); + continue; + } + + RaydiumLiquidityPoolPositionConcentrated updatedPosition = + new RaydiumLiquidityPoolPositionConcentrated( + position.poolId(), + position.nftId(), + position.priceRange(), + position.amountMintA(), + position.amountMintB(), + accountingEntry + ); + + updatedEntries.put(nftId, updatedPosition); + } + + liquidityPositions = Map.copyOf(updatedEntries); + } + + private Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionAccounting> readPositionAccounting() + throws IOException { + Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionAccounting> result = new HashMap<>(); + + try (BufferedReader reader = Files.newBufferedReader(PATH_POSITIONS, StandardCharsets.UTF_8)) { + String line; + + while ((line = reader.readLine()) != null) { + line = line.trim(); + + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + // Remove comments at end of line + int index = line.indexOf('#'); + if(index > -1) { + line = line.substring(0, index).trim(); + } + + RaydiumLiquidityPoolPositionAccounting accounting = mapPositionAccounting(line); + + RaydiumLiquidityPoolPositionAccounting previous = result.put( + accounting.nftId(), + accounting + ); + + if (previous != null) { + throw new IOException("Duplicate position accounting for NFT id: " + accounting.nftId()); + } + } + } + + return Map.copyOf(result); + } + + private RaydiumLiquidityPoolPositionAccounting mapPositionAccounting(String line) throws IOException { + String[] values = line.split(";"); + + if (values.length != 5) { + String errormsg = "Expected position accounting line with 5 fields: " + + "'NFTId ; AddedMintA ; AddedMintB ; BorrowedMintA ; BorrowedMintB'. " + + "Line was: '" + line + "'"; + throw new IOException(errormsg); + } + + ΩRaydiumLiquidityPoolPositionNftIdΩ nftId = values[0].trim(); + ΩAmountΩ addedMintA = new BigDecimal(values[1].trim()); + ΩAmountΩ addedMintB = new BigDecimal(values[2].trim()); + ΩAmountΩ borrowedMintA = new BigDecimal(values[3].trim()); + ΩAmountΩ borrowedMintB = new BigDecimal(values[4].trim()); + + return new RaydiumLiquidityPoolPositionAccounting( + nftId, + addedMintA, + addedMintB, + borrowedMintA, + borrowedMintB + ); + } + + private ΩSolanaAddressΩ readSolanaAddress(BufferedReader reader) throws IOException { + ΩSolanaAddressΩ sa = readLineFromFile(reader); + return sa; + } + + private ΩRaydiumLiquidityPoolIdΩ readRaydiumPoolId(BufferedReader reader) throws IOException { + ΩRaydiumLiquidityPoolIdΩ rlpid = readLineFromFile(reader); + return rlpid; + } + + private ΩCurveWidthΩ readCurveWidth(BufferedReader reader) throws IOException { + String cwStr = readLineFromFile(reader); + ΩCurveWidthΩ cw = new ΩCurveWidthΩ(cwStr); + return cw; + } + + private ΩmilliSecondsΩ readIterationInterval(BufferedReader reader) throws IOException { + String iterationIntervalStr = readLineFromFile(reader); + ΩmilliSecondsΩ millis = Long.parseLong(iterationIntervalStr) * 1000; + return millis; + } + + private ΩSyrupAmountΩ readSyrupOwnedByEvelyn(BufferedReader reader) throws IOException { + String syrupOwnedByEvelynStr = readLineFromFile(reader); + CurrencyType ct = WellKnownCurrencyTypes.SYRUPUSDC.getCurrencyType(); + ΩSyrupAmountΩ ma = stringToMoneyAmount(syrupOwnedByEvelynStr, ct); + return ma; + } + + private static String readLineFromFile(BufferedReader reader) throws IOException { + String line; + + while ((line = reader.readLine()) != null) { + line = line.trim(); + + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + break; + } + return line; + } + + private MoneyAmount bigDecimalToMoneyAmount(BigDecimal bd, CurrencyType ct) { + MoneyAmount ma = new MoneyAmount(bd, ct); + return ma; + } + + private MoneyAmount stringToMoneyAmount(String str, CurrencyType ct) { + ΩAmountΩ a = new ΩAmountΩ(str); + MoneyAmount ma = new MoneyAmount(a, ct); + return ma; + } + + private static final Path PATH_EVELYN_STATE = Path.of("conf/evelyn.conf"); + private static final Path PATH_POSITIONS = Path.of("conf/positions.conf"); + + private final Raydium raydium; + + private ΩmilliSecondsΩ iterationInterval; + private ΩSyrupAmountΩ syrupOwnedByEvelyn; + private ΩSolanaAddressΩ solanaAddressForEvelynIOU; + private ΩSolanaAddressΩ solanaAddressForEvelynIOUBurner; + private ΩRaydiumLiquidityPoolIdΩ raydiumPoolId; + private ΩCurveWidthΩ curveWidth; + private Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityPositions; +} diff --git a/src/main/tjava/com/fanitas/evelyn/raydium/PriceRange.tjava b/src/main/tjava/com/fanitas/evelyn/raydium/PriceRange.tjava new file mode 100644 index 0000000..e235cde --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/raydium/PriceRange.tjava @@ -0,0 +1,33 @@ +package com.fanitas.evelyn.raydium; + +import com.r35157.libs.valuetypes.basic.CurrencyType; +import com.r35157.libs.valuetypes.basic.Range; +import org.jetbrains.annotations.NotNull; + +import java.math.BigDecimal; + +public record PriceRange( + CurrencyType currencyType, + Range<ΩPriceΩ> range +) { + public ΩPriceΩ from() { + return range.from(); + } + + public boolean fromIncluding() { + return range.fromIncluding(); + } + + public ΩPriceΩ to() { + return range.to(); + } + + public boolean toIncluding() { + return range.toIncluding(); + } + + @Override + public @NotNull String toString() { + return range + " " + currencyType().name(); + } +} \ No newline at end of file diff --git a/src/main/tjava/com/fanitas/evelyn/raydium/RaydiumLiquidityPoolPositionAccounting.tjava b/src/main/tjava/com/fanitas/evelyn/raydium/RaydiumLiquidityPoolPositionAccounting.tjava new file mode 100644 index 0000000..4fe3491 --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/raydium/RaydiumLiquidityPoolPositionAccounting.tjava @@ -0,0 +1,12 @@ +package com.fanitas.evelyn.raydium; + +import java.math.BigDecimal; + +public record RaydiumLiquidityPoolPositionAccounting( + ΩRaydiumLiquidityPoolPositionNftIdΩ nftId, + ΩAmountΩ addedMintA, + ΩAmountΩ addedMintB, + ΩAmountΩ borrowedMintA, + ΩAmountΩ borrowedMintB +) { +} \ No newline at end of file diff --git a/src/main/tjava/com/fanitas/evelyn/raydium/RaydiumLiquidityPoolPositionConcentrated.tjava b/src/main/tjava/com/fanitas/evelyn/raydium/RaydiumLiquidityPoolPositionConcentrated.tjava new file mode 100644 index 0000000..390e66a --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/raydium/RaydiumLiquidityPoolPositionConcentrated.tjava @@ -0,0 +1,31 @@ +package com.fanitas.evelyn.raydium; + +import com.r35157.libs.valuetypes.basic.MoneyAmount; + +import java.math.BigDecimal; + +/** + * Represents a concentrated liquidity position in a Raydium liquidity pool. + * + *

A concentrated liquidity position is represented on Solana by a position NFT. + * The NFT identifies the individual position, while the pool id identifies the + * Raydium concentrated liquidity pool that the position belongs to.

+ * + *

The position is only active within its configured price range. The two mint + * amounts represent the token amounts associated with the position at the time + * the position data was fetched or calculated.

+ * + * @param poolId the Raydium concentrated liquidity pool id + * @param nftId the Raydium liquidity pool position NFT id identifying this position + * @param priceRange the price range where this concentrated liquidity position is active + * @param accountingInfo the amount added to and borrowed from the position + */ +public record RaydiumLiquidityPoolPositionConcentrated( + ΩRaydiumLiquidityPoolConcentratedIdΩ poolId, + ΩRaydiumLiquidityPoolPositionNftIdΩ nftId, + PriceRange priceRange, + MoneyAmount amountMintA, + MoneyAmount amountMintB, + RaydiumLiquidityPoolPositionAccounting accountingInfo +) { +} \ No newline at end of file