Added Evelyn to the hub temporarily

This commit is contained in:
2026-06-10 08:08:21 +02:00
parent b48d809360
commit 9a7281907c
13 changed files with 1129 additions and 0 deletions
@@ -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
);
}
@@ -0,0 +1,5 @@
package com.fanitas.evelyn.core;
public interface Evelyn {
void executeService() throws Exception;
}
@@ -0,0 +1,9 @@
package com.fanitas.evelyn.core;
import java.math.BigDecimal;
public record Pair(
ΩAmountΩ amountA,
ΩAmountΩ amountB
) {
}
@@ -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();
}
@@ -0,0 +1,10 @@
package com.fanitas.evelyn.core;
import java.math.BigDecimal;
public record Triblet(
ΩAmountΩ currentAmount,
ΩAmountΩ suggestedAmount,
ΩAmountΩ diff
) {
}
@@ -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;
}
@@ -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<RaydiumLiquidityPoolPrice> 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<String, Triblet> 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<String, Triblet> calculateDiffs(
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, ΩSyrupAmountΩ> rebalancingProposal,
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositionMap
) {
HashMap<String, Triblet> 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;
}
@@ -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);
}
@@ -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;
}
@@ -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();
}
}
@@ -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
) {
}
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*
* @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
) {
}