61: Introduce PriceSource and PriceSink architecture for AssetAZ Ticker

This commit is contained in:
2026-08-07 00:14:27 +02:00
parent 0db74b3296
commit ef1b5e0adc
13 changed files with 918 additions and 167 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-06
@@ -0,0 +1,35 @@
## Context
See `proposal.md` for motivation and the `assetaz-ticker-service` delta for behavior. Nenjim's planned component model constructs every component independently in a context, leaves it initialized but unstarted, and later lets components query that context for peer plugins by interface. Issue #61 therefore requires plugin-style acquisition without constructor-time coupling between a price source and the ticker, while retaining the existing typed ValueTypes, durable append format, and temporary Cauldron placement.
## Goals / Non-Goals
**Goals:** Keep acquisition resources owned by independently constructible sources; make the ticker the source-discovery owner, lifecycle coordinator, and durable sink; isolate histories by stable source identity; preserve greatest-timestamp selection and persist-before-publish under concurrent callbacks; align dependencies with Nenjim's future component context.
**Non-Goals:** Implementing Nenjim's final context-query API in this change, dynamic source changes after ticker startup, automatic migration, source health/retry policy, Raydium or streaming integrations, public history queries, consumers, or automated tests.
## Decisions
- Add API-package `PriceSource` and functional `PriceSink` interfaces. `PriceSource.start(PriceSink)` performs lifecycle-time wiring, so a source constructor needs neither the ticker nor a sink. `TickerService` exposes `start`, `stop`, and latest-price queries but no `addPriceSource`; plugin management is an implementation responsibility rather than a client capability. `TickerService` does not extend `PriceSink`, while `TickerServiceImpl` implements both contracts.
- In the final Nenjim model, `TickerServiceImpl` queries its own context for `PriceSource` implementations after all components have been independently constructed. It does not query for sinks: the ticker is the sink and passes itself to each active source when calling `priceSource.start(this)`. The current public varargs constructor `TickerServiceImpl(PriceSource...)` is temporary Cauldron wiring that supplies exactly the instances a future context lookup will return; it is not public plugin-management API or the final discovery mechanism.
- Add `PriceSourceName` beneath `String -> Name` in `conf/detag.conf`, as explicitly authorized by issue #61. Store source identity in `PriceObservation` but not in history lines, because the source directory is the persistent identity and repeated line metadata would create disagreement risks.
- Represent each context-provided source internally by the source object, its `(TradingPair, sourceName)` key, derived path, activation state, and a per-history lock. Reject duplicate keys and unsafe names while capturing the constructor-provided/discovered set before startup. Compare callbacks by exact source object identity as well as stable metadata so an unknown impersonating instance cannot address another source's history.
- Centralize paths under the ticker data root using canonical base UUID, canonical quote UUID, validated source name, and sanitized symbols in the final filename. Source names are rejected rather than rewritten because they are technical persistent identity. Symbols are non-identity display text and are converted character-by-character to safe filename components, rejecting an empty result.
- During `start()`, validate and derive every obtained source, check exact file existence without scanning or creating, load all active histories into local state, compute per-pair maximum timestamps, then commit startup state and start active sources with `source.start(this)`. If loading or starting fails, stop every source already started and leave the ticker stopped rather than expose a partially started lifecycle.
- During `stop()`, prevent new callbacks, stop every started source, and aggregate/report lifecycle failures while ensuring every source receives a stop attempt. The obtained source set remains owned by the ticker across a subsequent restart; changing the context-provided set dynamically while starting, started, or stopping is outside this design.
- Move the hardcoded price, clock, one-minute delay, and scheduled executor into `HardcodedPriceSource`. Its public constructor has no sink or ticker dependency. `start(PriceSink)` first rejects an already-started call, then stores the supplied sink, announces immediately, and schedules subsequent attempts with `scheduleWithFixedDelay`; checking first prevents a repeated start from replacing the live sink. `stop()` shuts down its executor. The ticker contains none of those acquisition constants or resources.
- On `announce`, resolve the exact active registration, validate non-null price and timestamp, construct `AssetPrice` and `PriceObservation`, lock that source history, append the complete UTF-8 line with `FileChannel`, and call `force(true)`. Only afterward atomically update the pair's latest value when the new timestamp is greater. Per-history locks allow independent sources to persist concurrently without interleaving, while atomic per-pair maximum updates preserve cross-source ordering.
- Keep strict UTC parsing and the existing comment behavior. Loaded observations receive trading pair and source name from the obtained source context. The old flat history path is deliberately ignored; operator-controlled manual movement is the only migration.
- Temporary NenjimHub wiring constructs an unstarted `HardcodedPriceSource` independently, then constructs `TickerServiceImpl` with that source through the varargs Cauldron adapter and starts the ticker. The ticker performs sink wiring and lifecycle. This simulates future context lookup without transferring AssetAZ ownership or adding consumers.
## Risks / Trade-offs
- [A source throws during startup after earlier sources started] → Stop all sources that were successfully started, suppress secondary stop failures, and fail ticker startup clearly.
- [A source callback races with stop] → Guard lifecycle and active-registration checks so callbacks are accepted only while the ticker is started; source stop is responsible for terminating its own producer resources.
- [Multiple source callbacks contend for one trading pair] → Use per-history serialization for files and an atomic greatest-timestamp update for the shared pair view.
- [Symbol sanitization can make filenames less recognizable] → Preserve UUIDs as technical identity and use a deterministic safe replacement only for the human-readable leaf filename.
- [Manual migration can temporarily deactivate the hardcoded source] → Log the exact expected new path and never create or infer content, making operator action explicit and reversible.
## Migration Plan
Before deployment, stop the old service and move `data/assetaz/ticker/EVE_USDC.prices` to `data/assetaz/ticker/019c3f9f-41d1-7a73-b1df-d4c11c7ff301/019c3f9f-41d1-7a73-b1df-d4c11c7ff302/Hardcoded/EVE_USDC.prices`, creating parent directories only as an operator action. Deploy the API, ticker, hardcoded source, Detag configuration, and wiring together. Rollback restores the previous code and moves the file back to the legacy flat path; no automatic data transformation is involved.
@@ -0,0 +1,31 @@
## Why
The AssetAZ Ticker currently owns a hardcoded price generator, so acquisition, scheduling, persistence, and publication are coupled in one implementation. Nenjim's future component model constructs components independently and lets a component obtain peer plugins from its context after construction, so price sources must be independently constructible and wired to the ticker only when the ticker starts them.
## What Changes
- Add `PriceSource` and functional `PriceSink` API contracts in which independently constructed sources receive their sink through `start(PriceSink)` and own their acquisition resources.
- Keep plugin discovery and lifecycle inside `TickerServiceImpl`: it obtains `PriceSource` implementations from its Nenjim context, starts them with itself as sink, and stops them. Ordinary `TickerService` clients receive no plugin-management API.
- Add the `ΩPriceSourceNameΩ` ValueTag and include stable source identity in each in-memory `PriceObservation`.
- **BREAKING** Replace the single history file with an independent UUID-based history path for each obtained `(TradingPair, sourceName)` source identity; the old file is not migrated automatically.
- Refactor `TickerServiceImpl` into a source-agnostic sink that validates callbacks, persists each source independently, and publishes the greatest successfully persisted timestamp across active sources for a pair.
- Move the existing `EVE_USDC = 14.85` generation and fixed-delay scheduler into a stoppable `HardcodedPriceSource` plugin.
- Use a temporary `TickerServiceImpl(PriceSource...)` Cauldron constructor to simulate the future Nenjim context lookup while constructing `HardcodedPriceSource` independently.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `assetaz-ticker-service`: Generalize the ticker from one internal generator and history into context-provided source plugins with per-source activation, persistence, lifecycle, and cross-source latest selection.
## Impact
- Changes the AssetAZ ticker plugin API and reference implementation under `com.r35157.assetaz.core.service.ticker`; the ordinary `TickerService` API exposes lifecycle and price queries, not plugin registration.
- Adds `PriceSourceName` to the shared Detag hierarchy under `String -> Name`; this configuration change is explicitly required by issue #61.
- Changes the operator-managed history location and requires manual movement of the old hardcoded history.
- Updates temporary wiring in `NenjimHubImpl` to construct the source and ticker independently before injecting the source list as a stand-in for future context discovery.
- Adds no Raydium, WebSocket, history-query, retention, or automated-test functionality.
@@ -0,0 +1,122 @@
## ADDED Requirements
### Requirement: Price sources are discovered and lifecycle-managed
Nenjim SHALL be able to construct each price source independently in an initialized but unstarted state without supplying a ticker or sink. The ticker SHALL obtain `PriceSource` plugins from its Nenjim context, while ordinary ticker clients SHALL NOT receive a public operation for adding or managing sources. The ticker SHALL identify each obtained source by its `TradingPair` and stable source name, reject duplicate identities clearly, start each active source by passing itself as `PriceSink`, and stop every source it started. Source names SHALL be non-empty safe directory components and SHALL reject `/`, `\`, `..`, and control characters without rewriting them. A source SHALL announce typed prices and timestamps only through the sink received at start and SHALL remain unaware of its persistence path. A repeated source start SHALL reject the call before replacing its existing sink.
#### Scenario: Sources are constructed independently
- **WHEN** Nenjim constructs a price source before constructing or starting the ticker
- **THEN** source construction requires neither a `PriceSink` nor a ticker reference and leaves the source unstarted
#### Scenario: Ticker obtains source plugins
- **WHEN** the ticker initializes its source set from its Nenjim context
- **THEN** it looks for implementations of `PriceSource`, not implementations of `PriceSink`, and manages the obtained sources internally
#### Scenario: Ordinary clients cannot manage plugins
- **WHEN** a caller uses the public `TickerService` contract
- **THEN** the contract exposes ticker lifecycle and latest-price queries but no source-registration operation
#### Scenario: Distinct context sources are accepted
- **WHEN** the ticker obtains two sources with different source names or trading pairs from its context
- **THEN** it accepts both and manages each source independently
#### Scenario: Duplicate persistent identity is rejected
- **WHEN** the ticker obtains sources with the same trading pair and source name
- **THEN** initialization fails with an error identifying the duplicate source history
#### Scenario: Unsafe source name is rejected
- **WHEN** a source name is empty or contains a slash, backslash, `..`, or a control character
- **THEN** ticker initialization or startup fails clearly without rewriting the source name or creating filesystem content
#### Scenario: Active source lifecycle is managed
- **WHEN** the ticker starts and later stops with an active context-provided source
- **THEN** it calls `start` with itself as `PriceSink` and subsequently stops the source without leaving source-owned resources running
#### Scenario: Repeated source start preserves existing sink
- **WHEN** an already-started source receives another start call with a different sink
- **THEN** it rejects the call before replacing the sink from the successful start
## MODIFIED Requirements
### Requirement: Ticker exposes typed latest prices
The ticker SHALL expose the latest successfully persisted `PriceObservation` for a requested `TradingPair` across all active context-provided sources for that pair. A `PriceObservation` SHALL contain exactly an `AssetPrice`, its observation `Instant`, and the stable source name; the `AssetPrice` SHALL contain both its `ΩPriceΩ` value and `TradingPair`. If several active sources provide a pair, latest SHALL be the observation with the greatest `observedAt` timestamp, and its source name SHALL identify its source. A request for a pair with no obtained sources, no active sources, or no successfully persisted observation SHALL throw a clear exception rather than return `null`.
#### Scenario: Latest supported price is available
- **WHEN** a caller requests a trading pair after at least one observation has been loaded or persisted by an active source
- **THEN** the ticker returns the successfully persisted observation with the greatest timestamp and its source identity
#### Scenario: Newest observation is selected across sources
- **WHEN** multiple active sources for one trading pair have successfully persisted observations
- **THEN** the ticker returns the observation with the greatest `observedAt` across those sources
#### Scenario: Pair has no obtained or active source
- **WHEN** a caller requests a pair with no context-provided source or whose obtained sources are all inactive
- **THEN** the ticker throws an exception that clearly identifies the unavailable trading pair
#### Scenario: Active pair has no persisted observation
- **WHEN** a caller requests a pair whose active sources have no successfully persisted observation
- **THEN** the ticker throws an exception that clearly states that no persisted price is available for that trading pair
### Requirement: Price history explicitly activates each source
For each context-provided source, the ticker SHALL derive an independent history path as `data/assetaz/ticker/<base UUID>/<quote UUID>/<source name>/<safe base symbol>_<safe quote symbol>.prices`. UUIDs SHALL use their unchanged canonical representation and SHALL be the technical trading-pair identity; sanitized symbols SHALL be used only for the human-readable filename. Only an existing file at that exact path SHALL activate the source. The ticker SHALL neither scan for arbitrary sources nor create or migrate missing files or directories. It SHALL load every valid observation using the obtained source's trading pair and source name and select the greatest timestamp across all active histories for each pair, regardless of file order. Empty lines, full-line comments, and inline comments SHALL remain supported. An existing history with no data SHALL activate only that source without an initial observation, while a missing history SHALL leave only that source inactive and emit a warning containing the trading pair, source name, and complete expected path.
#### Scenario: Existing source history activates and restores identity
- **WHEN** a context-provided source has an existing valid history with permitted whitespace or comments
- **THEN** that source is active and its observations are restored with its captured trading pair and source name
#### Scenario: Empty source history activates without a price
- **WHEN** a context-provided source has an existing empty or comment-only history file
- **THEN** that source is active without an initial observation
#### Scenario: Missing source history leaves only that source inactive
- **WHEN** one obtained source history is missing while another source for the same pair has an existing history
- **THEN** the missing source is not started, the existing source remains active, and no missing path is created
#### Scenario: Missing history warning identifies the source
- **WHEN** a context-provided source's expected history file is absent
- **THEN** the ticker logs a warning containing its trading pair, source name, and complete expected UUID-based path
#### Scenario: Malformed source history is rejected clearly
- **WHEN** comment text is removed and a non-empty data line in an active source history lacks a valid UTC timestamp and price
- **THEN** ticker startup fails with an error containing the history filename, one-based physical line number, and identifying invalid content
#### Scenario: Legacy history is not migrated
- **WHEN** only `data/assetaz/ticker/EVE_USDC.prices` exists
- **THEN** the ticker does not read, move, copy, or create a replacement for that legacy file
### Requirement: History uses the human-editable observation format
Each source history data line SHALL retain the form `<UTC timestamp>:<price>`, where the timestamp uses `uuuuMMddHHmmssSSS'Z'` with millisecond precision in UTC. Trading-pair and source identity SHALL come from the context-provided source and its directory context and SHALL NOT be stored in individual data lines. 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** any source observation at `2026-08-05T13:15:42.783Z` with price `14.85` is persisted
- **THEN** the appended line is `20260805131542783Z:14.85`
#### Scenario: Identity is not duplicated in data lines
- **WHEN** an observation is appended to a source history
- **THEN** its data line contains neither source name nor trading-pair identity
#### Scenario: Existing operator annotations are preserved
- **WHEN** the ticker appends an observation to a source 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
Only an active source obtained and started by the ticker SHALL be allowed to announce an observation. For each accepted announcement, the ticker SHALL validate the source, typed price, and timestamp; construct an observation with the source's captured trading pair and source name; serialize writes to that source history; append and force the data to persistent storage; and only after persistence succeeds consider it for the trading pair's in-memory latest value. Concurrent callbacks from different sources SHALL neither corrupt nor interleave writes, and readers SHALL never observe an unpersisted price. Every accepted observation SHALL be persisted even when an existing observation has a later timestamp, and latest SHALL change only when the newly persisted timestamp is later. If persistence fails, the ticker SHALL report the trading pair, source, and path clearly and preserve the prior latest observation.
#### Scenario: Active obtained source observation is persisted
- **WHEN** an active source started by the ticker announces a valid price and timestamp
- **THEN** the ticker appends and forces the observation to that source's history before considering it for latest
#### Scenario: Inactive or unknown source callback is rejected
- **WHEN** an inactive source or a source not obtained by the ticker announces an observation
- **THEN** the ticker rejects the callback without persisting or publishing it
#### Scenario: Future-dated observation remains latest
- **WHEN** an accepted observation is persisted with a timestamp earlier than the current latest observation across active sources
- **THEN** the accepted observation remains in its source history and the current later observation remains latest
#### Scenario: Concurrent source callbacks remain durable
- **WHEN** active sources announce observations concurrently
- **THEN** each successful append is a complete non-interleaved history line and latest identifies the greatest successfully persisted timestamp
#### Scenario: Persistence fails
- **WHEN** appending or forcing an accepted observation fails
- **THEN** the failure identifies the trading pair, source, and path, the attempted observation is not published, and the previously persisted latest observation remains available
@@ -0,0 +1,23 @@
## 1. Ticker Source API
- [x] 1.1 Add `PriceSourceName` under `String -> Name` in `conf/detag.conf` and retain required generated-type imports in `.tjava` sources.
- [x] 1.2 Add the `PriceSource` and functional `PriceSink` API contracts with typed metadata and `PriceSource.start(PriceSink)` lifecycle wiring.
- [x] 1.3 Extend `PriceObservation` with source identity and extend `TickerService` with stop lifecycle while keeping plugin management out of the public service API.
## 2. Source Registry and Persistence
- [x] 2.1 Implement internal handling of context-provided sources with identity validation, duplicate rejection, lifecycle state checks, and exact-instance callback validation in `TickerServiceImpl`.
- [x] 2.2 Implement centralized UUID/source/safe-symbol history paths without scanning, creating, or migrating filesystem content.
- [x] 2.3 Load active per-source histories with strict parsing, comments, source-context observations, clear malformed-line errors, and greatest-timestamp restoration across sources.
- [x] 2.4 Persist concurrent active-source announcements to independent histories with complete-line serialization and `FileChannel.force(true)` before atomic greatest-timestamp publication.
- [x] 2.5 Implement ticker start/stop orchestration so only active sources receive `start(this)`, every started source receives a stop attempt, and partial startup is rolled back.
## 3. Hardcoded Source and Wiring
- [x] 3.1 Add an independently constructible, stoppable `HardcodedPriceSource` whose `start(PriceSink)` preserves an existing sink on repeated start, announces `EVE_USDC = 14.85` immediately, and owns one-minute fixed-delay scheduling.
- [x] 3.2 Update NenjimHub temporary autorun wiring to construct the hardcoded source independently and pass it through `TickerServiceImpl(PriceSource...)` as a Cauldron simulation of future context discovery.
## 4. Verification
- [x] 4.1 Compile the project and manually verify API shape, constructor-independent sources, start-time sink wiring and repeated-start protection, safe and duplicate source handling, missing/empty/malformed histories, UUID paths, legacy-file exclusion, source lifecycle, per-source formatting, persistence failure, concurrent callbacks, and greatest-timestamp selection without adding automated tests.
- [x] 4.2 Validate the OpenSpec change, confirm `TickerServiceImpl` has no generator/scheduler constants, and confirm no unrelated behavior or generated Detag source was edited.
+91 -36
View File
@@ -6,66 +6,121 @@ Provide AssetAZ callers with typed latest prices backed by explicitly enabled, d
## Requirements
### Requirement: Price sources are discovered and lifecycle-managed
Nenjim SHALL be able to construct each price source independently in an initialized but unstarted state without supplying a ticker or sink. The ticker SHALL obtain `PriceSource` plugins from its Nenjim context, while ordinary ticker clients SHALL NOT receive a public operation for adding or managing sources. The ticker SHALL identify each obtained source by its `TradingPair` and stable source name, reject duplicate identities clearly, start each active source by passing itself as `PriceSink`, and stop every source it started. Source names SHALL be non-empty safe directory components and SHALL reject `/`, `\`, `..`, and control characters without rewriting them. A source SHALL announce typed prices and timestamps only through the sink received at start and SHALL remain unaware of its persistence path. A repeated source start SHALL reject the call before replacing its existing sink.
#### Scenario: Sources are constructed independently
- **WHEN** Nenjim constructs a price source before constructing or starting the ticker
- **THEN** source construction requires neither a `PriceSink` nor a ticker reference and leaves the source unstarted
#### Scenario: Ticker obtains source plugins
- **WHEN** the ticker initializes its source set from its Nenjim context
- **THEN** it looks for implementations of `PriceSource`, not implementations of `PriceSink`, and manages the obtained sources internally
#### Scenario: Ordinary clients cannot manage plugins
- **WHEN** a caller uses the public `TickerService` contract
- **THEN** the contract exposes ticker lifecycle and latest-price queries but no source-registration operation
#### Scenario: Distinct context sources are accepted
- **WHEN** the ticker obtains two sources with different source names or trading pairs from its context
- **THEN** it accepts both and manages each source independently
#### Scenario: Duplicate persistent identity is rejected
- **WHEN** the ticker obtains sources with the same trading pair and source name
- **THEN** initialization fails with an error identifying the duplicate source history
#### Scenario: Unsafe source name is rejected
- **WHEN** a source name is empty or contains a slash, backslash, `..`, or a control character
- **THEN** ticker initialization or startup fails clearly without rewriting the source name or creating filesystem content
#### Scenario: Active source lifecycle is managed
- **WHEN** the ticker starts and later stops with an active context-provided source
- **THEN** it calls `start` with itself as `PriceSink` and subsequently stops the source without leaving source-owned resources running
#### Scenario: Repeated source start preserves existing sink
- **WHEN** an already-started source receives another start call with a different sink
- **THEN** it rejects the call before replacing the sink from the successful start
### 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`.
The ticker SHALL expose the latest successfully persisted `PriceObservation` for a requested `TradingPair` across all active context-provided sources for that pair. A `PriceObservation` SHALL contain exactly an `AssetPrice`, its observation `Instant`, and the stable source name; the `AssetPrice` SHALL contain both its `ΩPriceΩ` value and `TradingPair`. If several active sources provide a pair, latest SHALL be the observation with the greatest `observedAt` timestamp, and its source name SHALL identify its source. A request for a pair with no obtained sources, no active sources, or no successfully 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`
- **WHEN** a caller requests a trading pair after at least one observation has been loaded or persisted by an active source
- **THEN** the ticker returns the successfully persisted observation with the greatest timestamp and its source identity
#### Scenario: Unsupported or inactive pair is requested
- **WHEN** a caller requests a pair that is unsupported or not activated
#### Scenario: Newest observation is selected across sources
- **WHEN** multiple active sources for one trading pair have successfully persisted observations
- **THEN** the ticker returns the observation with the greatest `observedAt` across those sources
#### Scenario: Pair has no obtained or active source
- **WHEN** a caller requests a pair with no context-provided source or whose obtained sources are all inactive
- **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
#### Scenario: Active pair has no persisted observation
- **WHEN** a caller requests a pair whose active sources have no successfully persisted observation
- **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.
### Requirement: Price history explicitly activates each source
For each context-provided source, the ticker SHALL derive an independent history path as `data/assetaz/ticker/<base UUID>/<quote UUID>/<source name>/<safe base symbol>_<safe quote symbol>.prices`. UUIDs SHALL use their unchanged canonical representation and SHALL be the technical trading-pair identity; sanitized symbols SHALL be used only for the human-readable filename. Only an existing file at that exact path SHALL activate the source. The ticker SHALL neither scan for arbitrary sources nor create or migrate missing files or directories. It SHALL load every valid observation using the obtained source's trading pair and source name and select the greatest timestamp across all active histories for each pair, regardless of file order. Empty lines, full-line comments, and inline comments SHALL remain supported. An existing history with no data SHALL activate only that source without an initial observation, while a missing history SHALL leave only that source inactive and emit a warning containing the trading pair, source name, and complete expected path.
#### 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: Existing source history activates and restores identity
- **WHEN** a context-provided source has an existing valid history with permitted whitespace or comments
- **THEN** that source is active and its observations are restored with its captured trading pair and source name
#### 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: Empty source history activates without a price
- **WHEN** a context-provided source has an existing empty or comment-only history file
- **THEN** that source is active without an initial 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: Missing source history leaves only that source inactive
- **WHEN** one obtained source history is missing while another source for the same pair has an existing history
- **THEN** the missing source is not started, the existing source remains active, and no missing path is created
#### 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
#### Scenario: Missing history warning identifies the source
- **WHEN** a context-provided source's expected history file is absent
- **THEN** the ticker logs a warning containing its trading pair, source name, and complete expected UUID-based path
#### Scenario: Malformed source history is rejected clearly
- **WHEN** comment text is removed and a non-empty data line in an active source history lacks a valid UTC timestamp and price
- **THEN** ticker startup fails with an error containing the history filename, one-based physical line number, and identifying invalid content
#### Scenario: Legacy history is not migrated
- **WHEN** only `data/assetaz/ticker/EVE_USDC.prices` exists
- **THEN** the ticker does not read, move, copy, or create a replacement for that legacy file
### 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.
Each source history data line SHALL retain the form `<UTC timestamp>:<price>`, where the timestamp uses `uuuuMMddHHmmssSSS'Z'` with millisecond precision in UTC. Trading-pair and source identity SHALL come from the context-provided source and its directory context and SHALL NOT be stored in individual data lines. 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
- **WHEN** any source observation at `2026-08-05T13:15:42.783Z` with price `14.85` is persisted
- **THEN** the appended line is `20260805131542783Z:14.85`
#### Scenario: Identity is not duplicated in data lines
- **WHEN** an observation is appended to a source history
- **THEN** its data line contains neither source name nor trading-pair identity
#### Scenario: Existing operator annotations are preserved
- **WHEN** the ticker appends an observation to a history containing comments or blank lines
- **WHEN** the ticker appends an observation to a source 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.
Only an active source obtained and started by the ticker SHALL be allowed to announce an observation. For each accepted announcement, the ticker SHALL validate the source, typed price, and timestamp; construct an observation with the source's captured trading pair and source name; serialize writes to that source history; append and force the data to persistent storage; and only after persistence succeeds consider it for the trading pair's in-memory latest value. Concurrent callbacks from different sources SHALL neither corrupt nor interleave writes, and readers SHALL never observe an unpersisted price. Every accepted observation SHALL be persisted even when an existing observation has a later timestamp, and latest SHALL change only when the newly persisted timestamp is later. If persistence fails, the ticker SHALL report the trading pair, source, and path clearly and preserve the prior latest observation.
#### 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: Active obtained source observation is persisted
- **WHEN** an active source started by the ticker announces a valid price and timestamp
- **THEN** the ticker appends and forces the observation to that source's history 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: Inactive or unknown source callback is rejected
- **WHEN** an inactive source or a source not obtained by the ticker announces an observation
- **THEN** the ticker rejects the callback without persisting or publishing it
#### 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: Future-dated observation remains latest
- **WHEN** an accepted observation is persisted with a timestamp earlier than the current latest observation across active sources
- **THEN** the accepted observation remains in its source history and the current later observation remains latest
#### Scenario: Concurrent source callbacks remain durable
- **WHEN** active sources announce observations concurrently
- **THEN** each successful append is a complete non-interleaved history line and latest identifies the greatest successfully persisted timestamp
#### 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
- **WHEN** appending or forcing an accepted observation fails
- **THEN** the failure identifies the trading pair, source, and path, the attempted observation is not published, and the previously persisted latest observation remains available
@@ -8,10 +8,12 @@ import java.util.Objects;
public record PriceObservation(
@NotNull AssetPrice price,
@NotNull Instant observedAt
@NotNull Instant observedAt,
@NotNull ΩPriceSourceNameΩ sourceName
) {
public PriceObservation {
Objects.requireNonNull(price, "price");
Objects.requireNonNull(observedAt, "observedAt");
Objects.requireNonNull(sourceName, "sourceName");
}
}
@@ -0,0 +1,19 @@
package com.r35157.assetaz.core.service.ticker;
import org.jetbrains.annotations.NotNull;
import java.math.BigDecimal;
import java.time.Instant;
/**
* Receives raw typed observations from price sources.
*/
@FunctionalInterface
public interface PriceSink {
void announce(
@NotNull PriceSource source,
@NotNull ΩPriceΩ price,
@NotNull Instant observedAt
);
}
@@ -0,0 +1,18 @@
package com.r35157.assetaz.core.service.ticker;
import com.r35157.libs.valuetypes.basic.TradingPair;
import org.jetbrains.annotations.NotNull;
/**
* Obtains prices for one trading pair and announces them to a {@link PriceSink}.
*/
public interface PriceSource {
@NotNull TradingPair getTradingPair();
@NotNull ΩPriceSourceNameΩ getSourceName();
void start(@NotNull PriceSink priceSink);
void stop();
}
@@ -15,6 +15,11 @@ public interface TickerService {
*/
void start();
/**
* Stops every source started by this service.
*/
void stop();
/**
* Returns the latest available price observation for a trading pair.
*
@@ -0,0 +1,114 @@
package com.r35157.assetaz.core.service.ticker.impl.ref;
import com.r35157.assetaz.core.service.ticker.PriceSink;
import com.r35157.assetaz.core.service.ticker.PriceSource;
import com.r35157.libs.valuetypes.basic.TradingPair;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import static com.r35157.libs.valuetypes.basic.WellKnownTradingPairs.EVE_USDC;
/**
* Temporary reference source that produces a constant EVE/USDC price.
*/
public final class HardcodedPriceSource implements PriceSource {
public HardcodedPriceSource() {
this(Clock.systemUTC(), OBSERVATION_DELAY_MINUTES, TimeUnit.MINUTES);
}
HardcodedPriceSource(
@NotNull Clock clock,
long observationDelay,
@NotNull TimeUnit observationDelayUnit
) {
this.clock = Objects.requireNonNull(clock, "clock");
if (observationDelay <= 0) {
throw new IllegalArgumentException("observationDelay must be positive");
}
this.observationDelay = observationDelay;
this.observationDelayUnit = Objects.requireNonNull(
observationDelayUnit,
"observationDelayUnit"
);
}
@Override
public @NotNull TradingPair getTradingPair() {
return EVE_USDC.getTradingPair();
}
@Override
public @NotNull ΩPriceSourceNameΩ getSourceName() {
return SOURCE_NAME;
}
@Override
public synchronized void start(@NotNull PriceSink priceSink) {
if (scheduler != null) {
throw new IllegalStateException("Hardcoded price source is already started");
}
this.priceSink = Objects.requireNonNull(priceSink, "priceSink");
announcePrice();
scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "assetaz-hardcoded-price-source");
thread.setDaemon(true);
return thread;
});
scheduler.scheduleWithFixedDelay(
this::announcePriceSafely,
observationDelay,
observationDelay,
observationDelayUnit
);
}
@Override
public synchronized void stop() {
if (scheduler == null) {
return;
}
scheduler.shutdownNow();
scheduler = null;
}
private void announcePrice() {
priceSink.announce(
this,
new ΩPriceΩ(HARDCODED_PRICE),
clock.instant().truncatedTo(ChronoUnit.MILLIS)
);
}
private void announcePriceSafely() {
try {
announcePrice();
} catch (RuntimeException exception) {
log.error("Hardcoded price source failed to announce EVE_USDC price", exception);
}
}
private static final Logger log = LoggerFactory.getLogger(HardcodedPriceSource.class);
private static final ΩPriceSourceNameΩ SOURCE_NAME = "Hardcoded";
private static final String HARDCODED_PRICE = "14.85";
private static final long OBSERVATION_DELAY_MINUTES = 1;
private PriceSink priceSink;
private final Clock clock;
private final long observationDelay;
private final TimeUnit observationDelayUnit;
private ScheduledExecutorService scheduler;
}
@@ -1,6 +1,8 @@
package com.r35157.assetaz.core.service.ticker.impl.ref;
import com.r35157.assetaz.core.service.ticker.PriceObservation;
import com.r35157.assetaz.core.service.ticker.PriceSink;
import com.r35157.assetaz.core.service.ticker.PriceSource;
import com.r35157.assetaz.core.service.ticker.TickerService;
import com.r35157.libs.valuetypes.basic.AssetPrice;
import com.r35157.libs.valuetypes.basic.TradingPair;
@@ -17,60 +19,98 @@ 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.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import static com.r35157.libs.valuetypes.basic.WellKnownTradingPairs.EVE_USDC;
public final class TickerServiceImpl implements TickerService, PriceSink {
public TickerServiceImpl(PriceSource... priceSources) {
this(DATA_ROOT);
public final class TickerServiceImpl implements TickerService {
public TickerServiceImpl() {
this(PRICE_HISTORY_PATH, Clock.systemUTC());
for (PriceSource priceSource : priceSources) {
addPriceSource(priceSource);
}
}
TickerServiceImpl(@NotNull Path priceHistoryPath, Clock clock) {
this.priceHistoryPath = Objects.requireNonNull(priceHistoryPath, "priceHistoryPath");
this.clock = Objects.requireNonNull(clock, "clock");
TickerServiceImpl(@NotNull Path dataRoot) {
this.dataRoot = Objects.requireNonNull(dataRoot, "dataRoot");
}
@Override
public void start() {
public synchronized void start() {
if (state != LifecycleState.NEW && state != LifecycleState.STOPPED) {
throw new IllegalStateException("Ticker service is already starting or started");
}
state = LifecycleState.STARTING;
latestByPair.clear();
registrationsByKey.values().forEach(SourceRegistration::resetLifecycle);
try {
startInternal();
} catch (IOException exception) {
throw new IllegalStateException(
"Could not start Ticker service using history file: " + priceHistoryPath,
exception
);
activateAndLoadHistories();
state = LifecycleState.STARTED;
startActiveSources();
} catch (IOException | RuntimeException exception) {
rollbackFailedStart(exception);
}
}
@Override
public @NotNull PriceObservation getLatestPrice(@NotNull TradingPair tradingPair) {
public synchronized void stop() {
if (state == LifecycleState.NEW || state == LifecycleState.STOPPED) {
return;
}
if (state != LifecycleState.STARTED) {
throw new IllegalStateException("Ticker service cannot stop while in state " + state);
}
state = LifecycleState.STOPPING;
RuntimeException failure = stopStartedSources();
resetStoppedState();
if (failure != null) {
throw failure;
}
}
@Override
public synchronized @NotNull PriceObservation getLatestPrice(
@NotNull TradingPair tradingPair
) {
Objects.requireNonNull(tradingPair, "tradingPair");
if (!SUPPORTED_PAIR.equals(tradingPair)) {
boolean registered = registrationsByKey.keySet().stream()
.anyMatch(key -> key.tradingPair().equals(tradingPair));
if (!registered) {
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
"Ticker has no registered price source for trading pair: " + tradingPair
);
}
PriceObservation observation = latest.get();
boolean active = registrationsByKey.values().stream()
.anyMatch(registration -> registration.active
&& registration.key.tradingPair().equals(tradingPair));
if (!active || state != LifecycleState.STARTED) {
throw new IllegalStateException(
"Ticker has no active price source for trading pair: " + tradingPair
);
}
AtomicReference<PriceObservation> latestReference = latestByPair.get(tradingPair);
PriceObservation observation = latestReference == null ? null : latestReference.get();
if (observation == null) {
throw new IllegalStateException(
"No persisted price is available for trading pair: " + tradingPair
@@ -80,53 +120,203 @@ public final class TickerServiceImpl implements TickerService {
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
@Override
public void announce(
@NotNull PriceSource source,
@NotNull ΩPriceΩ price,
@NotNull Instant observedAt
) {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(price, "price");
Objects.requireNonNull(observedAt, "observedAt");
if (observedAt.getNano() % NANOS_PER_MILLISECOND != 0) {
throw new IllegalArgumentException(
"Ticker observation timestamp must have millisecond precision: " + observedAt
);
return;
}
PriceObservation loadedLatest;
callbackLock.readLock().lock();
try {
loadedLatest = loadLatestObservation();
} catch (IOException | RuntimeException exception) {
started = false;
throw exception;
SourceRegistration registration = registrationsByInstance.get(source);
if (registration == null) {
throw new IllegalArgumentException("Price callback came from an unregistered source");
}
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
if (state != LifecycleState.STARTED || !registration.active) {
throw new IllegalStateException(
"Price callback came from an inactive source: "
+ registration.key.tradingPair() + " / "
+ registration.key.sourceName()
);
}
private PriceObservation loadLatestObservation() throws IOException {
PriceObservation observation = new PriceObservation(
new AssetPrice(price, registration.key.tradingPair()),
observedAt,
registration.key.sourceName()
);
persistAndPublish(registration, observation);
} finally {
callbackLock.readLock().unlock();
}
}
private synchronized void addPriceSource(@NotNull PriceSource priceSource) {
Objects.requireNonNull(priceSource, "priceSource");
if (state != LifecycleState.NEW) {
throw new IllegalStateException(
"Price sources must be registered before the Ticker service is started"
);
}
if (registrationsByInstance.containsKey(priceSource)) {
throw new IllegalArgumentException("Price source instance is already registered");
}
TradingPair tradingPair = validateTradingPair(priceSource.getTradingPair());
ΩPriceSourceNameΩ sourceName = validateSourceName(priceSource.getSourceName());
SourceKey key = new SourceKey(tradingPair, sourceName);
Path historyPath = historyPath(tradingPair, sourceName);
if (registrationsByKey.containsKey(key)) {
throw new IllegalArgumentException(
"Duplicate price source registration for " + tradingPair
+ " source " + sourceName
+ " addresses history " + historyPath
);
}
SourceRegistration existingHistoryRegistration =
registrationsByHistoryPath.get(historyPath);
if (existingHistoryRegistration != null) {
throw new IllegalArgumentException(
"Price source history is already registered: " + historyPath
+ " is used by "
+ existingHistoryRegistration.key.tradingPair()
+ " source "
+ existingHistoryRegistration.key.sourceName()
);
}
SourceRegistration registration = new SourceRegistration(
priceSource,
key,
historyPath
);
registrationsByKey.put(key, registration);
registrationsByInstance.put(priceSource, registration);
registrationsByHistoryPath.put(historyPath, registration);
}
private void activateAndLoadHistories() throws IOException {
for (SourceRegistration registration : registrationsByKey.values()) {
validateRegistrationMetadata(registration);
if (!Files.exists(registration.historyPath)) {
log.warn(
"Ticker source is inactive because its history is missing: pair={}, source={}, path={}",
registration.key.tradingPair(),
registration.key.sourceName(),
registration.historyPath
);
continue;
}
PriceObservation loadedLatest = loadLatestObservation(registration);
registration.active = true;
considerLatest(loadedLatest);
}
}
private void validateRegistrationMetadata(SourceRegistration registration) {
TradingPair currentPair = validateTradingPair(registration.source.getTradingPair());
ΩPriceSourceNameΩ currentName = validateSourceName(registration.source.getSourceName());
if (!registration.key.tradingPair().equals(currentPair)
|| !registration.key.sourceName().equals(currentName)) {
throw new IllegalStateException(
"Registered price source identity changed before startup: expected "
+ registration.key.tradingPair() + " / "
+ registration.key.sourceName() + ", found "
+ currentPair + " / " + currentName
);
}
}
private void startActiveSources() {
for (SourceRegistration registration : registrationsByKey.values()) {
if (!registration.active) {
continue;
}
registration.started = true;
registration.source.start(this);
}
}
private void rollbackFailedStart(Exception startupFailure) {
state = LifecycleState.STOPPING;
RuntimeException stopFailure = stopStartedSources();
resetStoppedState();
IllegalStateException failure = new IllegalStateException(
"Could not start Ticker service: " + startupFailure.getMessage(),
startupFailure
);
if (stopFailure != null) {
failure.addSuppressed(stopFailure);
}
throw failure;
}
private RuntimeException stopStartedSources() {
// State is already STOPPING. Taking and releasing the write lock drains
// callbacks that were accepted while STARTED. Do not hold it while a
// source stops, because the source may wait for one of its own threads.
callbackLock.writeLock().lock();
try {
} finally {
callbackLock.writeLock().unlock();
}
RuntimeException failure = null;
List<SourceRegistration> registrations = new ArrayList<>(
registrationsByKey.values()
);
for (int index = registrations.size() - 1; index >= 0; index--) {
SourceRegistration registration = registrations.get(index);
if (!registration.started) {
continue;
}
try {
registration.source.stop();
} catch (RuntimeException exception) {
if (failure == null) {
failure = new IllegalStateException(
"One or more price sources failed to stop"
);
}
failure.addSuppressed(exception);
} finally {
registration.started = false;
}
}
return failure;
}
private void resetStoppedState() {
registrationsByKey.values().forEach(SourceRegistration::resetLifecycle);
latestByPair.clear();
state = LifecycleState.STOPPED;
}
private PriceObservation loadLatestObservation(
SourceRegistration registration
) throws IOException {
PriceObservation loadedLatest = null;
try (BufferedReader reader = Files.newBufferedReader(
priceHistoryPath,
registration.historyPath,
StandardCharsets.UTF_8
)) {
String rawLine;
@@ -139,7 +329,12 @@ public final class TickerServiceImpl implements TickerService {
continue;
}
PriceObservation observation = parseObservation(data, rawLine, lineNumber);
PriceObservation observation = parseObservation(
registration,
data,
rawLine,
lineNumber
);
if (loadedLatest == null
|| observation.observedAt().isAfter(loadedLatest.observedAt())) {
loadedLatest = observation;
@@ -151,6 +346,7 @@ public final class TickerServiceImpl implements TickerService {
}
private PriceObservation parseObservation(
SourceRegistration registration,
String data,
String rawLine,
int lineNumber
@@ -158,7 +354,7 @@ public final class TickerServiceImpl implements TickerService {
int separator = data.indexOf(':');
if (separator <= 0 || separator != data.lastIndexOf(':')
|| separator == data.length() - 1) {
throw malformedHistory(lineNumber, rawLine, null);
throw malformedHistory(registration.historyPath, lineNumber, rawLine, null);
}
try {
@@ -170,68 +366,72 @@ public final class TickerServiceImpl implements TickerService {
ΩPriceΩ price = new ΩPriceΩ(data.substring(separator + 1));
return new PriceObservation(
new AssetPrice(price, SUPPORTED_PAIR),
observedAt
new AssetPrice(price, registration.key.tradingPair()),
observedAt,
registration.key.sourceName()
);
} 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,
throw malformedHistory(
registration.historyPath,
lineNumber,
rawLine,
exception
);
}
}
private void persistPeriodicObservation() {
try {
persistGeneratedObservation();
} catch (RuntimeException exception) {
log.error("Unexpected failure generating ticker observation for {}", SUPPORTED_PAIR, exception);
}
private static IOException malformedHistory(
Path historyPath,
int lineNumber,
String rawLine,
RuntimeException cause
) {
return new IOException(
"Malformed ticker history in " + historyPath
+ " at line " + lineNumber + ": " + rawLine,
cause
);
}
private void persist(PriceObservation observation) throws IOException {
private void persistAndPublish(
SourceRegistration registration,
PriceObservation observation
) {
registration.persistenceLock.lock();
try {
persist(registration.historyPath, observation);
} catch (IOException exception) {
log.error(
"Failed to persist ticker observation: pair={}, source={}, path={}; retaining prior latest observation",
registration.key.tradingPair(),
registration.key.sourceName(),
registration.historyPath,
exception
);
return;
} finally {
registration.persistenceLock.unlock();
}
considerLatest(observation);
}
private static void persist(
Path historyPath,
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,
historyPath,
StandardOpenOption.READ,
StandardOpenOption.WRITE
)) {
long size = channel.size();
boolean needsLineSeparator = size > 0 && !endsWithLineSeparator(channel, size);
boolean needsLineSeparator = size > 0
&& !endsWithLineSeparator(channel, size, historyPath);
channel.position(size);
if (needsLineSeparator) {
@@ -245,11 +445,91 @@ public final class TickerServiceImpl implements TickerService {
}
}
private boolean endsWithLineSeparator(FileChannel channel, long size) throws IOException {
private void considerLatest(PriceObservation observation) {
if (observation == null) {
return;
}
latestByPair.computeIfAbsent(
observation.price().tradingPair(),
ignored -> new AtomicReference<>()
).updateAndGet(current -> current == null
|| observation.observedAt().isAfter(current.observedAt())
? observation
: current);
}
private Path historyPath(
TradingPair tradingPair,
ΩPriceSourceNameΩ sourceName
) {
String filename = safeSymbol(tradingPair.base().symbol())
+ "_" + safeSymbol(tradingPair.quote().symbol())
+ ".prices";
return dataRoot
.resolve(tradingPair.base().id().toString())
.resolve(tradingPair.quote().id().toString())
.resolve(sourceName)
.resolve(filename);
}
private static TradingPair validateTradingPair(TradingPair tradingPair) {
Objects.requireNonNull(tradingPair, "priceSource.tradingPair");
Objects.requireNonNull(tradingPair.base(), "priceSource.tradingPair.base");
Objects.requireNonNull(tradingPair.quote(), "priceSource.tradingPair.quote");
Objects.requireNonNull(tradingPair.base().id(), "priceSource.tradingPair.base.id");
Objects.requireNonNull(tradingPair.quote().id(), "priceSource.tradingPair.quote.id");
Objects.requireNonNull(tradingPair.base().symbol(), "priceSource.tradingPair.base.symbol");
Objects.requireNonNull(tradingPair.quote().symbol(), "priceSource.tradingPair.quote.symbol");
return tradingPair;
}
private static ΩPriceSourceNameΩ validateSourceName(ΩPriceSourceNameΩ sourceName) {
Objects.requireNonNull(sourceName, "priceSource.sourceName");
if (sourceName.isBlank()
|| sourceName.equals(".")
|| sourceName.contains("/")
|| sourceName.contains("\\")
|| sourceName.contains("..")
|| sourceName.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(
"Unsafe price source name cannot be used as a history directory: "
+ sourceName
);
}
return sourceName;
}
private static String safeSymbol(String symbol) {
if (symbol.isEmpty()) {
throw new IllegalArgumentException("Currency symbol cannot be empty");
}
StringBuilder safe = new StringBuilder();
symbol.codePoints().forEach(codePoint -> {
if (codePoint >= 'A' && codePoint <= 'Z'
|| codePoint >= 'a' && codePoint <= 'z'
|| codePoint >= '0' && codePoint <= '9'
|| codePoint == '-'
|| codePoint == '_') {
safe.appendCodePoint(codePoint);
} else {
safe.append('_');
}
});
return safe.toString();
}
private static boolean endsWithLineSeparator(
FileChannel channel,
long size,
Path historyPath
) 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);
throw new IOException("Could not read final byte of ticker history: " + historyPath);
}
return finalByte.array()[0] == '\n' || finalByte.array()[0] == '\r';
@@ -266,25 +546,62 @@ public final class TickerServiceImpl implements TickerService {
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 enum LifecycleState {
NEW,
STARTING,
STARTED,
STOPPING,
STOPPED
}
private final AtomicReference<PriceObservation> latest = new AtomicReference<>();
private final Path priceHistoryPath;
private final Clock clock;
private record SourceKey(
TradingPair tradingPair,
ΩPriceSourceNameΩ sourceName
) {
}
private static final class SourceRegistration {
private SourceRegistration(
PriceSource source,
SourceKey key,
Path historyPath
) {
this.source = source;
this.key = key;
this.historyPath = historyPath;
}
private void resetLifecycle() {
active = false;
started = false;
}
private final PriceSource source;
private final SourceKey key;
private final Path historyPath;
private final ReentrantLock persistenceLock = new ReentrantLock();
private volatile boolean active;
private boolean started;
private ScheduledExecutorService scheduler;
}
private static final Logger log = LoggerFactory.getLogger(TickerServiceImpl.class);
private static final Path DATA_ROOT = Path.of("data", "assetaz", "ticker");
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter
.ofPattern("uuuuMMddHHmmssSSS'Z'", Locale.ROOT)
.withResolverStyle(ResolverStyle.STRICT);
private static final int NANOS_PER_MILLISECOND = 1_000_000;
private final Path dataRoot;
private final Map<SourceKey, SourceRegistration> registrationsByKey =
new LinkedHashMap<>();
private final Map<PriceSource, SourceRegistration> registrationsByInstance =
new IdentityHashMap<>();
private final Map<Path, SourceRegistration> registrationsByHistoryPath =
new LinkedHashMap<>();
private final Map<TradingPair, AtomicReference<PriceObservation>> latestByPair =
new ConcurrentHashMap<>();
private final ReentrantReadWriteLock callbackLock = new ReentrantReadWriteLock();
private volatile LifecycleState state = LifecycleState.NEW;
}
@@ -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.HardcodedPriceSource;
import com.r35157.assetaz.core.service.ticker.impl.ref.TickerServiceImpl;
import com.r35157.evelyn.emc.EvelynMissionControl;
import com.r35157.evelyn.emc.impl.ref.EvelynMissionControlImpl;
@@ -88,8 +89,15 @@ public class NenjimHubImpl implements NenjimHub {
*/
}
private void startAssetAZTickerService() throws Exception {
new TickerServiceImpl().start();
private void startAssetAZTickerService() {
// Nenjim creates this unstarted PriceSource first...
HardcodedPriceSource priceSource = new HardcodedPriceSource();
// The TickerServiceImpl will ask Nenjim for implementers of the PriceSource interface in this context
// This do not work yet - so we will just inject it in the constructor now. In the future it will
// not be injected in the constructor but TickerServiceImpl will ask Nenjim for them.
TickerServiceImpl tickerService = new TickerServiceImpl(priceSource);
tickerService.start();
}
@Override