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 @@ -5,6 +5,7 @@
import com.idea2strategy.backend.application.common.CurrentPrincipal;
import com.idea2strategy.backend.application.competition.AnonymousLeaderboardQueryService;
import com.idea2strategy.backend.application.competition.OfficialCompetitionRoomCreationService;
import com.idea2strategy.backend.application.competition.OfficialBacktestCompetitionRoomCreationService;
import com.idea2strategy.backend.application.competition.OperatorRoomManagementService;
import com.idea2strategy.backend.application.competition.OwnedBotComparisonQueryService;
import com.idea2strategy.backend.application.competition.OwnedRoomManagementQueryService;
Expand Down Expand Up @@ -34,6 +35,7 @@
import com.idea2strategy.backend.persistence.competition.CompetitionRoomRulesJpaEntity;
import com.idea2strategy.backend.persistence.competition.CompetitionRoomScheduleJpaEntity;
import com.idea2strategy.backend.persistence.competition.OperatorRoomJooqAdapter;
import com.idea2strategy.backend.persistence.competition.OfficialBacktestRoomJooqCommandAdapter;
import com.idea2strategy.backend.persistence.competition.OwnedRoomManagementJooqAdapter;
import com.idea2strategy.backend.persistence.competition.PostEvaluationChoiceJooqAdapter;
import com.idea2strategy.backend.persistence.competition.RoomConfigurationJooqAdapter;
Expand Down Expand Up @@ -81,6 +83,7 @@
})
@Import({
CompetitionRoomJpaCommandAdapter.class,
OfficialBacktestRoomJooqCommandAdapter.class,
OperatorRoomJooqAdapter.class,
OwnedRoomManagementJooqAdapter.class,
AnonymousLeaderboardJooqAdapter.class,
Expand Down Expand Up @@ -329,4 +332,18 @@ OfficialCompetitionRoomCreationService officialCompetitionRoomCreationService(
UUID::randomUUID,
new ObjectMapper());
}

@Bean
@ConditionalOnBean(CurrentOperatorPrincipal.class)
OfficialBacktestCompetitionRoomCreationService officialBacktestCompetitionRoomCreationService(
OfficialBacktestRoomJooqCommandAdapter commandAdapter,
ScoringTemplateCatalogService scoringCatalog,
CurrentOperatorPrincipal principal) {
return new OfficialBacktestCompetitionRoomCreationService(
commandAdapter,
scoringCatalog,
principal,
Clock.systemUTC(),
UUID::randomUUID);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.idea2strategy.backend.api.competition;

import com.idea2strategy.backend.application.competition.BacktestEvaluationPlanDefinition;
import com.idea2strategy.backend.application.competition.CreateOfficialBacktestRoomCommand;
import com.idea2strategy.backend.application.competition.OfficialBacktestCompetitionRoomCreationService;
import com.idea2strategy.backend.domain.competition.RoomAccessType;
import com.idea2strategy.backend.domain.competition.RoomSchedule;
import com.idea2strategy.backend.domain.competition.RoomStatus;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

/** Operator-only control plane for hidden, server-locked BACKTEST competitions. */
@RestController
@RequestMapping("/api/v1/operations/competition/backtest-rooms")
@ConditionalOnBean(OfficialBacktestCompetitionRoomCreationService.class)
public class OfficialBacktestCompetitionRoomController {
private final OfficialBacktestCompetitionRoomCreationService service;

public OfficialBacktestCompetitionRoomController(
OfficialBacktestCompetitionRoomCreationService service) {
this.service = service;
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Response create(@RequestBody Request request) {
var room = service.create(request.toCommand());
return new Response(room.id(), room.accessType(), room.status(), room.lockedAt());
}

public record Request(
String name,
RoomAccessType accessType,
UUID scoringTemplateVersionId,
BigDecimal initialCashAmount,
int botParticipationLimit,
int perAccountBotLimit,
UUID feePolicyId,
UUID buyingPowerBufferPolicyId,
Map<String, Object> eligibilityCriteria,
Map<String, Object> marketScope,
String precisionRulesVersion,
Instant recruitmentOpensAt,
Instant participationOpensAt,
Instant evaluationStartsAt,
Instant participationClosesAt,
Instant evaluationEndsAt,
Instant finalizationDeadlineAt,
String timezoneName,
String planVersion,
String planHash,
String commitmentHash,
String commitmentNonceCiphertext,
int nonceKeyVersion,
List<Period> periods) {
CreateOfficialBacktestRoomCommand toCommand() {
return new CreateOfficialBacktestRoomCommand(
name,
accessType,
scoringTemplateVersionId,
initialCashAmount,
botParticipationLimit,
perAccountBotLimit,
feePolicyId,
buyingPowerBufferPolicyId,
eligibilityCriteria,
marketScope,
precisionRulesVersion,
new RoomSchedule(
recruitmentOpensAt,
participationOpensAt,
evaluationStartsAt,
participationClosesAt,
evaluationEndsAt,
finalizationDeadlineAt,
timezoneName),
planVersion,
planHash,
commitmentHash,
commitmentNonceCiphertext,
nonceKeyVersion,
periods.stream().map(Period::toCommand).toList());
}
}

public record Period(
LocalDate evaluationStart,
LocalDate evaluationEnd,
BigDecimal importanceWeight,
String inputSetHash,
List<Dataset> datasets,
List<FeatureMaterialization> featureMaterializations) {
CreateOfficialBacktestRoomCommand.Period toCommand() {
return new CreateOfficialBacktestRoomCommand.Period(
evaluationStart,
evaluationEnd,
importanceWeight,
inputSetHash,
datasets.stream().map(Dataset::toDefinition).toList(),
featureMaterializations.stream().map(FeatureMaterialization::toDefinition).toList());
}
}

public record Dataset(UUID manifestId, String purposeCode, String lockedDatasetHash) {
BacktestEvaluationPlanDefinition.Dataset toDefinition() {
return new BacktestEvaluationPlanDefinition.Dataset(
manifestId, purposeCode, lockedDatasetHash);
}
}

public record FeatureMaterialization(UUID id, String lockedResultHash) {
BacktestEvaluationPlanDefinition.FeatureMaterialization toDefinition() {
return new BacktestEvaluationPlanDefinition.FeatureMaterialization(id, lockedResultHash);
}
}

public record Response(UUID id, RoomAccessType accessType, RoomStatus status, Instant lockedAt) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package com.idea2strategy.backend.api.competition;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.idea2strategy.backend.application.competition.BacktestEvaluationPlanDefinition;
import com.idea2strategy.backend.application.competition.OfficialBacktestCompetitionRoomCreationService;
import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogQueryPort;
import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogRecord;
import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogService;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

class OfficialBacktestCompetitionRoomControllerTest {
private static final Instant NOW = Instant.parse("2026-08-10T04:00:00Z");
private static final UUID ROOM = id(1);
private static final UUID OPERATOR = id(2);
private static final UUID TEMPLATE = id(3);
private static final AtomicReference<BacktestEvaluationPlanDefinition> SAVED_PLAN = new AtomicReference<>();

@Test
void acceptsHiddenOfficialInputsOnlyOnTheOperatorControlPlane() throws Exception {
SAVED_PLAN.set(null);
var mvc = MockMvcBuilders.standaloneSetup(
new OfficialBacktestCompetitionRoomController(service()))
.setControllerAdvice(new CompetitionRoomExceptionHandler())
.build();

mvc.perform(post("/api/v1/operations/competition/backtest-rooms")
.contentType(MediaType.APPLICATION_JSON)
.content(request()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(ROOM.toString()))
.andExpect(jsonPath("$.accessType").value("PUBLIC"))
.andExpect(jsonPath("$.status").value("DRAFT"))
.andExpect(jsonPath("$.lockedAt").value(NOW.toString()));

assertThat(SAVED_PLAN.get()).isNotNull();
assertThat(SAVED_PLAN.get().periods()).hasSize(2);
assertThat(SAVED_PLAN.get().periods()).allSatisfy(period ->
assertThat(period.datasets()).singleElement());
}

private static OfficialBacktestCompetitionRoomCreationService service() {
var catalog = new ScoringTemplateCatalogService(
new Catalog(), Clock.fixed(NOW, ZoneOffset.UTC), new ObjectMapper());
return new OfficialBacktestCompetitionRoomCreationService(
(room, plan) -> SAVED_PLAN.set(plan),
catalog,
() -> Optional.of(OPERATOR),
Clock.fixed(NOW, ZoneOffset.UTC),
() -> ROOM);
}

private static String request() {
return """
{
"name":"INT04-A official backtest room",
"accessType":"PUBLIC",
"scoringTemplateVersionId":"a0420000-0000-4000-8000-000000000003",
"initialCashAmount":100000.00000000,
"botParticipationLimit":10,
"perAccountBotLimit":2,
"feePolicyId":"a0420000-0000-4000-8000-000000000004",
"buyingPowerBufferPolicyId":"a0420000-0000-4000-8000-000000000005",
"eligibilityCriteria":{"minimumAccountState":"ACTIVE"},
"marketScope":{"market":"US"},
"precisionRulesVersion":"v1",
"recruitmentOpensAt":"2026-08-10T04:00:10Z",
"participationOpensAt":"2026-08-10T04:00:20Z",
"evaluationStartsAt":"2026-08-10T04:00:30Z",
"participationClosesAt":"2026-08-10T04:02:00Z",
"evaluationEndsAt":"2026-08-10T04:03:00Z",
"finalizationDeadlineAt":"2026-08-10T04:04:00Z",
"timezoneName":"UTC",
"planVersion":"int04-a.v1",
"planHash":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"commitmentHash":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"commitmentNonceCiphertext":"kms:ciphertext:int04-a",
"nonceKeyVersion":1,
"periods":[
{
"evaluationStart":"2024-01-01",
"evaluationEnd":"2024-06-30",
"importanceWeight":0.5,
"inputSetHash":"sha256:1111111111111111111111111111111111111111111111111111111111111111",
"datasets":[{
"manifestId":"a0420000-0000-4000-8000-000000000006",
"purposeCode":"MARKET_BARS",
"lockedDatasetHash":"sha256:2222222222222222222222222222222222222222222222222222222222222222"
}],
"featureMaterializations":[]
},
{
"evaluationStart":"2024-07-01",
"evaluationEnd":"2024-12-31",
"importanceWeight":0.5,
"inputSetHash":"sha256:3333333333333333333333333333333333333333333333333333333333333333",
"datasets":[{
"manifestId":"a0420000-0000-4000-8000-000000000007",
"purposeCode":"MARKET_BARS",
"lockedDatasetHash":"sha256:4444444444444444444444444444444444444444444444444444444444444444"
}],
"featureMaterializations":[]
}
]
}
""";
}

private static UUID id(int suffix) {
return UUID.fromString("a0420000-0000-4000-8000-" + String.format("%012d", suffix));
}

private static final class Catalog implements ScoringTemplateCatalogQueryPort {
private final ScoringTemplateCatalogRecord record = new ScoringTemplateCatalogRecord(
TEMPLATE,
"SINGLE_TOTAL_RETURN_V1",
"int04-a",
"""
{
"kind":"SINGLE",
"calculationRulesVersion":"official-room-scoring.v1",
"components":[
{"metric":"TOTAL_RETURN","direction":"HIGHER_IS_BETTER","coefficient":1}
],
"adjustments":[]
}
""",
"sha256:" + "a".repeat(64),
NOW.minusSeconds(60),
null);

@Override
public List<ScoringTemplateCatalogRecord> findSelectableAt(Instant at) {
return List.of(record);
}

@Override
public Optional<ScoringTemplateCatalogRecord> findSelectableById(UUID id, Instant at) {
return record.id().equals(id) ? Optional.of(record) : Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.idea2strategy.backend.batch;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.idea2strategy.backend.application.competition.BacktestCompetitionSettlementService;
import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogService;
import com.idea2strategy.backend.persistence.competition.BacktestCompetitionSettlementJooqAdapter;
import com.idea2strategy.backend.persistence.competition.ScoringTemplateCatalogJooqQueryAdapter;
import java.time.Clock;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration(proxyBeanMethods = false)
@EnableScheduling
@ConditionalOnProperty(
name = "idea2strategy.batch.backtest-competition-settlement.enabled",
havingValue = "true",
matchIfMissing = true)
@Import({ScoringTemplateCatalogJooqQueryAdapter.class, BacktestCompetitionSettlementJooqAdapter.class})
class BacktestCompetitionSettlementBatchConfiguration {
@Bean
ScoringTemplateCatalogService backtestCompetitionScoringCatalog(
ScoringTemplateCatalogJooqQueryAdapter adapter) {
return new ScoringTemplateCatalogService(
adapter, Clock.systemUTC(), new ObjectMapper().findAndRegisterModules());
}

@Bean
BacktestCompetitionSettlementService backtestCompetitionSettlementService(
BacktestCompetitionSettlementJooqAdapter adapter) {
return new BacktestCompetitionSettlementService(adapter, Clock.systemUTC());
}

@Bean
BacktestCompetitionSettlementBatchRunner backtestCompetitionSettlementBatchRunner(
BacktestCompetitionSettlementService service,
@Value("${idea2strategy.batch.backtest-competition-settlement.batch-size:100}") int batchSize) {
return new BacktestCompetitionSettlementBatchRunner(service, batchSize);
}
}
Loading