60: Add initial AssetAZ Ticker service

This commit is contained in:
2026-08-06 17:34:49 +02:00
parent d8dc494afa
commit 0db74b3296
13 changed files with 588 additions and 12 deletions
@@ -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);
}
@@ -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();