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 @@ -662,14 +662,14 @@ private OrderedIncrementalFeatureRuntime features() {
}
}

private static final class PositionTracker {
static final class PositionTracker {
private static final ZoneId MARKET_ZONE = ZoneId.of("America/New_York");
private final BigDecimal averageEntryPrice;
private final Instant openedAt;
private BigDecimal peakPrice;
private final Map<String, Long> closedBars = new LinkedHashMap<>();

private PositionTracker(PositionSnapshot snapshot) {
PositionTracker(PositionSnapshot snapshot) {
this.averageEntryPrice = snapshot.averageEntryPrice();
this.openedAt = snapshot.openedAt();
this.peakPrice = averageEntryPrice;
Expand All @@ -680,7 +680,7 @@ private boolean matches(PositionSnapshot snapshot) {
&& openedAt.equals(snapshot.openedAt());
}

private void publish(
void publish(
Map<String, String> values,
PositionSnapshot snapshot,
BigDecimal price,
Expand All @@ -707,12 +707,22 @@ private void publish(
Long.toString(tradingWeekdaysBetween(opened, current)));
}

/**
* A published position metric, under {@code precision:1.0.0}: 8 fractional digits,
* HALF_EVEN.
*
* <p>HALF_UP here was the wrong half of a pair. These values are what a
* {@code POSITION_RETURN} step compares against a threshold, and the backtest quantizes
* them HALF_EVEN as the precision rules require, so an exact tie at the ninth decimal
* produced two different published numbers for one position — and the rendered form
* reaches the step trace, so it produced two different traces as well.
*/
private static BigDecimal percentage(BigDecimal numerator, BigDecimal denominator) {
if (denominator.signum() == 0) {
return BigDecimal.ZERO;
}
return numerator.multiply(BigDecimal.valueOf(100))
.divide(denominator, 8, java.math.RoundingMode.HALF_UP);
.divide(denominator, 8, java.math.RoundingMode.HALF_EVEN);
}

private static long tradingWeekdaysBetween(LocalDate start, LocalDate end) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.idea2strategy.trading.worker.runtime;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;

import com.idea2strategy.trading.worker.runtime.EvaluatingBotRuntime.PositionSnapshot;
import com.idea2strategy.trading.worker.runtime.EvaluatingBotRuntime.PositionTracker;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;

/**
* The position metrics a strategy reads, and the precision rules they are published under.
*
* <p>These values are what a {@code POSITION_RETURN} step compares against a threshold, and their
* rendered form reaches the step trace. The official backtest publishes the same metrics from
* {@code wiring._metric_percent}, so a difference in either the arithmetic or the rounding is a
* difference in what the strategy decides.
*/
class PositionTrackerTest {

private static final Instant OPENED_AT = Instant.parse("2025-12-01T14:30:00Z");
private static final Instant OCCURRED_AT = Instant.parse("2025-12-01T21:00:00Z");

/**
* {@code precision:1.0.0} is 8 fractional digits with HALF_EVEN, and the backtest quantizes
* these HALF_EVEN accordingly. Rounding HALF_UP here made one position publish two different
* return percentages depending on which runtime computed it.
*
* <p>The entry price is chosen so the percentage is an exact tie at the ninth decimal whose
* eighth digit is even, which is the only place the two modes disagree: a gain of
* 2.5e-10 on an entry of 1 is 0.000000025 percent, and HALF_EVEN keeps the even 2 while
* HALF_UP would carry it to 3.
*/
@Test
void publishesAPercentageRoundedHalfEven() {
Map<String, String> values = publish(
new BigDecimal("1"), new BigDecimal("1.00000000025"));

assertEquals("0.00000002", values.get("position.returnPercent"));
}

@Test
void publishesEveryMetricAStrategyCanRead() {
Map<String, String> values = publish(new BigDecimal("100"), new BigDecimal("110"));

assertAll(
() -> assertEquals("100", values.get("position.averageEntryPrice")),
() -> assertEquals("10.00000000", values.get("position.returnPercent")),
() -> assertEquals("10.00000000", values.get("position.peakReturnPercent")),
() -> assertEquals("0.00000000", values.get("position.drawdownPercent")),
() -> assertEquals("0", values.get("position.holdingTradingDays")));
}

/**
* Trading days are <em>elapsed</em> days, so the entry day is day zero.
*
* <p>This is why the catalog offers {@code 당일 장 마감} as its own option: if the entry day
* counted as one, a one-trading-day hold would fire on the entry day too and the two options
* would mean the same thing. The backtest counts the same way.
*/
@Test
void countsTradingDaysAsElapsedRatherThanInclusive() {
PositionTracker tracker = new PositionTracker(
new PositionSnapshot(new BigDecimal("100"), OPENED_AT));
Map<String, String> values = new LinkedHashMap<>();

tracker.publish(
values,
new PositionSnapshot(new BigDecimal("100"), OPENED_AT),
new BigDecimal("100"),
Instant.parse("2025-12-02T21:00:00Z"),
Map.of("bar.closed.30m", "true"));

assertEquals("1", values.get("position.holdingTradingDays"));
}

/** The peak is the highest price seen, so a fall from it is a drawdown rather than a loss. */
@Test
void remembersThePeakAcrossBars() {
PositionTracker tracker = new PositionTracker(
new PositionSnapshot(new BigDecimal("100"), OPENED_AT));
publish(tracker, new BigDecimal("120"));

Map<String, String> values = publish(tracker, new BigDecimal("110"));

assertAll(
() -> assertEquals("10.00000000", values.get("position.returnPercent")),
() -> assertEquals("20.00000000", values.get("position.peakReturnPercent")),
() -> assertEquals("8.33333333", values.get("position.drawdownPercent")));
}

/** The counter starts with the position, so its first closed bar is bar one. */
@Test
void countsTheEntryBarAsTheFirstHeldBar() {
PositionTracker tracker = new PositionTracker(
new PositionSnapshot(new BigDecimal("100"), OPENED_AT));

Map<String, String> values = publish(tracker, new BigDecimal("100"));

assertEquals("1", values.get("position.holdingBars.30m"));
}

@Test
void countsOnlyTheResolutionsWhoseBarClosed() {
PositionTracker tracker = new PositionTracker(
new PositionSnapshot(new BigDecimal("100"), OPENED_AT));

Map<String, String> values = publish(tracker, new BigDecimal("100"));

assertAll(
() -> assertEquals("1", values.get("position.holdingBars.30m")),
() -> assertEquals("0", values.get("position.holdingBars.1h")),
() -> assertEquals("0", values.get("position.holdingBars.4h")),
() -> assertEquals("0", values.get("position.holdingBars.1d")));
}

private static Map<String, String> publish(BigDecimal entryPrice, BigDecimal price) {
return publish(
new PositionTracker(new PositionSnapshot(entryPrice, OPENED_AT)), price);
}

private static Map<String, String> publish(PositionTracker tracker, BigDecimal price) {
Map<String, String> values = new LinkedHashMap<>();
tracker.publish(
values,
new PositionSnapshot(price, OPENED_AT),
price,
OCCURRED_AT,
Map.of("bar.closed.30m", "true"));
return values;
}
}