From 21b7dfce4473d6c8ac00081aa13b43860d9743a0 Mon Sep 17 00:00:00 2001 From: Junyou Park Date: Sat, 8 Aug 2026 03:00:31 +0900 Subject: [PATCH 1/4] feat: separate realtime display prices from strategy candles --- apps/market-gateway/build.gradle.kts | 1 + .../gateway/FinalizedCandlePollingWorker.java | 68 +++++++++ .../gateway/MarketGatewayConfiguration.java | 91 ++++++++++++ .../trading/gateway/MarketGatewayRunner.java | 67 ++++++--- .../src/main/resources/application.yaml | 3 + .../MarketGatewayPublishingE2ETest.java | 70 ++++----- .../MarketEventTransportConfiguration.java | 2 +- .../RedisMarketEventStreamConsumer.java | 7 + .../worker/runtime/EvaluatingBotRuntime.java | 29 +++- .../runtime/StrategyEvaluationTimeframe.java | 68 +++++++++ .../control/BotControlIntegrationE2ETest.java | 2 +- .../market/MarketEventStreamE2ETest.java | 2 +- ...ProjectedMarketAvailabilityPolicyTest.java | 2 +- .../worker/runtime/EvaluationLoopE2ETest.java | 4 +- .../StrategyEvaluationTimeframeTest.java | 22 +++ .../alpaca/AlpacaSipInboundMessage.java | 21 ++- .../market/alpaca/AlpacaSipMessageParser.java | 83 ++++++----- .../alpaca/AlpacaSipSubscriptionManager.java | 73 ++++++++-- .../market/alpaca/AlpacaSipTransport.java | 4 +- .../alpaca/AlpacaSipWebSocketTransport.java | 13 +- .../candle/AlpacaThirtyMinuteBarsClient.java | 16 ++ .../AlpacaThirtyMinuteBarsJsonParser.java | 88 +++++++++++ .../FinalizedCandleBoundaryPlanner.java | 34 +++++ .../market/candle/FinalizedCandleCycle.java | 88 +++++++++++ .../HttpAlpacaThirtyMinuteBarsClient.java | 76 ++++++++++ .../candle/MarketCandleEventFactory.java | 31 ++++ .../candle/MarketEvaluationCoordinator.java | 70 +++++++++ .../SessionAlignedCandleAggregator.java | 137 ++++++++++++++++++ .../DisplayTradeSubscriptionSource.java | 9 ++ .../market/display/LatestTradeCoalescer.java | 113 +++++++++++++++ .../display/RedisDisplayPricePublisher.java | 103 +++++++++++++ .../RedisDisplayTradeSubscriptionSource.java | 54 +++++++ .../redis/RedisMarketEventPublisher.java | 49 +++++-- ...HttpAlpacaOfficialMarketSessionSource.java | 68 +++++++++ .../session/OfficialMarketSessionSource.java | 9 ++ .../alpaca/AlpacaSipMessageParserTest.java | 120 +++------------ .../alpaca/AlpacaSipRightsGateTest.java | 11 +- ...caSipSubscriptionManagerReconnectTest.java | 6 +- .../AlpacaSipWebSocketTransportTest.java | 8 +- .../alpaca/ContractFixtureResourcesTest.java | 4 +- .../AlpacaThirtyMinuteBarsJsonParserTest.java | 37 +++++ .../FinalizedCandleBoundaryPlannerTest.java | 29 ++++ .../candle/FinalizedCandleCycleTest.java | 60 ++++++++ .../MarketEvaluationCoordinatorTest.java | 46 ++++++ .../SessionAlignedCandleAggregatorTest.java | 109 ++++++++++++++ .../display/LatestTradeCoalescerTest.java | 65 +++++++++ ...RedisDisplayPricePublisherPayloadTest.java | 37 +++++ .../RedisMarketEventPublisherUnitTest.java | 36 +++-- .../fixtures/c19/recorded-aapl-market.csv | 4 +- .../messaging/market/DisplayPriceUpdate.java | 66 +++++++++ .../messaging/market/MarketCandle.java | 60 ++++++++ .../messaging/market/MarketEventType.java | 6 +- .../messaging/market/MarketTimeframe.java | 33 +++++ .../v1/provider-neutral-market-events.json | 2 +- 54 files changed, 2047 insertions(+), 269 deletions(-) create mode 100644 apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/FinalizedCandlePollingWorker.java create mode 100644 apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/StrategyEvaluationTimeframe.java create mode 100644 apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/StrategyEvaluationTimeframeTest.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsClient.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsJsonParser.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/FinalizedCandleBoundaryPlanner.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/FinalizedCandleCycle.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/HttpAlpacaThirtyMinuteBarsClient.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketCandleEventFactory.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinator.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/SessionAlignedCandleAggregator.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/DisplayTradeSubscriptionSource.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/LatestTradeCoalescer.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/RedisDisplayPricePublisher.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/RedisDisplayTradeSubscriptionSource.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/session/HttpAlpacaOfficialMarketSessionSource.java create mode 100644 modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/session/OfficialMarketSessionSource.java create mode 100644 modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsJsonParserTest.java create mode 100644 modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/FinalizedCandleBoundaryPlannerTest.java create mode 100644 modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/FinalizedCandleCycleTest.java create mode 100644 modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinatorTest.java create mode 100644 modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/SessionAlignedCandleAggregatorTest.java create mode 100644 modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/display/LatestTradeCoalescerTest.java create mode 100644 modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/display/RedisDisplayPricePublisherPayloadTest.java create mode 100644 modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/DisplayPriceUpdate.java create mode 100644 modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketCandle.java create mode 100644 modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketTimeframe.java diff --git a/apps/market-gateway/build.gradle.kts b/apps/market-gateway/build.gradle.kts index 35fc222..aea9dda 100644 --- a/apps/market-gateway/build.gradle.kts +++ b/apps/market-gateway/build.gradle.kts @@ -16,4 +16,5 @@ dependencies { testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:testcontainers") testImplementation("org.java-websocket:Java-WebSocket:1.6.0") + testImplementation("io.lettuce:lettuce-core") } diff --git a/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/FinalizedCandlePollingWorker.java b/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/FinalizedCandlePollingWorker.java new file mode 100644 index 0000000..982f342 --- /dev/null +++ b/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/FinalizedCandlePollingWorker.java @@ -0,0 +1,68 @@ +package com.idea2strategy.trading.gateway; + +import com.idea2strategy.trading.market.candle.FinalizedCandleBoundaryPlanner; +import com.idea2strategy.trading.market.candle.FinalizedCandleCycle; +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import com.idea2strategy.trading.market.session.OfficialMarketSessionEvaluator; +import com.idea2strategy.trading.market.session.OfficialMarketSessionSource; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; + +/** Polls only official 30m boundaries; Redis event IDs make restart catch-up idempotent. */ +final class FinalizedCandlePollingWorker { + private static final Logger log = LoggerFactory.getLogger(FinalizedCandlePollingWorker.class); + + private final FinalizedCandleCycle cycle; + private final ApprovedInstruments instruments; + private final OfficialMarketSessionSource sessions; + private final Clock clock; + private final Duration grace; + private final FinalizedCandleBoundaryPlanner planner = new FinalizedCandleBoundaryPlanner(); + private final Set completed = new HashSet<>(); + + FinalizedCandlePollingWorker( + FinalizedCandleCycle cycle, + ApprovedInstruments instruments, + OfficialMarketSessionSource sessions, + Clock clock, + Duration grace) { + this.cycle = Objects.requireNonNull(cycle, "cycle"); + this.instruments = Objects.requireNonNull(instruments, "instruments"); + this.sessions = Objects.requireNonNull(sessions, "sessions"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.grace = Objects.requireNonNull(grace, "grace"); + } + + @Scheduled(fixedDelayString = "${market-gateway.candle-poll-delay:PT5S}") + public void poll() { + Instant now = clock.instant(); + LocalDate tradingDate = now.atZone(OfficialMarketSessionEvaluator.NEW_YORK).toLocalDate(); + OfficialMarketSession session = sessions.session(tradingDate).orElse(null); + if (session == null) { + return; + } + for (Instant boundary : planner.readyBoundaries(session, now, grace)) { + String key = tradingDate + ":" + boundary; + if (completed.contains(key)) { + continue; + } + try { + var result = cycle.run(instruments.bySymbol(), session, boundary); + completed.add(key); + log.info("finalized 30m boundary {}: evaluated={}, missing={}", + boundary, result.evaluatedInstrumentCount(), result.missingInstrumentCount()); + } catch (RuntimeException failure) { + log.error("finalized 30m boundary {} failed and will be retried", boundary, failure); + return; + } + } + } +} diff --git a/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/MarketGatewayConfiguration.java b/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/MarketGatewayConfiguration.java index c839b85..dd95236 100644 --- a/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/MarketGatewayConfiguration.java +++ b/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/MarketGatewayConfiguration.java @@ -12,7 +12,19 @@ import com.idea2strategy.trading.market.alpaca.ProviderRightsGate; import com.idea2strategy.trading.market.alpaca.ReconnectBackoff; import com.idea2strategy.trading.market.redis.RedisMarketEventPublisher; +import com.idea2strategy.trading.market.candle.AlpacaThirtyMinuteBarsJsonParser; +import com.idea2strategy.trading.market.candle.FinalizedCandleCycle; +import com.idea2strategy.trading.market.candle.HttpAlpacaThirtyMinuteBarsClient; +import com.idea2strategy.trading.market.display.LatestTradeCoalescer; +import com.idea2strategy.trading.market.display.RedisDisplayPricePublisher; +import com.idea2strategy.trading.market.display.RedisDisplayTradeSubscriptionSource; +import com.idea2strategy.trading.market.session.HttpAlpacaOfficialMarketSessionSource; +import com.idea2strategy.trading.market.session.OfficialMarketSessionSource; +import com.idea2strategy.trading.market.availability.MarketDataAvailabilityResult; +import com.idea2strategy.trading.market.availability.MarketDataAvailabilityStatus; +import com.idea2strategy.trading.messaging.market.MarketEventType; import java.net.URI; +import java.net.http.HttpClient; import java.nio.file.Path; import java.time.Clock; import java.time.Duration; @@ -22,9 +34,11 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; +import org.springframework.scheduling.annotation.EnableScheduling; @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(prefix = "market-gateway", name = "redis-uri") +@EnableScheduling public class MarketGatewayConfiguration { @Bean VerifiedGatewayMaterialization verifiedGatewayMaterialization( @@ -47,6 +61,77 @@ RedisMarketEventPublisher marketEventPublisher( return RedisMarketEventPublisher.connect(redisUri, keyPrefix, recentBarCapacity); } + @Bean(destroyMethod = "close") + RedisDisplayPricePublisher displayPricePublisher( + @Value("${market-gateway.redis-uri}") String redisUri, + @Value("${market-gateway.redis-key-prefix}") String keyPrefix) { + return RedisDisplayPricePublisher.connect(redisUri, keyPrefix); + } + + @Bean(destroyMethod = "close") + RedisDisplayTradeSubscriptionSource displayTradeSubscriptionSource( + @Value("${market-gateway.redis-uri}") String redisUri, + @Value("${market-gateway.redis-key-prefix}") String keyPrefix) { + return RedisDisplayTradeSubscriptionSource.connect(redisUri, keyPrefix); + } + + @Bean + @ConditionalOnProperty( + prefix = "market-gateway", name = "strategy-candles-enabled", havingValue = "true", matchIfMissing = true) + OfficialMarketSessionSource officialMarketSessionSource( + @Value("${market-gateway.alpaca-calendar-endpoint:https://api.alpaca.markets/v2/calendar}") + String endpoint, + AlpacaCredentialsProvider credentialsProvider, + Clock marketGatewayClock) { + return new HttpAlpacaOfficialMarketSessionSource( + HttpClient.newHttpClient(), URI.create(endpoint), credentialsProvider, marketGatewayClock); + } + + @Bean + @ConditionalOnProperty( + prefix = "market-gateway", name = "strategy-candles-enabled", havingValue = "true", matchIfMissing = true) + FinalizedCandleCycle finalizedCandleCycle( + @Value("${market-gateway.alpaca-bars-endpoint:https://data.alpaca.markets/v2/stocks/bars}") + String endpoint, + @Value("${market-gateway.candle-fetch-batch-size:200}") int batchSize, + AlpacaCredentialsProvider credentialsProvider, + RedisMarketEventPublisher publisher, + Clock marketGatewayClock) { + var client = new HttpAlpacaThirtyMinuteBarsClient( + HttpClient.newHttpClient(), + URI.create(endpoint), + credentialsProvider, + new AlpacaThirtyMinuteBarsJsonParser()); + var ordering = new MarketEventOrderingProcessor(); + return new FinalizedCandleCycle(client, event -> { + publisher.publish(ordering.process(event)); + if (event.eventType() == MarketEventType.MARKET_EVALUATION_READY) { + publisher.publishAvailability( + event.instrumentId(), + event.sequence(), + event.receivedAt(), + new MarketDataAvailabilityResult( + MarketDataAvailabilityStatus.AVAILABLE, + true, + true, + java.util.Set.of(), + java.util.List.of())); + } + }, marketGatewayClock, batchSize); + } + + @Bean + @ConditionalOnProperty( + prefix = "market-gateway", name = "strategy-candles-enabled", havingValue = "true", matchIfMissing = true) + FinalizedCandlePollingWorker finalizedCandlePollingWorker( + FinalizedCandleCycle cycle, + ApprovedInstruments instruments, + OfficialMarketSessionSource sessions, + Clock marketGatewayClock, + @Value("${market-gateway.candle-finalization-grace:PT2S}") Duration grace) { + return new FinalizedCandlePollingWorker(cycle, instruments, sessions, marketGatewayClock, grace); + } + @Bean ApprovedInstruments approvedInstruments( @Value("${market-gateway.instrument-mapping-path}") String mappingPath, @@ -102,6 +187,9 @@ MarketGatewayRunner marketGatewayRunner( AlpacaCredentialsProvider credentialsProvider, AlpacaMarketEventNormalizer normalizer, RedisMarketEventPublisher publisher, + ApprovedInstruments approvedInstruments, + RedisDisplayPricePublisher displayPricePublisher, + RedisDisplayTradeSubscriptionSource displayTradeSubscriptionSource, FileReadinessMarker readinessMarker, Clock marketGatewayClock) { AlpacaDataFeed feed = AlpacaDataFeed.parse(feedValue); @@ -117,6 +205,9 @@ MarketGatewayRunner marketGatewayRunner( normalizer, new MarketEventOrderingProcessor(), publisher, + new LatestTradeCoalescer(approvedInstruments.bySymbol(), marketGatewayClock), + displayPricePublisher, + displayTradeSubscriptionSource, readinessMarker, new ReconnectBackoff(reconnectInitialDelay, reconnectMaxDelay), marketGatewayClock); diff --git a/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/MarketGatewayRunner.java b/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/MarketGatewayRunner.java index 0eaf18d..098a4b3 100644 --- a/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/MarketGatewayRunner.java +++ b/apps/market-gateway/src/main/java/com/idea2strategy/trading/gateway/MarketGatewayRunner.java @@ -19,6 +19,9 @@ import com.idea2strategy.trading.market.availability.MarketDataAvailabilityResult; import com.idea2strategy.trading.market.availability.MarketDataAvailabilityStatus; import com.idea2strategy.trading.market.availability.MarketDataDegradationReason; +import com.idea2strategy.trading.market.display.DisplayTradeSubscriptionSource; +import com.idea2strategy.trading.market.display.LatestTradeCoalescer; +import com.idea2strategy.trading.market.display.RedisDisplayPricePublisher; import com.idea2strategy.trading.market.redis.MarketEventPublishResult; import com.idea2strategy.trading.market.redis.RedisMarketEventPublisher; import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; @@ -56,6 +59,9 @@ public final class MarketGatewayRunner implements SmartLifecycle { private final AlpacaMarketEventNormalizer normalizer; private final MarketEventOrderingProcessor orderingProcessor; private final RedisMarketEventPublisher publisher; + private final LatestTradeCoalescer tradeCoalescer; + private final RedisDisplayPricePublisher displayPricePublisher; + private final DisplayTradeSubscriptionSource displaySubscriptions; private final FileReadinessMarker readinessMarker; private final ReconnectBackoff backoff; private final Clock clock; @@ -68,6 +74,7 @@ public final class MarketGatewayRunner implements SmartLifecycle { }); private final AtomicInteger failedAttempts = new AtomicInteger(); private final AtomicReference activeSocket = new AtomicReference<>(); + private final AtomicReference activeSubscription = new AtomicReference<>(); private final Map unpublishedFrames = new ConcurrentHashMap<>(); private final Map latestSequenceByInstrument = new ConcurrentHashMap<>(); private final Set instrumentsWithSequenceGap = ConcurrentHashMap.newKeySet(); @@ -83,6 +90,9 @@ public MarketGatewayRunner( AlpacaMarketEventNormalizer normalizer, MarketEventOrderingProcessor orderingProcessor, RedisMarketEventPublisher publisher, + LatestTradeCoalescer tradeCoalescer, + RedisDisplayPricePublisher displayPricePublisher, + DisplayTradeSubscriptionSource displaySubscriptions, FileReadinessMarker readinessMarker, ReconnectBackoff backoff, Clock clock) { @@ -95,6 +105,9 @@ public MarketGatewayRunner( this.normalizer = Objects.requireNonNull(normalizer, "normalizer"); this.orderingProcessor = Objects.requireNonNull(orderingProcessor, "orderingProcessor"); this.publisher = Objects.requireNonNull(publisher, "publisher"); + this.tradeCoalescer = Objects.requireNonNull(tradeCoalescer, "tradeCoalescer"); + this.displayPricePublisher = Objects.requireNonNull(displayPricePublisher, "displayPricePublisher"); + this.displaySubscriptions = Objects.requireNonNull(displaySubscriptions, "displaySubscriptions"); this.readinessMarker = Objects.requireNonNull(readinessMarker, "readinessMarker"); this.backoff = Objects.requireNonNull(backoff, "backoff"); this.clock = Objects.requireNonNull(clock, "clock"); @@ -106,6 +119,8 @@ public void start() { credentialsProvider.load(); running = true; log.info("market-gateway connecting to {} for {} symbols", endpoint, universe.symbols().size()); + scheduler.scheduleAtFixedRate(this::flushDisplayPrices, 250, 250, TimeUnit.MILLISECONDS); + scheduler.scheduleAtFixedRate(this::reconcileDisplaySubscriptions, 0, 1, TimeUnit.SECONDS); scheduler.execute(this::connect); } @@ -151,6 +166,26 @@ private void scheduleReconnect() { scheduler.schedule(this::connect, delay.toMillis(), TimeUnit.MILLISECONDS); } + private void flushDisplayPrices() { + try { + tradeCoalescer.flush().forEach(displayPricePublisher::publish); + } catch (RuntimeException failure) { + log.error("display price coalescing failed", failure); + } + } + + private void reconcileDisplaySubscriptions() { + AlpacaSipSubscriptionManager manager = activeSubscription.get(); + if (manager == null || !manager.isAuthenticated()) { + return; + } + try { + manager.replaceTradeSubscriptions(displaySubscriptions.desiredSymbols(clock.instant())); + } catch (RuntimeException failure) { + log.error("display trade subscription reconciliation failed", failure); + } + } + private void stopForRightsFailure(ProviderRightsUnavailableException failure) { log.error("Alpaca {} rights are no longer verified; the gateway stays down until restarted " + "with current rights evidence", feed.eventValue(), failure); @@ -174,6 +209,7 @@ public void onOpen(WebSocket webSocket) { credentialsProvider, AlpacaSipWebSocketTransport.connected(webSocket), feed); + activeSubscription.set(subscription); webSocket.request(1); } @@ -217,41 +253,31 @@ private void handle(WebSocket webSocket, String text) { private void dispatch(AlpacaSipInboundMessage message) { switch (message) { case AlpacaSipInboundMessage.Connected ignored -> subscription.onConnected(); - case AlpacaSipInboundMessage.Authenticated ignored -> subscription.onAuthenticationApproved(); + case AlpacaSipInboundMessage.Authenticated ignored -> { + subscription.onAuthenticationApproved(); + failedAttempts.set(0); + readinessMarker.markReady(); + reconcileDisplaySubscriptions(); + } case AlpacaSipInboundMessage.SubscriptionConfirmed confirmed -> { - subscription.onSubscriptionApproved(confirmed.barSymbols()); + subscription.onSubscriptionApproved(confirmed.tradeSymbols()); failedAttempts.set(0); readinessMarker.markReady(); - log.info("Alpaca {} subscription active for {} symbols", - feed.eventValue(), confirmed.barSymbols().size()); + log.info("Alpaca {} display trade subscription active for {} symbols", + feed.eventValue(), confirmed.tradeSymbols().size()); } case AlpacaSipInboundMessage.ProviderError error -> { log.warn("Alpaca {} error {}: {}", feed.eventValue(), error.code(), error.message()); readinessMarker.markNotReady(); publishUnavailable(MarketDataDegradationReason.PROVIDER_DISCONNECTED); } - case AlpacaSipInboundMessage.MinuteBar bar -> publishBar(bar); + case AlpacaSipInboundMessage.TradeTick tick -> tradeCoalescer.accept(tick); case AlpacaSipInboundMessage.UnsupportedFrame unsupported -> unpublishedFrames.computeIfAbsent(unsupported.frameType(), key -> new LongAdder()) .increment(); } } - private void publishBar(AlpacaSipInboundMessage.MinuteBar bar) { - MarketEventEnvelope envelope; - try { - envelope = normalizer.normalize(bar.input()); - } catch (UnsupportedInstrumentException exception) { - unpublishedFrames.computeIfAbsent("unsupported-instrument", key -> new LongAdder()).increment(); - return; - } - MarketEventHandlingResult handling = orderingProcessor.process(envelope); - MarketEventPublishResult result = publisher.publish(handling); - publishAvailability(envelope, handling); - log.debug("bar {} {} handling={} publish={}", - envelope.instrumentId(), envelope.occurredAt(), handling.status(), result.status()); - } - private void handleDisconnect(String reason) { readinessMarker.markNotReady(); AlpacaSipSubscriptionManager manager = subscription; @@ -259,6 +285,7 @@ private void handleDisconnect(String reason) { manager.onDisconnected(); } activeSocket.set(null); + activeSubscription.compareAndSet(subscription, null); publishUnavailable(MarketDataDegradationReason.PROVIDER_DISCONNECTED); if (!unpublishedFrames.isEmpty()) { log.info("Alpaca {} frames received without a publishing path this connection: {}", diff --git a/apps/market-gateway/src/main/resources/application.yaml b/apps/market-gateway/src/main/resources/application.yaml index 450b7a7..0e915fb 100644 --- a/apps/market-gateway/src/main/resources/application.yaml +++ b/apps/market-gateway/src/main/resources/application.yaml @@ -17,6 +17,9 @@ spring: # instrument-mapping-path: /etc/market-gateway/instruments.json # minimum-instrument-count: 500 # recent-bar-capacity: 390 +# candle-fetch-batch-size: 200 +# candle-finalization-grace: PT2S +# candle-poll-delay: PT5S # alpaca-feed: sip # rights-evidence-path: /etc/market-gateway/alpaca-sip-rights.json # materialization-receipt-path: /etc/market-gateway/materialization.properties diff --git a/apps/market-gateway/src/test/java/com/idea2strategy/trading/gateway/MarketGatewayPublishingE2ETest.java b/apps/market-gateway/src/test/java/com/idea2strategy/trading/gateway/MarketGatewayPublishingE2ETest.java index 1c77b92..af91d08 100644 --- a/apps/market-gateway/src/test/java/com/idea2strategy/trading/gateway/MarketGatewayPublishingE2ETest.java +++ b/apps/market-gateway/src/test/java/com/idea2strategy/trading/gateway/MarketGatewayPublishingE2ETest.java @@ -9,8 +9,7 @@ import com.idea2strategy.trading.market.alpaca.ProviderRightsUnavailableException; import com.idea2strategy.trading.market.availability.MarketDataAvailabilityStatus; import com.idea2strategy.trading.market.redis.RedisMarketEventPublisher; -import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; -import com.idea2strategy.trading.messaging.market.MarketEventType; +import io.lettuce.core.RedisClient; import java.io.IOException; import java.math.BigDecimal; import java.net.InetSocketAddress; @@ -44,9 +43,10 @@ @Testcontainers(disabledWithoutDocker = true) class MarketGatewayPublishingE2ETest { private static final UUID AAPL_ID = UUID.fromString("8a35e6b5-cf84-4f63-920d-57c1f1b95df0"); - private static final String BAR_FRAME = - "[{\"T\":\"b\",\"S\":\"AAPL\",\"o\":210.10,\"h\":210.25,\"l\":210.05," - + "\"c\":210.20,\"v\":2500,\"t\":\"2026-07-31T14:30:00Z\"}]"; + private static final String TRADE_FRAME = + "[{\"T\":\"t\",\"S\":\"AAPL\",\"i\":529835250,\"x\":\"V\"," + + "\"p\":210.20,\"s\":20,\"c\":[\"@\"]," + + "\"t\":\"2026-07-31T14:30:00.123456Z\",\"z\":\"C\"}]"; @Container static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")) @@ -56,45 +56,32 @@ class MarketGatewayPublishingE2ETest { Path configDir; @Test - void publishesRealAlpacaBarsToRedisAndSurvivesADroppedConnection() throws Exception { + void publishesCoalescedTradesToDisplayRedisAndSurvivesADroppedConnection() throws Exception { FakeAlpacaSipServer server = new FakeAlpacaSipServer(1); Path mapping = mapping(); Path readinessFile = readinessFile(mapping); server.startAndAwait(); try { + String redisUri = "redis://" + REDIS.getHost() + ":" + REDIS.getMappedPort(6379); + try (RedisClient client = RedisClient.create(redisUri); + var connection = client.connect()) { + connection.sync().zadd( + "{test-e2e:market}:display:subscription-leases", + Instant.now().plusSeconds(60).toEpochMilli(), + "test-connection|AAPL"); + } try (ConfigurableApplicationContext context = gateway(server.port(), validRights(), mapping)) { - RedisMarketEventPublisher publisher = context.getBean(RedisMarketEventPublisher.class); - - waitUntil(() -> publisher.streamLength() >= 1, Duration.ofSeconds(30)); waitUntil(() -> Files.isRegularFile(readinessFile), Duration.ofSeconds(10)); - Thread.sleep(500); - - assertEquals(1, publisher.streamLength()); - MarketEventEnvelope latest = - publisher.findLatest(AAPL_ID, MarketEventType.BAR_1M).orElseThrow(); - assertEquals("bar-20260731T143000Z", latest.providerEventId()); - assertEquals("ALPACA", latest.provider()); - assertEquals("SIP", latest.feed()); - assertEquals(Instant.parse("2026-07-31T14:30:00Z"), latest.occurredAt()); - assertEquals(Instant.parse("2026-07-31T14:30:00Z").getEpochSecond() / 60, latest.sequence()); - assertEquals( - Map.of( - "open", new BigDecimal("210.10"), - "high", new BigDecimal("210.25"), - "low", new BigDecimal("210.05"), - "close", new BigDecimal("210.20"), - "volume", new BigDecimal("2500")), - latest.values()); - var availability = publisher.findAvailability(AAPL_ID).orElseThrow(); - assertEquals(latest.sequence(), availability.marketSequence()); - assertEquals(MarketDataAvailabilityStatus.AVAILABLE, availability.status()); - assertTrue(availability.evaluationAllowed()); + try (RedisClient client = RedisClient.create(redisUri); var connection = client.connect()) { + waitUntil(() -> "210.20".equals(connection.sync().hget( + "{test-e2e:market}:display:latest:" + AAPL_ID, "price")), Duration.ofSeconds(30)); + } assertEquals(2, server.connections.get()); assertTrue(server.received.stream().anyMatch(frame -> frame.contains("\"action\":\"auth\"") && frame.contains("\"key\":\"test-key\""))); assertTrue(server.received.stream().anyMatch(frame -> - frame.contains("\"action\":\"subscribe\"") && frame.contains("\"bars\":[\"AAPL\"]"))); + frame.contains("\"action\":\"subscribe\"") && frame.contains("\"trades\":[\"AAPL\"]"))); } assertFalse(Files.exists(readinessFile)); } finally { @@ -103,20 +90,16 @@ void publishesRealAlpacaBarsToRedisAndSurvivesADroppedConnection() throws Except } @Test - void explicitIexFallbackPublishesTruthfulIexEvents() throws Exception { + void explicitIexFallbackAuthenticatesWithoutCreatingASecondConnection() throws Exception { FakeAlpacaSipServer server = new FakeAlpacaSipServer(0); Path mapping = mapping(); server.startAndAwait(); try { Path rights = validRights("iex"); try (ConfigurableApplicationContext context = gateway(server.port(), rights, mapping, "iex")) { - RedisMarketEventPublisher publisher = context.getBean(RedisMarketEventPublisher.class); - - waitUntil(() -> publisher.streamLength() >= 1, Duration.ofSeconds(30)); - MarketEventEnvelope latest = - publisher.findLatest(AAPL_ID, MarketEventType.BAR_1M).orElseThrow(); - assertEquals("ALPACA", latest.provider()); - assertEquals("IEX", latest.feed()); + waitUntil(() -> server.received.stream().anyMatch(frame -> frame.contains("\"action\":\"auth\"")), + Duration.ofSeconds(30)); + assertEquals(1, server.connections.get()); } } finally { server.stop(); @@ -190,7 +173,8 @@ private String[] baseProperties(int port, Path rights, Path mapping) throws IOEx private static String[] baseProperties(int port, Path rights, Path mapping, Path receipt) { return new String[] { "market-gateway.redis-uri=redis://" + REDIS.getHost() + ":" + REDIS.getMappedPort(6379), - "market-gateway.redis-key-prefix=test:" + UUID.randomUUID(), + "market-gateway.redis-key-prefix=test-e2e", + "market-gateway.strategy-candles-enabled=false", "market-gateway.instrument-mapping-path=" + mapping, "market-gateway.rights-evidence-path=" + rights, "market-gateway.materialization-receipt-path=" + receipt, @@ -337,8 +321,8 @@ public void onMessage(WebSocket connection, String message) { } else if (message.contains("\"action\":\"subscribe\"")) { connection.send("[{\"T\":\"subscription\",\"trades\":[\"AAPL\"]," + "\"quotes\":[\"AAPL\"],\"bars\":[\"AAPL\"]}]"); - connection.send(BAR_FRAME); - connection.send(BAR_FRAME); + connection.send(TRADE_FRAME); + connection.send(TRADE_FRAME); } } diff --git a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/market/MarketEventTransportConfiguration.java b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/market/MarketEventTransportConfiguration.java index 204da29..0c123bc 100644 --- a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/market/MarketEventTransportConfiguration.java +++ b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/market/MarketEventTransportConfiguration.java @@ -60,7 +60,7 @@ RedisMarketEventStreamConsumer marketEventStreamConsumer( return new RedisMarketEventStreamConsumer( connection.sync(), runtime, - "{" + prefix + ":market}:events", + "{" + prefix + ":market}:strategy:evaluation-ready:v1", environment.getProperty( "trading.market-events.consumer-name", "worker-" + java.util.UUID.randomUUID()), diff --git a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/market/RedisMarketEventStreamConsumer.java b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/market/RedisMarketEventStreamConsumer.java index 15203fb..898395f 100644 --- a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/market/RedisMarketEventStreamConsumer.java +++ b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/market/RedisMarketEventStreamConsumer.java @@ -2,6 +2,7 @@ import com.idea2strategy.trading.market.redis.MarketEventStreamEntry; import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; +import com.idea2strategy.trading.messaging.market.MarketEventType; import com.idea2strategy.trading.worker.runtime.EvaluatingBotRuntime; import io.lettuce.core.Consumer; import io.lettuce.core.RedisBusyException; @@ -169,6 +170,12 @@ private int deliver(List> messages) { } try { MarketEventEnvelope event = MarketEventStreamEntry.decode(message.getBody()); + if (event.eventType() != MarketEventType.MARKET_EVALUATION_READY) { + commands.xack(streamKey, CONSUMER_GROUP, message.getId()); + log.warn("non-evaluation event {} appeared on the evaluation stream and was ignored", + event.eventId()); + continue; + } if (!availabilityPolicy.permits(event)) { // The projection may be racing the event publication or may recover later. Leave // the entry pending so reclaim retries it; acknowledging would permanently lose diff --git a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/EvaluatingBotRuntime.java b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/EvaluatingBotRuntime.java index 5ffdaf0..0a3711a 100644 --- a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/EvaluatingBotRuntime.java +++ b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/EvaluatingBotRuntime.java @@ -6,6 +6,7 @@ import com.idea2strategy.trading.messaging.evaluation.OrderCandidateBatch; import com.idea2strategy.trading.messaging.evaluation.OrderSide; import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; +import com.idea2strategy.trading.messaging.market.MarketEventType; import com.idea2strategy.trading.strategy.runtime.basic.BasicDecisionStatus; import com.idea2strategy.trading.strategy.runtime.basic.BasicExecutionRequest; import com.idea2strategy.trading.strategy.runtime.basic.BasicExecutionResult; @@ -117,13 +118,14 @@ public void start(LoadedExecutionPlan plan, PreparedWarmup warmup, EvaluationWin Objects.requireNonNull(plan, "plan"); Objects.requireNonNull(window, "window"); var interpreted = interpreter.interpret(plan.planPayload()); + var evaluationTimeframe = StrategyEvaluationTimeframe.fromPlan(plan.planPayload()); var calculator = new BoundedWindowFeatureCalculator(OfficialFeatureCatalog.RSI_14); var features = new OrderedIncrementalFeatureRuntime( plan.botId(), -1, List.of(calculator), Map.of(calculator.key(), seedFrom(warmup, calculator))); bots.put(plan.botId(), new RegisteredBot( - plan.botId(), interpreted, features, calculator, window)); + plan.botId(), interpreted, features, calculator, evaluationTimeframe, window)); log.info("bot {} registered for evaluation over {} instruments within {}", plan.botId(), interpreted.subscribedInstruments().size(), window); } @@ -150,11 +152,18 @@ public boolean isEvaluating(UUID botId) { */ public List feed(MarketEventEnvelope event) { Objects.requireNonNull(event, "event"); + if (event.eventType() != MarketEventType.MARKET_EVALUATION_READY) { + log.debug("ignoring non-evaluation market event {} ({})", event.eventId(), event.eventType()); + return List.of(); + } List results = new ArrayList<>(); for (RegisteredBot bot : bots.values()) { if (!bot.plan().subscribedInstruments().contains(event.instrumentId())) { continue; } + if (!closesRequiredTimeframe(event, bot.evaluationTimeframe())) { + continue; + } if (!bot.window().admits(event.occurredAt())) { // A room bot waits for its evaluation window to open and stops deciding the moment it // closes. Refusing here rather than relying on the stop arriving punctually is what @@ -347,6 +356,17 @@ private static BigDecimal priceOf(MarketEventEnvelope event) { return close != null ? close : event.values().get("price"); } + private static boolean closesRequiredTimeframe( + MarketEventEnvelope event, StrategyEvaluationTimeframe timeframe) { + BigDecimal flag = event.values().get(timeframe.closedFlag()); + if (flag != null) { + return flag.signum() > 0; + } + // Schema v1 evaluation events predate explicit timeframe flags and represent the minimum + // live cadence. Schema v2+ must always state which strategy candles closed. + return event.schemaVersion() == 1 && timeframe == StrategyEvaluationTimeframe.THIRTY_MINUTES; + } + private static UUID derived(String kind, String material) { return UUID.nameUUIDFromBytes((kind + ":" + material).getBytes(StandardCharsets.UTF_8)); } @@ -363,6 +383,7 @@ private static final class RegisteredBot { private final BasicPlanInterpreter.InterpretedPlan plan; private final OrderedIncrementalFeatureRuntime features; private final BoundedWindowFeatureCalculator calculator; + private final StrategyEvaluationTimeframe evaluationTimeframe; private final EvaluationWindow window; private final Map signalStates = new LinkedHashMap<>(); private final Map positionTrackers = new LinkedHashMap<>(); @@ -378,11 +399,13 @@ private RegisteredBot( BasicPlanInterpreter.InterpretedPlan plan, OrderedIncrementalFeatureRuntime features, BoundedWindowFeatureCalculator calculator, + StrategyEvaluationTimeframe evaluationTimeframe, EvaluationWindow window) { this.botId = botId; this.plan = plan; this.features = features; this.calculator = calculator; + this.evaluationTimeframe = evaluationTimeframe; this.window = window; } @@ -419,6 +442,10 @@ private BoundedWindowFeatureCalculator calculator() { return calculator; } + private StrategyEvaluationTimeframe evaluationTimeframe() { + return evaluationTimeframe; + } + private EvaluationWindow window() { return window; } diff --git a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/StrategyEvaluationTimeframe.java b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/StrategyEvaluationTimeframe.java new file mode 100644 index 0000000..f8e37a7 --- /dev/null +++ b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/StrategyEvaluationTimeframe.java @@ -0,0 +1,68 @@ +package com.idea2strategy.trading.worker.runtime; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashSet; +import java.util.Set; + +/** Resolves the one live candle cadence a Basic runtime can advance from a compiled plan. */ +enum StrategyEvaluationTimeframe { + THIRTY_MINUTES("closed30m"), + ONE_HOUR("closed1h"), + FOUR_HOURS("closed4h"), + ONE_DAY("closed1d"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final String closedFlag; + + StrategyEvaluationTimeframe(String closedFlag) { + this.closedFlag = closedFlag; + } + + String closedFlag() { + return closedFlag; + } + + static StrategyEvaluationTimeframe fromPlan(String planPayload) { + try { + Set resolved = new LinkedHashSet<>(); + collect(MAPPER.readTree(planPayload), resolved); + if (resolved.isEmpty()) { + throw new IllegalArgumentException("compiled plan declares no supported live resolution"); + } + if (resolved.size() != 1) { + throw new IllegalArgumentException( + "one Basic bot cannot mix multiple live feature resolutions: " + resolved); + } + return resolved.iterator().next(); + } catch (com.fasterxml.jackson.core.JsonProcessingException failure) { + throw new IllegalArgumentException("compiled plan is not valid JSON", failure); + } + } + + private static void collect(JsonNode node, Set result) { + if (node.isObject()) { + node.fields().forEachRemaining(field -> { + if ("resolution".equals(field.getKey()) && field.getValue().isTextual()) { + result.add(parse(field.getValue().textValue())); + } else { + collect(field.getValue(), result); + } + }); + } else if (node.isArray()) { + node.forEach(value -> collect(value, result)); + } + } + + private static StrategyEvaluationTimeframe parse(String value) { + return switch (value.trim().toUpperCase(java.util.Locale.ROOT)) { + // Existing locked 1m plans are deliberately migrated to the new minimum live cadence. + case "1M", "PT1M", "30M", "PT30M" -> THIRTY_MINUTES; + case "1H", "PT1H" -> ONE_HOUR; + case "4H", "PT4H" -> FOUR_HOURS; + case "1D", "P1D", "PT24H" -> ONE_DAY; + default -> throw new IllegalArgumentException( + "live resolution must be one of 30m, 1h, 4h, 1d: " + value); + }; + } +} diff --git a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/control/BotControlIntegrationE2ETest.java b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/control/BotControlIntegrationE2ETest.java index 246277a..42d08de 100644 --- a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/control/BotControlIntegrationE2ETest.java +++ b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/control/BotControlIntegrationE2ETest.java @@ -482,7 +482,7 @@ private static PreparedWarmup warmup() { private MarketEventEnvelope event(long sequence, String close) { return new MarketEventEnvelope( - "market-" + sequence, 1, INSTRUMENT, "ALPACA", "SIP", MarketEventType.BAR_1M, + "market-" + sequence, 1, INSTRUMENT, "ALPACA", "SIP", MarketEventType.MARKET_EVALUATION_READY, "provider-" + sequence, EVENT_AT, EVENT_AT, sequence, 0, null, Map.of("close", new BigDecimal(close))); } diff --git a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java index 22e89ad..0d7e508 100644 --- a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java +++ b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java @@ -276,7 +276,7 @@ private long pendingEntries() { /** Publishes through the gateway's own publisher, so the layout under test is the real one. */ private void publish(long sequence, String close) { var envelope = new MarketEventEnvelope( - "evt_rt3_" + sequence, 1, INSTRUMENT, "ALPACA", "SIP", MarketEventType.BAR_1M, + "evt_rt3_" + sequence, 1, INSTRUMENT, "ALPACA", "SIP", MarketEventType.MARKET_EVALUATION_READY, "provider-" + sequence, EVENT_AT, EVENT_AT, sequence, 0, null, Map.of("close", new BigDecimal(close))); publisher.publish(new com.idea2strategy.trading.market.alpaca.MarketEventHandlingResult( diff --git a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/RedisProjectedMarketAvailabilityPolicyTest.java b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/RedisProjectedMarketAvailabilityPolicyTest.java index c00095c..ad72bac 100644 --- a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/RedisProjectedMarketAvailabilityPolicyTest.java +++ b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/RedisProjectedMarketAvailabilityPolicyTest.java @@ -79,7 +79,7 @@ private static MarketEventEnvelope event(long sequence) { INSTRUMENT, "ALPACA", "SIP", - MarketEventType.BAR_1M, + MarketEventType.MARKET_EVALUATION_READY, "bar-" + sequence, NOW.minusSeconds(30), NOW.minusSeconds(29), diff --git a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/EvaluationLoopE2ETest.java b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/EvaluationLoopE2ETest.java index 3e2173e..e12d6c3 100644 --- a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/EvaluationLoopE2ETest.java +++ b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/EvaluationLoopE2ETest.java @@ -339,7 +339,7 @@ void anUnsubscribedInstrumentIsIgnored() { MarketEventEnvelope other = new MarketEventEnvelope( "market-other", 1, UUID.fromString("c2000000-0000-4000-8000-0000000000ff"), - "ALPACA", "SIP", MarketEventType.BAR_1M, "p-other", EVENT_AT, EVENT_AT, + "ALPACA", "SIP", MarketEventType.MARKET_EVALUATION_READY, "p-other", EVENT_AT, EVENT_AT, 99, 0, null, Map.of("close", new BigDecimal("84"))); assertTrue(runtime.feed(other).isEmpty()); @@ -388,7 +388,7 @@ private MarketEventEnvelope event(long sequence, String close) { private MarketEventEnvelope eventAt(long sequence, String close, Instant observedAt) { return new MarketEventEnvelope( - "market-" + sequence, 1, INSTRUMENT, "ALPACA", "SIP", MarketEventType.BAR_1M, + "market-" + sequence, 1, INSTRUMENT, "ALPACA", "SIP", MarketEventType.MARKET_EVALUATION_READY, "provider-" + sequence, observedAt, observedAt, sequence, 0, null, Map.of("close", new BigDecimal(close))); } diff --git a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/StrategyEvaluationTimeframeTest.java b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/StrategyEvaluationTimeframeTest.java new file mode 100644 index 0000000..3fab4ed --- /dev/null +++ b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/StrategyEvaluationTimeframeTest.java @@ -0,0 +1,22 @@ +package com.idea2strategy.trading.worker.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class StrategyEvaluationTimeframeTest { + @Test + void resolvesSupportedCadencesAndMigratesLockedOneMinutePlansToThirtyMinutes() { + assertEquals(StrategyEvaluationTimeframe.FOUR_HOURS, + StrategyEvaluationTimeframe.fromPlan("{\"resolution\":\"4h\"}")); + assertEquals(StrategyEvaluationTimeframe.THIRTY_MINUTES, + StrategyEvaluationTimeframe.fromPlan("{\"resolution\":\"PT1M\"}")); + } + + @Test + void rejectsMixedCadencesBecauseOneFeatureStateCannotRepresentBoth() { + assertThrows(IllegalArgumentException.class, () -> StrategyEvaluationTimeframe.fromPlan( + "{\"steps\":[{\"resolution\":\"30m\"},{\"resolution\":\"1h\"}]}")); + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipInboundMessage.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipInboundMessage.java index d880cb4..33db39a 100644 --- a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipInboundMessage.java +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipInboundMessage.java @@ -1,5 +1,7 @@ package com.idea2strategy.trading.market.alpaca; +import java.math.BigDecimal; +import java.time.Instant; import java.util.List; public sealed interface AlpacaSipInboundMessage { @@ -7,15 +9,28 @@ record Connected() implements AlpacaSipInboundMessage {} record Authenticated() implements AlpacaSipInboundMessage {} - record SubscriptionConfirmed(List barSymbols) implements AlpacaSipInboundMessage { + record SubscriptionConfirmed(List tradeSymbols) implements AlpacaSipInboundMessage { public SubscriptionConfirmed { - barSymbols = List.copyOf(barSymbols); + tradeSymbols = List.copyOf(tradeSymbols); } } record ProviderError(int code, String message) implements AlpacaSipInboundMessage {} - record MinuteBar(AlpacaMarketInput input) implements AlpacaSipInboundMessage {} + record TradeTick( + String symbol, + long tradeId, + String exchange, + BigDecimal price, + BigDecimal size, + Instant occurredAt, + Instant receivedAt, + List conditions, + String tape) implements AlpacaSipInboundMessage { + public TradeTick { + conditions = List.copyOf(conditions); + } + } record UnsupportedFrame(String frameType) implements AlpacaSipInboundMessage {} } diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipMessageParser.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipMessageParser.java index 27b4331..da51c79 100644 --- a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipMessageParser.java +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipMessageParser.java @@ -5,21 +5,14 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.cfg.JsonNodeFeature; -import com.idea2strategy.trading.messaging.market.MarketEventType; import java.math.BigDecimal; import java.time.Instant; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Objects; public final class AlpacaSipMessageParser { - private static final DateTimeFormatter BAR_EVENT_ID_FORMAT = - DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); - private final ObjectMapper mapper = new ObjectMapper() .configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true) .configure(JsonNodeFeature.STRIP_TRAILING_BIGDECIMAL_ZEROES, false); @@ -61,66 +54,80 @@ private static AlpacaSipInboundMessage message( case "authenticated" -> new AlpacaSipInboundMessage.Authenticated(); default -> new AlpacaSipInboundMessage.UnsupportedFrame("success:" + text(node, "msg")); }; - case "subscription" -> new AlpacaSipInboundMessage.SubscriptionConfirmed(symbols(node)); + case "subscription" -> new AlpacaSipInboundMessage.SubscriptionConfirmed(symbols(node, "trades")); case "error" -> new AlpacaSipInboundMessage.ProviderError( node.path("code").asInt(), node.path("msg").asText("")); - case "b" -> new AlpacaSipInboundMessage.MinuteBar(bar(node, receivedAt, feed)); + case "b" -> new AlpacaSipInboundMessage.UnsupportedFrame("b"); + case "t" -> trade(node, receivedAt); default -> new AlpacaSipInboundMessage.UnsupportedFrame(type); }; } - private static AlpacaMarketInput bar(JsonNode node, Instant receivedAt, AlpacaDataFeed feed) { - Instant occurredAt = instant(node); - return new AlpacaMarketInput( - MarketEventType.BAR_1M, - "bar-" + BAR_EVENT_ID_FORMAT.format(occurredAt), + private static AlpacaSipInboundMessage.TradeTick trade(JsonNode node, Instant receivedAt) { + return new AlpacaSipInboundMessage.TradeTick( text(node, "S"), - feed.eventValue(), - occurredAt, + integer(node, "i"), + text(node, "x"), + decimal(node, "p"), + decimal(node, "s"), + instant(node, "t"), receivedAt, - occurredAt.getEpochSecond() / 60, - 0, - Map.of( - "open", decimal(node, "o"), - "high", decimal(node, "h"), - "low", decimal(node, "l"), - "close", decimal(node, "c"), - "volume", decimal(node, "v"))); + strings(node, "c"), + text(node, "z")); } - private static List symbols(JsonNode node) { - JsonNode bars = node.path("bars"); - if (!bars.isArray()) { - throw new IllegalArgumentException("subscription frame is missing the bars symbol list"); + private static List symbols(JsonNode node, String field) { + JsonNode values = node.path(field); + if (!values.isArray()) { + throw new IllegalArgumentException("subscription frame is missing the " + field + " symbol list"); } List symbols = new ArrayList<>(); - for (JsonNode symbol : bars) { + for (JsonNode symbol : values) { symbols.add(symbol.asText()); } return symbols; } - private static String text(JsonNode node, String field) { - JsonNode value = node.path(field); - if (!value.isTextual() || value.asText().isBlank()) { - throw new IllegalArgumentException("SIP message field " + field + " must be present text"); + private static List strings(JsonNode node, String field) { + JsonNode values = node.path(field); + if (!values.isArray()) { + throw new IllegalArgumentException("SIP trade field " + field + " must be an array"); } - return value.asText(); + List result = new ArrayList<>(); + values.forEach(value -> result.add(value.asText())); + return result; } private static BigDecimal decimal(JsonNode node, String field) { JsonNode value = node.path(field); if (!value.isNumber()) { - throw new IllegalArgumentException("SIP bar field " + field + " must be a number"); + throw new IllegalArgumentException("SIP trade field " + field + " must be a number"); } return value.decimalValue(); } - private static Instant instant(JsonNode node) { + private static long integer(JsonNode node, String field) { + JsonNode value = node.path(field); + if (!value.canConvertToLong()) { + throw new IllegalArgumentException("SIP trade field " + field + " must be an integer"); + } + return value.longValue(); + } + + private static Instant instant(JsonNode node, String field) { try { - return Instant.parse(text(node, "t")); + return Instant.parse(text(node, field)); } catch (DateTimeParseException exception) { - throw new IllegalArgumentException("SIP bar field t must be an RFC-3339 instant", exception); + throw new IllegalArgumentException("SIP trade field " + field + " must be an RFC-3339 instant", exception); } } + + private static String text(JsonNode node, String field) { + JsonNode value = node.path(field); + if (!value.isTextual() || value.asText().isBlank()) { + throw new IllegalArgumentException("SIP message field " + field + " must be present text"); + } + return value.asText(); + } + } diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipSubscriptionManager.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipSubscriptionManager.java index cd1bf14..84a9571 100644 --- a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipSubscriptionManager.java +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipSubscriptionManager.java @@ -2,7 +2,11 @@ import java.util.Collection; import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; import java.util.Objects; +import java.util.Set; public final class AlpacaSipSubscriptionManager { private final ApprovedSymbolUniverse universe; @@ -13,8 +17,8 @@ public final class AlpacaSipSubscriptionManager { private boolean connected; private boolean authenticated; - private boolean subscriptionRequested; - private boolean subscriptionApproved; + private Set desiredTradeSymbols = Set.of(); + private Set activeTradeSymbols = Set.of(); public AlpacaSipSubscriptionManager( ApprovedSymbolUniverse universe, @@ -43,40 +47,61 @@ public synchronized void onConnected() { transport.authenticate(credentials); connected = true; authenticated = false; - subscriptionRequested = false; - subscriptionApproved = false; + activeTradeSymbols = Set.of(); } public synchronized void onAuthenticationApproved() { if (!connected) { throw new IllegalStateException("SIP connection is not active"); } + if (authenticated) { + return; + } rightsGate.requireCurrentAlpacaRights(feed); authenticated = true; - if (!subscriptionRequested) { - transport.subscribe(universe.symbols()); - subscriptionRequested = true; + if (!desiredTradeSymbols.isEmpty()) { + transport.subscribeTrades(List.copyOf(desiredTradeSymbols)); } } public synchronized void onSubscriptionApproved(Collection approvedSymbols) { - if (!authenticated || !subscriptionRequested) { - throw new IllegalStateException("SIP subscription was not requested"); + if (!authenticated) { + throw new IllegalStateException("SIP connection is not authenticated"); } rightsGate.requireCurrentAlpacaRights(feed); - ApprovedSymbolUniverse approved = new ApprovedSymbolUniverse(approvedSymbols); - if (!new HashSet<>(universe.symbols()).equals(new HashSet<>(approved.symbols()))) { + Set approved = normalize(approvedSymbols); + if (!new HashSet<>(universe.symbols()).containsAll(approved)) { throw new IllegalStateException( - "Alpaca " + feed.eventValue() + " did not approve the entire configured universe"); + "Alpaca " + feed.eventValue() + " approved a trade symbol outside the configured universe"); + } + activeTradeSymbols = approved; + } + + /** Reconciles chart demand without opening another Alpaca WebSocket connection. */ + public synchronized void replaceTradeSubscriptions(Collection symbols) { + Set desired = normalize(symbols); + if (!new HashSet<>(universe.symbols()).containsAll(desired)) { + throw new IllegalArgumentException("trade subscription contains a symbol outside the approved universe"); + } + Set additions = new LinkedHashSet<>(desired); + additions.removeAll(desiredTradeSymbols); + Set removals = new LinkedHashSet<>(desiredTradeSymbols); + removals.removeAll(desired); + desiredTradeSymbols = Set.copyOf(desired); + if (authenticated) { + if (!additions.isEmpty()) { + transport.subscribeTrades(List.copyOf(additions)); + } + if (!removals.isEmpty()) { + transport.unsubscribeTrades(List.copyOf(removals)); + } } - subscriptionApproved = true; } public synchronized void onDisconnected() { connected = false; authenticated = false; - subscriptionRequested = false; - subscriptionApproved = false; + activeTradeSymbols = Set.of(); } public synchronized boolean isAuthenticated() { @@ -84,6 +109,22 @@ public synchronized boolean isAuthenticated() { } public synchronized boolean isSubscribed() { - return subscriptionApproved; + return authenticated && activeTradeSymbols.equals(desiredTradeSymbols); + } + + public synchronized Set desiredTradeSymbols() { + return desiredTradeSymbols; + } + + private static Set normalize(Collection symbols) { + Objects.requireNonNull(symbols, "symbols"); + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String symbol : symbols) { + if (symbol == null || symbol.isBlank()) { + throw new IllegalArgumentException("symbol must not be blank"); + } + normalized.add(symbol.trim().toUpperCase(Locale.ROOT)); + } + return Set.copyOf(normalized); } } diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipTransport.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipTransport.java index 51f4903..c889b86 100644 --- a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipTransport.java +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipTransport.java @@ -5,5 +5,7 @@ public interface AlpacaSipTransport { void authenticate(AlpacaCredentials credentials); - void subscribe(List symbols); + void subscribeTrades(List symbols); + + void unsubscribeTrades(List symbols); } diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipWebSocketTransport.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipWebSocketTransport.java index a26e653..ed099bc 100644 --- a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipWebSocketTransport.java +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/alpaca/AlpacaSipWebSocketTransport.java @@ -29,12 +29,21 @@ public void authenticate(AlpacaCredentials credentials) { } @Override - public void subscribe(List symbols) { + public void subscribeTrades(List symbols) { + sendSymbols("subscribe", symbols); + } + + @Override + public void unsubscribeTrades(List symbols) { + sendSymbols("unsubscribe", symbols); + } + + private void sendSymbols(String action, List symbols) { ApprovedSymbolUniverse universe = new ApprovedSymbolUniverse(symbols); String symbolArray = universe.symbols().stream() .map(AlpacaSipWebSocketTransport::quote) .collect(Collectors.joining(",", "[", "]")); - sender.send("{\"action\":\"subscribe\",\"bars\":" + symbolArray + "}"); + sender.send("{\"action\":" + quote(action) + ",\"trades\":" + symbolArray + "}"); } private static String quote(String value) { diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsClient.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsClient.java new file mode 100644 index 0000000..27eb731 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsClient.java @@ -0,0 +1,16 @@ +package com.idea2strategy.trading.market.candle; + +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import com.idea2strategy.trading.messaging.market.MarketCandle; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@FunctionalInterface +public interface AlpacaThirtyMinuteBarsClient { + Map> fetch( + Map instruments, + OfficialMarketSession session, + Instant throughBoundary); +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsJsonParser.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsJsonParser.java new file mode 100644 index 0000000..ba7a50d --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsJsonParser.java @@ -0,0 +1,88 @@ +package com.idea2strategy.trading.market.candle; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.cfg.JsonNodeFeature; +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import com.idea2strategy.trading.messaging.market.MarketCandle; +import com.idea2strategy.trading.messaging.market.MarketTimeframe; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public final class AlpacaThirtyMinuteBarsJsonParser { + private final ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true) + .configure(JsonNodeFeature.STRIP_TRAILING_BIGDECIMAL_ZEROES, false); + + public Map> parse( + String body, + Map instruments, + OfficialMarketSession session, + Instant throughBoundary) { + Objects.requireNonNull(instruments, "instruments"); + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(throughBoundary, "throughBoundary"); + try { + JsonNode barsBySymbol = mapper.readTree(body).path("bars"); + if (!barsBySymbol.isObject()) { + throw new IllegalArgumentException("Alpaca bars response is missing bars"); + } + Map> result = new LinkedHashMap<>(); + for (Map.Entry instrument : instruments.entrySet()) { + JsonNode bars = barsBySymbol.path(instrument.getKey()); + List parsed = new ArrayList<>(); + if (bars.isArray()) { + for (JsonNode bar : bars) { + Instant opensAt = Instant.parse(requiredText(bar, "t")); + Instant closesAt = opensAt.plus(Duration.ofMinutes(30)); + if (opensAt.isBefore(session.opensAt()) + || closesAt.isAfter(session.closesAt()) + || closesAt.isAfter(throughBoundary)) { + continue; + } + parsed.add(new MarketCandle( + instrument.getValue(), + MarketTimeframe.THIRTY_MINUTES, + opensAt, + closesAt, + decimal(bar, "o"), + decimal(bar, "h"), + decimal(bar, "l"), + decimal(bar, "c"), + decimal(bar, "v"), + false)); + } + } + result.put(instrument.getKey(), List.copyOf(parsed)); + } + return Map.copyOf(result); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("Alpaca bars response is invalid JSON", exception); + } + } + + private static String requiredText(JsonNode node, String field) { + JsonNode value = node.path(field); + if (!value.isTextual() || value.textValue().isBlank()) { + throw new IllegalArgumentException("Alpaca bar field " + field + " must be text"); + } + return value.textValue(); + } + + private static BigDecimal decimal(JsonNode node, String field) { + JsonNode value = node.path(field); + if (!value.isNumber()) { + throw new IllegalArgumentException("Alpaca bar field " + field + " must be numeric"); + } + return value.decimalValue(); + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/FinalizedCandleBoundaryPlanner.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/FinalizedCandleBoundaryPlanner.java new file mode 100644 index 0000000..3a45f75 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/FinalizedCandleBoundaryPlanner.java @@ -0,0 +1,34 @@ +package com.idea2strategy.trading.market.candle; + +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class FinalizedCandleBoundaryPlanner { + public List readyBoundaries( + OfficialMarketSession session, + Instant now, + Duration finalizationGrace) { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(now, "now"); + Objects.requireNonNull(finalizationGrace, "finalizationGrace"); + if (finalizationGrace.isNegative()) { + throw new IllegalArgumentException("finalizationGrace must not be negative"); + } + Instant finalizedThrough = now.minus(finalizationGrace); + if (!finalizedThrough.isAfter(session.opensAt())) { + return List.of(); + } + Instant last = finalizedThrough.isBefore(session.closesAt()) ? finalizedThrough : session.closesAt(); + List result = new ArrayList<>(); + for (Instant boundary = session.opensAt().plus(Duration.ofMinutes(30)); + !boundary.isAfter(last); + boundary = boundary.plus(Duration.ofMinutes(30))) { + result.add(boundary); + } + return List.copyOf(result); + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/FinalizedCandleCycle.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/FinalizedCandleCycle.java new file mode 100644 index 0000000..754f464 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/FinalizedCandleCycle.java @@ -0,0 +1,88 @@ +package com.idea2strategy.trading.market.candle; + +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import com.idea2strategy.trading.messaging.market.MarketCandle; +import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Consumer; + +/** Executes one finalized 30m boundary across the approved instrument catalog. */ +public final class FinalizedCandleCycle { + private final AlpacaThirtyMinuteBarsClient client; + private final SessionAlignedCandleAggregator aggregator; + private final MarketCandleEventFactory candleEvents; + private final MarketEvaluationCoordinator evaluations; + private final Consumer sink; + private final Clock clock; + private final int batchSize; + + public FinalizedCandleCycle( + AlpacaThirtyMinuteBarsClient client, + Consumer sink, + Clock clock, + int batchSize) { + this.client = Objects.requireNonNull(client, "client"); + this.aggregator = new SessionAlignedCandleAggregator(); + this.candleEvents = new MarketCandleEventFactory(); + this.evaluations = new MarketEvaluationCoordinator(); + this.sink = Objects.requireNonNull(sink, "sink"); + this.clock = Objects.requireNonNull(clock, "clock"); + if (batchSize < 1 || batchSize > 500) { + throw new IllegalArgumentException("batchSize must be between 1 and 500"); + } + this.batchSize = batchSize; + } + + public FinalizedCandleCycleResult run( + Map instruments, + OfficialMarketSession session, + Instant boundary) { + validateBoundary(session, boundary); + List> entries = new ArrayList<>(instruments.entrySet()); + int evaluated = 0; + int missing = 0; + for (int offset = 0; offset < entries.size(); offset += batchSize) { + Map batch = new LinkedHashMap<>(); + entries.subList(offset, Math.min(entries.size(), offset + batchSize)) + .forEach(entry -> batch.put(entry.getKey(), entry.getValue())); + Map> fetched = client.fetch(batch, session, boundary); + for (Map.Entry instrument : batch.entrySet()) { + List source = fetched.getOrDefault(instrument.getKey(), List.of()); + if (source.isEmpty() || !source.getLast().closesAt().equals(boundary)) { + missing++; + continue; + } + List closed; + try { + closed = aggregator.closedAt(source, session, boundary); + } catch (IllegalArgumentException missingSource) { + missing++; + continue; + } + Instant receivedAt = clock.instant(); + closed.forEach(candle -> sink.accept(candleEvents.event(candle, receivedAt))); + sink.accept(evaluations.ready(instrument.getValue(), boundary, closed, receivedAt)); + evaluated++; + } + } + return new FinalizedCandleCycleResult(evaluated, missing); + } + + private static void validateBoundary(OfficialMarketSession session, Instant boundary) { + long elapsed = Duration.between(session.opensAt(), boundary).toSeconds(); + if (elapsed <= 0 || elapsed % Duration.ofMinutes(30).toSeconds() != 0 + || boundary.isAfter(session.closesAt())) { + throw new IllegalArgumentException("boundary must be a finalized 30m session boundary"); + } + } + + public record FinalizedCandleCycleResult(int evaluatedInstrumentCount, int missingInstrumentCount) {} +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/HttpAlpacaThirtyMinuteBarsClient.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/HttpAlpacaThirtyMinuteBarsClient.java new file mode 100644 index 0000000..267ea02 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/HttpAlpacaThirtyMinuteBarsClient.java @@ -0,0 +1,76 @@ +package com.idea2strategy.trading.market.candle; + +import com.idea2strategy.trading.market.alpaca.AlpacaCredentials; +import com.idea2strategy.trading.market.alpaca.AlpacaCredentialsProvider; +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import com.idea2strategy.trading.messaging.market.MarketCandle; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** Batch REST client for finalized Alpaca 30Min bars; it never requests or reconstructs 1m bars. */ +public final class HttpAlpacaThirtyMinuteBarsClient implements AlpacaThirtyMinuteBarsClient { + public static final URI DEFAULT_ENDPOINT = URI.create("https://data.alpaca.markets/v2/stocks/bars"); + + private final HttpClient httpClient; + private final URI endpoint; + private final AlpacaCredentialsProvider credentialsProvider; + private final AlpacaThirtyMinuteBarsJsonParser parser; + + public HttpAlpacaThirtyMinuteBarsClient( + HttpClient httpClient, + URI endpoint, + AlpacaCredentialsProvider credentialsProvider, + AlpacaThirtyMinuteBarsJsonParser parser) { + this.httpClient = Objects.requireNonNull(httpClient, "httpClient"); + this.endpoint = Objects.requireNonNull(endpoint, "endpoint"); + this.credentialsProvider = Objects.requireNonNull(credentialsProvider, "credentialsProvider"); + this.parser = Objects.requireNonNull(parser, "parser"); + } + + @Override + public Map> fetch( + Map instruments, + OfficialMarketSession session, + Instant throughBoundary) { + if (instruments.isEmpty()) { + return Map.of(); + } + AlpacaCredentials credentials = credentialsProvider.load(); + String symbols = String.join(",", instruments.keySet()); + String query = "symbols=" + encode(symbols) + + "&timeframe=30Min&start=" + encode(session.opensAt().toString()) + + "&end=" + encode(throughBoundary.toString()) + + "&limit=10000&feed=sip&adjustment=raw&sort=asc"; + HttpRequest request = HttpRequest.newBuilder(URI.create(endpoint + "?" + query)) + .header("APCA-API-KEY-ID", credentials.apiKey()) + .header("APCA-API-SECRET-KEY", credentials.apiSecret()) + .GET() + .build(); + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new IllegalStateException("Alpaca 30Min bars request failed with HTTP " + response.statusCode()); + } + return parser.parse(response.body(), instruments, session, throughBoundary); + } catch (IOException exception) { + throw new IllegalStateException("Alpaca 30Min bars request failed", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Alpaca 30Min bars request was interrupted", exception); + } + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketCandleEventFactory.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketCandleEventFactory.java new file mode 100644 index 0000000..2e5e681 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketCandleEventFactory.java @@ -0,0 +1,31 @@ +package com.idea2strategy.trading.market.candle; + +import com.idea2strategy.trading.messaging.market.MarketCandle; +import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** Converts one finalized candle into the durable provider-neutral event contract. */ +public final class MarketCandleEventFactory { + public MarketEventEnvelope event(MarketCandle candle, Instant receivedAt) { + Objects.requireNonNull(candle, "candle"); + String material = candle.instrumentId() + ":" + candle.timeframe().value() + ":" + candle.opensAt(); + String stableId = UUID.nameUUIDFromBytes(material.getBytes(StandardCharsets.UTF_8)).toString(); + return new MarketEventEnvelope( + "candle-" + stableId, + 1, + candle.instrumentId(), + "ALPACA", + "SIP_30MIN_REST", + candle.timeframe().eventType(), + "bar-" + candle.timeframe().value() + "-" + candle.opensAt(), + candle.closesAt(), + Objects.requireNonNull(receivedAt, "receivedAt"), + candle.closesAt().getEpochSecond() / 1800, + 0, + null, + candle.values()); + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinator.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinator.java new file mode 100644 index 0000000..a375c97 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinator.java @@ -0,0 +1,70 @@ +package com.idea2strategy.trading.market.candle; + +import com.idea2strategy.trading.messaging.market.MarketCandle; +import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; +import com.idea2strategy.trading.messaging.market.MarketEventType; +import com.idea2strategy.trading.messaging.market.MarketTimeframe; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** Collapses every timeframe finalized at one boundary into one durable evaluation trigger. */ +public final class MarketEvaluationCoordinator { + + public MarketEventEnvelope ready( + UUID instrumentId, + Instant boundary, + List candlesClosedAtBoundary, + Instant receivedAt) { + Objects.requireNonNull(instrumentId, "instrumentId"); + Objects.requireNonNull(boundary, "boundary"); + List candles = List.copyOf( + Objects.requireNonNull(candlesClosedAtBoundary, "candlesClosedAtBoundary")); + if (candles.isEmpty()) { + throw new IllegalArgumentException("at least one finalized candle is required"); + } + for (MarketCandle candle : candles) { + if (!instrumentId.equals(candle.instrumentId()) || !boundary.equals(candle.closesAt())) { + throw new IllegalArgumentException("all candles must belong to the instrument and boundary"); + } + } + MarketCandle thirtyMinute = candles.stream() + .filter(candle -> candle.timeframe() == MarketTimeframe.THIRTY_MINUTES) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("the 30m finalized candle is required")); + + Map values = new LinkedHashMap<>(); + values.put("close", thirtyMinute.close()); + values.put("closed30m", flag(candles, MarketTimeframe.THIRTY_MINUTES)); + values.put("closed1h", flag(candles, MarketTimeframe.ONE_HOUR)); + values.put("closed4h", flag(candles, MarketTimeframe.FOUR_HOURS)); + values.put("closed1d", flag(candles, MarketTimeframe.ONE_DAY)); + String material = instrumentId + ":" + boundary; + String stableId = UUID.nameUUIDFromBytes(material.getBytes(StandardCharsets.UTF_8)).toString(); + return new MarketEventEnvelope( + "evaluation-ready-" + stableId, + 2, + instrumentId, + "ALPACA", + "SIP_30MIN_REST", + MarketEventType.MARKET_EVALUATION_READY, + "evaluation-ready-" + boundary, + boundary, + Objects.requireNonNull(receivedAt, "receivedAt"), + boundary.getEpochSecond() / 1800, + 0, + null, + values); + } + + private static BigDecimal flag(List candles, MarketTimeframe timeframe) { + return candles.stream().anyMatch(candle -> candle.timeframe() == timeframe) + ? BigDecimal.ONE + : BigDecimal.ZERO; + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/SessionAlignedCandleAggregator.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/SessionAlignedCandleAggregator.java new file mode 100644 index 0000000..f291ca0 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/SessionAlignedCandleAggregator.java @@ -0,0 +1,137 @@ +package com.idea2strategy.trading.market.candle; + +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import com.idea2strategy.trading.messaging.market.MarketCandle; +import com.idea2strategy.trading.messaging.market.MarketTimeframe; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** Builds finalized strategy candles on boundaries aligned to the official session open. */ +public final class SessionAlignedCandleAggregator { + + public List closedAt( + List completedThirtyMinuteCandles, + OfficialMarketSession session, + Instant boundary) { + Objects.requireNonNull(completedThirtyMinuteCandles, "completedThirtyMinuteCandles"); + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(boundary, "boundary"); + if (boundary.isAfter(session.closesAt()) || !boundary.isAfter(session.opensAt())) { + throw new IllegalArgumentException("boundary must be inside the official market session"); + } + + List source = completedThirtyMinuteCandles.stream() + .sorted(Comparator.comparing(MarketCandle::opensAt)) + .toList(); + if (source.isEmpty() || !source.getLast().closesAt().equals(boundary)) { + throw new IllegalArgumentException("the finalized 30m source must end at the boundary"); + } + UUID instrumentId = source.getFirst().instrumentId(); + validateSource(source, session, boundary, instrumentId); + + List closed = new ArrayList<>(); + closed.add(source.getLast()); + addIfClosed(closed, source, session, boundary, instrumentId, MarketTimeframe.ONE_HOUR); + addIfClosed(closed, source, session, boundary, instrumentId, MarketTimeframe.FOUR_HOURS); + if (boundary.equals(session.closesAt())) { + aggregateIfComplete( + source, instrumentId, MarketTimeframe.ONE_DAY, + session.opensAt(), session.closesAt(), false).ifPresent(closed::add); + } + return List.copyOf(closed); + } + + private static void addIfClosed( + List result, + List source, + OfficialMarketSession session, + Instant boundary, + UUID instrumentId, + MarketTimeframe timeframe) { + long periodSeconds = timeframe.duration().toSeconds(); + long elapsedSeconds = Duration.between(session.opensAt(), boundary).toSeconds(); + boolean officialClose = boundary.equals(session.closesAt()); + if (elapsedSeconds % periodSeconds != 0 && !officialClose) { + return; + } + long groupIndex = Math.max(0, (elapsedSeconds - 1) / periodSeconds); + Instant groupOpen = session.opensAt().plusSeconds(groupIndex * periodSeconds); + List group = source.stream() + .filter(candle -> !candle.opensAt().isBefore(groupOpen)) + .filter(candle -> !candle.closesAt().isAfter(boundary)) + .toList(); + boolean partial = Duration.between(groupOpen, boundary).compareTo(timeframe.duration()) < 0; + aggregateIfComplete(group, instrumentId, timeframe, groupOpen, boundary, partial) + .ifPresent(result::add); + } + + private static java.util.Optional aggregateIfComplete( + List source, + UUID instrumentId, + MarketTimeframe timeframe, + Instant opensAt, + Instant closesAt, + boolean partial) { + if (source.isEmpty() + || !source.getFirst().opensAt().equals(opensAt) + || !source.getLast().closesAt().equals(closesAt)) { + return java.util.Optional.empty(); + } + Instant expectedOpen = opensAt; + for (MarketCandle candle : source) { + if (!candle.opensAt().equals(expectedOpen)) { + return java.util.Optional.empty(); + } + expectedOpen = candle.closesAt(); + } + BigDecimal high = source.stream().map(MarketCandle::high).max(BigDecimal::compareTo).orElseThrow(); + BigDecimal low = source.stream().map(MarketCandle::low).min(BigDecimal::compareTo).orElseThrow(); + BigDecimal volume = source.stream() + .map(MarketCandle::volume) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return java.util.Optional.of(new MarketCandle( + instrumentId, + timeframe, + opensAt, + closesAt, + source.getFirst().open(), + high, + low, + source.getLast().close(), + volume, + partial)); + } + + private static void validateSource( + List source, + OfficialMarketSession session, + Instant boundary, + UUID instrumentId) { + Instant previousClose = null; + for (MarketCandle candle : source) { + if (candle.timeframe() != MarketTimeframe.THIRTY_MINUTES) { + throw new IllegalArgumentException("source candles must all be 30m"); + } + if (!candle.instrumentId().equals(instrumentId)) { + throw new IllegalArgumentException("source candles must belong to one instrument"); + } + if (candle.opensAt().isBefore(session.opensAt()) + || !Duration.between(candle.opensAt(), candle.closesAt()).equals(Duration.ofMinutes(30))) { + throw new IllegalArgumentException("source candles must be official 30m session bars"); + } + if (candle.closesAt().isAfter(boundary)) { + throw new IllegalArgumentException("source candle closes after the requested boundary"); + } + if (previousClose != null && candle.opensAt().isBefore(previousClose)) { + throw new IllegalArgumentException("source candles must not overlap"); + } + previousClose = candle.closesAt(); + } + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/DisplayTradeSubscriptionSource.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/DisplayTradeSubscriptionSource.java new file mode 100644 index 0000000..0f808b3 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/DisplayTradeSubscriptionSource.java @@ -0,0 +1,9 @@ +package com.idea2strategy.trading.market.display; + +import java.time.Instant; +import java.util.Set; + +@FunctionalInterface +public interface DisplayTradeSubscriptionSource { + Set desiredSymbols(Instant now); +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/LatestTradeCoalescer.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/LatestTradeCoalescer.java new file mode 100644 index 0000000..cd6aa04 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/LatestTradeCoalescer.java @@ -0,0 +1,113 @@ +package com.idea2strategy.trading.market.display; + +import com.idea2strategy.trading.market.alpaca.AlpacaSipInboundMessage; +import com.idea2strategy.trading.messaging.market.DisplayPriceUpdate; +import java.math.BigDecimal; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** Retains interval OHLCV and the latest trade, bounding client fan-out to one update per flush. */ +public final class LatestTradeCoalescer { + private final Map instruments; + private final Clock clock; + private final ConcurrentHashMap pending = new ConcurrentHashMap<>(); + + public LatestTradeCoalescer(Map instruments, Clock clock) { + Objects.requireNonNull(instruments, "instruments"); + Map normalized = new java.util.HashMap<>(); + instruments.forEach((symbol, instrumentId) -> normalized.put( + symbol.trim().toUpperCase(Locale.ROOT), Objects.requireNonNull(instrumentId, symbol))); + this.instruments = Map.copyOf(normalized); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public void accept(AlpacaSipInboundMessage.TradeTick tick) { + Objects.requireNonNull(tick, "tick"); + String symbol = tick.symbol().trim().toUpperCase(Locale.ROOT); + if (!instruments.containsKey(symbol)) { + return; + } + pending.compute(symbol, (ignored, accumulator) -> accumulator == null + ? new Accumulator(tick) + : accumulator.add(tick)); + } + + public List flush() { + List updates = new ArrayList<>(); + for (String symbol : List.copyOf(pending.keySet())) { + Accumulator accumulator = pending.remove(symbol); + if (accumulator != null) { + AlpacaSipInboundMessage.TradeTick latest = accumulator.latest; + updates.add(new DisplayPriceUpdate( + instruments.get(symbol), + symbol, + latest.price(), + latest.size(), + accumulator.earliest.price(), + accumulator.high, + accumulator.low, + latest.price(), + accumulator.volume, + accumulator.count, + latest.tradeId(), + latest.occurredAt(), + clock.instant())); + } + } + updates.sort(java.util.Comparator.comparing(DisplayPriceUpdate::symbol)); + return List.copyOf(updates); + } + + private static final class Accumulator { + private final AlpacaSipInboundMessage.TradeTick earliest; + private final AlpacaSipInboundMessage.TradeTick latest; + private final BigDecimal high; + private final BigDecimal low; + private final BigDecimal volume; + private final long count; + + private Accumulator(AlpacaSipInboundMessage.TradeTick tick) { + this(tick, tick, tick.price(), tick.price(), tick.size(), 1); + } + + private Accumulator( + AlpacaSipInboundMessage.TradeTick earliest, + AlpacaSipInboundMessage.TradeTick latest, + BigDecimal high, + BigDecimal low, + BigDecimal volume, + long count) { + this.earliest = earliest; + this.latest = latest; + this.high = high; + this.low = low; + this.volume = volume; + this.count = count; + } + + private Accumulator add(AlpacaSipInboundMessage.TradeTick tick) { + AlpacaSipInboundMessage.TradeTick first = compare(tick, earliest) < 0 ? tick : earliest; + AlpacaSipInboundMessage.TradeTick newest = compare(tick, latest) >= 0 ? tick : latest; + return new Accumulator( + first, + newest, + high.max(tick.price()), + low.min(tick.price()), + volume.add(tick.size()), + count + 1); + } + + private static int compare( + AlpacaSipInboundMessage.TradeTick left, + AlpacaSipInboundMessage.TradeTick right) { + int occurredAt = left.occurredAt().compareTo(right.occurredAt()); + return occurredAt != 0 ? occurredAt : Long.compare(left.tradeId(), right.tradeId()); + } + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/RedisDisplayPricePublisher.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/RedisDisplayPricePublisher.java new file mode 100644 index 0000000..037d79b --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/RedisDisplayPricePublisher.java @@ -0,0 +1,103 @@ +package com.idea2strategy.trading.market.display; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.idea2strategy.trading.messaging.market.DisplayPriceUpdate; +import io.lettuce.core.RedisClient; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.sync.RedisCommands; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Display-only Redis Pub/Sub publisher. This class intentionally has no Redis Stream writes. */ +public final class RedisDisplayPricePublisher implements AutoCloseable { + private final RedisClient client; + private final StatefulRedisConnection connection; + private final RedisCommands commands; + private final ObjectMapper mapper = new ObjectMapper(); + private final String keyBase; + + private RedisDisplayPricePublisher(RedisClient client, String keyPrefix) { + this.client = Objects.requireNonNull(client, "client"); + this.connection = client.connect(); + this.commands = connection.sync(); + this.keyBase = keyBase(keyPrefix); + } + + RedisDisplayPricePublisher(RedisCommands commands, String keyPrefix) { + this.client = null; + this.connection = null; + this.commands = Objects.requireNonNull(commands, "commands"); + this.keyBase = keyBase(keyPrefix); + } + + public static RedisDisplayPricePublisher connect(String redisUri, String keyPrefix) { + return new RedisDisplayPricePublisher(RedisClient.create(redisUri), keyPrefix); + } + + public void publish(DisplayPriceUpdate update) { + Objects.requireNonNull(update, "update"); + String json = json(update); + commands.hset(latestKey(update.instrumentId().toString()), Map.of( + "instrumentId", update.instrumentId().toString(), + "symbol", update.symbol(), + "price", update.price().toPlainString(), + "occurredAt", update.occurredAt().toString(), + "publishedAt", update.publishedAt().toString(), + "payload", json)); + commands.publish(updatesChannel(), json); + } + + public String updatesChannel() { + return keyBase + ":display:price-updates"; + } + + private String latestKey(String instrumentId) { + return keyBase + ":display:latest:" + instrumentId; + } + + private String json(DisplayPriceUpdate update) { + try { + return mapper.writeValueAsString(payload(update)); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("display price update cannot be serialized", exception); + } + } + + static Map payload(DisplayPriceUpdate update) { + Map payload = new LinkedHashMap<>(); + payload.put("schemaVersion", 1); + payload.put("instrumentId", update.instrumentId().toString()); + payload.put("symbol", update.symbol()); + payload.put("price", update.price()); + payload.put("lastTradeSize", update.lastTradeSize()); + payload.put("intervalOpen", update.intervalOpen()); + payload.put("intervalHigh", update.intervalHigh()); + payload.put("intervalLow", update.intervalLow()); + payload.put("intervalClose", update.intervalClose()); + payload.put("intervalVolume", update.intervalVolume()); + payload.put("intervalTradeCount", update.intervalTradeCount()); + payload.put("providerTradeId", update.providerTradeId()); + payload.put("occurredAt", update.occurredAt().toString()); + payload.put("publishedAt", update.publishedAt().toString()); + return Map.copyOf(payload); + } + + @Override + public void close() { + if (connection != null) { + connection.close(); + } + if (client != null) { + client.shutdown(); + } + } + + private static String keyBase(String prefix) { + if (prefix == null || prefix.isBlank()) { + throw new IllegalArgumentException("keyPrefix must not be blank"); + } + return "{" + prefix + ":market}"; + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/RedisDisplayTradeSubscriptionSource.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/RedisDisplayTradeSubscriptionSource.java new file mode 100644 index 0000000..2fe3dba --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/display/RedisDisplayTradeSubscriptionSource.java @@ -0,0 +1,54 @@ +package com.idea2strategy.trading.market.display; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.sync.RedisCommands; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** Reads renewable browser subscription leases so the single Alpaca socket follows active charts. */ +public final class RedisDisplayTradeSubscriptionSource implements DisplayTradeSubscriptionSource, AutoCloseable { + private final RedisClient client; + private final StatefulRedisConnection connection; + private final RedisCommands commands; + private final String leasesKey; + + private RedisDisplayTradeSubscriptionSource(RedisClient client, String keyPrefix) { + this.client = Objects.requireNonNull(client, "client"); + this.connection = client.connect(); + this.commands = connection.sync(); + this.leasesKey = "{" + keyPrefix + ":market}:display:subscription-leases"; + } + + public static RedisDisplayTradeSubscriptionSource connect(String redisUri, String keyPrefix) { + return new RedisDisplayTradeSubscriptionSource(RedisClient.create(redisUri), keyPrefix); + } + + @Override + public Set desiredSymbols(Instant now) { + Objects.requireNonNull(now, "now"); + double epochMillis = now.toEpochMilli(); + commands.zremrangebyscore(leasesKey, 0, epochMillis); + LinkedHashSet symbols = new LinkedHashSet<>(); + for (String lease : commands.zrangebyscore(leasesKey, epochMillis, Double.POSITIVE_INFINITY)) { + int separator = lease.lastIndexOf('|'); + if (separator >= 0 && separator + 1 < lease.length()) { + symbols.add(lease.substring(separator + 1).trim().toUpperCase(Locale.ROOT)); + } + } + return Set.copyOf(symbols); + } + + public String leasesKey() { + return leasesKey; + } + + @Override + public void close() { + connection.close(); + client.shutdown(); + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisher.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisher.java index 0e280c5..d3ff587 100644 --- a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisher.java +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisher.java @@ -45,7 +45,7 @@ local function assert_type(key, expected) if type_error ~= nil then return type_error end - if ARGV[6] == 'BAR_1M' then + if string.sub(ARGV[6], 1, 4) == 'BAR_' then type_error = assert_type(KEYS[4], 'zset') if type_error ~= nil then return type_error @@ -100,7 +100,7 @@ and tonumber(ARGV[11]) > tonumber(stored_revision)) then end end - if ARGV[6] == 'BAR_1M' then + if string.sub(ARGV[6], 1, 4) == 'BAR_' then redis.call('ZREMRANGEBYSCORE', KEYS[4], ARGV[10], ARGV[10]) redis.call('ZADD', KEYS[4], ARGV[10], ARGV[16]) local bar_count = redis.call('ZCARD', KEYS[4]) @@ -108,7 +108,6 @@ and tonumber(ARGV[11]) > tonumber(stored_revision)) then if bar_count > capacity then redis.call('ZREMRANGEBYRANK', KEYS[4], 0, bar_count - capacity - 1) end - redis.call('PUBLISH', KEYS[5], ARGV[16]) end return {1, stream_id, latest_updated} @@ -250,11 +249,10 @@ public MarketEventPublishResult publish(MarketEventHandlingResult handlingResult List result = evalList( PUBLISH_SCRIPT, new String[] { - streamKey(), + streamKey(event.eventType()), latestKey(event.instrumentId(), event.eventType()), deduplicationKey(), - recentBarsKey(event.instrumentId()), - barUpdatesChannel() + recentBarsKey(event.instrumentId(), event.eventType()) }, event.eventId(), Integer.toString(event.schemaVersion()), @@ -292,7 +290,7 @@ public Optional findLatest(UUID instrumentId, MarketEventTy } public long streamLength() { - return commands.xlen(streamKey()); + return commands.xlen(evaluationStreamKey()); } /** Publishes the gateway's C09 result; older or duplicate observations cannot overwrite it. */ @@ -329,7 +327,7 @@ public ConsumerLagMeasurement measureConsumerLag(String consumerGroup) { if (consumerGroup == null || consumerGroup.isBlank()) { throw new IllegalArgumentException("consumerGroup must not be blank"); } - List result = evalList(LAG_SCRIPT, new String[] {streamKey()}, consumerGroup); + List result = evalList(LAG_SCRIPT, new String[] {evaluationStreamKey()}, consumerGroup); long entryLag = number(result.get(0)); String lastDeliveredId = result.get(1).toString(); String deliveredOccurredAt = result.get(2).toString(); @@ -346,8 +344,25 @@ public ConsumerLagMeasurement measureConsumerLag(String consumerGroup) { return new ConsumerLagMeasurement(entryLag, observationLag, lastDeliveredId); } + public String evaluationStreamKey() { + return keyBase + ":strategy:evaluation-ready:v1"; + } + + /** The worker-facing stream is now evaluation-only. */ public String streamKey() { - return keyBase + ":events"; + return evaluationStreamKey(); + } + + public String candleStreamKey() { + return keyBase + ":strategy:candles:v1"; + } + + private String streamKey(MarketEventType eventType) { + return switch (eventType) { + case MARKET_EVALUATION_READY -> evaluationStreamKey(); + case BAR_30M, BAR_1H, BAR_4H, BAR_1D -> candleStreamKey(); + default -> keyBase + ":events"; + }; } String deduplicationKey() { @@ -360,13 +375,17 @@ String latestKey(UUID instrumentId, MarketEventType eventType) { return keyBase + ":latest:" + instrumentId + ":" + eventType.name(); } - public String recentBarsKey(UUID instrumentId) { + public String recentBarsKey(UUID instrumentId, MarketEventType eventType) { Objects.requireNonNull(instrumentId, "instrumentId"); - return keyBase + ":bars:" + instrumentId + ":1m"; - } - - public String barUpdatesChannel() { - return keyBase + ":bar-updates"; + Objects.requireNonNull(eventType, "eventType"); + String timeframe = switch (eventType) { + case BAR_30M -> "30m"; + case BAR_1H -> "1h"; + case BAR_4H -> "4h"; + case BAR_1D -> "1d"; + default -> "none"; + }; + return keyBase + ":bars:" + instrumentId + ":" + timeframe; } public String availabilityKey(UUID instrumentId) { diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/session/HttpAlpacaOfficialMarketSessionSource.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/session/HttpAlpacaOfficialMarketSessionSource.java new file mode 100644 index 0000000..5f8de59 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/session/HttpAlpacaOfficialMarketSessionSource.java @@ -0,0 +1,68 @@ +package com.idea2strategy.trading.market.session; + +import com.idea2strategy.trading.market.alpaca.AlpacaCredentials; +import com.idea2strategy.trading.market.alpaca.AlpacaCredentialsProvider; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.LocalDate; +import java.util.Objects; +import java.util.Optional; + +/** Daily cached official Alpaca calendar lookup, including holidays and early closes. */ +public final class HttpAlpacaOfficialMarketSessionSource implements OfficialMarketSessionSource { + public static final URI DEFAULT_ENDPOINT = URI.create("https://api.alpaca.markets/v2/calendar"); + + private final HttpClient client; + private final URI endpoint; + private final AlpacaCredentialsProvider credentialsProvider; + private final AlpacaCalendarJsonParser parser; + private final Clock clock; + private volatile LocalDate cachedDate; + private volatile Optional cachedSession = Optional.empty(); + + public HttpAlpacaOfficialMarketSessionSource( + HttpClient client, + URI endpoint, + AlpacaCredentialsProvider credentialsProvider, + Clock clock) { + this.client = Objects.requireNonNull(client, "client"); + this.endpoint = Objects.requireNonNull(endpoint, "endpoint"); + this.credentialsProvider = Objects.requireNonNull(credentialsProvider, "credentialsProvider"); + this.parser = new AlpacaCalendarJsonParser(); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public synchronized Optional session(LocalDate tradingDate) { + Objects.requireNonNull(tradingDate, "tradingDate"); + if (tradingDate.equals(cachedDate)) { + return cachedSession; + } + AlpacaCredentials credentials = credentialsProvider.load(); + URI uri = URI.create(endpoint + "?start=" + tradingDate + "&end=" + tradingDate); + HttpRequest request = HttpRequest.newBuilder(uri) + .header("APCA-API-KEY-ID", credentials.apiKey()) + .header("APCA-API-SECRET-KEY", credentials.apiSecret()) + .GET() + .build(); + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new IllegalStateException("Alpaca calendar request failed with HTTP " + response.statusCode()); + } + cachedSession = parser.parse(response.body(), tradingDate, tradingDate, clock.instant()) + .sessions().stream().findFirst(); + cachedDate = tradingDate; + return cachedSession; + } catch (IOException exception) { + throw new IllegalStateException("Alpaca calendar request failed", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Alpaca calendar request was interrupted", exception); + } + } +} diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/session/OfficialMarketSessionSource.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/session/OfficialMarketSessionSource.java new file mode 100644 index 0000000..ef82c16 --- /dev/null +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/session/OfficialMarketSessionSource.java @@ -0,0 +1,9 @@ +package com.idea2strategy.trading.market.session; + +import java.time.LocalDate; +import java.util.Optional; + +@FunctionalInterface +public interface OfficialMarketSessionSource { + Optional session(LocalDate tradingDate); +} diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipMessageParserTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipMessageParserTest.java index 4526c84..3cead41 100644 --- a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipMessageParserTest.java +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipMessageParserTest.java @@ -1,133 +1,49 @@ package com.idea2strategy.trading.market.alpaca; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; -import com.idea2strategy.trading.messaging.market.MarketEventType; import java.math.BigDecimal; import java.time.Instant; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.Test; class AlpacaSipMessageParserTest { private static final Instant RECEIVED_AT = Instant.parse("2026-07-31T14:31:00.050Z"); - private final AlpacaSipMessageParser parser = new AlpacaSipMessageParser(); @Test - void configuredIexFeedIsPreservedInTheProviderNeutralEvent() { - AlpacaSipMessageParser iexParser = new AlpacaSipMessageParser(AlpacaDataFeed.IEX); - - AlpacaSipInboundMessage.MinuteBar bar = (AlpacaSipInboundMessage.MinuteBar) iexParser.parse( - "[{\"T\":\"b\",\"S\":\"AAPL\",\"o\":1,\"h\":1,\"l\":1,\"c\":1,\"v\":1," - + "\"t\":\"2026-07-31T14:30:00Z\"}]", - RECEIVED_AT).get(0); - - assertEquals("IEX", bar.input().feed()); - } - - @Test - void parsesTheOfficialControlFrameSequence() { - assertEquals( - List.of(new AlpacaSipInboundMessage.Connected()), + void parsesTheOfficialControlFrameSequenceForTradeSubscriptions() { + assertEquals(List.of(new AlpacaSipInboundMessage.Connected()), parser.parse("[{\"T\":\"success\",\"msg\":\"connected\"}]", RECEIVED_AT)); - assertEquals( - List.of(new AlpacaSipInboundMessage.Authenticated()), + assertEquals(List.of(new AlpacaSipInboundMessage.Authenticated()), parser.parse("[{\"T\":\"success\",\"msg\":\"authenticated\"}]", RECEIVED_AT)); assertEquals( List.of(new AlpacaSipInboundMessage.SubscriptionConfirmed(List.of("AAPL", "MSFT"))), - parser.parse( - "[{\"T\":\"subscription\",\"trades\":[\"AAPL\",\"MSFT\"]," - + "\"quotes\":[\"AAPL\",\"MSFT\"],\"bars\":[\"AAPL\",\"MSFT\"]}]", - RECEIVED_AT)); - assertEquals( - List.of(new AlpacaSipInboundMessage.ProviderError(406, "connection limit exceeded")), - parser.parse("[{\"T\":\"error\",\"code\":406,\"msg\":\"connection limit exceeded\"}]", RECEIVED_AT)); + parser.parse("[{\"T\":\"subscription\",\"trades\":[\"AAPL\",\"MSFT\"]," + + "\"quotes\":[],\"bars\":[]}]", RECEIVED_AT)); } @Test - void parsesMinuteBarIntoTheProviderNeutralContractShape() { + void parsesTradeTicksWithoutTurningThemIntoStrategyEvents() { List messages = parser.parse( - "[{\"T\":\"b\",\"S\":\"AAPL\",\"o\":210.10,\"h\":210.25,\"l\":210.05," - + "\"c\":210.20,\"v\":2500,\"t\":\"2026-07-31T14:30:00Z\",\"n\":181,\"vw\":210.17}]", + "[{\"T\":\"t\",\"S\":\"AAPL\",\"i\":529835250,\"x\":\"V\"," + + "\"p\":210.125,\"s\":20,\"c\":[\"@\"]," + + "\"t\":\"2026-07-31T14:30:00.123456Z\",\"z\":\"C\"}]", RECEIVED_AT); - Instant barStart = Instant.parse("2026-07-31T14:30:00Z"); - AlpacaMarketInput expected = new AlpacaMarketInput( - MarketEventType.BAR_1M, - "bar-20260731T143000Z", - "AAPL", - "SIP", - barStart, - RECEIVED_AT, - barStart.getEpochSecond() / 60, - 0, - Map.of( - "open", new BigDecimal("210.10"), - "high", new BigDecimal("210.25"), - "low", new BigDecimal("210.05"), - "close", new BigDecimal("210.20"), - "volume", new BigDecimal("2500"))); - assertEquals(List.of(new AlpacaSipInboundMessage.MinuteBar(expected)), messages); + assertEquals(List.of(new AlpacaSipInboundMessage.TradeTick( + "AAPL", 529835250L, "V", new BigDecimal("210.125"), new BigDecimal("20"), + Instant.parse("2026-07-31T14:30:00.123456Z"), RECEIVED_AT, List.of("@"), "C")), messages); } @Test - void barSequenceIsTheEpochMinuteSoRedeliveryAndRestartsAgree() { - String frame = "[{\"T\":\"b\",\"S\":\"AAPL\",\"o\":1,\"h\":1,\"l\":1,\"c\":1,\"v\":1," - + "\"t\":\"2026-07-31T14:30:00Z\"}]"; - - AlpacaSipInboundMessage.MinuteBar first = (AlpacaSipInboundMessage.MinuteBar) - parser.parse(frame, RECEIVED_AT).get(0); - AlpacaSipInboundMessage.MinuteBar redelivered = (AlpacaSipInboundMessage.MinuteBar) - parser.parse(frame, RECEIVED_AT.plusSeconds(90)).get(0); - - assertEquals(first.input().sequence(), redelivered.input().sequence()); - assertEquals(first.input().providerEventId(), redelivered.input().providerEventId()); + void oneMinuteBarsAreExplicitlyIgnoredAndMalformedTradesFailClosed() { assertEquals( - first.input().sequence() + 1, - ((AlpacaSipInboundMessage.MinuteBar) parser.parse( - frame.replace("14:30:00Z", "14:31:00Z"), RECEIVED_AT.plusSeconds(60)).get(0)) - .input().sequence()); - } - - @Test - void quoteTradeAndUpdatedBarFramesAreReportedNotSilentlyDropped() { - List messages = parser.parse( - "[{\"T\":\"q\",\"S\":\"AAPL\"},{\"T\":\"t\",\"S\":\"AAPL\"},{\"T\":\"u\",\"S\":\"AAPL\"}]", - RECEIVED_AT); - - assertEquals( - List.of( - new AlpacaSipInboundMessage.UnsupportedFrame("q"), - new AlpacaSipInboundMessage.UnsupportedFrame("t"), - new AlpacaSipInboundMessage.UnsupportedFrame("u")), - messages); - } - - @Test - void rejectsMalformedFramesInsteadOfGuessing() { - assertThrows(IllegalArgumentException.class, () -> parser.parse("not json", RECEIVED_AT)); - assertThrows( - IllegalArgumentException.class, - () -> parser.parse("{\"T\":\"success\",\"msg\":\"connected\"}", RECEIVED_AT)); - IllegalArgumentException missingField = assertThrows( - IllegalArgumentException.class, - () -> parser.parse( - "[{\"T\":\"b\",\"S\":\"AAPL\",\"o\":1,\"h\":1,\"l\":1,\"c\":1," - + "\"t\":\"2026-07-31T14:30:00Z\"}]", - RECEIVED_AT)); - assertInstanceOf(IllegalArgumentException.class, missingField); - assertEquals("SIP bar field v must be a number", missingField.getMessage()); - assertThrows( - IllegalArgumentException.class, - () -> parser.parse( - "[{\"T\":\"b\",\"S\":\"AAPL\",\"o\":1,\"h\":1,\"l\":1,\"c\":1,\"v\":1," - + "\"t\":\"yesterday\"}]", - RECEIVED_AT)); - assertThrows( - IllegalArgumentException.class, - () -> parser.parse("[{\"T\":\"subscription\",\"trades\":[\"AAPL\"]}]", RECEIVED_AT)); + List.of(new AlpacaSipInboundMessage.UnsupportedFrame("b")), + parser.parse("[{\"T\":\"b\",\"S\":\"AAPL\"}]", RECEIVED_AT)); + assertThrows(IllegalArgumentException.class, () -> parser.parse( + "[{\"T\":\"t\",\"S\":\"AAPL\",\"i\":1,\"x\":\"V\",\"p\":1," + + "\"s\":1,\"c\":[],\"t\":\"bad\",\"z\":\"C\"}]", RECEIVED_AT)); } } diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipRightsGateTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipRightsGateTest.java index 343a081..d0813c7 100644 --- a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipRightsGateTest.java +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipRightsGateTest.java @@ -62,18 +62,20 @@ void exactConfiguredFeedMustMatchTheRightsEvidence() { } @Test - void subscriptionIsActiveOnlyAfterTheEntireUniverseIsApproved() { + void subscriptionIsActiveOnlyAfterTheDesiredDisplaySymbolsAreApproved() { AtomicInteger credentialLoads = new AtomicInteger(); RecordingTransport transport = new RecordingTransport(); ProviderRightsGate gate = new ProviderRightsGate( () -> new ProviderRightsEvidence("alpaca", "sip", NOW.minusSeconds(1), NOW.plusSeconds(60)), Clock.fixed(NOW, ZoneOffset.UTC)); AlpacaSipSubscriptionManager manager = manager(gate, credentialLoads, transport); + manager.replaceTradeSubscriptions(List.of("AAPL", "MSFT")); manager.onConnected(); manager.onAuthenticationApproved(); - assertThrows(IllegalStateException.class, () -> manager.onSubscriptionApproved(List.of("AAPL"))); + manager.onSubscriptionApproved(List.of("AAPL")); + assertEquals(false, manager.isSubscribed()); manager.onSubscriptionApproved(List.of("AAPL", "MSFT")); assertEquals(true, manager.isSubscribed()); } @@ -101,6 +103,9 @@ public void authenticate(AlpacaCredentials credentials) { } @Override - public void subscribe(List symbols) {} + public void subscribeTrades(List symbols) {} + + @Override + public void unsubscribeTrades(List symbols) {} } } diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipSubscriptionManagerReconnectTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipSubscriptionManagerReconnectTest.java index acfba0a..a9e68e5 100644 --- a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipSubscriptionManagerReconnectTest.java +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipSubscriptionManagerReconnectTest.java @@ -31,6 +31,7 @@ void resubscribesTheEntireApprovedUniverseExactlyOnceAfterReconnect() { rightsGate, () -> new AlpacaCredentials("environment-key", "environment-secret"), transport); + manager.replaceTradeSubscriptions(approvedSymbols); manager.onConnected(); manager.onAuthenticationApproved(); @@ -58,8 +59,11 @@ public void authenticate(AlpacaCredentials credentials) { } @Override - public void subscribe(List symbols) { + public void subscribeTrades(List symbols) { subscriptionBatches.add(List.copyOf(symbols)); } + + @Override + public void unsubscribeTrades(List symbols) {} } } diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipWebSocketTransportTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipWebSocketTransportTest.java index 85280d4..4da3a2e 100644 --- a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipWebSocketTransportTest.java +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/AlpacaSipWebSocketTransportTest.java @@ -9,7 +9,7 @@ class AlpacaSipWebSocketTransportTest { @Test - void emitsOfficialAlpacaAuthenticationAndStockSubscriptionFrames() { + void emitsOfficialAlpacaAuthenticationAndDynamicTradeFrames() { List frames = new ArrayList<>(); AlpacaSipWebSocketTransport transport = new AlpacaSipWebSocketTransport(text -> { frames.add(text); @@ -17,12 +17,14 @@ void emitsOfficialAlpacaAuthenticationAndStockSubscriptionFrames() { }); transport.authenticate(new AlpacaCredentials("api-key", "api-secret")); - transport.subscribe(List.of("msft", "AAPL", "AAPL")); + transport.subscribeTrades(List.of("msft", "AAPL", "AAPL")); + transport.unsubscribeTrades(List.of("MSFT")); assertEquals( List.of( "{\"action\":\"auth\",\"key\":\"api-key\",\"secret\":\"api-secret\"}", - "{\"action\":\"subscribe\",\"bars\":[\"AAPL\",\"MSFT\"]}"), + "{\"action\":\"subscribe\",\"trades\":[\"AAPL\",\"MSFT\"]}", + "{\"action\":\"unsubscribe\",\"trades\":[\"MSFT\"]}"), frames); } } diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/ContractFixtureResourcesTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/ContractFixtureResourcesTest.java index d24c293..7e05ee4 100644 --- a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/ContractFixtureResourcesTest.java +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/alpaca/ContractFixtureResourcesTest.java @@ -28,14 +28,14 @@ void packagesMarketEventEdgeCasesWithRequiredEnvelopeFields() throws IOException } @Test - void packagesProviderNeutralQuoteTradeAndOneMinuteBarExamples() throws IOException { + void packagesProviderNeutralQuoteTradeAndThirtyMinuteBarExamples() throws IOException { var fixture = resource("contracts/v1/provider-neutral-market-events.json"); assertContains( fixture, "\"QUOTE\"", "\"TRADE\"", - "\"BAR_1M\"", + "\"BAR_30M\"", "\"ALPACA\"", "\"SIP\"", "\"instrumentId\"", diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsJsonParserTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsJsonParserTest.java new file mode 100644 index 0000000..1ecada3 --- /dev/null +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/AlpacaThirtyMinuteBarsJsonParserTest.java @@ -0,0 +1,37 @@ +package com.idea2strategy.trading.market.candle; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import com.idea2strategy.trading.messaging.market.MarketCandle; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class AlpacaThirtyMinuteBarsJsonParserTest { + @Test + void parsesOnlyFinalizedThirtyMinuteBarsThroughTheRequestedBoundary() { + UUID instrument = UUID.fromString("68ed5d6c-7472-44d7-a606-bb1d32726d80"); + OfficialMarketSession session = new OfficialMarketSession( + LocalDate.parse("2026-08-03"), + Instant.parse("2026-08-03T13:30:00Z"), + Instant.parse("2026-08-03T20:00:00Z")); + String body = "{\"bars\":{\"AAPL\":[" + + "{\"t\":\"2026-08-03T13:30:00Z\",\"o\":100,\"h\":102,\"l\":99,\"c\":101,\"v\":10}," + + "{\"t\":\"2026-08-03T14:00:00Z\",\"o\":101,\"h\":104,\"l\":100,\"c\":103,\"v\":20}," + + "{\"t\":\"2026-08-03T14:30:00Z\",\"o\":103,\"h\":105,\"l\":102,\"c\":104,\"v\":30}" + + "]},\"next_page_token\":null}"; + + List bars = new AlpacaThirtyMinuteBarsJsonParser() + .parse(body, Map.of("AAPL", instrument), session, Instant.parse("2026-08-03T14:30:00Z")) + .get("AAPL"); + + assertEquals(2, bars.size()); + assertEquals(new BigDecimal("103"), bars.getLast().close()); + assertEquals(Instant.parse("2026-08-03T14:30:00Z"), bars.getLast().closesAt()); + } +} diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/FinalizedCandleBoundaryPlannerTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/FinalizedCandleBoundaryPlannerTest.java new file mode 100644 index 0000000..f865709 --- /dev/null +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/FinalizedCandleBoundaryPlannerTest.java @@ -0,0 +1,29 @@ +package com.idea2strategy.trading.market.candle; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FinalizedCandleBoundaryPlannerTest { + @Test + void waitsForGraceAndStopsAtTheOfficialEarlyClose() { + OfficialMarketSession session = new OfficialMarketSession( + LocalDate.parse("2026-11-27"), + Instant.parse("2026-11-27T14:30:00Z"), + Instant.parse("2026-11-27T18:00:00Z")); + FinalizedCandleBoundaryPlanner planner = new FinalizedCandleBoundaryPlanner(); + + assertEquals(List.of(), planner.readyBoundaries( + session, Instant.parse("2026-11-27T15:00:01Z"), Duration.ofSeconds(2))); + List all = planner.readyBoundaries( + session, Instant.parse("2026-11-27T20:00:00Z"), Duration.ofSeconds(2)); + + assertEquals(7, all.size()); + assertEquals(session.closesAt(), all.getLast()); + } +} diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/FinalizedCandleCycleTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/FinalizedCandleCycleTest.java new file mode 100644 index 0000000..dbc4e60 --- /dev/null +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/FinalizedCandleCycleTest.java @@ -0,0 +1,60 @@ +package com.idea2strategy.trading.market.candle; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import com.idea2strategy.trading.messaging.market.MarketCandle; +import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; +import com.idea2strategy.trading.messaging.market.MarketEventType; +import com.idea2strategy.trading.messaging.market.MarketTimeframe; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class FinalizedCandleCycleTest { + private static final UUID AAPL = UUID.fromString("68ed5d6c-7472-44d7-a606-bb1d32726d80"); + private static final UUID MSFT = UUID.fromString("7a06416a-facf-4e60-a453-9350f729ff06"); + + @Test + void emitsCandlesThenExactlyOneEvaluationEventAndSkipsMissingSymbols() { + OfficialMarketSession session = new OfficialMarketSession( + LocalDate.parse("2026-08-03"), + Instant.parse("2026-08-03T13:30:00Z"), + Instant.parse("2026-08-03T20:00:00Z")); + Instant boundary = Instant.parse("2026-08-03T14:30:00Z"); + List events = new ArrayList<>(); + AlpacaThirtyMinuteBarsClient client = (instruments, ignoredSession, ignoredBoundary) -> Map.of( + "AAPL", List.of( + candle(AAPL, session.opensAt(), "100"), + candle(AAPL, session.opensAt().plusSeconds(1800), "101")), + "MSFT", List.of()); + FinalizedCandleCycle cycle = new FinalizedCandleCycle( + client, + events::add, + Clock.fixed(boundary.plusMillis(250), ZoneOffset.UTC), + 200); + + FinalizedCandleCycle.FinalizedCandleCycleResult result = cycle.run( + Map.of("AAPL", AAPL, "MSFT", MSFT), session, boundary); + + assertEquals(1, result.evaluatedInstrumentCount()); + assertEquals(1, result.missingInstrumentCount()); + assertEquals( + List.of(MarketEventType.BAR_30M, MarketEventType.BAR_1H, MarketEventType.MARKET_EVALUATION_READY), + events.stream().map(MarketEventEnvelope::eventType).toList()); + } + + private static MarketCandle candle(UUID instrument, Instant opensAt, String close) { + BigDecimal price = new BigDecimal(close); + return new MarketCandle( + instrument, MarketTimeframe.THIRTY_MINUTES, opensAt, opensAt.plusSeconds(1800), + price, price, price, price, BigDecimal.TEN, false); + } +} diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinatorTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinatorTest.java new file mode 100644 index 0000000..0b1cc80 --- /dev/null +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinatorTest.java @@ -0,0 +1,46 @@ +package com.idea2strategy.trading.market.candle; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.idea2strategy.trading.messaging.market.MarketCandle; +import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; +import com.idea2strategy.trading.messaging.market.MarketEventType; +import com.idea2strategy.trading.messaging.market.MarketTimeframe; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class MarketEvaluationCoordinatorTest { + private static final UUID INSTRUMENT = UUID.fromString("68ed5d6c-7472-44d7-a606-bb1d32726d80"); + + @Test + void collapsesAllTimeframesClosedAtOneBoundaryIntoOneDeterministicEvent() { + Instant boundary = Instant.parse("2026-08-03T17:30:00Z"); + List closed = List.of( + candle(MarketTimeframe.THIRTY_MINUTES, boundary.minusSeconds(1800), boundary, false), + candle(MarketTimeframe.ONE_HOUR, boundary.minusSeconds(3600), boundary, false), + candle(MarketTimeframe.FOUR_HOURS, boundary.minusSeconds(14400), boundary, false)); + MarketEvaluationCoordinator coordinator = new MarketEvaluationCoordinator(); + + MarketEventEnvelope first = coordinator.ready(INSTRUMENT, boundary, closed, boundary.plusMillis(50)); + MarketEventEnvelope redelivery = coordinator.ready(INSTRUMENT, boundary, closed, boundary.plusSeconds(2)); + + assertEquals(MarketEventType.MARKET_EVALUATION_READY, first.eventType()); + assertEquals(first.eventId(), redelivery.eventId()); + assertEquals(BigDecimal.ONE, first.values().get("closed30m")); + assertEquals(BigDecimal.ONE, first.values().get("closed1h")); + assertEquals(BigDecimal.ONE, first.values().get("closed4h")); + assertEquals(BigDecimal.ZERO, first.values().get("closed1d")); + assertEquals(new BigDecimal("103"), first.values().get("close")); + } + + private static MarketCandle candle( + MarketTimeframe timeframe, Instant opensAt, Instant closesAt, boolean partial) { + return new MarketCandle( + INSTRUMENT, timeframe, opensAt, closesAt, + new BigDecimal("100"), new BigDecimal("104"), new BigDecimal("99"), + new BigDecimal("103"), new BigDecimal("30"), partial); + } +} diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/SessionAlignedCandleAggregatorTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/SessionAlignedCandleAggregatorTest.java new file mode 100644 index 0000000..52e97d9 --- /dev/null +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/SessionAlignedCandleAggregatorTest.java @@ -0,0 +1,109 @@ +package com.idea2strategy.trading.market.candle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.idea2strategy.trading.market.session.OfficialMarketSession; +import com.idea2strategy.trading.messaging.market.MarketCandle; +import com.idea2strategy.trading.messaging.market.MarketTimeframe; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class SessionAlignedCandleAggregatorTest { + private static final UUID INSTRUMENT = UUID.fromString("68ed5d6c-7472-44d7-a606-bb1d32726d80"); + + @Test + void closesThirtyMinuteAndHourlyCandlesAtTheSameBoundary() { + OfficialMarketSession session = session("2026-08-03T13:30:00Z", "2026-08-03T20:00:00Z"); + List source = List.of( + candle("2026-08-03T13:30:00Z", "2026-08-03T14:00:00Z", "100", "102", "99", "101", "10"), + candle("2026-08-03T14:00:00Z", "2026-08-03T14:30:00Z", "101", "104", "100", "103", "20")); + + List closed = new SessionAlignedCandleAggregator() + .closedAt(source, session, Instant.parse("2026-08-03T14:30:00Z")); + + assertEquals(List.of(MarketTimeframe.THIRTY_MINUTES, MarketTimeframe.ONE_HOUR), + closed.stream().map(MarketCandle::timeframe).toList()); + MarketCandle hourly = closed.get(1); + assertEquals(new BigDecimal("100"), hourly.open()); + assertEquals(new BigDecimal("104"), hourly.high()); + assertEquals(new BigDecimal("99"), hourly.low()); + assertEquals(new BigDecimal("103"), hourly.close()); + assertEquals(new BigDecimal("30"), hourly.volume()); + assertTrue(!hourly.partial()); + } + + @Test + void preservesFinalPartialHourAndFourHourCandleAtAnEarlyClose() { + OfficialMarketSession session = session("2026-11-27T14:30:00Z", "2026-11-27T18:00:00Z"); + List source = candles( + Instant.parse("2026-11-27T14:30:00Z"), 7); + + List closed = new SessionAlignedCandleAggregator() + .closedAt(source, session, session.closesAt()); + + assertEquals( + List.of( + MarketTimeframe.THIRTY_MINUTES, + MarketTimeframe.ONE_HOUR, + MarketTimeframe.FOUR_HOURS, + MarketTimeframe.ONE_DAY), + closed.stream().map(MarketCandle::timeframe).toList()); + assertTrue(closed.get(1).partial()); + assertTrue(closed.get(2).partial()); + assertTrue(!closed.get(3).partial()); + assertEquals(session.opensAt(), closed.get(2).opensAt()); + assertEquals(session.closesAt(), closed.get(2).closesAt()); + } + + @Test + void aMissingEarlierBarDoesNotSuppressALaterValidThirtyMinuteEvaluation() { + OfficialMarketSession session = session("2026-08-03T13:30:00Z", "2026-08-03T20:00:00Z"); + List source = List.of( + candle("2026-08-03T13:30:00Z", "2026-08-03T14:00:00Z", "100", "101", "99", "100", "10"), + candle("2026-08-03T14:30:00Z", "2026-08-03T15:00:00Z", "101", "102", "100", "101", "10")); + + List closed = new SessionAlignedCandleAggregator() + .closedAt(source, session, Instant.parse("2026-08-03T15:00:00Z")); + + assertEquals(List.of(MarketTimeframe.THIRTY_MINUTES), + closed.stream().map(MarketCandle::timeframe).toList()); + } + + private static List candles(Instant firstOpen, int count) { + List candles = new ArrayList<>(); + for (int index = 0; index < count; index++) { + Instant open = firstOpen.plusSeconds(index * 1800L); + candles.add(candle( + open.toString(), open.plusSeconds(1800).toString(), + Integer.toString(100 + index), Integer.toString(102 + index), + Integer.toString(99 + index), Integer.toString(101 + index), "10")); + } + return candles; + } + + private static MarketCandle candle( + String opensAt, String closesAt, String open, String high, String low, String close, String volume) { + return new MarketCandle( + INSTRUMENT, + MarketTimeframe.THIRTY_MINUTES, + Instant.parse(opensAt), + Instant.parse(closesAt), + new BigDecimal(open), + new BigDecimal(high), + new BigDecimal(low), + new BigDecimal(close), + new BigDecimal(volume), + false); + } + + private static OfficialMarketSession session(String opensAt, String closesAt) { + return new OfficialMarketSession( + LocalDate.parse(opensAt.substring(0, 10)), Instant.parse(opensAt), Instant.parse(closesAt)); + } +} diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/display/LatestTradeCoalescerTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/display/LatestTradeCoalescerTest.java new file mode 100644 index 0000000..8941b38 --- /dev/null +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/display/LatestTradeCoalescerTest.java @@ -0,0 +1,65 @@ +package com.idea2strategy.trading.market.display; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.idea2strategy.trading.market.alpaca.AlpacaSipInboundMessage; +import com.idea2strategy.trading.messaging.market.DisplayPriceUpdate; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class LatestTradeCoalescerTest { + @Test + void preservesIntervalOhlcvWhileEmittingTheLatestTrade() { + UUID instrument = UUID.fromString("68ed5d6c-7472-44d7-a606-bb1d32726d80"); + Instant publishedAt = Instant.parse("2026-08-03T14:30:00.250Z"); + LatestTradeCoalescer coalescer = new LatestTradeCoalescer( + Map.of("AAPL", instrument), Clock.fixed(publishedAt, ZoneOffset.UTC)); + coalescer.accept(tick(1, "100.00", "2", "2026-08-03T14:30:00.010Z")); + coalescer.accept(tick(2, "101.50", "3", "2026-08-03T14:30:00.080Z")); + coalescer.accept(tick(3, "99.75", "4", "2026-08-03T14:30:00.120Z")); + + List first = coalescer.flush(); + + assertEquals(1, first.size()); + assertEquals(new BigDecimal("99.75"), first.getFirst().price()); + assertEquals(new BigDecimal("100.00"), first.getFirst().intervalOpen()); + assertEquals(new BigDecimal("101.50"), first.getFirst().intervalHigh()); + assertEquals(new BigDecimal("99.75"), first.getFirst().intervalLow()); + assertEquals(new BigDecimal("99.75"), first.getFirst().intervalClose()); + assertEquals(new BigDecimal("9"), first.getFirst().intervalVolume()); + assertEquals(3, first.getFirst().intervalTradeCount()); + assertEquals(List.of(), coalescer.flush()); + } + + @Test + void choosesIntervalOpenAndCloseByProviderTimeWhenTicksArriveOutOfOrder() { + UUID instrument = UUID.fromString("68ed5d6c-7472-44d7-a606-bb1d32726d80"); + LatestTradeCoalescer coalescer = new LatestTradeCoalescer( + Map.of("AAPL", instrument), + Clock.fixed(Instant.parse("2026-08-03T14:30:00.250Z"), ZoneOffset.UTC)); + coalescer.accept(tick(3, "101.00", "1", "2026-08-03T14:30:00.120Z")); + coalescer.accept(tick(1, "99.50", "1", "2026-08-03T14:30:00.010Z")); + coalescer.accept(tick(2, "102.00", "1", "2026-08-03T14:30:00.080Z")); + + DisplayPriceUpdate update = coalescer.flush().getFirst(); + + assertEquals(new BigDecimal("99.50"), update.intervalOpen()); + assertEquals(new BigDecimal("102.00"), update.intervalHigh()); + assertEquals(new BigDecimal("99.50"), update.intervalLow()); + assertEquals(new BigDecimal("101.00"), update.intervalClose()); + } + + private static AlpacaSipInboundMessage.TradeTick tick( + long id, String price, String size, String occurredAt) { + Instant at = Instant.parse(occurredAt); + return new AlpacaSipInboundMessage.TradeTick( + "AAPL", id, "V", new BigDecimal(price), new BigDecimal(size), at, at, + List.of("@"), "C"); + } +} diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/display/RedisDisplayPricePublisherPayloadTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/display/RedisDisplayPricePublisherPayloadTest.java new file mode 100644 index 0000000..81ae3ea --- /dev/null +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/display/RedisDisplayPricePublisherPayloadTest.java @@ -0,0 +1,37 @@ +package com.idea2strategy.trading.market.display; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.idea2strategy.trading.messaging.market.DisplayPriceUpdate; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class RedisDisplayPricePublisherPayloadTest { + @Test + void publishesIntervalOhlcvForAccurateBrowserCandles() { + DisplayPriceUpdate update = new DisplayPriceUpdate( + UUID.fromString("68ed5d6c-7472-44d7-a606-bb1d32726d80"), + "AAPL", + new BigDecimal("101.00"), + new BigDecimal("2"), + new BigDecimal("100.00"), + new BigDecimal("102.00"), + new BigDecimal("99.50"), + new BigDecimal("101.00"), + new BigDecimal("8"), + 4, + 42, + Instant.parse("2026-08-06T14:31:10.100Z"), + Instant.parse("2026-08-06T14:31:10.250Z")); + + Map payload = RedisDisplayPricePublisher.payload(update); + + assertEquals(new BigDecimal("100.00"), payload.get("intervalOpen")); + assertEquals(new BigDecimal("102.00"), payload.get("intervalHigh")); + assertEquals(new BigDecimal("99.50"), payload.get("intervalLow")); + assertEquals(new BigDecimal("101.00"), payload.get("intervalClose")); + } +} diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisherUnitTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisherUnitTest.java index 6d069d1..0e82446 100644 --- a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisherUnitTest.java +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisherUnitTest.java @@ -82,8 +82,7 @@ void passesStableIdentityAndOrderingFieldsToOneAtomicScript() { "{unit-test:market}:events", "{unit-test:market}:latest:" + AAPL_ID + ":QUOTE", "{unit-test:market}:seen", - "{unit-test:market}:bars:" + AAPL_ID + ":1m", - "{unit-test:market}:bar-updates"), + "{unit-test:market}:bars:" + AAPL_ID + ":none"), List.of(keys.getValue())); assertEquals(event.eventId(), arguments.getValue()[0]); assertEquals("42", arguments.getValue()[9]); @@ -93,24 +92,43 @@ void passesStableIdentityAndOrderingFieldsToOneAtomicScript() { @Test @SuppressWarnings("unchecked") - void retainsAndBroadcastsMinuteBarsForUserCharts() { + void retainsThirtyMinuteStrategyBarsWithoutDisplayFanout() { doReturn(List.of(1L, "1722510000000-0", 1L)) .when(commands) .eval(anyString(), eq(ScriptOutputType.MULTI), any(String[].class), any(String[].class)); - MarketEventEnvelope bar = minuteBar(42, "210.12"); + MarketEventEnvelope bar = thirtyMinuteBar(42, "210.12"); publisher.publish(new MarketEventOrderingProcessor().process(bar)); ArgumentCaptor keys = ArgumentCaptor.forClass(String[].class); ArgumentCaptor arguments = ArgumentCaptor.forClass(String[].class); verify(commands).eval(anyString(), eq(ScriptOutputType.MULTI), keys.capture(), arguments.capture()); - assertEquals("{unit-test:market}:bars:" + AAPL_ID + ":1m", keys.getValue()[3]); - assertEquals("{unit-test:market}:bar-updates", keys.getValue()[4]); + assertEquals("{unit-test:market}:bars:" + AAPL_ID + ":30m", keys.getValue()[3]); assertEquals("390", arguments.getValue()[14]); assertEquals(true, arguments.getValue()[15].contains("\"instrumentId\":\"" + AAPL_ID + "\"")); assertEquals(true, arguments.getValue()[15].contains("\"close\":210.12")); } + @Test + @SuppressWarnings("unchecked") + void routesOnlyEvaluationReadyEventsToTheWorkerFacingStream() { + doReturn(List.of(1L, "1722510000000-0", 1L)) + .when(commands) + .eval(anyString(), eq(ScriptOutputType.MULTI), any(String[].class), any(String[].class)); + Instant boundary = Instant.parse("2026-08-01T15:00:00Z"); + MarketEventEnvelope evaluation = new MarketEventEnvelope( + "evaluation-1", 2, AAPL_ID, "ALPACA", "SIP_30MIN_REST", + MarketEventType.MARKET_EVALUATION_READY, "evaluation-boundary-1", + boundary, boundary.plusSeconds(2), boundary.getEpochSecond() / 1800, 0, null, + Map.of("close", new BigDecimal("210.12"), "closed30m", BigDecimal.ONE)); + + publisher.publish(new MarketEventOrderingProcessor().process(evaluation)); + + ArgumentCaptor keys = ArgumentCaptor.forClass(String[].class); + verify(commands).eval(anyString(), eq(ScriptOutputType.MULTI), keys.capture(), any(String[].class)); + assertEquals("{unit-test:market}:strategy:evaluation-ready:v1", keys.getValue()[0]); + } + @Test void reconstructsTheLatestObservationWithoutChangingDecimalMeaning() { when(commands.hgetall("{unit-test:market}:latest:" + AAPL_ID + ":QUOTE")) @@ -233,10 +251,10 @@ private static MarketEventEnvelope event( Map.of("price", new BigDecimal(price)))); } - private static MarketEventEnvelope minuteBar(long sequence, String close) { - Instant occurredAt = Instant.parse("2026-08-01T14:30:00Z").plusSeconds(sequence * 60); + private static MarketEventEnvelope thirtyMinuteBar(long sequence, String close) { + Instant occurredAt = Instant.parse("2026-08-01T14:30:00Z").plusSeconds(sequence * 1800); return NORMALIZER.normalize(new AlpacaMarketInput( - MarketEventType.BAR_1M, + MarketEventType.BAR_30M, "bar-" + sequence, "AAPL", "sip", diff --git a/modules/strategy-runtime/src/test/resources/fixtures/c19/recorded-aapl-market.csv b/modules/strategy-runtime/src/test/resources/fixtures/c19/recorded-aapl-market.csv index f0c71e2..4b537c8 100644 --- a/modules/strategy-runtime/src/test/resources/fixtures/c19/recorded-aapl-market.csv +++ b/modules/strategy-runtime/src/test/resources/fixtures/c19/recorded-aapl-market.csv @@ -1,5 +1,5 @@ phase,providerEventId,symbol,eventType,occurredAt,receivedAt,sequence,revision,price -WARMUP,aapl-warmup-1,AAPL,BAR_1M,2026-07-31T14:28:00Z,2026-07-31T14:28:01Z,0,0,100.00 -WARMUP,aapl-warmup-2,AAPL,BAR_1M,2026-07-31T14:29:00Z,2026-07-31T14:29:01Z,1,0,101.00 +WARMUP,aapl-warmup-1,AAPL,BAR_30M,2026-07-31T13:30:00Z,2026-07-31T14:00:01Z,0,0,100.00 +WARMUP,aapl-warmup-2,AAPL,BAR_30M,2026-07-31T14:00:00Z,2026-07-31T14:30:01Z,1,0,101.00 LIVE,aapl-live-1,AAPL,TRADE,2026-07-31T14:30:00Z,2026-07-31T14:30:00.100Z,0,0,102.00 LIVE,aapl-live-2,AAPL,TRADE,2026-07-31T14:30:01Z,2026-07-31T14:30:01.100Z,1,0,105.00 diff --git a/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/DisplayPriceUpdate.java b/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/DisplayPriceUpdate.java new file mode 100644 index 0000000..3ce0ec7 --- /dev/null +++ b/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/DisplayPriceUpdate.java @@ -0,0 +1,66 @@ +package com.idea2strategy.trading.messaging.market; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** A coalesced display-only price update. It is never a strategy evaluation trigger. */ +public record DisplayPriceUpdate( + UUID instrumentId, + String symbol, + BigDecimal price, + BigDecimal lastTradeSize, + BigDecimal intervalOpen, + BigDecimal intervalHigh, + BigDecimal intervalLow, + BigDecimal intervalClose, + BigDecimal intervalVolume, + long intervalTradeCount, + long providerTradeId, + Instant occurredAt, + Instant publishedAt) { + public DisplayPriceUpdate { + instrumentId = Objects.requireNonNull(instrumentId, "instrumentId"); + symbol = requireText(symbol, "symbol"); + price = requireNonNegative(price, "price"); + lastTradeSize = requireNonNegative(lastTradeSize, "lastTradeSize"); + intervalOpen = requireNonNegative(intervalOpen, "intervalOpen"); + intervalHigh = requireNonNegative(intervalHigh, "intervalHigh"); + intervalLow = requireNonNegative(intervalLow, "intervalLow"); + intervalClose = requireNonNegative(intervalClose, "intervalClose"); + intervalVolume = requireNonNegative(intervalVolume, "intervalVolume"); + if (intervalHigh.compareTo(intervalOpen.max(intervalClose)) < 0) { + throw new IllegalArgumentException("intervalHigh must cover interval open and close"); + } + if (intervalLow.compareTo(intervalOpen.min(intervalClose)) > 0) { + throw new IllegalArgumentException("intervalLow must cover interval open and close"); + } + if (price.compareTo(intervalClose) != 0) { + throw new IllegalArgumentException("price must equal intervalClose"); + } + if (intervalTradeCount < 1) { + throw new IllegalArgumentException("intervalTradeCount must be positive"); + } + if (providerTradeId < 0) { + throw new IllegalArgumentException("providerTradeId must not be negative"); + } + occurredAt = Objects.requireNonNull(occurredAt, "occurredAt"); + publishedAt = Objects.requireNonNull(publishedAt, "publishedAt"); + } + + private static String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value; + } + + private static BigDecimal requireNonNegative(BigDecimal value, String field) { + Objects.requireNonNull(value, field); + if (value.signum() < 0) { + throw new IllegalArgumentException(field + " must not be negative"); + } + return value; + } +} diff --git a/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketCandle.java b/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketCandle.java new file mode 100644 index 0000000..085d95b --- /dev/null +++ b/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketCandle.java @@ -0,0 +1,60 @@ +package com.idea2strategy.trading.messaging.market; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** One finalized strategy candle. A partial candle is only valid at an official session close. */ +public record MarketCandle( + UUID instrumentId, + MarketTimeframe timeframe, + Instant opensAt, + Instant closesAt, + BigDecimal open, + BigDecimal high, + BigDecimal low, + BigDecimal close, + BigDecimal volume, + boolean partial) { + + public MarketCandle { + instrumentId = Objects.requireNonNull(instrumentId, "instrumentId"); + timeframe = Objects.requireNonNull(timeframe, "timeframe"); + opensAt = Objects.requireNonNull(opensAt, "opensAt"); + closesAt = Objects.requireNonNull(closesAt, "closesAt"); + if (!opensAt.isBefore(closesAt)) { + throw new IllegalArgumentException("opensAt must precede closesAt"); + } + open = requireNonNegative(open, "open"); + high = requireNonNegative(high, "high"); + low = requireNonNegative(low, "low"); + close = requireNonNegative(close, "close"); + volume = requireNonNegative(volume, "volume"); + if (high.compareTo(open) < 0 || high.compareTo(close) < 0 || high.compareTo(low) < 0) { + throw new IllegalArgumentException("high must be the greatest price"); + } + if (low.compareTo(open) > 0 || low.compareTo(close) > 0) { + throw new IllegalArgumentException("low must be the least price"); + } + } + + public Map values() { + return Map.of( + "open", open, + "high", high, + "low", low, + "close", close, + "volume", volume, + "partial", partial ? BigDecimal.ONE : BigDecimal.ZERO); + } + + private static BigDecimal requireNonNegative(BigDecimal value, String field) { + Objects.requireNonNull(value, field); + if (value.signum() < 0) { + throw new IllegalArgumentException(field + " must not be negative"); + } + return value; + } +} diff --git a/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketEventType.java b/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketEventType.java index 4e7bb89..6445d16 100644 --- a/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketEventType.java +++ b/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketEventType.java @@ -3,5 +3,9 @@ public enum MarketEventType { QUOTE, TRADE, - BAR_1M + BAR_30M, + BAR_1H, + BAR_4H, + BAR_1D, + MARKET_EVALUATION_READY } diff --git a/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketTimeframe.java b/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketTimeframe.java new file mode 100644 index 0000000..fd93129 --- /dev/null +++ b/modules/trading-messaging/src/main/java/com/idea2strategy/trading/messaging/market/MarketTimeframe.java @@ -0,0 +1,33 @@ +package com.idea2strategy.trading.messaging.market; + +import java.time.Duration; + +/** Strategy candle periods supported by the live evaluation path. */ +public enum MarketTimeframe { + THIRTY_MINUTES("30m", Duration.ofMinutes(30), MarketEventType.BAR_30M), + ONE_HOUR("1h", Duration.ofHours(1), MarketEventType.BAR_1H), + FOUR_HOURS("4h", Duration.ofHours(4), MarketEventType.BAR_4H), + ONE_DAY("1d", Duration.ofDays(1), MarketEventType.BAR_1D); + + private final String value; + private final Duration duration; + private final MarketEventType eventType; + + MarketTimeframe(String value, Duration duration, MarketEventType eventType) { + this.value = value; + this.duration = duration; + this.eventType = eventType; + } + + public String value() { + return value; + } + + public Duration duration() { + return duration; + } + + public MarketEventType eventType() { + return eventType; + } +} diff --git a/modules/trading-messaging/src/testFixtures/resources/contracts/v1/provider-neutral-market-events.json b/modules/trading-messaging/src/testFixtures/resources/contracts/v1/provider-neutral-market-events.json index 1d10f49..f3ff5f3 100644 --- a/modules/trading-messaging/src/testFixtures/resources/contracts/v1/provider-neutral-market-events.json +++ b/modules/trading-messaging/src/testFixtures/resources/contracts/v1/provider-neutral-market-events.json @@ -37,7 +37,7 @@ "instrumentId": "8a35e6b5-cf84-4f63-920d-57c1f1b95df0", "provider": "ALPACA", "feed": "SIP", - "eventType": "BAR_1M", + "eventType": "BAR_30M", "providerEventId": "bar-20260731T143000Z", "occurredAt": "2026-07-31T14:30:00Z", "receivedAt": "2026-07-31T14:31:00.050Z", From 5413bec52693ed4a77b572c6ba46e3538c06ce5c Mon Sep 17 00:00:00 2001 From: Junyou Park Date: Sat, 8 Aug 2026 03:14:33 +0900 Subject: [PATCH 2/4] fix: consume finalized strategy candles --- .../runtime/BasicMarketSignalState.java | 105 +++++++----------- .../worker/runtime/EvaluatingBotRuntime.java | 2 +- .../runtime/BasicMarketSignalStateTest.java | 62 +++++++---- .../candle/MarketEvaluationCoordinator.java | 15 +++ .../MarketEvaluationCoordinatorTest.java | 5 + 5 files changed, 104 insertions(+), 85 deletions(-) diff --git a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/BasicMarketSignalState.java b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/BasicMarketSignalState.java index fc07252..e0c06dc 100644 --- a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/BasicMarketSignalState.java +++ b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/BasicMarketSignalState.java @@ -4,7 +4,6 @@ import com.idea2strategy.trading.messaging.market.MarketEventType; import java.math.BigDecimal; import java.time.DayOfWeek; -import java.time.Instant; import java.time.LocalDate; import java.time.YearMonth; import java.time.ZoneId; @@ -20,21 +19,19 @@ /** * Builds the deterministic rolling bar inputs consumed by the complete Basic block catalog. * - *

The market gateway supplies completed one-minute bars. This state keeps those bars and rolls - * them into every resolution the Basic editor exposes. A larger resolution only reports - * {@code bar.closed.=true} when a complete aggregate becomes available; the interpreter - * therefore never trades from a partially formed candle. + *

The market gateway supplies one evaluation event containing every strategy candle finalized + * at a 30-minute boundary. This state keeps only 30m, 1h, 4h, and 1d candles, so display-only + * minute bars can never trigger strategy evaluation. */ final class BasicMarketSignalState { private static final ZoneId MARKET_ZONE = ZoneId.of("America/New_York"); private static final int MAX_BARS = 180; private static final List RESOLUTIONS = List.of( - new Resolution("1m", 1), new Resolution("3m", 3), - new Resolution("5m", 5), new Resolution("15m", 15), - new Resolution("30m", 30), new Resolution("1h", 60), - new Resolution("4h", 240), new Resolution("1d", 1_440), - new Resolution("1w", 10_080)); + new Resolution("30m", "closed30m"), + new Resolution("1h", "closed1h"), + new Resolution("4h", "closed4h"), + new Resolution("1d", "closed1d")); private final Map series = new LinkedHashMap<>(); private LocalDate tradingDay; @@ -57,7 +54,7 @@ Map accept(MarketEventEnvelope event) { if (newTradingDay) { tradingDay = eventDay; tradingDayIndex++; - sessionOpen = first(event.values(), "open", "price", "close"); + sessionOpen = first(event.values(), "open30m", "open", "price", "close"); } if (sessionOpen != null) { values.put("session.open", sessionOpen.toPlainString()); @@ -74,20 +71,33 @@ Map accept(MarketEventEnvelope event) { boolean sessionClose = marketTime.getHour() == 16 && marketTime.getMinute() < 2; values.put("session.close", Boolean.toString(sessionClose)); - if (event.eventType() == MarketEventType.BAR_1M) { - Bar bar = Bar.from(event); - if (bar != null) { - for (Series item : series.values()) { - if (item.accept(bar, event.occurredAt())) { + if (event.eventType() == MarketEventType.MARKET_EVALUATION_READY) { + for (Series item : series.values()) { + if (flag(event.values(), item.resolution.closedFlag())) { + Bar bar = Bar.from(event, item.resolution.code()); + if (bar != null) { + item.append(bar); values.put("bar.closed." + item.resolution.code(), "true"); } } } } series.forEach((resolution, item) -> item.publish(values)); + publishLegacyOneMinuteAlias(values); return Map.copyOf(values); } + private void publishLegacyOneMinuteAlias(Map values) { + Series thirtyMinute = series.get("30m"); + values.put("bar.closed.1m", values.get("bar.closed.30m")); + thirtyMinute.publish(values, "1m"); + } + + private static boolean flag(Map values, String key) { + BigDecimal value = values.get(key); + return value != null && value.signum() != 0; + } + private static BigDecimal first(Map values, String... keys) { for (String key : keys) { BigDecimal value = values.get(key); @@ -164,52 +174,16 @@ private static LocalDate easterSunday(int year) { return LocalDate.of(year, month, day); } - private record Resolution(String code, int minutes) { - long bucket(Instant occurredAt) { - ZonedDateTime marketTime = occurredAt.atZone(MARKET_ZONE); - if ("1d".equals(code)) { - return marketTime.toLocalDate().toEpochDay(); - } - if ("1w".equals(code)) { - LocalDate monday = marketTime.toLocalDate() - .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - return monday.toEpochDay(); - } - return Math.floorDiv(occurredAt.getEpochSecond(), minutes * 60L); - } - } + private record Resolution(String code, String closedFlag) {} private static final class Series { private final Resolution resolution; private final Deque completed = new ArrayDeque<>(); - private Long bucket; - private Bar forming; private Series(Resolution resolution) { this.resolution = resolution; } - private boolean accept(Bar bar, Instant occurredAt) { - if (resolution.minutes() == 1) { - append(bar); - return true; - } - long nextBucket = resolution.bucket(occurredAt); - if (bucket == null) { - bucket = nextBucket; - forming = bar; - return false; - } - if (bucket == nextBucket) { - forming = forming.merge(bar); - return false; - } - append(forming); - bucket = nextBucket; - forming = bar; - return true; - } - private void append(Bar bar) { completed.addLast(bar); while (completed.size() > MAX_BARS) { @@ -218,10 +192,13 @@ private void append(Bar bar) { } private void publish(Map values) { + publish(values, resolution.code()); + } + + private void publish(Map values, String suffix) { if (completed.isEmpty()) { return; } - String suffix = resolution.code(); values.put("closes." + suffix, join(completed, Value.CLOSE)); values.put("opens." + suffix, join(completed, Value.OPEN)); values.put("highs." + suffix, join(completed, Value.HIGH)); @@ -249,21 +226,19 @@ private record Bar( BigDecimal open, BigDecimal high, BigDecimal low, BigDecimal close, BigDecimal volume) { - static Bar from(MarketEventEnvelope event) { - BigDecimal close = first(event.values(), "close", "price"); + static Bar from(MarketEventEnvelope event, String suffix) { + BigDecimal close = event.values().get("close" + suffix); + if (close == null && "30m".equals(suffix)) { + close = event.values().get("close"); + } if (close == null) { return null; } - BigDecimal open = first(event.values(), "open", "price", "close"); - BigDecimal high = first(event.values(), "high", "price", "close"); - BigDecimal low = first(event.values(), "low", "price", "close"); - BigDecimal volume = event.values().getOrDefault("volume", BigDecimal.ZERO); + BigDecimal open = first(event.values(), "open" + suffix, "close" + suffix); + BigDecimal high = first(event.values(), "high" + suffix, "close" + suffix); + BigDecimal low = first(event.values(), "low" + suffix, "close" + suffix); + BigDecimal volume = event.values().getOrDefault("volume" + suffix, BigDecimal.ZERO); return new Bar(open, high, low, close, volume); } - - Bar merge(Bar next) { - return new Bar(open, high.max(next.high), low.min(next.low), next.close, - volume.add(next.volume)); - } } } diff --git a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/EvaluatingBotRuntime.java b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/EvaluatingBotRuntime.java index 0a3711a..ea6df26 100644 --- a/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/EvaluatingBotRuntime.java +++ b/apps/trading-worker/src/main/java/com/idea2strategy/trading/worker/runtime/EvaluatingBotRuntime.java @@ -496,7 +496,7 @@ private void publish( values.put("position.returnPercent", currentReturn.toPlainString()); values.put("position.peakReturnPercent", peakReturn.toPlainString()); values.put("position.drawdownPercent", drawdown.toPlainString()); - for (String resolution : List.of("1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d", "1w")) { + for (String resolution : List.of("1m", "30m", "1h", "4h", "1d")) { if (Boolean.parseBoolean(marketValues.getOrDefault("bar.closed." + resolution, "false"))) { closedBars.merge(resolution, 1L, Long::sum); } diff --git a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/BasicMarketSignalStateTest.java b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/BasicMarketSignalStateTest.java index 17b538a..f9c3e51 100644 --- a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/BasicMarketSignalStateTest.java +++ b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/BasicMarketSignalStateTest.java @@ -1,6 +1,7 @@ package com.idea2strategy.trading.worker.runtime; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; import com.idea2strategy.trading.messaging.market.MarketEventType; @@ -15,28 +16,34 @@ class BasicMarketSignalStateTest { private static final UUID INSTRUMENT = UUID.fromString("00000000-0000-4000-8000-000000000301"); @Test - void publishesRollingOneMinuteBarsAndOnlyClosesAnAggregateOnRollover() { + void publishesOnlyFinalizedStrategyCandlesAndKeepsLegacyOneMinutePlansOnThirtyMinuteCadence() { BasicMarketSignalState state = new BasicMarketSignalState(); - Map first = state.accept(bar(1, "2026-08-07T13:31:00Z", "100", "10")); - Map second = state.accept(bar(2, "2026-08-07T13:32:00Z", "101", "20")); - Map rollover = state.accept(bar(3, "2026-08-07T13:35:00Z", "103", "30")); + Map first = state.accept(ready( + 1, "2026-08-07T14:00:00Z", "100", "10", false)); + Map second = state.accept(ready( + 2, "2026-08-07T14:30:00Z", "101", "20", true)); - assertEquals("100,101,103", rollover.get("closes.1m")); + assertEquals("100,101", second.get("closes.30m")); + assertEquals("101", second.get("closes.1h")); + assertEquals("true", first.get("bar.closed.30m")); + assertEquals("false", first.get("bar.closed.1h")); + assertEquals("true", second.get("bar.closed.1h")); + assertEquals("100,101", second.get("closes.1m")); assertEquals("true", first.get("bar.closed.1m")); - assertEquals("false", second.get("bar.closed.5m")); - assertEquals("true", rollover.get("bar.closed.5m")); - assertEquals("101", rollover.get("price.5m")); - assertEquals("30", rollover.get("volumes.5m")); + assertNull(second.get("bar.closed.5m")); } @Test void emitsOneScheduleTriggerPerObservedTradingDay() { BasicMarketSignalState state = new BasicMarketSignalState(); - Map first = state.accept(bar(1, "2026-08-03T13:31:00Z", "100", "10")); - Map sameDay = state.accept(bar(2, "2026-08-03T13:32:00Z", "101", "10")); - Map nextDay = state.accept(bar(3, "2026-08-04T13:31:00Z", "102", "10")); + Map first = state.accept(ready( + 1, "2026-08-03T14:00:00Z", "100", "10", false)); + Map sameDay = state.accept(ready( + 2, "2026-08-03T14:30:00Z", "101", "10", true)); + Map nextDay = state.accept(ready( + 3, "2026-08-04T14:00:00Z", "102", "10", false)); assertEquals("true", first.get("schedule.newTradingDay")); assertEquals("true", first.get("schedule.weekFirstTradingDay")); @@ -45,15 +52,32 @@ void emitsOneScheduleTriggerPerObservedTradingDay() { assertEquals("2", nextDay.get("schedule.tradingDayIndex")); } - private static MarketEventEnvelope bar(long sequence, String at, String close, String volume) { + private static MarketEventEnvelope ready( + long sequence, String at, String close, String volume, boolean closesHour) { Instant instant = Instant.parse(at); BigDecimal price = new BigDecimal(close); + Map values = new java.util.LinkedHashMap<>(); + values.put("close", price); + values.put("closed30m", BigDecimal.ONE); + values.put("closed1h", closesHour ? BigDecimal.ONE : BigDecimal.ZERO); + values.put("closed4h", BigDecimal.ZERO); + values.put("closed1d", BigDecimal.ZERO); + putCandle(values, "30m", price, volume); + if (closesHour) { + putCandle(values, "1h", price, volume); + } return new MarketEventEnvelope( - "event-" + sequence, 1, INSTRUMENT, "alpaca", "sip", - MarketEventType.BAR_1M, "provider-" + sequence, instant, instant, - sequence, 0, null, Map.of( - "open", price.subtract(BigDecimal.ONE), "high", price, - "low", price.subtract(BigDecimal.TEN), "close", price, - "volume", new BigDecimal(volume))); + "event-" + sequence, 2, INSTRUMENT, "alpaca", "sip", + MarketEventType.MARKET_EVALUATION_READY, "provider-" + sequence, + instant, instant, sequence, 0, null, values); + } + + private static void putCandle( + Map values, String suffix, BigDecimal close, String volume) { + values.put("open" + suffix, close.subtract(BigDecimal.ONE)); + values.put("high" + suffix, close); + values.put("low" + suffix, close.subtract(BigDecimal.TEN)); + values.put("close" + suffix, close); + values.put("volume" + suffix, new BigDecimal(volume)); } } diff --git a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinator.java b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinator.java index a375c97..5ce5ca3 100644 --- a/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinator.java +++ b/modules/market-data-adapter/src/main/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinator.java @@ -44,6 +44,7 @@ public MarketEventEnvelope ready( values.put("closed1h", flag(candles, MarketTimeframe.ONE_HOUR)); values.put("closed4h", flag(candles, MarketTimeframe.FOUR_HOURS)); values.put("closed1d", flag(candles, MarketTimeframe.ONE_DAY)); + candles.forEach(candle -> putCandle(values, candle)); String material = instrumentId + ":" + boundary; String stableId = UUID.nameUUIDFromBytes(material.getBytes(StandardCharsets.UTF_8)).toString(); return new MarketEventEnvelope( @@ -67,4 +68,18 @@ private static BigDecimal flag(List candles, MarketTimeframe timef ? BigDecimal.ONE : BigDecimal.ZERO; } + + private static void putCandle(Map values, MarketCandle candle) { + String suffix = switch (candle.timeframe()) { + case THIRTY_MINUTES -> "30m"; + case ONE_HOUR -> "1h"; + case FOUR_HOURS -> "4h"; + case ONE_DAY -> "1d"; + }; + values.put("open" + suffix, candle.open()); + values.put("high" + suffix, candle.high()); + values.put("low" + suffix, candle.low()); + values.put("close" + suffix, candle.close()); + values.put("volume" + suffix, candle.volume()); + } } diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinatorTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinatorTest.java index 0b1cc80..32ca927 100644 --- a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinatorTest.java +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/candle/MarketEvaluationCoordinatorTest.java @@ -1,6 +1,7 @@ package com.idea2strategy.trading.market.candle; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import com.idea2strategy.trading.messaging.market.MarketCandle; import com.idea2strategy.trading.messaging.market.MarketEventEnvelope; @@ -34,6 +35,10 @@ void collapsesAllTimeframesClosedAtOneBoundaryIntoOneDeterministicEvent() { assertEquals(BigDecimal.ONE, first.values().get("closed4h")); assertEquals(BigDecimal.ZERO, first.values().get("closed1d")); assertEquals(new BigDecimal("103"), first.values().get("close")); + assertEquals(new BigDecimal("100"), first.values().get("open30m")); + assertEquals(new BigDecimal("104"), first.values().get("high1h")); + assertEquals(new BigDecimal("30"), first.values().get("volume4h")); + assertFalse(first.values().containsKey("open1d")); } private static MarketCandle candle( From cb4a87995e9ec65ecbaac05fbb0aef74d404c54d Mon Sep 17 00:00:00 2001 From: Junyou Park Date: Sat, 8 Aug 2026 03:27:13 +0900 Subject: [PATCH 3/4] test: align evaluation stream fixtures --- .../market/MarketEventStreamE2ETest.java | 19 +++++++++++++-- .../worker/runtime/EvaluationLoopE2ETest.java | 23 +++++++++++++++---- .../redis/RedisMarketEventPublisherTest.java | 12 ++++++---- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java index 0d7e508..18a2efb 100644 --- a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java +++ b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java @@ -276,9 +276,9 @@ private long pendingEntries() { /** Publishes through the gateway's own publisher, so the layout under test is the real one. */ private void publish(long sequence, String close) { var envelope = new MarketEventEnvelope( - "evt_rt3_" + sequence, 1, INSTRUMENT, "ALPACA", "SIP", MarketEventType.MARKET_EVALUATION_READY, + "evt_rt3_" + sequence, 2, INSTRUMENT, "ALPACA", "SIP", MarketEventType.MARKET_EVALUATION_READY, "provider-" + sequence, EVENT_AT, EVENT_AT, sequence, 0, null, - Map.of("close", new BigDecimal(close))); + evaluationValues(close)); publisher.publish(new com.idea2strategy.trading.market.alpaca.MarketEventHandlingResult( com.idea2strategy.trading.market.alpaca.MarketEventHandlingStatus.APPLIED, envelope, @@ -287,6 +287,21 @@ private void publish(long sequence, String close) { true)); } + private static Map evaluationValues(String close) { + BigDecimal price = new BigDecimal(close); + return Map.ofEntries( + Map.entry("close", price), + Map.entry("closed30m", BigDecimal.ONE), + Map.entry("closed1h", BigDecimal.ZERO), + Map.entry("closed4h", BigDecimal.ZERO), + Map.entry("closed1d", BigDecimal.ZERO), + Map.entry("open30m", price), + Map.entry("high30m", price), + Map.entry("low30m", price), + Map.entry("close30m", price), + Map.entry("volume30m", BigDecimal.ONE)); + } + private LoadedExecutionPlan plan() { return new LoadedExecutionPlan( BOT, RELEASE, "basic-compiled-plan.v1", Set.of(), planDocument(), diff --git a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/EvaluationLoopE2ETest.java b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/EvaluationLoopE2ETest.java index e12d6c3..788638d 100644 --- a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/EvaluationLoopE2ETest.java +++ b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/runtime/EvaluationLoopE2ETest.java @@ -338,9 +338,9 @@ void anUnsubscribedInstrumentIsIgnored() { runtime.start(plan(), warmup(), EvaluationWindow.openEndedFrom(ELIGIBLE_FROM)); MarketEventEnvelope other = new MarketEventEnvelope( - "market-other", 1, UUID.fromString("c2000000-0000-4000-8000-0000000000ff"), + "market-other", 2, UUID.fromString("c2000000-0000-4000-8000-0000000000ff"), "ALPACA", "SIP", MarketEventType.MARKET_EVALUATION_READY, "p-other", EVENT_AT, EVENT_AT, - 99, 0, null, Map.of("close", new BigDecimal("84"))); + 99, 0, null, evaluationValues("84")); assertTrue(runtime.feed(other).isEmpty()); } @@ -388,9 +388,24 @@ private MarketEventEnvelope event(long sequence, String close) { private MarketEventEnvelope eventAt(long sequence, String close, Instant observedAt) { return new MarketEventEnvelope( - "market-" + sequence, 1, INSTRUMENT, "ALPACA", "SIP", MarketEventType.MARKET_EVALUATION_READY, + "market-" + sequence, 2, INSTRUMENT, "ALPACA", "SIP", MarketEventType.MARKET_EVALUATION_READY, "provider-" + sequence, observedAt, observedAt, sequence, 0, null, - Map.of("close", new BigDecimal(close))); + evaluationValues(close)); + } + + private static Map evaluationValues(String close) { + BigDecimal price = new BigDecimal(close); + return Map.ofEntries( + Map.entry("close", price), + Map.entry("closed30m", BigDecimal.ONE), + Map.entry("closed1h", BigDecimal.ZERO), + Map.entry("closed4h", BigDecimal.ZERO), + Map.entry("closed1d", BigDecimal.ZERO), + Map.entry("open30m", price), + Map.entry("high30m", price), + Map.entry("low30m", price), + Map.entry("close30m", price), + Map.entry("volume30m", BigDecimal.ONE)); } /** The document B publishes, buying when RSI_14 falls below 30 with equal allocation. */ diff --git a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisherTest.java b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisherTest.java index 81eae13..667b1ea 100644 --- a/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisherTest.java +++ b/modules/market-data-adapter/src/test/java/com/idea2strategy/trading/market/redis/RedisMarketEventPublisherTest.java @@ -59,7 +59,8 @@ void publishesOnceAndAdvancesLatestObservationAtomically() { assertTrue(first.latestUpdated()); assertEquals(MarketEventPublishStatus.DUPLICATE, duplicateAfterRestart.status()); assertEquals(1, publisher.streamLength()); - assertEquals(event, publisher.findLatest(AAPL_ID, MarketEventType.QUOTE).orElseThrow()); + assertEquals(event, publisher.findLatest( + AAPL_ID, MarketEventType.MARKET_EVALUATION_READY).orElseThrow()); } } @@ -78,7 +79,8 @@ void publishesHistoricalCorrectionWithoutMovingLatestObservationBackward() { assertEquals(MarketEventPublishStatus.PUBLISHED, correction.status()); assertFalse(correction.latestUpdated()); assertEquals(3, publisher.streamLength()); - assertEquals(latest, publisher.findLatest(AAPL_ID, MarketEventType.QUOTE).orElseThrow()); + assertEquals(latest, publisher.findLatest( + AAPL_ID, MarketEventType.MARKET_EVALUATION_READY).orElseThrow()); } } @@ -89,7 +91,9 @@ void rejectsWrongTypeBeforeWritingAnyPartOfTheOperation() { try (RedisClient client = RedisClient.create(redisUri()); var connection = client.connect(); RedisMarketEventPublisher publisher = RedisMarketEventPublisher.connect(redisUri(), prefix)) { - connection.sync().set(publisher.latestKey(AAPL_ID, MarketEventType.QUOTE), "wrong-type"); + connection.sync().set( + publisher.latestKey(AAPL_ID, MarketEventType.MARKET_EVALUATION_READY), + "wrong-type"); assertThrows( RedisCommandExecutionException.class, @@ -214,7 +218,7 @@ private static MarketEventEnvelope event( String price) { Instant occurredAt = Instant.parse("2026-08-01T14:30:00Z").plusSeconds(sequence); return NORMALIZER.normalize(new AlpacaMarketInput( - MarketEventType.QUOTE, + MarketEventType.MARKET_EVALUATION_READY, providerEventId, "AAPL", "sip", From c8c796026235d496cd08145333fc58e79b6141ad Mon Sep 17 00:00:00 2001 From: Junyou Park Date: Sat, 8 Aug 2026 03:33:53 +0900 Subject: [PATCH 4/4] test: consume the evaluation-ready stream --- .../trading/worker/market/MarketEventStreamE2ETest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java index 18a2efb..b905d28 100644 --- a/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java +++ b/apps/trading-worker/src/test/java/com/idea2strategy/trading/worker/market/MarketEventStreamE2ETest.java @@ -251,7 +251,7 @@ private RedisMarketEventStreamConsumer consumer(long maximumEntryLag, Duration r } private static String streamKey() { - return "{" + keyPrefix + ":market}:events"; + return publisher.evaluationStreamKey(); } /** Takes the entry as another replica would and never acknowledges it. */