60: Add initial AssetAZ Ticker service
This commit is contained in:
@@ -8,3 +8,4 @@ logs/*.log
|
||||
logs/*.log.gz
|
||||
conf/*.conf
|
||||
conf/*.xml
|
||||
data
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-05
|
||||
@@ -0,0 +1,31 @@
|
||||
## Context
|
||||
|
||||
See `proposal.md` for motivation. `TickerService` and `TickerServiceImpl` already exist as empty shells in AssetAZ-owned packages. `AssetPrice` wraps `ΩPriceΩ` and `TradingPair`; the latter identifies currencies structurally rather than by raw symbol. The repository temporarily hosts AssetAZ code as Cauldron, and NenjimHub's current autorun list directly constructs services.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** Establish a small reusable ticker contract, deterministic file-backed startup, atomic persist-before-publish behavior, and temporary autorun wiring while retaining AssetAZ ownership.
|
||||
|
||||
**Non-Goals:** Datasource plugins, real market data, pairs other than `EVE_USDC`, consumers, subscriptions/callbacks, history queries, retention, compaction, or automated tests.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Define `PriceObservation` in `com.r35157.assetaz.core.service.ticker` exactly as a record of `AssetPrice price` and `Instant observedAt`. Extend `TickerService` with `start()` and `PriceObservation getLatestPrice(TradingPair tradingPair)`. Use clear exceptions for unsupported/inactive pairs and enabled pairs without a persisted observation; `Optional` and `null` were rejected because issue #60 explicitly requires exceptions for unavailable prices. Raw symbol strings were rejected because callers must use the established ValueTypes.
|
||||
- Add stable EVE and USDC currency definitions and an `EVE_USDC` well-known `TradingPair`, then construct the sample price with `new ΩPriceΩ("14.85")`. This keeps identity consistent with other typed prices instead of parsing pair names at the API boundary.
|
||||
- Keep the public API and `PriceObservation` in the AssetAZ API package and implementation details in `com.r35157.assetaz.core.service.ticker.impl.ref`. Their current repository location is temporary Cauldron placement and does not imply Evelyn or NenjimHub ownership.
|
||||
- Use the fixed path `data/assetaz/ticker/EVE_USDC.prices`; do not create its directory or file. Check existence when initializing the pair. Missing means inactive, emits a warning containing the pair and expected path, and does not prevent the rest of Ticker startup; an existing zero-length, blank-only, or comment-only file is active with no latest value.
|
||||
- Store one observation per data line as `<uuuuMMddHHmmssSSS'Z'>:<decimal-price>`, using a strict formatter fixed to UTC. Read line by line, remove text from the first `#`, trim, and skip empty results. Split the remaining data at the required colon and parse it into `Instant`, `ΩPriceΩ`, and the fixed `EVE_USDC` pair. Wrap any structural, timestamp, or price failure with the filename, one-based physical line number, and identifying invalid content.
|
||||
- Load history synchronously during `start()` and select the parsed observation with the greatest `observedAt` timestamp rather than assuming the file is chronologically ordered. After loading, generate the first hardcoded observation immediately, then use a single-thread scheduled executor with fixed-delay scheduling: each subsequent attempt begins one minute after the preceding attempt completes. Fixed-rate scheduling was rejected because a long pause can cause rapid catch-up attempts. Do not schedule generation for an inactive pair. The scheduler is an implementation mechanism, not a public lifecycle or subscription API.
|
||||
- Serialize generation and publication, and keep the published latest value behind a thread-safe reference. Validate and encode every generated observation, append all encoded bytes through a `FileChannel`, and call `force(true)`. Only after successful persistence, compare its `observedAt` with the current latest and replace the in-memory reference only when the new timestamp is later. Thus concurrent callers see the successfully persisted observation with the greatest timestamp across loaded history and newly persisted observations, and a manually inserted future-dated observation remains latest until a later observation is persisted. A buffered-writer `flush()` alone was rejected because it does not satisfy the issue's explicit flush/force-to-persistence ordering. On open, write, or force failure, log/report the failure and retain the prior latest value; never publish the attempted observation.
|
||||
- Add a temporary `startAssetAZTickerService()` entry to `NenjimHubImpl` following its direct autorun pattern. It constructs and starts `TickerServiceImpl`; no Ticker dependency is passed to Evelyn, EMC, alarms, portfolios, or other services.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Filesystem or device guarantees can still limit crash durability after `FileChannel.force(true)`] → Force each complete appended record before publication and document that the implementation uses the strongest standard Java file-channel guarantee available here.
|
||||
- [An observation attempt can take arbitrarily long] → Fixed-delay scheduling waits until the attempt completes and then waits one minute, preventing overlapping or catch-up attempts.
|
||||
- [Malformed history prevents the service from starting] → Include exact filename and line number so operators can repair the activation file safely.
|
||||
- [Hardcoded identifiers and price are temporary] → Isolate them behind well-known ValueTypes and the reference implementation for later source replacement.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Operators opt in by creating `data/assetaz/ticker/EVE_USDC.prices`, optionally pre-populated in the documented format. Deploy the service and temporary autorun wiring together. Rollback removes the autorun call and ticker implementation while leaving the append-only data file intact.
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
AssetAZ has only empty ticker service shells, so callers cannot obtain a typed latest price or retain observations across restarts. A minimal reusable ticker establishes that service contract and a durable reference implementation before real market sources are introduced.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Define an AssetAZ ticker API that exposes the latest persisted observation by `TradingPair` using existing price ValueTypes and throws clear exceptions when no price is available.
|
||||
- Add a reference implementation for the single supported `EVE_USDC` pair, producing the hardcoded price `14.85` immediately at startup and starting each subsequent attempt one minute after the previous attempt completes.
|
||||
- Make an existing `.prices` file the explicit activation mechanism, load its valid history, append every new observation durably, and expose the successfully persisted observation with the greatest timestamp as latest.
|
||||
- Start the ticker through NenjimHub's current temporary autorun mechanism.
|
||||
- Leave the pair unavailable when its history file is missing, reject malformed history at startup, and retain the prior published observation when a new observation cannot be persisted safely.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `assetaz-ticker-service`: Typed latest-price access, file-backed activation and history loading, and durable periodic publication for the initial AssetAZ trading pair.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Extends the existing empty API and reference implementation under `com.r35157.assetaz.core.service.ticker` and adds `PriceObservation` there.
|
||||
- Reuses `AssetPrice`, `TradingPair`, and `ΩPriceΩ`; no datasource-plugin or consumer integration is introduced.
|
||||
- Adds temporary startup wiring to `NenjimHubImpl`, without transferring ownership of the ticker to NenjimHub.
|
||||
- Uses `data/assetaz/ticker/EVE_USDC.prices` as operator-controlled persistent activation and history.
|
||||
- Adds no automated tests.
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
## Purpose
|
||||
|
||||
Provide AssetAZ callers with typed latest prices backed by explicitly enabled, durable observation histories.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Ticker exposes typed latest prices
|
||||
The ticker SHALL expose the latest successfully persisted `PriceObservation` for a requested `TradingPair`. A `PriceObservation` SHALL contain exactly an `AssetPrice` and its observation `Instant`; the `AssetPrice` SHALL contain both its `ΩPriceΩ` value and `TradingPair`. The ticker SHALL initially support only `EVE_USDC`. A request for an unsupported or inactive pair, or for an enabled pair that has no persisted observation, SHALL throw a clear exception rather than return `null`.
|
||||
|
||||
#### Scenario: Latest supported price is available
|
||||
- **WHEN** a caller requests `EVE_USDC` after at least one observation has been loaded or published
|
||||
- **THEN** the ticker returns the latest observation with an `AssetPrice` identified by the `EVE_USDC` `TradingPair`
|
||||
|
||||
#### Scenario: Unsupported or inactive pair is requested
|
||||
- **WHEN** a caller requests a pair that is unsupported or not activated
|
||||
- **THEN** the ticker throws an exception that clearly identifies the unavailable trading pair
|
||||
|
||||
#### Scenario: Enabled pair has no persisted observation
|
||||
- **WHEN** a caller requests an enabled pair before any observation has been successfully persisted
|
||||
- **THEN** the ticker throws an exception that clearly states that no persisted price is available for that trading pair
|
||||
|
||||
### Requirement: Price history explicitly activates the pair
|
||||
The ticker SHALL treat the existence of `data/assetaz/ticker/EVE_USDC.prices` when the pair is initialized as activation of `EVE_USDC`. It SHALL load every valid observation and select the observation with the greatest timestamp as latest, regardless of file order. It SHALL accept empty lines, full-line comments whose first non-whitespace character is `#`, and inline comments beginning at the first `#` on a data line. A missing file SHALL produce a warning containing the trading pair and expected filepath, leave the pair unavailable, and SHALL NOT be created automatically; an existing file with no data observations SHALL enable the pair without an initial latest price.
|
||||
|
||||
#### Scenario: Existing history activates and restores the pair
|
||||
- **WHEN** the ticker starts with an existing history containing valid observations and permitted whitespace or comments
|
||||
- **THEN** `EVE_USDC` is available and the observation with the greatest timestamp is its latest persisted price
|
||||
|
||||
#### Scenario: Empty history activates without a price
|
||||
- **WHEN** the ticker starts with an existing empty history file
|
||||
- **THEN** `EVE_USDC` is enabled with no initial latest observation
|
||||
|
||||
#### Scenario: Missing history leaves the pair unavailable
|
||||
- **WHEN** the ticker starts without the `EVE_USDC` history file
|
||||
- **THEN** it logs a warning containing `EVE_USDC` and `data/assetaz/ticker/EVE_USDC.prices`, continues starting without that pair, and does not create the file or its parent directories
|
||||
|
||||
#### Scenario: Malformed history is rejected clearly
|
||||
- **WHEN** comment text is removed and a non-empty data line does not contain a valid UTC timestamp and price
|
||||
- **THEN** Ticker startup fails with an error containing the history filename, the one-based physical line number, and enough of the invalid content to identify it
|
||||
|
||||
### Requirement: History uses the human-editable observation format
|
||||
Each data line SHALL have the form `<UTC timestamp>:<price>`, where the timestamp uses `uuuuMMddHHmmssSSS'Z'` with millisecond precision in UTC. Automatic writes SHALL append plain data lines only and SHALL leave all existing comments and blank lines untouched.
|
||||
|
||||
#### Scenario: Observation is written in the required format
|
||||
- **WHEN** an observation at `2026-08-05T13:15:42.783Z` with price `14.85` is persisted
|
||||
- **THEN** the appended line is `20260805131542783Z:14.85`
|
||||
|
||||
#### Scenario: Existing operator annotations are preserved
|
||||
- **WHEN** the ticker appends an observation to a history containing comments or blank lines
|
||||
- **THEN** those existing lines remain unchanged and the appended observation contains neither a comment nor extra annotation
|
||||
|
||||
### Requirement: New observations are durable before publication
|
||||
For enabled `EVE_USDC`, the ticker SHALL generate price `14.85` immediately at startup. After that startup attempt completes, and after each subsequent attempt completes, the ticker SHALL wait one minute before beginning the next attempt. It SHALL NOT use fixed-rate scheduling or perform catch-up attempts after a delay. For each generated observation it SHALL validate the observation, append it to history, and flush or force the data to persistent storage; every generated observation SHALL be persisted unless that persistence attempt fails. Only after persistence succeeds SHALL the ticker replace the in-memory latest observation, and then only if the newly persisted observation's `observedAt` timestamp is later than the current latest timestamp. If persistence fails, it SHALL report the failure clearly, SHALL NOT publish the attempted observation, and SHALL preserve the previously persisted latest observation. Concurrent reads SHALL never observe an unpersisted price, and latest SHALL always be the successfully persisted observation with the greatest `observedAt` timestamp across loaded history and newly persisted observations.
|
||||
|
||||
#### Scenario: Startup observation is persisted
|
||||
- **WHEN** the ticker starts with an enabled writable history
|
||||
- **THEN** it appends and forces a `14.85` observation to persistent storage before considering it for latest
|
||||
|
||||
#### Scenario: Periodic observation is persisted
|
||||
- **WHEN** one minute has elapsed after the preceding observation attempt completed while the enabled ticker is running
|
||||
- **THEN** it begins another attempt and appends and forces its `14.85` observation to persistent storage before considering it for latest
|
||||
|
||||
#### Scenario: Future-dated history remains latest
|
||||
- **WHEN** history contains a successfully persisted observation whose timestamp is later than a newly generated observation
|
||||
- **THEN** the newly generated observation is still persisted and the future-dated history observation remains latest
|
||||
|
||||
#### Scenario: Persistence fails
|
||||
- **WHEN** appending or flushing a new observation fails
|
||||
- **THEN** the failure is reported clearly, the failed observation is not published to any reader, and the previously persisted latest observation remains available
|
||||
@@ -0,0 +1,21 @@
|
||||
## 1. AssetAZ Ticker API
|
||||
|
||||
- [x] 1.1 Add stable EVE and USDC currency ValueTypes and the well-known `EVE_USDC` `TradingPair`.
|
||||
- [x] 1.2 Add immutable `PriceObservation` with a timestamp and `AssetPrice` in the ticker API package.
|
||||
- [x] 1.3 Extend `TickerService` with `start()` and `PriceObservation getLatestPrice(TradingPair)`, using clear exceptions for unsupported/inactive pairs and enabled pairs without a persisted observation.
|
||||
|
||||
## 2. File-Backed Reference Implementation
|
||||
|
||||
- [x] 2.1 Implement explicit activation from the existing `data/assetaz/ticker/EVE_USDC.prices` file without creating a missing file or directory.
|
||||
- [x] 2.2 Implement the strict UTC `uuuuMMddHHmmssSSS'Z':<price>` format and line-by-line history parsing with empty-line and full-line/inline comment support.
|
||||
- [x] 2.3 Report malformed data with filename, one-based physical line number, and identifying invalid content, and restore the valid observation with the greatest `observedAt` timestamp as latest.
|
||||
- [x] 2.4 Implement thread-safe append-and-`FileChannel.force(true)` behavior that persists every generated observation, never exposes an unpersisted observation, preserves the prior latest observation on persistence failure, and replaces latest after successful persistence only when the new `observedAt` timestamp is later.
|
||||
- [x] 2.5 Generate the hardcoded `14.85` startup observation immediately and use fixed-delay scheduling only for the enabled pair so each subsequent attempt begins one minute after the preceding attempt completes.
|
||||
|
||||
## 3. Temporary Autorun Wiring
|
||||
|
||||
- [x] 3.1 Start `TickerServiceImpl` from NenjimHub's current direct autorun mechanism without wiring it to any consumer.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Compile the project and manually verify missing, empty/comment-only, out-of-order valid, future-dated, malformed, and unwritable history-file behavior, required exception cases, timestamp/price formatting, fixed-delay timing without catch-up attempts, persist-before-publish ordering, and greatest-`observedAt` latest selection without adding automated tests.
|
||||
@@ -0,0 +1,71 @@
|
||||
# assetaz-ticker-service Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Provide AssetAZ callers with typed latest prices backed by explicitly enabled, durable observation histories.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Ticker exposes typed latest prices
|
||||
The ticker SHALL expose the latest successfully persisted `PriceObservation` for a requested `TradingPair`. A `PriceObservation` SHALL contain exactly an `AssetPrice` and its observation `Instant`; the `AssetPrice` SHALL contain both its `ΩPriceΩ` value and `TradingPair`. The ticker SHALL initially support only `EVE_USDC`. A request for an unsupported or inactive pair, or for an enabled pair that has no persisted observation, SHALL throw a clear exception rather than return `null`.
|
||||
|
||||
#### Scenario: Latest supported price is available
|
||||
- **WHEN** a caller requests `EVE_USDC` after at least one observation has been loaded or published
|
||||
- **THEN** the ticker returns the latest observation with an `AssetPrice` identified by the `EVE_USDC` `TradingPair`
|
||||
|
||||
#### Scenario: Unsupported or inactive pair is requested
|
||||
- **WHEN** a caller requests a pair that is unsupported or not activated
|
||||
- **THEN** the ticker throws an exception that clearly identifies the unavailable trading pair
|
||||
|
||||
#### Scenario: Enabled pair has no persisted observation
|
||||
- **WHEN** a caller requests an enabled pair before any observation has been successfully persisted
|
||||
- **THEN** the ticker throws an exception that clearly states that no persisted price is available for that trading pair
|
||||
|
||||
### Requirement: Price history explicitly activates the pair
|
||||
The ticker SHALL treat the existence of `data/assetaz/ticker/EVE_USDC.prices` when the pair is initialized as activation of `EVE_USDC`. It SHALL load every valid observation and select the observation with the greatest timestamp as latest, regardless of file order. It SHALL accept empty lines, full-line comments whose first non-whitespace character is `#`, and inline comments beginning at the first `#` on a data line. A missing file SHALL produce a warning containing the trading pair and expected filepath, leave the pair unavailable, and SHALL NOT be created automatically; an existing file with no data observations SHALL enable the pair without an initial latest price.
|
||||
|
||||
#### Scenario: Existing history activates and restores the pair
|
||||
- **WHEN** the ticker starts with an existing history containing valid observations and permitted whitespace or comments
|
||||
- **THEN** `EVE_USDC` is available and the observation with the greatest timestamp is its latest persisted price
|
||||
|
||||
#### Scenario: Empty history activates without a price
|
||||
- **WHEN** the ticker starts with an existing empty history file
|
||||
- **THEN** `EVE_USDC` is enabled with no initial latest observation
|
||||
|
||||
#### Scenario: Missing history leaves the pair unavailable
|
||||
- **WHEN** the ticker starts without the `EVE_USDC` history file
|
||||
- **THEN** it logs a warning containing `EVE_USDC` and `data/assetaz/ticker/EVE_USDC.prices`, continues starting without that pair, and does not create the file or its parent directories
|
||||
|
||||
#### Scenario: Malformed history is rejected clearly
|
||||
- **WHEN** comment text is removed and a non-empty data line does not contain a valid UTC timestamp and price
|
||||
- **THEN** Ticker startup fails with an error containing the history filename, the one-based physical line number, and enough of the invalid content to identify it
|
||||
|
||||
### Requirement: History uses the human-editable observation format
|
||||
Each data line SHALL have the form `<UTC timestamp>:<price>`, where the timestamp uses `uuuuMMddHHmmssSSS'Z'` with millisecond precision in UTC. Automatic writes SHALL append plain data lines only and SHALL leave all existing comments and blank lines untouched.
|
||||
|
||||
#### Scenario: Observation is written in the required format
|
||||
- **WHEN** an observation at `2026-08-05T13:15:42.783Z` with price `14.85` is persisted
|
||||
- **THEN** the appended line is `20260805131542783Z:14.85`
|
||||
|
||||
#### Scenario: Existing operator annotations are preserved
|
||||
- **WHEN** the ticker appends an observation to a history containing comments or blank lines
|
||||
- **THEN** those existing lines remain unchanged and the appended observation contains neither a comment nor extra annotation
|
||||
|
||||
### Requirement: New observations are durable before publication
|
||||
For enabled `EVE_USDC`, the ticker SHALL generate price `14.85` immediately at startup. After that startup attempt completes, and after each subsequent attempt completes, the ticker SHALL wait one minute before beginning the next attempt. It SHALL NOT use fixed-rate scheduling or perform catch-up attempts after a delay. For each generated observation it SHALL validate the observation, append it to history, and flush or force the data to persistent storage; every generated observation SHALL be persisted unless that persistence attempt fails. Only after persistence succeeds SHALL the ticker replace the in-memory latest observation, and then only if the newly persisted observation's `observedAt` timestamp is later than the current latest timestamp. If persistence fails, it SHALL report the failure clearly, SHALL NOT publish the attempted observation, and SHALL preserve the previously persisted latest observation. Concurrent reads SHALL never observe an unpersisted price, and latest SHALL always be the successfully persisted observation with the greatest `observedAt` timestamp across loaded history and newly persisted observations.
|
||||
|
||||
#### Scenario: Startup observation is persisted
|
||||
- **WHEN** the ticker starts with an enabled writable history
|
||||
- **THEN** it appends and forces a `14.85` observation to persistent storage before considering it for latest
|
||||
|
||||
#### Scenario: Periodic observation is persisted
|
||||
- **WHEN** one minute has elapsed after the preceding observation attempt completed while the enabled ticker is running
|
||||
- **THEN** it begins another attempt and appends and forces its `14.85` observation to persistent storage before considering it for latest
|
||||
|
||||
#### Scenario: Future-dated history remains latest
|
||||
- **WHEN** history contains a successfully persisted observation whose timestamp is later than a newly generated observation
|
||||
- **THEN** the newly generated observation is still persisted and the future-dated history observation remains latest
|
||||
|
||||
#### Scenario: Persistence fails
|
||||
- **WHEN** appending or flushing a new observation fails
|
||||
- **THEN** the failure is reported clearly, the failed observation is not published to any reader, and the previously persisted latest observation remains available
|
||||
@@ -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