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 1c79072..73fc635 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 @@ -68,7 +68,14 @@ Map accept(MarketEventEnvelope event) { || !YearMonth.from(previousTradingDay).equals(YearMonth.from(eventDay))))); values.put("schedule.monthLastTradingDay", Boolean.toString(newTradingDay && eventDay.equals(lastTradingDay(YearMonth.from(eventDay))))); - boolean sessionClose = marketTime.getHour() == 16 && marketTime.getMinute() < 2; + /* The session's own close, taken from the calendar rather than from the clock. The daily + candle is finalized at the session close, so closed1d is true on exactly the session's + last boundary. A fixed 16:00 test was wrong on every early close - the day after + Thanksgiving, Christmas Eve, July 3 all close at 13:00 ET - so on those days live never + published session.close at all and a SESSION_CLOSE exit silently did not run, while the + backtest, which reads the session's real closesAt, exited as written. */ + boolean sessionClose = event.eventType() == MarketEventType.MARKET_EVALUATION_READY + && flag(event.values(), "closed1d"); values.put("session.close", Boolean.toString(sessionClose)); if (event.eventType() == MarketEventType.MARKET_EVALUATION_READY) { 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 0029eb8..935120b 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 @@ -52,8 +52,62 @@ void emitsOneScheduleTriggerPerObservedTradingDay() { assertEquals("2", nextDay.get("schedule.tradingDayIndex")); } + /** + * The session closes when its daily candle does, whatever the clock says. + * + *

These four cases are the live half of the backtest's {@code session.close} rule. The + * market closes at 13:00 ET the day after Thanksgiving, at Christmas Eve, and on July 3, so a + * fixed 16:00 test never fired on those days and a {@code SESSION_CLOSE} exit silently did not + * run, while the backtest read the session's real close and exited as written. + */ + @Test + void closesTheSessionWhenTheDailyCandleCloses() { + BasicMarketSignalState state = new BasicMarketSignalState(); + + // 2025-11-28, the day after Thanksgiving: the session ends 18:00Z, which is 13:00 ET. + Map beforeEarlyClose = state.accept(ready( + 1, "2025-11-28T17:30:00Z", "100", "10", false, false)); + Map atEarlyClose = state.accept(ready( + 2, "2025-11-28T18:00:00Z", "101", "10", true, true)); + + assertEquals("false", beforeEarlyClose.get("session.close")); + assertEquals("true", atEarlyClose.get("session.close")); + } + + @Test + void doesNotCloseTheSessionAtAnHourThatOnlyUsuallyEndsIt() { + BasicMarketSignalState state = new BasicMarketSignalState(); + + // 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. + Map values = state.accept(ready( + 1, "2025-12-01T21:00:00Z", "100", "10", true, false)); + + assertEquals("false", values.get("session.close")); + } + + @Test + void closesTheSessionOnARegularDayToo() { + BasicMarketSignalState state = new BasicMarketSignalState(); + + Map values = state.accept(ready( + 1, "2025-12-01T21:00:00Z", "100", "10", true, true)); + + assertEquals("true", values.get("session.close")); + } + private static MarketEventEnvelope ready( long sequence, String at, String close, String volume, boolean closesHour) { + return ready(sequence, at, close, volume, closesHour, false); + } + + private static MarketEventEnvelope ready( + long sequence, + String at, + String close, + String volume, + boolean closesHour, + boolean closesDay) { Instant instant = Instant.parse(at); BigDecimal price = new BigDecimal(close); Map values = new java.util.LinkedHashMap<>(); @@ -61,11 +115,14 @@ private static MarketEventEnvelope ready( values.put("closed30m", BigDecimal.ONE); values.put("closed1h", closesHour ? BigDecimal.ONE : BigDecimal.ZERO); values.put("closed4h", BigDecimal.ZERO); - values.put("closed1d", BigDecimal.ZERO); + values.put("closed1d", closesDay ? BigDecimal.ONE : BigDecimal.ZERO); putCandle(values, "30m", price, volume); if (closesHour) { putCandle(values, "1h", price, volume); } + if (closesDay) { + putCandle(values, "1d", price, volume); + } return new MarketEventEnvelope( "event-" + sequence, 2, INSTRUMENT, "alpaca", "sip", MarketEventType.MARKET_EVALUATION_READY, "provider-" + sequence, diff --git a/modules/strategy-runtime/src/main/java/com/idea2strategy/trading/strategy/runtime/feature/OfficialFeatureCatalog.java b/modules/strategy-runtime/src/main/java/com/idea2strategy/trading/strategy/runtime/feature/OfficialFeatureCatalog.java index bc96b8c..41f6e8f 100644 --- a/modules/strategy-runtime/src/main/java/com/idea2strategy/trading/strategy/runtime/feature/OfficialFeatureCatalog.java +++ b/modules/strategy-runtime/src/main/java/com/idea2strategy/trading/strategy/runtime/feature/OfficialFeatureCatalog.java @@ -118,7 +118,7 @@ private static BigDecimal computeRsi14(List closes) { * and the reproducibility hash — D hit exactly this and fixed it the same way, so one number * cannot produce two hashes. */ - static BigDecimal quantize(BigDecimal value) { + public static BigDecimal quantize(BigDecimal value) { BigDecimal quantized = value.setScale(VALUE_SCALE, RoundingMode.HALF_EVEN); return quantized.signum() == 0 ? quantized.abs() : quantized; } diff --git a/modules/strategy-runtime/src/main/java/com/idea2strategy/trading/strategy/runtime/plan/BasicPlanInterpreter.java b/modules/strategy-runtime/src/main/java/com/idea2strategy/trading/strategy/runtime/plan/BasicPlanInterpreter.java index 998bc6f..1857f36 100644 --- a/modules/strategy-runtime/src/main/java/com/idea2strategy/trading/strategy/runtime/plan/BasicPlanInterpreter.java +++ b/modules/strategy-runtime/src/main/java/com/idea2strategy/trading/strategy/runtime/plan/BasicPlanInterpreter.java @@ -77,6 +77,8 @@ public final class BasicPlanInterpreter { "1회만", "주기마다", "대기 후 재진입", "대기 후 재실행"); private static final Set WAIT_MODES = Set.of("조건 재충족", "N봉 이후", "N거래일 이후"); private static final MathContext MATH = new MathContext(18, RoundingMode.HALF_UP); + /** The precision every official feature computation uses: {@code decimal128}, HALF_EVEN. */ + private static final MathContext FEATURE_MATH = OfficialFeatureCatalog.WORKING_PRECISION; /** Pinned by the official RSI_14 definition for a window with neither gains nor losses. */ private static final BigDecimal FLAT_WINDOW_RSI = BigDecimal.valueOf(50); @@ -657,30 +659,55 @@ private static BigDecimal percent(BigDecimal numerator, BigDecimal denominator) return numerator.multiply(BigDecimal.valueOf(100), MATH).divide(denominator, MATH); } + /** + * The RSI of the window ending {@code offset} bars from the newest close. + * + *

For the period the official catalog defines this delegates to that definition rather than + * reproducing it. Two implementations that merely agree are not the same feature: this one ran + * at 18 significant digits with HALF_UP and never quantized, while {@code rsi:1.0.0} is 34 + * digits with HALF_EVEN quantized to 8, and the backtest reads the series that definition + * published. A window whose RSI sits a rounding step from the threshold crossed it on one side + * and not the other, which is exactly the disagreement a released bot may not have with its own + * backtest. + */ private static BigDecimal rsi(List closes, int period, int offset) { int end = closes.size() - offset; int start = end - period - 1; - BigDecimal gains = BigDecimal.ZERO; - BigDecimal losses = BigDecimal.ZERO; - for (int index = start + 1; index < end; index++) { - BigDecimal change = closes.get(index).subtract(closes.get(index - 1)); + List window = closes.subList(start, end); + if (period == OfficialFeatureCatalog.RSI_14.periods()) { + return OfficialFeatureCatalog.RSI_14.compute(window); + } + /* No catalog definition exists for any other period, so there is no published series to + read and nothing to delegate to. The same procedure is applied at the same precision so a + period the catalog has not yet defined cannot diverge in rounding either. */ + BigDecimal gainTotal = BigDecimal.ZERO; + BigDecimal lossTotal = BigDecimal.ZERO; + for (int index = 1; index < window.size(); index++) { + BigDecimal change = window.get(index).subtract(window.get(index - 1), FEATURE_MATH); if (change.signum() > 0) { - gains = gains.add(change); - } else { - losses = losses.add(change.abs()); + gainTotal = gainTotal.add(change, FEATURE_MATH); + } else if (change.signum() < 0) { + lossTotal = lossTotal.subtract(change, FEATURE_MATH); } } - if (losses.signum() == 0) { + BigDecimal periods = BigDecimal.valueOf(period); + BigDecimal averageGain = gainTotal.divide(periods, FEATURE_MATH); + BigDecimal averageLoss = lossTotal.divide(periods, FEATURE_MATH); + BigDecimal value; + if (averageLoss.signum() == 0) { /* A perfectly flat window has no relative strength to compute, so the value is a - convention rather than a result. The official RSI_14 definition pins it to the - neutral 50 — a market that did not move is not a market that only rose — and the - backtest reads that definition's published series instead of recomputing. Returning - 0 here made the same strategy oversold live and neutral in its own backtest. */ - return gains.signum() == 0 ? FLAT_WINDOW_RSI : BigDecimal.valueOf(100); - } - BigDecimal relativeStrength = gains.divide(losses, MATH); - return BigDecimal.valueOf(100).subtract(BigDecimal.valueOf(100) - .divide(BigDecimal.ONE.add(relativeStrength), MATH)); + convention rather than a result. The official definition pins it to the neutral 50 — + a market that did not move is not a market that only rose. Returning 0 here made the + same strategy oversold live and neutral in its own backtest. */ + value = averageGain.signum() == 0 ? FLAT_WINDOW_RSI : BigDecimal.valueOf(100); + } else { + BigDecimal relativeStrength = averageGain.divide(averageLoss, FEATURE_MATH); + value = BigDecimal.valueOf(100).subtract( + BigDecimal.valueOf(100).divide( + BigDecimal.ONE.add(relativeStrength, FEATURE_MATH), FEATURE_MATH), + FEATURE_MATH); + } + return OfficialFeatureCatalog.quantize(value); } private static List macdHistogram( diff --git a/modules/strategy-runtime/src/test/java/com/idea2strategy/trading/strategy/runtime/plan/BasicPlanInterpreterTest.java b/modules/strategy-runtime/src/test/java/com/idea2strategy/trading/strategy/runtime/plan/BasicPlanInterpreterTest.java index 251dcbe..42dfd44 100644 --- a/modules/strategy-runtime/src/test/java/com/idea2strategy/trading/strategy/runtime/plan/BasicPlanInterpreterTest.java +++ b/modules/strategy-runtime/src/test/java/com/idea2strategy/trading/strategy/runtime/plan/BasicPlanInterpreterTest.java @@ -12,6 +12,8 @@ import com.idea2strategy.trading.strategy.runtime.basic.BasicInstrumentInput; import com.idea2strategy.trading.strategy.runtime.basic.BasicOrderSide; import com.idea2strategy.trading.strategy.runtime.basic.BasicStrategyExecutor; +import com.idea2strategy.trading.strategy.runtime.feature.OfficialFeatureCatalog; +import java.math.BigDecimal; import java.util.List; import java.util.Map; import java.util.UUID; @@ -195,6 +197,41 @@ void aFlatRsiWindowIsNeutralRatherThanOversold() { assertNotEquals(BasicDecisionStatus.CANDIDATE, result.decisions().getFirst().status()); } + /** + * The value live publishes for RSI_14 must be the official definition's value, not a second + * implementation that happens to be close. This one ran at 18 significant digits with HALF_UP + * and never quantized, where {@code rsi:1.0.0} is 34 digits with HALF_EVEN quantized to 8 — so a + * window sitting a rounding step from the threshold crossed it live and not in the backtest, + * which reads the series that definition published. + */ + @Test + void rsi14IsTheOfficialCatalogValueRatherThanASecondImplementation() { + var plan = interpreter.interpret(directPlan("RSI_CROSS", + "\"resolution\":\"30m\",\"direction\":\"UP\",\"period\":\"14\",\"threshold\":\"30\"", "BUY")); + // Sixteen closes with uneven moves, so the ratio does not terminate and the two roundings + // cannot coincide by luck. + List closes = List.of( + "100", "101.37", "100.42", "102.9", "101.11", "103.68", "102.05", "104.33", + "103.7", "105.29", "104.02", "106.55", "105.13", "107.87", "106.4", "108.26") + .stream().map(BigDecimal::new).toList(); + String series = closes.stream().map(BigDecimal::toPlainString).reduce((a, b) -> a + "," + b).orElseThrow(); + + BasicExecutionResult result = executor.execute(new BasicExecutionRequest( + EVALUATION, plan.flows(), Map.of(INSTRUMENT, new BasicInstrumentInput(INSTRUMENT, + Map.of("bar.closed.30m", "true", "closes.30m", series))))); + + // The newest window is the last fifteen closes; the one before it drops the newest bar. + BigDecimal expectedCurrent = OfficialFeatureCatalog.RSI_14.compute(closes.subList(1, 16)); + BigDecimal expectedPrevious = OfficialFeatureCatalog.RSI_14.compute(closes.subList(0, 15)); + Map evidence = result.decisions().getFirst().trace().getFirst().evidence(); + + assertAll( + () -> assertEquals( + expectedCurrent.stripTrailingZeros().toPlainString(), evidence.get("current")), + () -> assertEquals( + expectedPrevious.stripTrailingZeros().toPlainString(), evidence.get("previous"))); + } + @Test void directPricePositionAndScheduleBlocksMakeRealDecisions() { var price = interpreter.interpret(directPlan("PRICE_COMPARE",