Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,11 +35,13 @@ final class BasicMarketSignalState {
new Resolution("1d", "closed1d"));

private final Map<String, Series> 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)));
}

Expand All @@ -52,8 +55,8 @@ Map<String, String> 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) {
Expand Down Expand Up @@ -93,6 +96,28 @@ Map<String, String> 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<String, BigDecimal> values, String key) {
BigDecimal value = values.get(key);
return value != null && value.signum() != 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class BasicMarketSignalStateTest {

@Test
void publishesOnlyFinalizedStrategyCandles() {
BasicMarketSignalState state = new BasicMarketSignalState();
BasicMarketSignalState state = stateEligibleFrom("2026-08-07T13:30:00Z");

Map<String, String> first = state.accept(ready(
1, "2026-08-07T14:00:00Z", "100", "10", false));
Expand All @@ -36,7 +36,7 @@ void publishesOnlyFinalizedStrategyCandles() {

@Test
void emitsOneScheduleTriggerPerObservedTradingDay() {
BasicMarketSignalState state = new BasicMarketSignalState();
BasicMarketSignalState state = stateEligibleFrom("2026-08-03T13:30:00Z");

Map<String, String> first = state.accept(ready(
1, "2026-08-03T14:00:00Z", "100", "10", false));
Expand All @@ -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<String, String> 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.
*
Expand All @@ -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<String, String> beforeEarlyClose = state.accept(ready(
Expand All @@ -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.
Expand All @@ -88,14 +104,18 @@ void doesNotCloseTheSessionAtAnHourThatOnlyUsuallyEndsIt() {

@Test
void closesTheSessionOnARegularDayToo() {
BasicMarketSignalState state = new BasicMarketSignalState();
BasicMarketSignalState state = stateEligibleFrom("2025-12-01T14:30:00Z");

Map<String, String> values = state.accept(ready(
1, "2025-12-01T21:00:00Z", "100", "10", true, true));

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);
Expand Down