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 @@ -68,7 +68,14 @@ Map<String, String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,77 @@ void emitsOneScheduleTriggerPerObservedTradingDay() {
assertEquals("2", nextDay.get("schedule.tradingDayIndex"));
}

/**
* The session closes when its daily candle does, whatever the clock says.
*
* <p>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<String, String> beforeEarlyClose = state.accept(ready(
1, "2025-11-28T17:30:00Z", "100", "10", false, false));
Map<String, String> 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<String, String> 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<String, String> 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<String, BigDecimal> 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);
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ private static BigDecimal computeRsi14(List<BigDecimal> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ public final class BasicPlanInterpreter {
"1회만", "주기마다", "대기 후 재진입", "대기 후 재실행");
private static final Set<String> 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);

Expand Down Expand Up @@ -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.
*
* <p>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<BigDecimal> 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<BigDecimal> 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<BigDecimal> macdHistogram(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<BigDecimal> 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<String, String> 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",
Expand Down