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 73fc635..1d888a8 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,6 +4,7 @@ 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; @@ -34,11 +35,13 @@ final class BasicMarketSignalState { new Resolution("1d", "closed1d")); private final Map series = new LinkedHashMap<>(); + private final LocalDate executionEligibleDate; private LocalDate tradingDay; private long tradingDayIndex; private BigDecimal sessionOpen; - BasicMarketSignalState() { + BasicMarketSignalState(Instant executionEligibleFrom) { + this.executionEligibleDate = executionEligibleFrom.atZone(MARKET_ZONE).toLocalDate(); RESOLUTIONS.forEach(resolution -> series.put(resolution.code(), new Series(resolution))); } @@ -52,8 +55,8 @@ Map accept(MarketEventEnvelope event) { LocalDate previousTradingDay = tradingDay; boolean newTradingDay = !eventDay.equals(previousTradingDay); if (newTradingDay) { + tradingDayIndex = tradingDayIndexAt(eventDay, previousTradingDay); tradingDay = eventDay; - tradingDayIndex++; sessionOpen = first(event.values(), "open30m", "open", "price", "close"); } if (sessionOpen != null) { @@ -93,6 +96,28 @@ Map accept(MarketEventEnvelope event) { return Map.copyOf(values); } + private long tradingDayIndexAt(LocalDate eventDay, LocalDate previousTradingDay) { + if (eventDay.isBefore(executionEligibleDate)) { + throw new IllegalArgumentException( + "market event predates execution eligibility: " + eventDay); + } + if (previousTradingDay != null && eventDay.isAfter(previousTradingDay)) { + return tradingDayIndex + + tradingDaysBetween(previousTradingDay.plusDays(1), eventDay); + } + return tradingDaysBetween(executionEligibleDate, eventDay); + } + + private static long tradingDaysBetween(LocalDate firstDate, LocalDate lastDate) { + long count = 0; + for (LocalDate date = firstDate; !date.isAfter(lastDate); date = date.plusDays(1)) { + if (isRegularTradingDay(date)) { + count++; + } + } + return count; + } + private static boolean flag(Map values, String key) { BigDecimal value = values.get(key); return value != null && value.signum() != 0; 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 89537ca..3e37769 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 @@ -530,7 +530,8 @@ private EvaluationWindow window() { } private BasicMarketSignalState signalState(UUID instrumentId) { - return signalStates.computeIfAbsent(instrumentId, ignored -> new BasicMarketSignalState()); + return signalStates.computeIfAbsent( + instrumentId, ignored -> new BasicMarketSignalState(window.eligibleFrom())); } private PositionTracker positionTracker(UUID instrumentId, PositionSnapshot snapshot) { 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 935120b..062cf51 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 @@ -17,7 +17,7 @@ class BasicMarketSignalStateTest { @Test void publishesOnlyFinalizedStrategyCandles() { - BasicMarketSignalState state = new BasicMarketSignalState(); + BasicMarketSignalState state = stateEligibleFrom("2026-08-07T13:30:00Z"); Map first = state.accept(ready( 1, "2026-08-07T14:00:00Z", "100", "10", false)); @@ -36,7 +36,7 @@ void publishesOnlyFinalizedStrategyCandles() { @Test void emitsOneScheduleTriggerPerObservedTradingDay() { - BasicMarketSignalState state = new BasicMarketSignalState(); + BasicMarketSignalState state = stateEligibleFrom("2026-08-03T13:30:00Z"); Map first = state.accept(ready( 1, "2026-08-03T14:00:00Z", "100", "10", false)); @@ -52,6 +52,22 @@ void emitsOneScheduleTriggerPerObservedTradingDay() { assertEquals("2", nextDay.get("schedule.tradingDayIndex")); } + @Test + void tradingDayIndexSurvivesARestart() { + BasicMarketSignalState beforeRestart = stateEligibleFrom("2025-11-24T14:30:00Z"); + beforeRestart.accept(ready( + 1, "2025-11-24T14:30:00Z", "100", "10", false)); + beforeRestart.accept(ready( + 2, "2025-11-26T14:30:00Z", "101", "10", false)); + + BasicMarketSignalState afterRestart = stateEligibleFrom("2025-11-24T14:30:00Z"); + Map resumed = afterRestart.accept(ready( + 3, "2025-12-01T14:30:00Z", "102", "10", false)); + + // Nov 24, 25, 26, 28, and Dec 1 are the five NYSE sessions in this period. + assertEquals("5", resumed.get("schedule.tradingDayIndex")); + } + /** * The session closes when its daily candle does, whatever the clock says. * @@ -62,7 +78,7 @@ void emitsOneScheduleTriggerPerObservedTradingDay() { */ @Test void closesTheSessionWhenTheDailyCandleCloses() { - BasicMarketSignalState state = new BasicMarketSignalState(); + BasicMarketSignalState state = stateEligibleFrom("2025-11-28T14:30:00Z"); // 2025-11-28, the day after Thanksgiving: the session ends 18:00Z, which is 13:00 ET. Map beforeEarlyClose = state.accept(ready( @@ -76,7 +92,7 @@ void closesTheSessionWhenTheDailyCandleCloses() { @Test void doesNotCloseTheSessionAtAnHourThatOnlyUsuallyEndsIt() { - BasicMarketSignalState state = new BasicMarketSignalState(); + BasicMarketSignalState state = stateEligibleFrom("2025-12-01T14:30:00Z"); // 21:00Z is 16:00 ET, the usual close -- but this event's daily candle did not finalize, // so the session has not ended and the hour alone must not say that it has. @@ -88,7 +104,7 @@ void doesNotCloseTheSessionAtAnHourThatOnlyUsuallyEndsIt() { @Test void closesTheSessionOnARegularDayToo() { - BasicMarketSignalState state = new BasicMarketSignalState(); + BasicMarketSignalState state = stateEligibleFrom("2025-12-01T14:30:00Z"); Map values = state.accept(ready( 1, "2025-12-01T21:00:00Z", "100", "10", true, true)); @@ -96,6 +112,10 @@ void closesTheSessionOnARegularDayToo() { assertEquals("true", values.get("session.close")); } + private static BasicMarketSignalState stateEligibleFrom(String at) { + return new BasicMarketSignalState(Instant.parse(at)); + } + private static MarketEventEnvelope ready( long sequence, String at, String close, String volume, boolean closesHour) { return ready(sequence, at, close, volume, closesHour, false);