From 9e1e450fa594998a51c368c38986bbfadb2c3e69 Mon Sep 17 00:00:00 2001 From: Ryan Min Date: Sat, 8 Aug 2026 11:47:11 +0900 Subject: [PATCH] fix(runtime): make the live runtime agree with the official backtest Two places where a released bot did not do what its own backtest showed. `session.close` came from the clock, not the calendar. It was true only when the market hour read 16:00, but the market closes at 13:00 ET the day after Thanksgiving, on Christmas Eve, and on July 3. On those days live never published the flag at all, so a SESSION_CLOSE exit silently did not run while the backtest, which reads the session's real closesAt, exited as written. The flag now follows the daily candle, which is finalized at the session close, so one calendar governs both runtimes and early closes need no special case. RSI_14 had a second implementation. The interpreter recomputed it from the closes series at 18 significant digits with HALF_UP and never quantized, while `rsi:1.0.0` is 34 digits with HALF_EVEN quantized to 8 and the backtest reads the series that definition published. Two implementations that merely agree are not one feature: a window whose value sat a rounding step from the threshold crossed it on one side and not the other. RSI_14 now delegates to OfficialFeatureCatalog, so the definition has exactly one implementation, and periods the catalog does not define use the same procedure at the same precision rather than a looser one. OfficialFeatureCatalog.quantize is public for that reason -- one quantizer, not a copy of it. Both are pinned by tests that fail without the change. Co-Authored-By: Claude Opus 5 --- .../runtime/BasicMarketSignalState.java | 9 ++- .../runtime/BasicMarketSignalStateTest.java | 59 +++++++++++++++++- .../feature/OfficialFeatureCatalog.java | 2 +- .../runtime/plan/BasicPlanInterpreter.java | 61 +++++++++++++------ .../plan/BasicPlanInterpreterTest.java | 37 +++++++++++ 5 files changed, 148 insertions(+), 20 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 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",