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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,13 @@ gradle :apps:trading-worker:bootRun

두 App 모두 Docker 내부 네트워크에서 동작하며 호스트 포트를 열지 않습니다.

## Basic catalog execution

`basic-elements:2026-08-08`로 발행된 전략은 `30m`, `1h`, `4h`, `1d` 중 하나의 주기만
사용합니다. 전체 Basic 조건 카탈로그를 실시간 상태로 평가하며, 주문 블록의 주문 비율,
1회 실행, 주기 실행, 조건 재충족, N봉·N거래일 대기, 최대 실행 횟수를 bot별 순차 gate에서
결정적으로 적용합니다. 매수 비율은 equal-allocation share에, 매도 비율은 composer가 읽은
실제 보유 포지션에 적용됩니다. worker 재시작 시에는 현재 포지션 주기의 canonical order
intent에서 실행 횟수와 마지막 실행 시각을 복원하며, 포지션이 완전히 종료된 뒤 다음 주기가
시작될 때 gate를 초기화합니다. 이전 catalog reader는 이미 릴리스된 bot의 동작 보존용입니다.

Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public final class OrderCandidateBatchAdapter {

public CandidateBatch toDomain(OrderCandidateBatch source) {
int version = source.schemaVersion();
if (version < MINIMUM_SCHEMA_VERSION || version > OrderCandidateBatch.ALLOCATION_SCHEMA_VERSION) {
if (version < MINIMUM_SCHEMA_VERSION || version > OrderCandidateBatch.PARTIAL_POSITION_SCHEMA_VERSION) {
throw new IllegalArgumentException(
"Unsupported order candidate batch schema version: " + version);
}
Expand Down Expand Up @@ -76,6 +76,19 @@ private static void requireShapeOfVersion(int version, OrderCandidate candidate)
"schema version " + version + " BUY candidate " + candidate.candidateId()
+ " must carry an allocation share");
}
if (version < OrderCandidateBatch.PARTIAL_POSITION_SCHEMA_VERSION
&& candidate.requestedPositionPercent().isPresent()) {
throw new IllegalArgumentException(
"schema version " + version + " candidate " + candidate.candidateId()
+ " must not carry a position percentage");
}
if (version >= OrderCandidateBatch.PARTIAL_POSITION_SCHEMA_VERSION
&& candidate.side() == OrderSide.SELL
&& candidate.requestedPositionPercent().isEmpty()) {
throw new IllegalArgumentException(
"schema version " + version + " SELL candidate " + candidate.candidateId()
+ " must carry a position percentage");
}
}

