60: Add initial AssetAZ Ticker service
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package com.r35157.assetaz.core.service.ticker;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
public record PriceObservation(
|
||||
@NotNull AssetPrice price,
|
||||
@NotNull Instant observedAt
|
||||
) {
|
||||
public PriceObservation {
|
||||
Objects.requireNonNull(price, "price");
|
||||
Objects.requireNonNull(observedAt, "observedAt");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,29 @@
|
||||
package com.r35157.assetaz.core.service.ticker;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.TradingPair;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Provides price observations for trading pairs.
|
||||
*/
|
||||
public interface TickerService {
|
||||
|
||||
/**
|
||||
* Starts the ticker service.
|
||||
*
|
||||
* @throws IllegalStateException if the service cannot be started
|
||||
*/
|
||||
void start();
|
||||
|
||||
/**
|
||||
* Returns the latest available price observation for a trading pair.
|
||||
*
|
||||
* @param tradingPair the requested trading pair
|
||||
* @return the latest available price observation
|
||||
*
|
||||
* @throws NullPointerException if {@code tradingPair} is {@code null}
|
||||
* @throws IllegalArgumentException if the trading pair is not supported
|
||||
* @throws IllegalStateException if no price observation is available
|
||||
*/
|
||||
@NotNull PriceObservation getLatestPrice(@NotNull TradingPair tradingPair);
|
||||
}
|
||||
|
||||
+285
-1
@@ -1,6 +1,290 @@
|
||||
package com.r35157.assetaz.core.service.ticker.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.core.service.ticker.PriceObservation;
|
||||
import com.r35157.assetaz.core.service.ticker.TickerService;
|
||||
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||
import com.r35157.libs.valuetypes.basic.TradingPair;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class TickerServiceImpl implements TickerService {
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.ResolverStyle;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static com.r35157.libs.valuetypes.basic.WellKnownTradingPairs.EVE_USDC;
|
||||
|
||||
public final class TickerServiceImpl implements TickerService {
|
||||
public TickerServiceImpl() {
|
||||
this(PRICE_HISTORY_PATH, Clock.systemUTC());
|
||||
}
|
||||
|
||||
TickerServiceImpl(@NotNull Path priceHistoryPath, Clock clock) {
|
||||
this.priceHistoryPath = Objects.requireNonNull(priceHistoryPath, "priceHistoryPath");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
try {
|
||||
startInternal();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException(
|
||||
"Could not start Ticker service using history file: " + priceHistoryPath,
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull PriceObservation getLatestPrice(@NotNull TradingPair tradingPair) {
|
||||
Objects.requireNonNull(tradingPair, "tradingPair");
|
||||
|
||||
if (!SUPPORTED_PAIR.equals(tradingPair)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Ticker price is unavailable for unsupported trading pair: " + tradingPair
|
||||
);
|
||||
}
|
||||
if (!active) {
|
||||
throw new IllegalStateException(
|
||||
"Ticker price is unavailable for inactive trading pair: " + tradingPair
|
||||
);
|
||||
}
|
||||
|
||||
PriceObservation observation = latest.get();
|
||||
if (observation == null) {
|
||||
throw new IllegalStateException(
|
||||
"No persisted price is available for trading pair: " + tradingPair
|
||||
);
|
||||
}
|
||||
|
||||
return observation;
|
||||
}
|
||||
|
||||
private synchronized void startInternal() throws IOException {
|
||||
if (started) {
|
||||
throw new IllegalStateException("Ticker service has already been started");
|
||||
}
|
||||
|
||||
started = true;
|
||||
|
||||
if (!Files.exists(priceHistoryPath)) {
|
||||
log.warn(
|
||||
"Ticker pair {} is inactive because its history file is missing: {}",
|
||||
SUPPORTED_PAIR,
|
||||
priceHistoryPath
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
PriceObservation loadedLatest;
|
||||
try {
|
||||
loadedLatest = loadLatestObservation();
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
started = false;
|
||||
throw exception;
|
||||
}
|
||||
|
||||
latest.set(loadedLatest);
|
||||
active = true;
|
||||
|
||||
persistGeneratedObservation();
|
||||
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "assetaz-ticker-EVE_USDC");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::persistPeriodicObservation,
|
||||
OBSERVATION_DELAY_MINUTES,
|
||||
OBSERVATION_DELAY_MINUTES,
|
||||
TimeUnit.MINUTES
|
||||
);
|
||||
}
|
||||
|
||||
private PriceObservation loadLatestObservation() throws IOException {
|
||||
PriceObservation loadedLatest = null;
|
||||
|
||||
try (BufferedReader reader = Files.newBufferedReader(
|
||||
priceHistoryPath,
|
||||
StandardCharsets.UTF_8
|
||||
)) {
|
||||
String rawLine;
|
||||
int lineNumber = 0;
|
||||
|
||||
while ((rawLine = reader.readLine()) != null) {
|
||||
lineNumber++;
|
||||
String data = removeComment(rawLine).trim();
|
||||
if (data.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PriceObservation observation = parseObservation(data, rawLine, lineNumber);
|
||||
if (loadedLatest == null
|
||||
|| observation.observedAt().isAfter(loadedLatest.observedAt())) {
|
||||
loadedLatest = observation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return loadedLatest;
|
||||
}
|
||||
|
||||
private PriceObservation parseObservation(
|
||||
String data,
|
||||
String rawLine,
|
||||
int lineNumber
|
||||
) throws IOException {
|
||||
int separator = data.indexOf(':');
|
||||
if (separator <= 0 || separator != data.lastIndexOf(':')
|
||||
|| separator == data.length() - 1) {
|
||||
throw malformedHistory(lineNumber, rawLine, null);
|
||||
}
|
||||
|
||||
try {
|
||||
LocalDateTime localDateTime = LocalDateTime.parse(
|
||||
data.substring(0, separator),
|
||||
TIMESTAMP_FORMATTER
|
||||
);
|
||||
Instant observedAt = localDateTime.toInstant(ZoneOffset.UTC);
|
||||
ΩPriceΩ price = new ΩPriceΩ(data.substring(separator + 1));
|
||||
|
||||
return new PriceObservation(
|
||||
new AssetPrice(price, SUPPORTED_PAIR),
|
||||
observedAt
|
||||
);
|
||||
} catch (RuntimeException exception) {
|
||||
throw malformedHistory(lineNumber, rawLine, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private IOException malformedHistory(
|
||||
int lineNumber,
|
||||
String rawLine,
|
||||
RuntimeException cause
|
||||
) {
|
||||
return new IOException(
|
||||
"Malformed ticker history in " + priceHistoryPath
|
||||
+ " at line " + lineNumber + ": " + rawLine,
|
||||
cause
|
||||
);
|
||||
}
|
||||
|
||||
private synchronized void persistGeneratedObservation() {
|
||||
PriceObservation observation = new PriceObservation(
|
||||
new AssetPrice(new ΩPriceΩ(HARDCODED_PRICE), SUPPORTED_PAIR),
|
||||
clock.instant().truncatedTo(ChronoUnit.MILLIS)
|
||||
);
|
||||
|
||||
try {
|
||||
persist(observation);
|
||||
latest.updateAndGet(current -> current == null
|
||||
|| observation.observedAt().isAfter(current.observedAt())
|
||||
? observation
|
||||
: current);
|
||||
} catch (IOException exception) {
|
||||
log.error(
|
||||
"Failed to persist ticker observation for {} to {}; retaining prior latest observation",
|
||||
SUPPORTED_PAIR,
|
||||
priceHistoryPath,
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void persistPeriodicObservation() {
|
||||
try {
|
||||
persistGeneratedObservation();
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("Unexpected failure generating ticker observation for {}", SUPPORTED_PAIR, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void persist(PriceObservation observation) throws IOException {
|
||||
String encodedObservation = TIMESTAMP_FORMATTER.format(
|
||||
LocalDateTime.ofInstant(observation.observedAt(), ZoneOffset.UTC)
|
||||
) + ":" + observation.price().price().toPlainString() + "\n";
|
||||
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
priceHistoryPath,
|
||||
StandardOpenOption.READ,
|
||||
StandardOpenOption.WRITE
|
||||
)) {
|
||||
long size = channel.size();
|
||||
boolean needsLineSeparator = size > 0 && !endsWithLineSeparator(channel, size);
|
||||
channel.position(size);
|
||||
|
||||
if (needsLineSeparator) {
|
||||
writeFully(channel, ByteBuffer.wrap(new byte[] {'\n'}));
|
||||
}
|
||||
writeFully(
|
||||
channel,
|
||||
ByteBuffer.wrap(encodedObservation.getBytes(StandardCharsets.UTF_8))
|
||||
);
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean endsWithLineSeparator(FileChannel channel, long size) throws IOException {
|
||||
ByteBuffer finalByte = ByteBuffer.allocate(1);
|
||||
channel.position(size - 1);
|
||||
if (channel.read(finalByte) != 1) {
|
||||
throw new IOException("Could not read final byte of ticker history: " + priceHistoryPath);
|
||||
}
|
||||
|
||||
return finalByte.array()[0] == '\n' || finalByte.array()[0] == '\r';
|
||||
}
|
||||
|
||||
private static void writeFully(FileChannel channel, ByteBuffer bytes) throws IOException {
|
||||
while (bytes.hasRemaining()) {
|
||||
channel.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static String removeComment(String line) {
|
||||
int commentStart = line.indexOf('#');
|
||||
return commentStart < 0 ? line : line.substring(0, commentStart);
|
||||
}
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TickerServiceImpl.class);
|
||||
private static final TradingPair SUPPORTED_PAIR = EVE_USDC.getTradingPair();
|
||||
private static final Path PRICE_HISTORY_PATH = Path.of(
|
||||
"data",
|
||||
"assetaz",
|
||||
"ticker",
|
||||
"EVE_USDC.prices"
|
||||
);
|
||||
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter
|
||||
.ofPattern("uuuuMMddHHmmssSSS'Z'", Locale.ROOT)
|
||||
.withResolverStyle(ResolverStyle.STRICT);
|
||||
private static final String HARDCODED_PRICE = "14.85";
|
||||
private static final long OBSERVATION_DELAY_MINUTES = 1;
|
||||
|
||||
private final AtomicReference<PriceObservation> latest = new AtomicReference<>();
|
||||
private final Path priceHistoryPath;
|
||||
private final Clock clock;
|
||||
|
||||
private volatile boolean active;
|
||||
private boolean started;
|
||||
private ScheduledExecutorService scheduler;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,32 @@ package com.r35157.libs.valuetypes.basic;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Defines well-known currency types used by the Solana integration.
|
||||
* Defines well-known currencies used across the system.
|
||||
*
|
||||
* <p>Each enum value wraps a {@link CurrencyType} with a stable identifier and a
|
||||
* human-readable currency name. These predefined values are intended for common
|
||||
* currencies that the Solana-related modules need to reference consistently.</p>
|
||||
* <p>Each enum value provides a stable {@link CurrencyType} identity for a
|
||||
* currency that may be referenced by multiple services and integrations.</p>
|
||||
*/
|
||||
public enum WellKnownCurrencyTypes {
|
||||
/**
|
||||
* Native Solana currency.
|
||||
* Evelyn IOU Token
|
||||
*/
|
||||
EVE(new CurrencyType(
|
||||
UUID.fromString("019c3f9f-41d1-7a73-b1df-d4c11c7ff301"),
|
||||
"EVE",
|
||||
"EVE")
|
||||
),
|
||||
|
||||
/**
|
||||
* USD Coin
|
||||
*/
|
||||
USDC(new CurrencyType(
|
||||
UUID.fromString("019c3f9f-41d1-7a73-b1df-d4c11c7ff302"),
|
||||
"USD Coin",
|
||||
"USDC")
|
||||
),
|
||||
|
||||
/**
|
||||
* Native Solana currency
|
||||
*/
|
||||
SOLANA(new CurrencyType(
|
||||
UUID.fromString("019e0116-fce5-792f-a647-fa6da4dffec5"),
|
||||
@@ -20,7 +37,7 @@ public enum WellKnownCurrencyTypes {
|
||||
),
|
||||
|
||||
/**
|
||||
* Syrup USDC token currency.
|
||||
* SyrupUSDC currency
|
||||
*/
|
||||
SYRUPUSDC(new CurrencyType(
|
||||
UUID.fromString("019e1d51-0600-7956-8231-f3b7058a91c2"),
|
||||
@@ -29,16 +46,16 @@ public enum WellKnownCurrencyTypes {
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a well-known currency type entry.
|
||||
* Creates a well-known currency entry.
|
||||
*
|
||||
* @param currencyType the currency type represented by this enum value
|
||||
* @param currencyType the stable currency identity represented by the entry
|
||||
*/
|
||||
WellKnownCurrencyTypes(CurrencyType currencyType) {
|
||||
this.currencyType = currencyType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency type represented by this enum value.
|
||||
* Returns the represented currency type.
|
||||
*
|
||||
* @return the represented currency type
|
||||
*/
|
||||
@@ -47,4 +64,4 @@ public enum WellKnownCurrencyTypes {
|
||||
}
|
||||
|
||||
private final CurrencyType currencyType;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.r35157.libs.valuetypes.basic;
|
||||
|
||||
import static com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes.EVE;
|
||||
import static com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes.SOLANA;
|
||||
import static com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes.SYRUPUSDC;
|
||||
import static com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes.USDC;
|
||||
|
||||
public enum WellKnownTradingPairs {
|
||||
SOL_SYRUPUSDC(new TradingPair(SOLANA.getCurrencyType(), SYRUPUSDC.getCurrencyType()));
|
||||
SOL_SYRUPUSDC(new TradingPair(SOLANA.getCurrencyType(), SYRUPUSDC.getCurrencyType())),
|
||||
EVE_USDC(new TradingPair(EVE.getCurrencyType(), USDC.getCurrencyType()));
|
||||
|
||||
WellKnownTradingPairs(TradingPair tradingPair) {
|
||||
this.tradingPair = tradingPair;
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.r35157.nenjim.hubd.impl.ref;
|
||||
|
||||
import com.fanitas.evelyn.core.Evelyn;
|
||||
import com.fanitas.evelyn.core.impl.ref.EvelynImpl;
|
||||
import com.r35157.assetaz.core.service.ticker.impl.ref.TickerServiceImpl;
|
||||
import com.r35157.evelyn.emc.EvelynMissionControl;
|
||||
import com.r35157.evelyn.emc.impl.ref.EvelynMissionControlImpl;
|
||||
import com.r35157.jupiterperpsalarm.impl.ref.JupiterPerpsAlarmImpl;
|
||||
@@ -58,6 +59,7 @@ public class NenjimHubImpl implements NenjimHub {
|
||||
}
|
||||
|
||||
private void startAutoRunProcesses() throws Exception {
|
||||
startAssetAZTickerService();
|
||||
startJupiterPerpsAlarm(); // TODO: Hardcoded/hacky way to auto-start but good enough for now.
|
||||
Evelyn evelynProd = new EvelynImpl();
|
||||
Evelyn evelynTest = new EvelynImpl();
|
||||
@@ -86,6 +88,10 @@ public class NenjimHubImpl implements NenjimHub {
|
||||
*/
|
||||
}
|
||||
|
||||
private void startAssetAZTickerService() throws Exception {
|
||||
new TickerServiceImpl().start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startProcess(String className) {
|
||||
ClassLoader loader = ClassLoader.getSystemClassLoader();
|
||||
|
||||
Reference in New Issue
Block a user