private CandidateOrder toDomain(OrderCandidate source) {
Expand All @@ -90,6 +103,7 @@ private CandidateOrder toDomain(OrderCandidate source) {
.orElse(null),
source.referencePrice(),
source.limitPrice(),
source.reasonCodes());
source.reasonCodes(),
source.positionPercent());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -88,6 +89,7 @@ public final class EvaluatingBotRuntime implements BotRuntimeLifecycle {
private final BotScopeResolver scopeResolver;
private final EvaluationRunRecorder runRecorder;
private final PositionMetricSource positionMetricSource;
private final ExecutionGateStateSource executionGateStateSource;
private final BasicPlanInterpreter interpreter = new BasicPlanInterpreter();
private final BasicStrategyExecutor executor = new BasicStrategyExecutor();
private final BasicCandidateConverger converger = new BasicCandidateConverger();
Expand All @@ -98,7 +100,8 @@ public EvaluatingBotRuntime(
OrderCandidateBatchAdapter adapter,
BotScopeResolver scopeResolver,
EvaluationRunRecorder runRecorder) {
this(processor, adapter, scopeResolver, runRecorder, PositionMetricSource.none());
this(processor, adapter, scopeResolver, runRecorder,
PositionMetricSource.none(), ExecutionGateStateSource.none());
}

public EvaluatingBotRuntime(
Expand All @@ -107,11 +110,24 @@ public EvaluatingBotRuntime(
BotScopeResolver scopeResolver,
EvaluationRunRecorder runRecorder,
PositionMetricSource positionMetricSource) {
this(processor, adapter, scopeResolver, runRecorder,
positionMetricSource, ExecutionGateStateSource.none());
}

public EvaluatingBotRuntime(
CandidateBatchProcessor processor,
OrderCandidateBatchAdapter adapter,
BotScopeResolver scopeResolver,
EvaluationRunRecorder runRecorder,
PositionMetricSource positionMetricSource,
ExecutionGateStateSource executionGateStateSource) {
this.processor = Objects.requireNonNull(processor, "processor");
this.adapter = Objects.requireNonNull(adapter, "adapter");
this.scopeResolver = Objects.requireNonNull(scopeResolver, "scopeResolver");
this.runRecorder = Objects.requireNonNull(runRecorder, "runRecorder");
this.positionMetricSource = Objects.requireNonNull(positionMetricSource, "positionMetricSource");
this.executionGateStateSource =
Objects.requireNonNull(executionGateStateSource, "executionGateStateSource");
}

@Override
Expand Down Expand Up @@ -229,7 +245,7 @@ private Optional<CandidateBatchProcessingResult> evaluate(
evaluationId, bot.plan().flows(),
inputsFor(bot, event, snapshot, price, marketValues)));
BasicCandidateConvergenceResult converged =
converger.converge(evaluationId, acceptedOf(execution, evaluationId));
converger.converge(evaluationId, acceptedOf(bot, execution, evaluationId, event));
if (converged.acceptedCandidates().isEmpty()) {
return Optional.empty();
}
Expand Down Expand Up @@ -309,18 +325,36 @@ private Map<UUID, BasicInstrumentInput> inputsFor(
return inputs;
}

private List<BasicOrderCandidate> acceptedOf(BasicExecutionResult execution, UUID evaluationId) {
private List<BasicOrderCandidate> acceptedOf(
RegisteredBot bot,
BasicExecutionResult execution,
UUID evaluationId,
MarketEventEnvelope event) {
List<BasicOrderCandidate> candidates = new ArrayList<>();
execution.decisions().stream()
.filter(decision -> decision.status() == BasicDecisionStatus.CANDIDATE)
.forEach(decision -> candidates.add(new BasicOrderCandidate(
.filter(decision -> decision.instrumentId().equals(event.instrumentId()))
.forEach(decision -> {
BasicPlanInterpreter.ExecutionPolicy policy =
bot.plan().executionPolicyByFlowKey().get(decision.flowId());
if (!bot.executionGate(
decision.flowId(),
decision.instrumentId(),
() -> executionGateStateSource.resolve(
bot.botId(), decision.flowId(), decision.instrumentId()))
.accepts(decision.status(), policy, event.occurredAt())) {
return;
}
candidates.add(new BasicOrderCandidate(
derived("candidate",
evaluationId + ":" + decision.flowId() + ":" + decision.instrumentId()),
decision.flowId(),
decision.instrumentId(),
decision.side(),
decision.buyAllocation(),
Map.of("evaluationId", evaluationId.toString()))));
Map.of(
"evaluationId", evaluationId.toString(),
"orderPercent", Integer.toString(policy.orderPercent()))));
});
return candidates;
}

Expand All @@ -341,18 +375,19 @@ private OrderCandidateBatch batchOf(
BigDecimal referencePrice) {
List<OrderCandidate> candidates = new ArrayList<>();
for (BasicOrderCandidate candidate : converged.acceptedCandidates()) {
int orderPercent = Integer.parseInt(candidate.actionParameters().get("orderPercent"));
candidates.add(candidate.side() == BasicOrderSide.BUY
? OrderCandidate.allocatedBuy(
candidate.candidateId(), candidate.instrumentId(), scope.flowId(),
candidate.buyAllocation().orElseThrow().numerator(),
candidate.buyAllocation().orElseThrow().denominator(),
candidate.buyAllocation().orElseThrow().numerator() * orderPercent,
candidate.buyAllocation().orElseThrow().denominator() * 100,
referencePrice, null, List.of("BASIC_RULE_MATCHED"))
: OrderCandidate.heldSell(
: OrderCandidate.partialHeldSell(
candidate.candidateId(), candidate.instrumentId(), scope.flowId(),
referencePrice, null, List.of("BASIC_RULE_MATCHED")));
orderPercent, referencePrice, null, List.of("BASIC_RULE_MATCHED")));
}
return new OrderCandidateBatch(
OrderCandidateBatch.ALLOCATION_SCHEMA_VERSION,
OrderCandidateBatch.PARTIAL_POSITION_SCHEMA_VERSION,
derived("candidate-batch", bot.botId() + ":" + event.eventId()),
evaluationId,
bot.botId(),
Expand Down Expand Up @@ -449,6 +484,7 @@ private static final class RegisteredBot {
private final EvaluationWindow window;
private final Map<UUID, BasicMarketSignalState> signalStates = new LinkedHashMap<>();
private final Map<UUID, PositionTracker> positionTrackers = new LinkedHashMap<>();
private final Map<String, ExecutionGate> executionGates = new LinkedHashMap<>();

private RegisteredBot(
UUID botId,
Expand Down Expand Up @@ -498,12 +534,100 @@ private BasicMarketSignalState signalState(UUID instrumentId) {
}

private PositionTracker positionTracker(UUID instrumentId, PositionSnapshot snapshot) {
return positionTrackers.compute(instrumentId, (ignored, current) ->
current != null && current.matches(snapshot) ? current : new PositionTracker(snapshot));
PositionTracker current = positionTrackers.get(instrumentId);
if (current != null && current.matches(snapshot)) {
return current;
}
clearExecutionGates(instrumentId);
PositionTracker replacement = new PositionTracker(snapshot);
positionTrackers.put(instrumentId, replacement);
return replacement;
}

private void clearPositionTracker(UUID instrumentId) {
positionTrackers.remove(instrumentId);
if (positionTrackers.remove(instrumentId) != null) {
clearExecutionGates(instrumentId);
}
}

private ExecutionGate executionGate(
String flowId, UUID instrumentId, Supplier<ExecutionGateSnapshot> snapshot) {
return executionGates.computeIfAbsent(
flowId + ":" + instrumentId, ignored -> new ExecutionGate(snapshot.get()));
}

private void clearExecutionGates(UUID instrumentId) {
String suffix = ":" + instrumentId;
executionGates.keySet().removeIf(key -> key.endsWith(suffix));
}
}

static final class ExecutionGate {
private int executions;
private int barsSinceExecution;
private Instant lastExecutionAt;
private boolean conditionRearmed = true;

ExecutionGate() {
this(ExecutionGateSnapshot.empty());
}

ExecutionGate(ExecutionGateSnapshot snapshot) {
Objects.requireNonNull(snapshot, "snapshot");
executions = snapshot.executions();
lastExecutionAt = snapshot.lastExecutionAt();
conditionRearmed = executions == 0;
}

boolean accepts(
BasicDecisionStatus status,
BasicPlanInterpreter.ExecutionPolicy policy,
Instant occurredAt) {
if (lastExecutionAt != null) {
barsSinceExecution++;
}
if (status != BasicDecisionStatus.CANDIDATE) {
if (status == BasicDecisionStatus.CONDITION_NOT_MET) {
conditionRearmed = true;
}
return false;
}
int limit = policy.executionMode().equals("1회만")
? 1
: policy.maxExecutions();
if (executions >= limit) {
return false;
}
boolean eligible = executions == 0
|| policy.executionMode().equals("주기마다")
|| switch (policy.waitMode()) {
case "조건 재충족" -> conditionRearmed;
case "N봉 이후" -> barsSinceExecution >= policy.waitInterval();
case "N거래일 이후" -> tradingDaysSince(lastExecutionAt, occurredAt)
>= policy.waitInterval();
default -> false;
};
if (!eligible) {
return false;
}
executions++;
barsSinceExecution = 0;
lastExecutionAt = occurredAt;
conditionRearmed = false;
return true;
}

private static long tradingDaysSince(Instant start, Instant end) {
LocalDate cursor = start.atZone(ZoneId.of("America/New_York")).toLocalDate();
LocalDate through = end.atZone(ZoneId.of("America/New_York")).toLocalDate();
long days = 0;
while (cursor.isBefore(through)) {
cursor = cursor.plusDays(1);
if (cursor.getDayOfWeek().getValue() <= 5) {
days++;
}
}
return days;
}
}

Expand Down Expand Up @@ -642,6 +766,31 @@ static PositionMetricSource none() {
}
}

/** Restores a flow's execution limiter from canonical intents in the current position cycle. */
public interface ExecutionGateStateSource {
ExecutionGateSnapshot resolve(UUID botId, String flowKey, UUID instrumentId);

static ExecutionGateStateSource none() {
return (botId, flowKey, instrumentId) -> ExecutionGateSnapshot.empty();
}
}

public record ExecutionGateSnapshot(int executions, Instant lastExecutionAt) {
public ExecutionGateSnapshot {
if (executions < 0) {
throw new IllegalArgumentException("executions must not be negative");
}
if ((executions == 0) != (lastExecutionAt == null)) {
throw new IllegalArgumentException(
"lastExecutionAt must exist exactly when executions are present");
}
}

static ExecutionGateSnapshot empty() {
return new ExecutionGateSnapshot(0, null);
}
}

public record PositionSnapshot(BigDecimal averageEntryPrice, Instant openedAt) {
public PositionSnapshot {
Objects.requireNonNull(averageEntryPrice, "averageEntryPrice");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,21 @@ PostgresPositionMetricSource positionMetricSource(JdbcClient jdbc) {
return new PostgresPositionMetricSource(jdbc);
}

@Bean
PostgresExecutionGateStateSource executionGateStateSource(JdbcClient jdbc) {
return new PostgresExecutionGateStateSource(jdbc);
}

@Bean
EvaluatingBotRuntime evaluatingBotRuntime(
CandidateBatchProcessor processor,
OrderCandidateBatchAdapter adapter,
PostgresBotScopeResolver scopeResolver,
PostgresEvaluationRunRecorder runRecorder,
PostgresPositionMetricSource positionMetricSource) {
PostgresPositionMetricSource positionMetricSource,
PostgresExecutionGateStateSource executionGateStateSource) {
return new EvaluatingBotRuntime(
processor, adapter, scopeResolver, runRecorder, positionMetricSource);
processor, adapter, scopeResolver, runRecorder,
positionMetricSource, executionGateStateSource);
}
}
Loading