Compare commits

...

26 Commits

Author SHA1 Message Date
Codex 0fe3bd864e Improve nonlinear PM diagnostics 2026-06-28 09:59:36 +08:00
Codex 6be4bb976a Add Entry opportunity training diagnostics 2026-06-28 09:27:59 +08:00
Codex e8420f76fe Add Direction label and PM probe diagnostics 2026-06-28 09:00:15 +08:00
Codex 5ad77ffe90 Support conditional Entry training 2026-06-28 08:40:30 +08:00
Codex 0323fb3caf Add conditional Entry training probe 2026-06-28 08:33:49 +08:00
Codex 7268f640a6 Add Entry low-drawdown diagnostics 2026-06-28 08:28:55 +08:00
Codex 3f49af5ba6 Add Entry condition pair diagnostics 2026-06-28 08:21:01 +08:00
Codex dc4d00a373 Require actual plan edge in Entry screening 2026-06-28 07:29:17 +08:00
Codex 2a86a6e2fa Use actual plan edge for Entry PM training 2026-06-28 07:26:59 +08:00
Codex 3c0f2d0d91 Use actual plan edge in OFI diagnostics 2026-06-28 07:09:34 +08:00
Codex 5a9786d861 Allow named dynamic exit search outputs 2026-06-28 06:52:52 +08:00
Codex 340e220b28 Add dynamic exit plan search diagnostics 2026-06-28 06:51:39 +08:00
Codex 1fd46ff3c9 Handle sparse event buckets in entry screening 2026-06-28 00:53:54 +08:00
Codex 340d1dd91b Improve Trader entry quality training diagnostics 2026-06-28 00:50:37 +08:00
Codex 87849a66a7 Align Continue labels with price plan outcomes 2026-06-27 23:53:58 +08:00
Codex 38a728c00b Expose state Continue Huber tuning 2026-06-27 23:39:40 +08:00
Codex c463be1741 Add state Continue path interaction features 2026-06-27 23:22:29 +08:00
Codex 5d4de011b2 Tighten state Continue verdict checks 2026-06-27 23:09:33 +08:00
Codex 062440fac2 Add state Continue diagnostic controls 2026-06-27 23:06:43 +08:00
Codex 6d816b21ad Add state-aware Continue diagnostic experiment 2026-06-27 20:28:31 +08:00
Codex 9acb3460a1 Improve Trader V4 training pipeline
Align entry labels with max future edge, tune direction labeling, and harden regression evaluation.

Add training diagnostics, price-plan search, feature screening, and nonlinear benchmark scripts.
2026-06-27 19:57:29 +08:00
Codex e58e4a5572 Implement Trader V4 training artifact pipeline 2026-06-27 16:15:23 +08:00
Codex dad6b831b4 Track replay position state across cycles 2026-06-26 22:17:48 +08:00
Codex 4e5f49d6fe Load trader V4 artifacts from manifests 2026-06-26 22:07:43 +08:00
Codex 6bbedda97d Persist trader V4 P0 decision trace 2026-06-26 22:01:25 +08:00
Codex 5d210053d0 Rewrite trader service for V4 P0 2026-06-26 21:53:22 +08:00
300 changed files with 19273 additions and 6967 deletions
+2
View File
@@ -3,6 +3,8 @@ target/
*.iml
*.log
.DS_Store
__pycache__/
*.pyc
# Runtime and local data stay outside source control.
logs/
+49 -42
View File
@@ -1,65 +1,72 @@
# quant-trader-service
Clean P0 rebuild of the Trader-style strategy service.
Trader V4 P0 decision service.
## Scope
This implementation follows the desktop design documents in
`/Users/zach/Desktop/app/trader`:
The service follows the V4 Trader documents under `/Users/zach/Desktop/app/trader`.
P0 only allows `REPLAY_SIM` and `SHADOW`; `PAPER`, `REAL`, real fills, and old
strategy-service contracts are rejected.
- `00-操盘手Trader新策略服务概要设计-20260622.md`
- `03-Trader服务详细设计说明书-20260623.md`
## Implemented Surface
P0 keeps the service in replay/shadow preparation mode:
- Spring Boot service under `com.quantai.trader`
- strict P0 runtime guard for run mode, execution mode, and trading switch
- JSON artifact loader for model bundle manifest, PM config manifest, and model output policy
- five-model output contract: Direction, Entry, Continue, Exit, Risk
- dynamic PM sizing with side-aware stop/target price calculation
- Risk Gate for hard blockers and forced close decisions
- JDBC persistence for run, cycle, model output, PM decision, risk decision, action, evidence, and outbox
- Flyway V1 schema for the V4 P0 tables
- health, replay-cycle, and feedback endpoints
- JaCoCo line coverage gate at 90%
- no real trading
- no old V3 `latest/promoted` path
- no `SCALE_IN` action surface
- no real App feedback by default
- no model training that can control live size
## Local Database
## Implemented P0 Surface
Default configuration:
- Spring Boot P0 service under `com.quantai.trader`
- Playbook YAML loading, validation, normalized JSON, SHA-256 definition hash
- MySQL 8 Flyway DDL for the P0 trader tables
- Core domain records for cycles, actions, entry plans, risk, evidence, feedback, samples, and reports
- State-machine guardrails for initial entry, planned legs, and management actions
- Dynamic position sizing from signal, execution quality, and risk scores
- Risk gate that records every allow/block decision
- Evidence appender and proxy-only training sample exporter
- Async replay-run registry and report contract
- MyBatis-Plus repositories aligned with `quant-app-server`
- Deterministic JSONL replay fixtures for accepted, rejected, blocked, and hard-fail paths
- Feedback endpoint that returns `TRADER_FEEDBACK_DISABLED` unless explicitly enabled
- Focused unit and MVC tests
```text
jdbc:mysql://127.0.0.1:3306/quant_trader
username: quant_trader
password: quant_trader
```
Override with `TRADER_DB_URL`, `TRADER_DB_USERNAME`, and `TRADER_DB_PASSWORD`.
## Artifact Root
Default artifact root:
```text
/Users/zach/Desktop/quant-strategy-training-data/trader-v4/artifact_bundle
```
Required files:
```text
manifests/model_bundle_manifest.json
manifests/position_manager_manifest.json
model_output_policy.json
```
The loader requires the configured model/calibration/PM version triple to match
the manifests, all five model families to be present, and the model/PM manifests
to be `ACTIVE`.
## Commands
```bash
mvn test
mvn spring-boot:run
mvn clean test
TRADER_DB_USERNAME=quant_trader TRADER_DB_PASSWORD=quant_trader SERVER_PORT=18080 mvn spring-boot:run
```
The service uses MySQL through MyBatis-Plus. Configure the database with
`TRADER_DB_URL`, `TRADER_DB_USERNAME`, and `TRADER_DB_PASSWORD` when the local
defaults are not available.
Replay acceptance fixtures live under `src/test/resources/replay-fixtures`.
They are deterministic P0 contract samples, not a profitability backtest corpus.
## HTTP
```text
GET /api/trader/health
GET /api/trader/playbooks
GET /api/trader/playbooks/{playbookId}
POST /api/trader/replay/runs
GET /api/trader/replay/runs/{runId}
POST /api/trader/replay/runs/{runId}/cancel
GET /api/trader/replay/runs/{runId}/report
POST /api/trader/replay/cycles
POST /api/trader/feedback
```
Remark: `/api/trader/feedback` is intentionally disabled in P0 unless
`trader.integration.http-feedback-enabled=true`.
`/api/trader/feedback` is disabled by default in P0 and still rejects
`PAPER_APP`, `REAL_APP`, and any real fill when explicitly enabled for tests.
+53 -14
View File
@@ -15,11 +15,11 @@
<artifactId>quant-trader-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>quant-trader-service</name>
<description>Clean P0 rebuild of the Trader-style strategy service.</description>
<description>Trader V4 P0 decision service: REPLAY_SIM and SHADOW only.</description>
<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<jacoco.version>0.8.13</jacoco.version>
</properties>
<dependencies>
@@ -35,15 +35,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
@@ -53,14 +44,22 @@
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.10.0</version>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>com.microsoft.onnxruntime</groupId>
<artifactId>onnxruntime</artifactId>
<version>1.22.0</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
@@ -99,6 +98,46 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check-line-coverage</id>
<phase>test</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,11 +1,9 @@
package com.quantai.trader;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@MapperScan("com.quantai.trader.infrastructure.mapper")
@SpringBootApplication
@ConfigurationPropertiesScan
public class QuantTraderServiceApplication {
@@ -0,0 +1,177 @@
package com.quantai.trader.artifact;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quantai.trader.enums.TraderRunMode;
import com.quantai.trader.persistence.TraderJsonCodec;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@Repository
public class JdbcTraderArtifactManifestRepository implements TraderArtifactManifestRepository {
private final JdbcTemplate jdbcTemplate;
private final TraderJsonCodec jsonCodec;
public JdbcTraderArtifactManifestRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.jsonCodec = new TraderJsonCodec(objectMapper);
}
@Override
public void upsertActiveBundle(TraderArtifactBundle bundle) {
upsertModelBundle(bundle.modelBundleManifest());
bundle.modelManifests().forEach(this::upsertModelManifest);
bundle.calibrationManifests().forEach(this::upsertCalibrationManifest);
upsertPmConfigManifest(bundle.pmConfigManifest());
}
private void upsertModelBundle(TraderModelBundleManifest manifest) {
jdbcTemplate.update("""
insert into trader_model_bundle_manifest
(manifest_schema_version, model_bundle_version, calibration_bundle_version,
feature_version, label_version, split_version, training_run_id, training_export_id,
backtest_manifest_id, required_models_json, provided_models_json, missing_models_json,
allowed_run_modes_json, bundle_hash_sha256, complete, status)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
on duplicate key update
manifest_schema_version = values(manifest_schema_version),
feature_version = values(feature_version),
label_version = values(label_version),
split_version = values(split_version),
training_run_id = values(training_run_id),
training_export_id = values(training_export_id),
backtest_manifest_id = values(backtest_manifest_id),
required_models_json = values(required_models_json),
provided_models_json = values(provided_models_json),
missing_models_json = values(missing_models_json),
allowed_run_modes_json = values(allowed_run_modes_json),
bundle_hash_sha256 = values(bundle_hash_sha256),
complete = values(complete),
status = values(status)
""",
manifest.manifestSchemaVersion(), manifest.modelBundleVersion(), manifest.calibrationBundleVersion(),
manifest.featureVersion(), manifest.labelVersion(), manifest.splitVersion(),
manifest.trainingRunId(), manifest.trainingExportId(), manifest.backtestManifestId(),
jsonCodec.toJson(manifest.requiredModels()),
jsonCodec.toJson(manifest.providedModels()), jsonCodec.toJson(manifest.missingModels()),
jsonCodec.toJson(manifest.allowedRunModes().stream().map(TraderRunMode::name).toList()),
manifest.bundleHashSha256(), manifest.complete(), manifest.status());
}
private void upsertModelManifest(TraderModelManifest manifest) {
jdbcTemplate.update("""
insert into trader_model_manifest
(model_bundle_version, calibration_bundle_version, model_name, model_type, side,
symbol_scope_json, bar_interval, horizon_minutes, model_format, model_runtime,
model_runtime_version, onnx_opset_version, producer_name, producer_version,
artifact_path, artifact_hash_sha256, source_hash, feature_version, feature_schema_path,
feature_schema_hash, feature_order_path, feature_order_hash, input_tensor_name, input_dtype, input_shape_json,
input_example_path, output_schema_path, output_schema_hash, output_tensor_names_json,
output_mapping_json, output_value_rules_json, label_version, split_version, training_fold,
train_start, train_end, validation_start, validation_end, test_start, test_end,
metrics_json, status)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
on duplicate key update
model_type = values(model_type),
symbol_scope_json = values(symbol_scope_json),
bar_interval = values(bar_interval),
model_format = values(model_format),
model_runtime = values(model_runtime),
model_runtime_version = values(model_runtime_version),
onnx_opset_version = values(onnx_opset_version),
producer_name = values(producer_name),
producer_version = values(producer_version),
artifact_path = values(artifact_path),
artifact_hash_sha256 = values(artifact_hash_sha256),
source_hash = values(source_hash),
feature_version = values(feature_version),
feature_schema_path = values(feature_schema_path),
feature_schema_hash = values(feature_schema_hash),
feature_order_path = values(feature_order_path),
feature_order_hash = values(feature_order_hash),
input_tensor_name = values(input_tensor_name),
input_dtype = values(input_dtype),
input_shape_json = values(input_shape_json),
input_example_path = values(input_example_path),
output_schema_path = values(output_schema_path),
output_schema_hash = values(output_schema_hash),
output_tensor_names_json = values(output_tensor_names_json),
output_mapping_json = values(output_mapping_json),
output_value_rules_json = values(output_value_rules_json),
label_version = values(label_version),
split_version = values(split_version),
training_fold = values(training_fold),
train_start = values(train_start),
train_end = values(train_end),
validation_start = values(validation_start),
validation_end = values(validation_end),
test_start = values(test_start),
test_end = values(test_end),
metrics_json = values(metrics_json),
status = values(status)
""",
manifest.modelBundleVersion(), manifest.calibrationBundleVersion(), manifest.modelName(),
manifest.modelType(), manifest.side(), jsonCodec.toJson(manifest.symbolScope()),
manifest.barInterval(), manifest.horizonMinutes(), manifest.modelFormat(), manifest.modelRuntime(),
manifest.modelRuntimeVersion(), manifest.onnxOpsetVersion(), manifest.producerName(),
manifest.producerVersion(), manifest.artifactPath(), manifest.artifactHashSha256(),
manifest.sourceHash(), manifest.featureVersion(), manifest.featureSchemaPath(),
manifest.featureSchemaHash(), manifest.featureOrderPath(), manifest.featureOrderHash(), manifest.inputTensorName(),
manifest.inputDtype(), jsonCodec.toJson(manifest.inputShapeJson()), manifest.inputExamplePath(),
manifest.outputSchemaPath(), manifest.outputSchemaHash(), jsonCodec.toJson(manifest.outputTensorNames()),
jsonCodec.toJson(manifest.outputMapping()), jsonCodec.toJson(manifest.outputValueRules()),
manifest.labelVersion(), manifest.splitVersion(), manifest.trainingFold(),
java.sql.Timestamp.from(manifest.trainStart()), java.sql.Timestamp.from(manifest.trainEnd()),
java.sql.Timestamp.from(manifest.validationStart()), java.sql.Timestamp.from(manifest.validationEnd()),
java.sql.Timestamp.from(manifest.testStart()), java.sql.Timestamp.from(manifest.testEnd()),
jsonCodec.toJson(manifest.metricsJson()), manifest.status());
}
private void upsertCalibrationManifest(TraderCalibrationManifest manifest) {
jdbcTemplate.update("""
insert into trader_calibration_manifest
(calibration_bundle_version, model_bundle_version, model_name, calibrator_version,
calibration_method, calibrator_path, calibrator_hash_sha256,
calibration_window_from, calibration_window_to, calibration_metrics_json,
bucket_metrics_json, output_after_calibration_schema_hash, status)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
on duplicate key update
calibrator_version = values(calibrator_version),
calibration_method = values(calibration_method),
calibrator_path = values(calibrator_path),
calibrator_hash_sha256 = values(calibrator_hash_sha256),
calibration_window_from = values(calibration_window_from),
calibration_window_to = values(calibration_window_to),
calibration_metrics_json = values(calibration_metrics_json),
bucket_metrics_json = values(bucket_metrics_json),
output_after_calibration_schema_hash = values(output_after_calibration_schema_hash),
status = values(status)
""",
manifest.calibrationBundleVersion(), manifest.modelBundleVersion(), manifest.modelName(),
manifest.calibratorVersion(), manifest.calibrationMethod(), manifest.calibratorPath(),
manifest.calibratorHashSha256(), java.sql.Timestamp.from(manifest.calibrationWindowFrom()),
java.sql.Timestamp.from(manifest.calibrationWindowTo()), jsonCodec.toJson(manifest.calibrationMetrics()),
jsonCodec.toJson(manifest.bucketMetricsJson()), manifest.outputAfterCalibrationSchemaHash(),
manifest.status());
}
private void upsertPmConfigManifest(TraderPmConfigManifest manifest) {
jdbcTemplate.update("""
insert into trader_pm_config_manifest
(pm_config_version, model_bundle_version, calibration_bundle_version, threshold_stability_json,
allowed_run_modes_json, config_json, config_hash_sha256, status)
values (?, ?, ?, ?, ?, ?, ?, ?)
on duplicate key update
model_bundle_version = values(model_bundle_version),
calibration_bundle_version = values(calibration_bundle_version),
threshold_stability_json = values(threshold_stability_json),
allowed_run_modes_json = values(allowed_run_modes_json),
config_json = values(config_json),
config_hash_sha256 = values(config_hash_sha256),
status = values(status)
""",
manifest.pmConfigVersion(), manifest.modelBundleVersion(), manifest.calibrationBundleVersion(),
jsonCodec.toJson(manifest.thresholdStabilityJson()),
jsonCodec.toJson(manifest.allowedRunModes().stream().map(TraderRunMode::name).toList()),
jsonCodec.toJson(manifest.config()), manifest.configHashSha256(), manifest.status());
}
}
@@ -0,0 +1,44 @@
package com.quantai.trader.artifact;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.domain.TraderPmConfig;
import com.quantai.trader.domain.TraderPricePlanContext;
import com.quantai.trader.enums.TraderErrorCode;
import java.util.List;
import java.util.Set;
public record TraderArtifactBundle(
String modelBundleVersion,
String calibrationBundleVersion,
String pmConfigVersion,
String bundleHashSha256,
Set<String> providedModels,
TraderModelBundleManifest modelBundleManifest,
List<TraderModelManifest> modelManifests,
List<TraderCalibrationManifest> calibrationManifests,
TraderPmConfigManifest pmConfigManifest,
TraderPmConfig pmConfig,
TraderPricePlanContext pricePlanContext,
TraderReplayModelFixture replayModelFixture
) {
public TraderArtifactBundle {
if (providedModels == null || !providedModels.containsAll(Set.of("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"))) {
throw new IllegalArgumentException("artifact bundle must provide all five V4 models");
}
modelBundleManifest = java.util.Objects.requireNonNull(modelBundleManifest, "modelBundleManifest is required");
modelManifests = List.copyOf(modelManifests);
calibrationManifests = List.copyOf(calibrationManifests);
pmConfigManifest = java.util.Objects.requireNonNull(pmConfigManifest, "pmConfigManifest is required");
pmConfig = java.util.Objects.requireNonNull(pmConfig, "pmConfig is required");
pricePlanContext = java.util.Objects.requireNonNull(pricePlanContext, "pricePlanContext is required");
}
public TraderReplayModelFixture requireReplayModelFixture() {
if (replayModelFixture == null) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"replay model fixture is required for replay fixture inference");
}
return replayModelFixture;
}
}
@@ -0,0 +1,463 @@
package com.quantai.trader.artifact;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.quantai.trader.config.TraderProperties;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.domain.TraderPmConfig;
import com.quantai.trader.domain.TraderPricePlanContext;
import com.quantai.trader.enums.TraderRunMode;
import com.quantai.trader.enums.TraderErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
@Component
public class TraderArtifactLoader {
private static final Logger log = LoggerFactory.getLogger(TraderArtifactLoader.class);
private static final Set<String> REQUIRED_MODELS = Set.of("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK");
private static final int REQUIRED_FEATURE_COUNT = 54;
private static final int REQUIRED_ONNX_OPSET_VERSION = 17;
private static final Map<String, Set<String>> REQUIRED_OUTPUT_MAPPING_KEYS = Map.of(
"DIRECTION", Set.of("long_prob", "short_prob", "neutral_prob"),
"ENTRY", Set.of("long_entry_prob", "short_entry_prob", "long_expected_net_edge_bps", "short_expected_net_edge_bps"),
"CONTINUE", Set.of("long_continue_prob", "short_continue_prob", "long_expected_continue_edge_bps", "short_expected_continue_edge_bps"),
"EXIT", Set.of("long_exit_prob", "short_exit_prob", "long_adverse_move_bps", "short_adverse_move_bps",
"adverse_move_prob", "reversal_prob", "stop_hit_prob", "stagnation_prob"),
"RISK", Set.of("market_risk_prob", "long_position_risk_prob", "short_position_risk_prob",
"market_path_risk_bps", "long_position_path_risk_bps", "short_position_path_risk_bps",
"market_drawdown_prob", "volatility_expansion_prob", "spike_prob",
"liquidity_deterioration_prob", "position_drawdown_prob")
);
private static final Set<String> REJECTED_OUTPUT_MAPPING_KEYS = Set.of(
"expected_net_edge_bps",
"expected_continue_edge_bps",
"expected_giveback_bps",
"market_expected_shortfall_bps",
"position_expected_shortfall_bps",
"position_risk_target"
);
private final TraderProperties properties;
private final ObjectMapper objectMapper;
public TraderArtifactLoader(TraderProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
public TraderArtifactBundle loadActiveBundle() {
TraderProperties.Artifact artifact = properties.artifact();
Path root = Path.of(artifact.artifactRoot());
TraderModelBundleManifest modelManifest = readModelBundleManifest(root.resolve("manifests/model_bundle_manifest.json"));
List<TraderModelManifest> modelManifests = readModelManifests(root.resolve("manifests/model_manifest.json"));
List<TraderCalibrationManifest> calibrationManifests = readCalibrationManifests(root.resolve("manifests/calibration_manifest.json"));
TraderPmConfigManifest pmManifest = readPmConfigManifest(root.resolve("manifests/position_manager_manifest.json"));
TraderPricePlanContext pricePlanContext = readJson(root.resolve("price_plan_context.json"), TraderPricePlanContext.class);
TraderReplayModelFixture replayModelFixture = readOptionalJson(root.resolve("replay_model_fixture.json"), TraderReplayModelFixture.class);
validateVersions(artifact, modelManifest, pmManifest);
validateModelManifest(modelManifest);
validateModelArtifacts(root, modelManifest, modelManifests);
validateCalibrationArtifacts(root, modelManifest, calibrationManifests);
validatePmManifest(pmManifest, properties.runMode());
TraderArtifactBundle bundle = new TraderArtifactBundle(
modelManifest.modelBundleVersion(),
modelManifest.calibrationBundleVersion(),
pmManifest.pmConfigVersion(),
modelManifest.bundleHashSha256(),
modelManifest.providedModels(),
modelManifest,
modelManifests,
calibrationManifests,
pmManifest,
pmManifest.config(),
pricePlanContext,
replayModelFixture);
log.info("event=trader.artifact.loaded modelBundleVersion={} calibrationBundleVersion={} pmConfigVersion={} providedModels={}",
bundle.modelBundleVersion(), bundle.calibrationBundleVersion(), bundle.pmConfigVersion(), bundle.providedModels());
return bundle;
}
private List<TraderModelManifest> readModelManifests(Path path) {
JsonNode root = readJsonNode(path);
if (!root.isArray()) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model manifest must be an array: " + path);
}
return StreamSupport.stream(root.spliterator(), false)
.map(node -> new TraderModelManifest(
requiredText(node, "model_bundle_version", path),
requiredText(node, "calibration_bundle_version", path),
requiredText(node, "model_name", path),
requiredText(node, "model_type", path),
requiredText(node, "side", path),
textSet(node, "symbol_scope_json", path),
requiredText(node, "bar_interval", path),
node.path("horizon_minutes").asInt(-1),
requiredText(node, "model_format", path),
requiredText(node, "model_runtime", path),
requiredText(node, "model_runtime_version", path),
node.path("onnx_opset_version").asInt(-1),
requiredText(node, "producer_name", path),
requiredText(node, "producer_version", path),
requiredText(node, "feature_version", path),
requiredText(node, "feature_schema_path", path),
requiredText(node, "feature_schema_hash", path),
requiredText(node, "feature_order_path", path),
requiredText(node, "feature_order_hash", path),
requiredText(node, "input_tensor_name", path),
requiredText(node, "input_dtype", path),
objectMap(node, "input_shape_json", path),
requiredText(node, "input_example_path", path),
requiredText(node, "output_schema_path", path),
requiredText(node, "output_schema_hash", path),
textSet(node, "output_tensor_names_json", path),
objectMap(node, "output_mapping_json", path),
objectMap(node, "output_value_rules_json", path),
requiredText(node, "label_version", path),
requiredText(node, "split_version", path),
requiredText(node, "training_fold", path),
instant(node, "train_start", path),
instant(node, "train_end", path),
instant(node, "validation_start", path),
instant(node, "validation_end", path),
instant(node, "test_start", path),
instant(node, "test_end", path),
objectMap(node, "metrics_json", path),
requiredText(node, "artifact_path", path),
requiredText(node, "artifact_hash_sha256", path),
requiredText(node, "source_hash", path),
requiredText(node, "status", path)))
.toList();
}
private List<TraderCalibrationManifest> readCalibrationManifests(Path path) {
JsonNode root = readJsonNode(path);
if (!root.isArray()) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"calibration manifest must be an array: " + path);
}
return StreamSupport.stream(root.spliterator(), false)
.map(node -> new TraderCalibrationManifest(
requiredText(node, "calibration_bundle_version", path),
requiredText(node, "model_bundle_version", path),
requiredText(node, "model_name", path),
requiredText(node, "calibrator_version", path),
requiredText(node, "calibration_method", path),
requiredText(node, "calibrator_path", path),
requiredText(node, "calibrator_hash_sha256", path),
instant(node, "calibration_window_from", path),
instant(node, "calibration_window_to", path),
objectMap(node, "calibration_metrics_json", path),
objectMap(node, "bucket_metrics_json", path),
requiredText(node, "output_after_calibration_schema_hash", path),
requiredText(node, "status", path)))
.toList();
}
private TraderModelBundleManifest readModelBundleManifest(Path path) {
JsonNode root = readJsonNode(path);
return new TraderModelBundleManifest(
requiredText(root, "manifest_schema_version", path),
requiredText(root, "model_bundle_version", path),
requiredText(root, "calibration_bundle_version", path),
requiredText(root, "feature_version", path),
requiredText(root, "label_version", path),
requiredText(root, "split_version", path),
requiredText(root, "training_run_id", path),
requiredText(root, "training_export_id", path),
requiredText(root, "backtest_manifest_id", path),
textSet(root, "required_models_json", path),
textSet(root, "provided_models_json", path),
textSet(root, "missing_models_json", path),
enumSet(root, "allowed_run_modes_json", TraderRunMode.class, path),
requiredText(root, "bundle_hash_sha256", path),
root.path("complete").asBoolean(false),
requiredText(root, "status", path));
}
private TraderPmConfigManifest readPmConfigManifest(Path path) {
JsonNode root = readJsonNode(path);
return new TraderPmConfigManifest(
requiredText(root, "pm_config_version", path),
requiredText(root, "model_bundle_version", path),
requiredText(root, "calibration_bundle_version", path),
objectMap(root, "threshold_stability_json", path),
enumSet(root, "allowed_run_modes_json", TraderRunMode.class, path),
convert(root.path("config_json"), TraderPmConfig.class, path),
requiredText(root, "config_hash_sha256", path),
requiredText(root, "status", path));
}
private void validateVersions(TraderProperties.Artifact expected, TraderModelBundleManifest model, TraderPmConfigManifest pm) {
if (!expected.modelBundleVersion().equals(model.modelBundleVersion())
|| !expected.calibrationBundleVersion().equals(model.calibrationBundleVersion())
|| !expected.pmConfigVersion().equals(pm.pmConfigVersion())) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact version triple does not match configured model/calibration/pm versions");
}
if (!model.modelBundleVersion().equals(pm.modelBundleVersion())
|| !model.calibrationBundleVersion().equals(pm.calibrationBundleVersion())) {
throw new TraderException(TraderErrorCode.TRADER_CALIBRATION_MISMATCH,
"model and pm manifests reference different model/calibration versions");
}
}
private void validateModelManifest(TraderModelBundleManifest manifest) {
if (!manifest.complete() || !"ACTIVE".equals(manifest.status())) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model bundle manifest must be complete and ACTIVE");
}
if (!manifest.requiredModels().containsAll(REQUIRED_MODELS)
|| !manifest.providedModels().containsAll(REQUIRED_MODELS)
|| !manifest.missingModels().isEmpty()) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model bundle must provide all five V4 models with no missing model");
}
if (!manifest.allowedRunModes().contains(properties.runMode())) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model bundle does not allow current run mode");
}
}
private void validateModelArtifacts(Path root, TraderModelBundleManifest bundleManifest,
List<TraderModelManifest> manifests) {
Set<String> modelNames = manifests.stream().map(TraderModelManifest::modelName).collect(Collectors.toUnmodifiableSet());
if (!modelNames.containsAll(REQUIRED_MODELS)) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model_manifest.json must contain all five V4 model families");
}
for (TraderModelManifest manifest : manifests) {
if (!bundleManifest.modelBundleVersion().equals(manifest.modelBundleVersion())
|| !bundleManifest.calibrationBundleVersion().equals(manifest.calibrationBundleVersion())) {
throw new TraderException(TraderErrorCode.TRADER_CALIBRATION_MISMATCH,
"model manifest version does not match bundle manifest");
}
if (!"ACTIVE".equals(manifest.status())) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model manifest must be ACTIVE: " + manifest.modelName());
}
if (!REQUIRED_MODELS.contains(manifest.modelType())
|| !"ONNX".equals(manifest.modelFormat())
|| !"ONNX_RUNTIME_JAVA".equals(manifest.modelRuntime())
|| !"FLOAT32".equals(manifest.inputDtype())
|| manifest.horizonMinutes() <= 0
|| manifest.onnxOpsetVersion() != REQUIRED_ONNX_OPSET_VERSION
|| inputFeatureCount(manifest) != REQUIRED_FEATURE_COUNT
|| !"features".equals(manifest.inputTensorName())) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model manifest runtime contract is invalid: " + manifest.modelName());
}
validateOutputMapping(manifest);
validateSha256(root.resolve(manifest.artifactPath()), manifest.artifactHashSha256());
validateSha256(root.resolve(manifest.featureSchemaPath()), manifest.featureSchemaHash());
validateSha256(root.resolve(manifest.featureOrderPath()), manifest.featureOrderHash());
validateFeatureOrder(root.resolve(manifest.featureOrderPath()));
validateSha256(root.resolve(manifest.outputSchemaPath()), manifest.outputSchemaHash());
if (!Files.isRegularFile(root.resolve(manifest.inputExamplePath()))) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model input example is missing: " + manifest.inputExamplePath());
}
}
}
private void validateOutputMapping(TraderModelManifest manifest) {
if (manifest.outputMapping().keySet().stream().anyMatch(REJECTED_OUTPUT_MAPPING_KEYS::contains)) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model output mapping contains rejected legacy key: " + manifest.modelName());
}
Set<String> requiredKeys = REQUIRED_OUTPUT_MAPPING_KEYS.get(manifest.modelType());
if (requiredKeys == null || !manifest.outputMapping().keySet().containsAll(requiredKeys)) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"model output mapping does not match V4 contract: " + manifest.modelName());
}
}
private void validateCalibrationArtifacts(Path root, TraderModelBundleManifest modelManifest,
List<TraderCalibrationManifest> calibrationManifests) {
Set<String> calibratedModels = calibrationManifests.stream()
.map(TraderCalibrationManifest::modelName)
.collect(Collectors.toUnmodifiableSet());
if (!calibratedModels.containsAll(REQUIRED_MODELS)) {
throw new TraderException(TraderErrorCode.TRADER_CALIBRATION_MISMATCH,
"calibration_manifest.json must contain all five V4 model families");
}
for (TraderCalibrationManifest calibrationManifest : calibrationManifests) {
if (!modelManifest.modelBundleVersion().equals(calibrationManifest.modelBundleVersion())
|| !modelManifest.calibrationBundleVersion().equals(calibrationManifest.calibrationBundleVersion())) {
throw new TraderException(TraderErrorCode.TRADER_CALIBRATION_MISMATCH,
"calibration manifest version does not match bundle manifest");
}
if (!"ACTIVE".equals(calibrationManifest.status())) {
throw new TraderException(TraderErrorCode.TRADER_CALIBRATION_MISMATCH,
"calibration manifest must be ACTIVE");
}
validateSha256(root.resolve(calibrationManifest.calibratorPath()), calibrationManifest.calibratorHashSha256());
}
}
private void validatePmManifest(TraderPmConfigManifest manifest, TraderRunMode runMode) {
if (!"ACTIVE".equals(manifest.status())) {
throw new TraderException(TraderErrorCode.TRADER_PM_CONFIG_MISMATCH,
"pm config manifest must be ACTIVE");
}
if (!manifest.allowedRunModes().contains(runMode)) {
throw new TraderException(TraderErrorCode.TRADER_PM_CONFIG_MISMATCH,
"pm config manifest does not allow current run mode");
}
}
private int inputFeatureCount(TraderModelManifest manifest) {
Object features = manifest.inputShapeJson().get("features");
if (features instanceof Number number) {
return number.intValue();
}
if (features instanceof String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException ignored) {
return -1;
}
}
return -1;
}
private void validateFeatureOrder(Path path) {
JsonNode featureOrder = readJsonNode(path);
if (!featureOrder.isArray() || featureOrder.size() != REQUIRED_FEATURE_COUNT) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"feature_order.json must contain exactly " + REQUIRED_FEATURE_COUNT + " fields: " + path);
}
}
private JsonNode readJsonNode(Path path) {
if (!Files.isRegularFile(path)) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact file is missing: " + path);
}
try {
return objectMapper.readTree(path.toFile());
} catch (IOException exception) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact file cannot be read: " + path);
}
}
private void validateSha256(Path path, String expectedHash) {
if (!Files.isRegularFile(path)) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact referenced by manifest is missing: " + path);
}
String actualHash;
try {
actualHash = sha256(path);
} catch (IOException exception) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact referenced by manifest cannot be read: " + path);
}
if (!expectedHash.equals(actualHash)) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact sha256 mismatch: " + path);
}
}
private String sha256(Path path) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(Files.readAllBytes(path));
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value & 0xff));
}
return builder.toString();
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is not available", exception);
}
}
private <T> T readJson(Path path, Class<T> type) {
if (!Files.isRegularFile(path)) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact file is missing: " + path);
}
try {
return objectMapper.readValue(path.toFile(), type);
} catch (IOException exception) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact file cannot be read: " + path);
}
}
private <T> T readOptionalJson(Path path, Class<T> type) {
if (!Files.isRegularFile(path)) {
return null;
}
return readJson(path, type);
}
private <T> T convert(JsonNode node, Class<T> type, Path path) {
if (node == null || node.isMissingNode() || node.isNull()) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact field is missing: " + path + "#config_json");
}
try {
return objectMapper.treeToValue(node, type);
} catch (IOException exception) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact field cannot be parsed: " + path + "#config_json");
}
}
private String requiredText(JsonNode node, String field, Path path) {
String value = node.path(field).asText("");
if (value.isBlank()) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact field is required: " + path + "#" + field);
}
return value;
}
private Instant instant(JsonNode node, String field, Path path) {
return Instant.parse(requiredText(node, field, path));
}
private Map<String, Object> objectMap(JsonNode node, String field, Path path) {
JsonNode value = node.path(field);
if (value.isMissingNode() || value.isNull() || !value.isObject()) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact object field is required: " + path + "#" + field);
}
return objectMapper.convertValue(value, new TypeReference<>() {
});
}
private Set<String> textSet(JsonNode node, String field, Path path) {
JsonNode array = node.path(field);
if (!array.isArray()) {
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING,
"artifact field must be array: " + path + "#" + field);
}
return StreamSupport.stream(array.spliterator(), false)
.map(JsonNode::asText)
.collect(Collectors.toUnmodifiableSet());
}
private <E extends Enum<E>> Set<E> enumSet(JsonNode node, String field, Class<E> type, Path path) {
return textSet(node, field, path).stream()
.map(value -> Enum.valueOf(type, value))
.collect(Collectors.toUnmodifiableSet());
}
}
@@ -0,0 +1,5 @@
package com.quantai.trader.artifact;
public interface TraderArtifactManifestRepository {
void upsertActiveBundle(TraderArtifactBundle bundle);
}
@@ -0,0 +1,25 @@
package com.quantai.trader.artifact;
import java.time.Instant;
import java.util.Map;
public record TraderCalibrationManifest(
String calibrationBundleVersion,
String modelBundleVersion,
String modelName,
String calibratorVersion,
String calibrationMethod,
String calibratorPath,
String calibratorHashSha256,
Instant calibrationWindowFrom,
Instant calibrationWindowTo,
Map<String, Object> calibrationMetrics,
Map<String, Object> bucketMetricsJson,
String outputAfterCalibrationSchemaHash,
String status
) {
public TraderCalibrationManifest {
calibrationMetrics = Map.copyOf(calibrationMetrics == null ? Map.of() : calibrationMetrics);
bucketMetricsJson = Map.copyOf(bucketMetricsJson == null ? Map.of() : bucketMetricsJson);
}
}
@@ -0,0 +1,25 @@
package com.quantai.trader.artifact;
import com.quantai.trader.enums.TraderRunMode;
import java.util.Set;
public record TraderModelBundleManifest(
String manifestSchemaVersion,
String modelBundleVersion,
String calibrationBundleVersion,
String featureVersion,
String labelVersion,
String splitVersion,
String trainingRunId,
String trainingExportId,
String backtestManifestId,
Set<String> requiredModels,
Set<String> providedModels,
Set<String> missingModels,
Set<TraderRunMode> allowedRunModes,
String bundleHashSha256,
boolean complete,
String status
) {
}
@@ -0,0 +1,59 @@
package com.quantai.trader.artifact;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
public record TraderModelManifest(
String modelBundleVersion,
String calibrationBundleVersion,
String modelName,
String modelType,
String side,
Set<String> symbolScope,
String barInterval,
int horizonMinutes,
String modelFormat,
String modelRuntime,
String modelRuntimeVersion,
int onnxOpsetVersion,
String producerName,
String producerVersion,
String featureVersion,
String featureSchemaPath,
String featureSchemaHash,
String featureOrderPath,
String featureOrderHash,
String inputTensorName,
String inputDtype,
Map<String, Object> inputShapeJson,
String inputExamplePath,
String outputSchemaPath,
String outputSchemaHash,
Set<String> outputTensorNames,
Map<String, Object> outputMapping,
Map<String, Object> outputValueRules,
String labelVersion,
String splitVersion,
String trainingFold,
Instant trainStart,
Instant trainEnd,
Instant validationStart,
Instant validationEnd,
Instant testStart,
Instant testEnd,
Map<String, Object> metricsJson,
String artifactPath,
String artifactHashSha256,
String sourceHash,
String status
) {
public TraderModelManifest {
symbolScope = Set.copyOf(symbolScope == null ? Set.of() : symbolScope);
inputShapeJson = Map.copyOf(inputShapeJson == null ? Map.of() : inputShapeJson);
outputTensorNames = Set.copyOf(outputTensorNames == null ? Set.of() : outputTensorNames);
outputMapping = Map.copyOf(outputMapping == null ? Map.of() : outputMapping);
outputValueRules = Map.copyOf(outputValueRules == null ? Map.of() : outputValueRules);
metricsJson = Map.copyOf(metricsJson == null ? Map.of() : metricsJson);
}
}
@@ -0,0 +1,22 @@
package com.quantai.trader.artifact;
import com.quantai.trader.domain.TraderPmConfig;
import com.quantai.trader.enums.TraderRunMode;
import java.util.Map;
import java.util.Set;
public record TraderPmConfigManifest(
String pmConfigVersion,
String modelBundleVersion,
String calibrationBundleVersion,
Map<String, Object> thresholdStabilityJson,
Set<TraderRunMode> allowedRunModes,
TraderPmConfig config,
String configHashSha256,
String status
) {
public TraderPmConfigManifest {
thresholdStabilityJson = Map.copyOf(thresholdStabilityJson == null ? Map.of() : thresholdStabilityJson);
}
}
@@ -0,0 +1,66 @@
package com.quantai.trader.artifact;
import java.math.BigDecimal;
import java.util.Map;
public record TraderReplayModelFixture(
DirectionFixture direction,
EntryFixture entry,
ContinueFixture continuation,
ExitFixture exit,
RiskFixture risk,
BigDecimal uncertainty,
BigDecimal oodScore,
String featureSchemaHash,
String featureOrderHash,
String outputSchemaHash
) {
public record DirectionFixture(
BigDecimal longProbWhenMarkGteIndex,
BigDecimal longProbWhenMarkLtIndex,
BigDecimal neutralProb
) {
}
public record EntryFixture(
BigDecimal longEntryProb,
BigDecimal shortEntryProb,
BigDecimal longExpectedNetEdgeBps,
BigDecimal shortExpectedNetEdgeBps
) {
}
public record ContinueFixture(
BigDecimal longContinueProb,
BigDecimal shortContinueProb,
BigDecimal longExpectedContinueEdgeBps,
BigDecimal shortExpectedContinueEdgeBps
) {
}
public record ExitFixture(
BigDecimal longExitProb,
BigDecimal shortExitProb,
BigDecimal longAdverseMoveBps,
BigDecimal shortAdverseMoveBps,
Map<String, BigDecimal> exitReasonScores
) {
public ExitFixture {
exitReasonScores = Map.copyOf(exitReasonScores == null ? Map.of() : exitReasonScores);
}
}
public record RiskFixture(
BigDecimal marketRiskProb,
BigDecimal longPositionRiskProb,
BigDecimal shortPositionRiskProb,
BigDecimal marketPathRiskBps,
BigDecimal longPositionPathRiskBps,
BigDecimal shortPositionPathRiskBps,
Map<String, BigDecimal> riskReasonScores
) {
public RiskFixture {
riskReasonScores = Map.copyOf(riskReasonScores == null ? Map.of() : riskReasonScores);
}
}
}
@@ -1,27 +0,0 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.ManagementDecision;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.enums.TraderActionType;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
public class LifecycleActionValueService {
public ManagementDecision decide(
TraderDecisionCycle cycle,
PlaybookCandidate candidate,
TraderPositionPath path,
TraderMarketSnapshot snapshot
) {
if (path != null && path.fullSize()) {
return new ManagementDecision(TraderActionType.CLOSE, "P0_PROXY_CLOSE_AFTER_FULL_PATH", Map.of("proxyOnly", true));
}
return new ManagementDecision(TraderActionType.HOLD, "P0_PROXY_HOLD", Map.of("proxyOnly", true));
}
}
@@ -1,85 +0,0 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.domain.TraderPricePlan;
import com.quantai.trader.enums.TraderErrorCode;
import com.quantai.trader.enums.TraderSide;
import com.quantai.trader.playbook.TraderPlaybookCatalog;
import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot;
import com.quantai.trader.util.Ids;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Component
public class PlaybookCandidateEngine {
private final TraderPlaybookCatalog catalog;
public PlaybookCandidateEngine(TraderPlaybookCatalog catalog) {
this.catalog = catalog;
}
public List<PlaybookCandidate> generate(TraderMarketSnapshot snapshot, TraderDecisionCycle cycle) {
if (!Boolean.TRUE.equals(snapshot.setupFeatures().get("setupPass"))) {
return List.of();
}
TraderPlaybookDefinitionSnapshot playbook = catalog.require(cycle.playbookId(), cycle.playbookVersion());
BigDecimal entry = requiredDecimal(snapshot.setupFeatures(), "entryPrice");
TraderPricePlan pricePlan = new TraderPricePlan(
entry,
requiredDecimal(snapshot.setupFeatures(), "invalidPrice"),
requiredDecimal(snapshot.setupFeatures(), "stopPrice"),
requiredDecimal(snapshot.setupFeatures(), "targetPrice"),
null,
300_000,
7_200_000
);
return List.of(new PlaybookCandidate(
cycle.runId(),
cycle.cycleId(),
Ids.candidateId(cycle, playbook.playbookId()),
playbook.playbookId(),
playbook.playbookVersion(),
requiredSide(snapshot.setupFeatures(), "side"),
playbook.variant(),
snapshot.snapshotTime(),
pricePlan,
playbook.definition().plannedEntryLegRule().maxPlannedEntryLegs(),
snapshot.setupFeatures()
));
}
private TraderSide requiredSide(Map<String, Object> map, String key) {
Object value = map.get(key);
if (value instanceof TraderSide side) {
return side;
}
if (value instanceof String text && !text.isBlank()) {
return TraderSide.valueOf(text.trim().toUpperCase());
}
throw new TraderException(
TraderErrorCode.TRADER_ENTRY_PLAN_INCOMPLETE,
"setup feature is required when setupPass=true: " + key
);
}
private BigDecimal requiredDecimal(Map<String, Object> map, String key) {
Object value = map.get(key);
if (value instanceof Number number) {
return BigDecimal.valueOf(number.doubleValue());
}
if (value instanceof String text && !text.isBlank()) {
return new BigDecimal(text);
}
throw new TraderException(
TraderErrorCode.TRADER_ENTRY_PLAN_INCOMPLETE,
"setup feature is required when setupPass=true: " + key
);
}
}
@@ -1,26 +0,0 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.StageDecision;
import com.quantai.trader.domain.TraderMarketSnapshot;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Component
public class TraderContextGate {
public StageDecision evaluate(TraderMarketSnapshot snapshot) {
Object missing = snapshot.dataQuality().get("missing_features");
if (missing instanceof List<?> list && !list.isEmpty()) {
return new StageDecision(false, "DATA_MISSING", "TRADER_DATA_QUALITY_FAILED", Map.of(
"missingFeatures", list
));
}
Object pass = snapshot.contextFeatures().get("contextPass");
if (Boolean.FALSE.equals(pass)) {
return StageDecision.block("CONTEXT_BLOCKED", "TRADER_RISK_BLOCKED");
}
return StageDecision.pass("CONTEXT_PASS");
}
}
@@ -1,14 +0,0 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.TraderAction;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.domain.TraderTrainingSample;
public record TraderCycleResult(
TraderDecisionCycle cycle,
TraderAction action,
TraderPositionPath path,
TraderTrainingSample sample
) {
}
@@ -1,165 +0,0 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.ExecutionDecision;
import com.quantai.trader.domain.ManagementDecision;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.RiskDecision;
import com.quantai.trader.domain.StageDecision;
import com.quantai.trader.domain.TraderAction;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderEntryPlan;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.domain.TraderTrainingSample;
import com.quantai.trader.evidence.EvidenceAppender;
import com.quantai.trader.enums.TraderState;
import com.quantai.trader.execution.ExecutionQualityGate;
import com.quantai.trader.execution.TraderEntryPlanner;
import com.quantai.trader.market.SnapshotBuilder;
import com.quantai.trader.position.TraderPositionManager;
import com.quantai.trader.replay.ReplayClockTick;
import com.quantai.trader.risk.TraderRiskGate;
import com.quantai.trader.sample.TrainingSampleExporter;
import com.quantai.trader.state.TraderDecisionCycleFactory;
import com.quantai.trader.state.TraderRuntimeState;
import com.quantai.trader.state.TraderStateMachine;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
public class TraderDecisionCycleRunner {
private final SnapshotBuilder snapshotBuilder;
private final TraderContextGate contextGate;
private final PlaybookCandidateEngine playbookCandidateEngine;
private final TriggerMarkoutService triggerMarkoutService;
private final TraderEntryPlanner entryPlanner;
private final ExecutionQualityGate executionQualityGate;
private final TraderRiskGate riskGate;
private final TraderStateMachine stateMachine;
private final TraderPositionManager positionManager;
private final LifecycleActionValueService actionValueService;
private final EvidenceAppender evidenceAppender;
private final TrainingSampleExporter sampleExporter;
public TraderDecisionCycleRunner(
SnapshotBuilder snapshotBuilder,
TraderContextGate contextGate,
PlaybookCandidateEngine playbookCandidateEngine,
TriggerMarkoutService triggerMarkoutService,
TraderEntryPlanner entryPlanner,
ExecutionQualityGate executionQualityGate,
TraderRiskGate riskGate,
TraderStateMachine stateMachine,
TraderPositionManager positionManager,
LifecycleActionValueService actionValueService,
EvidenceAppender evidenceAppender,
TrainingSampleExporter sampleExporter
) {
this.snapshotBuilder = snapshotBuilder;
this.contextGate = contextGate;
this.playbookCandidateEngine = playbookCandidateEngine;
this.triggerMarkoutService = triggerMarkoutService;
this.entryPlanner = entryPlanner;
this.executionQualityGate = executionQualityGate;
this.riskGate = riskGate;
this.stateMachine = stateMachine;
this.positionManager = positionManager;
this.actionValueService = actionValueService;
this.evidenceAppender = evidenceAppender;
this.sampleExporter = sampleExporter;
}
public TraderCycleResult runReplayTick(ReplayClockTick tick, TraderRuntimeState runtimeState) {
TraderMarketSnapshot snapshot = snapshotBuilder.build(tick, runtimeState);
TraderDecisionCycle cycle = TraderDecisionCycleFactory.create(snapshot, runtimeState);
StageDecision context = contextGate.evaluate(snapshot);
evidenceAppender.append(cycle, "CONTEXT_GATE", context);
if (context.blocked()) {
TraderTrainingSample sample = sampleExporter.export(cycle.withState(TraderState.BLOCKED, "BLOCKED", context.blocker()), snapshot, null, null, null);
return new TraderCycleResult(cycle, null, null, sample);
}
List<PlaybookCandidate> candidates = playbookCandidateEngine.generate(snapshot, cycle);
if (candidates.isEmpty()) {
evidenceAppender.append(cycle, "PLAYBOOK_CANDIDATE", StageDecision.block("NO_PLAYBOOK_CANDIDATE", "NO_PLAYBOOK_CANDIDATE"));
TraderTrainingSample sample = sampleExporter.export(cycle, snapshot, null, null, null);
return new TraderCycleResult(cycle, null, null, sample);
}
PlaybookCandidate selected = candidates.getFirst();
var trigger = triggerMarkoutService.evaluate(snapshot, selected);
evidenceAppender.append(cycle, "TRIGGER_MARKOUT", new StageDecision(trigger.pass(), trigger.reason(), trigger.blocker(), trigger.details()));
if (trigger.blocked()) {
TraderTrainingSample sample = sampleExporter.export(cycle.withState(TraderState.TRIGGER_WAIT, "WAIT", trigger.blocker()), snapshot, selected, null, null);
return new TraderCycleResult(cycle, null, null, sample);
}
TraderDecisionCycle entryCycle = cycle.withState(TraderState.ENTRY_PLANNED, "RUNNING", null);
TraderEntryPlan entryPlan = entryPlanner.planInitialEntry(entryCycle, selected, trigger);
ExecutionDecision execution = executionQualityGate.evaluate(snapshot, entryPlan);
evidenceAppender.append(entryCycle, "EXECUTION_QUALITY", new StageDecision(execution.pass(), execution.reason(), execution.blocker(), execution.details()));
RiskDecision risk = riskGate.evaluate(entryCycle, entryPlan, execution);
evidenceAppender.append(entryCycle, "RISK_GATE", new StageDecision(risk.allowAction(), risk.allowAction() ? "RISK_PASS" : "RISK_BLOCKED", risk.blocker(), risk.details()));
if (execution.blocked() || risk.blocked()) {
TraderTrainingSample sample = sampleExporter.export(entryCycle.withState(TraderState.BLOCKED, "BLOCKED", risk.blocker()), snapshot, selected, null, null);
return new TraderCycleResult(entryCycle, null, null, sample);
}
TraderAction action = stateMachine.toInitialEntryAction(entryCycle, selected, entryPlan);
TraderPositionPath path = positionManager.simulateOrUpdate(entryCycle, action, snapshot);
evidenceAppender.append(entryCycle, "OPEN_INITIAL", StageDecision.pass(action.reason()));
TraderLifecycleResult lifecycle = runPositionLifecycle(entryCycle, selected, action, path, snapshot);
TraderTrainingSample sample = sampleExporter.export(
lifecycle.finalCycle(),
snapshot,
selected,
lifecycle.lastAction(),
lifecycle.finalPath()
);
return new TraderCycleResult(lifecycle.finalCycle(), lifecycle.lastAction(), lifecycle.finalPath(), sample);
}
private TraderLifecycleResult runPositionLifecycle(
TraderDecisionCycle initialCycle,
PlaybookCandidate candidate,
TraderAction initialAction,
TraderPositionPath initialPath,
TraderMarketSnapshot snapshot
) {
TraderDecisionCycle cycle = initialCycle;
TraderPositionPath path = initialPath;
TraderAction lastAction = initialAction;
if (!path.fullSize()) {
cycle = cycle.withState(TraderState.PLANNED_LEG_WAIT, "RUNNING", null);
Optional<TraderEntryPlan> plannedLeg = entryPlanner.planNextDeclaredLeg(cycle, candidate, path);
if (plannedLeg.isPresent()) {
ExecutionDecision execution = executionQualityGate.evaluate(snapshot, plannedLeg.get());
RiskDecision risk = riskGate.evaluate(cycle, plannedLeg.get(), execution);
evidenceAppender.append(cycle, "PLANNED_LEG_EXECUTION", new StageDecision(execution.pass(), execution.reason(), execution.blocker(), execution.details()));
evidenceAppender.append(cycle, "PLANNED_LEG_RISK", new StageDecision(risk.allowAction(), risk.allowAction() ? "RISK_PASS" : "RISK_BLOCKED", risk.blocker(), risk.details()));
if (execution.pass() && risk.allowAction()) {
TraderAction legAction = stateMachine.toPlannedLegAction(cycle, plannedLeg.get(), path);
path = positionManager.simulateOrUpdate(cycle, legAction, snapshot);
evidenceAppender.append(cycle, "OPEN_PLANNED_LEG", StageDecision.pass(legAction.reason()));
lastAction = legAction;
}
}
}
cycle = cycle.withState(TraderState.MANAGING, "RUNNING", null);
ManagementDecision management = actionValueService.decide(cycle, candidate, path, snapshot);
evidenceAppender.append(cycle, "ACTION_VALUE", new StageDecision(true, management.reason(), null, management.details()));
TraderAction managementAction = stateMachine.toManagementAction(cycle, path, management.actionType());
riskGate.evaluateManagement(cycle, managementAction, path, management);
path = positionManager.simulateOrUpdate(cycle, managementAction, snapshot);
evidenceAppender.append(cycle, "MANAGEMENT_ACTION", StageDecision.pass(managementAction.reason()));
lastAction = managementAction;
return new TraderLifecycleResult(cycle.withState(TraderState.SAMPLE_EXPORTED, "COMPLETED", null), path, lastAction);
}
}
@@ -1,12 +0,0 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.TraderAction;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderPositionPath;
public record TraderLifecycleResult(
TraderDecisionCycle finalCycle,
TraderPositionPath finalPath,
TraderAction lastAction
) {
}
@@ -1,39 +0,0 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.domain.TriggerDecision;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Map;
@Component
public class TriggerMarkoutService {
public TriggerDecision evaluate(TraderMarketSnapshot snapshot, PlaybookCandidate candidate) {
BigDecimal score = readScore(snapshot.triggerFeatures().get("triggerScore"));
if (score == null) {
return new TriggerDecision(false, BigDecimal.ZERO, "TRIGGER_SCORE_MISSING", "NO_TRIGGER_MARKOUT", Map.of());
}
if (score.compareTo(new BigDecimal("0.50")) < 0) {
return new TriggerDecision(false, score, "TRIGGER_WAIT", "NO_TRIGGER_MARKOUT", Map.of(
"triggerScore", score
));
}
return new TriggerDecision(true, score, "TRIGGER_ACCEPTED", null, Map.of(
"triggerScore", score,
"proxyOnly", true
));
}
private BigDecimal readScore(Object value) {
if (value instanceof Number number) {
return BigDecimal.valueOf(number.doubleValue());
}
if (value instanceof String text && !text.isBlank()) {
return new BigDecimal(text);
}
return null;
}
}
@@ -0,0 +1,14 @@
package com.quantai.trader.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfig {
@Bean
ObjectMapper traderObjectMapper() {
return JsonMapper.builder().findAndAddModules().build();
}
}
@@ -1,299 +1,108 @@
package com.quantai.trader.config;
import com.quantai.trader.enums.TraderExecutionMode;
import com.quantai.trader.enums.TraderRunMode;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.math.BigDecimal;
import static com.quantai.trader.util.TraderNumbers.nonNegative;
import static com.quantai.trader.util.TraderNumbers.positive;
import static com.quantai.trader.util.TraderNumbers.requiredText;
@ConfigurationProperties(prefix = "trader")
public class TraderProperties {
private String serviceName = "quant-trader-service";
private TraderRunMode runMode = TraderRunMode.REPLAY;
private String symbol = "BTCUSDT";
private String featureVersion = "trader_feature_v0";
private String labelVersion = "trader_label_v0";
private Playbook playbook = new Playbook();
private Replay replay = new Replay();
private Integration integration = new Integration();
private Risk risk = new Risk();
private Sizing sizing = new Sizing();
private DataSource dataSource = new DataSource();
public String getServiceName() {
return serviceName;
public record TraderProperties(
String serviceName,
TraderRunMode runMode,
String symbol,
Artifact artifact,
Feedback feedback,
Execution execution,
Runtime runtime,
Outbox outbox,
Release release,
Risk risk,
PositionManager positionManager
) {
public TraderProperties {
serviceName = requiredText(serviceName, "serviceName");
runMode = require(runMode, "runMode");
symbol = requiredText(symbol, "symbol");
artifact = require(artifact, "artifact");
feedback = require(feedback, "feedback");
execution = require(execution, "execution");
runtime = require(runtime, "runtime");
outbox = require(outbox, "outbox");
release = require(release, "release");
risk = require(risk, "risk");
positionManager = require(positionManager, "positionManager");
}
public void setServiceName(String serviceName) {
this.serviceName = serviceName;
}
public TraderRunMode getRunMode() {
return runMode;
}
public void setRunMode(TraderRunMode runMode) {
this.runMode = runMode;
}
public String getSymbol() {
return symbol;
}
public void setSymbol(String symbol) {
this.symbol = symbol;
}
public String getFeatureVersion() {
return featureVersion;
}
public void setFeatureVersion(String featureVersion) {
this.featureVersion = featureVersion;
}
public String getLabelVersion() {
return labelVersion;
}
public void setLabelVersion(String labelVersion) {
this.labelVersion = labelVersion;
}
public Playbook getPlaybook() {
return playbook;
}
public void setPlaybook(Playbook playbook) {
this.playbook = playbook;
}
public Replay getReplay() {
return replay;
}
public void setReplay(Replay replay) {
this.replay = replay;
}
public Integration getIntegration() {
return integration;
}
public void setIntegration(Integration integration) {
this.integration = integration;
}
public Risk getRisk() {
return risk;
}
public void setRisk(Risk risk) {
this.risk = risk;
}
public Sizing getSizing() {
return sizing;
}
public void setSizing(Sizing sizing) {
this.sizing = sizing;
}
public DataSource getDataSource() {
return dataSource;
}
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public static class Playbook {
private String locationPattern = "classpath:/playbooks/*.yml";
public String getLocationPattern() {
return locationPattern;
private static <T> T require(T value, String field) {
if (value == null) {
throw new IllegalArgumentException(field + " is required");
}
return value;
}
public void setLocationPattern(String locationPattern) {
this.locationPattern = locationPattern;
public record Artifact(
String modelBundleVersion,
String calibrationBundleVersion,
String pmConfigVersion,
String artifactRoot
) {
public Artifact {
modelBundleVersion = requiredText(modelBundleVersion, "artifact.modelBundleVersion");
calibrationBundleVersion = requiredText(calibrationBundleVersion, "artifact.calibrationBundleVersion");
pmConfigVersion = requiredText(pmConfigVersion, "artifact.pmConfigVersion");
artifactRoot = requiredText(artifactRoot, "artifact.artifactRoot");
}
}
public static class Replay {
private String outputDir = "/Users/zach/Desktop/app/trader/replay-output";
private boolean failOnDataMissing = true;
public record Feedback(boolean httpEnabled) {
}
public String getOutputDir() {
return outputDir;
}
public void setOutputDir(String outputDir) {
this.outputDir = outputDir;
}
public boolean isFailOnDataMissing() {
return failOnDataMissing;
}
public void setFailOnDataMissing(boolean failOnDataMissing) {
this.failOnDataMissing = failOnDataMissing;
public record Execution(TraderExecutionMode mode, Integer maxApiErrorCount, Long maxExchangeLatencyMs) {
public Execution {
mode = require(mode, "execution.mode");
if (require(maxApiErrorCount, "execution.maxApiErrorCount") < 0) {
throw new IllegalArgumentException("execution.maxApiErrorCount must be >= 0");
}
if (require(maxExchangeLatencyMs, "execution.maxExchangeLatencyMs") <= 0) {
throw new IllegalArgumentException("execution.maxExchangeLatencyMs must be > 0");
}
}
}
public static class Integration {
private String appActionChannel = "JAR_FUTURE";
private boolean httpFeedbackEnabled = false;
public String getAppActionChannel() {
return appActionChannel;
}
public void setAppActionChannel(String appActionChannel) {
this.appActionChannel = appActionChannel;
}
public boolean isHttpFeedbackEnabled() {
return httpFeedbackEnabled;
}
public void setHttpFeedbackEnabled(boolean httpFeedbackEnabled) {
this.httpFeedbackEnabled = httpFeedbackEnabled;
public record Runtime(String redisKeyPrefix, boolean requireRedisForOpenAdd, boolean tradingEnabled) {
public Runtime {
redisKeyPrefix = requiredText(redisKeyPrefix, "runtime.redisKeyPrefix");
}
}
public static class Risk {
private BigDecimal leverageScreen = BigDecimal.TEN;
private boolean requireOneXNotNegative = true;
private int maxPlannedEntryLegs = 3;
private boolean allowFreeScaleIn = false;
private boolean allowReduceThenAdd = false;
private boolean requireStop = true;
private boolean requireTarget = true;
private boolean requireInvalid = true;
public BigDecimal getLeverageScreen() {
return leverageScreen;
}
public void setLeverageScreen(BigDecimal leverageScreen) {
this.leverageScreen = leverageScreen;
}
public boolean isRequireOneXNotNegative() {
return requireOneXNotNegative;
}
public void setRequireOneXNotNegative(boolean requireOneXNotNegative) {
this.requireOneXNotNegative = requireOneXNotNegative;
}
public int getMaxPlannedEntryLegs() {
return maxPlannedEntryLegs;
}
public void setMaxPlannedEntryLegs(int maxPlannedEntryLegs) {
this.maxPlannedEntryLegs = maxPlannedEntryLegs;
}
public boolean isAllowFreeScaleIn() {
return allowFreeScaleIn;
}
public void setAllowFreeScaleIn(boolean allowFreeScaleIn) {
this.allowFreeScaleIn = allowFreeScaleIn;
}
public boolean isAllowReduceThenAdd() {
return allowReduceThenAdd;
}
public void setAllowReduceThenAdd(boolean allowReduceThenAdd) {
this.allowReduceThenAdd = allowReduceThenAdd;
}
public boolean isRequireStop() {
return requireStop;
}
public void setRequireStop(boolean requireStop) {
this.requireStop = requireStop;
}
public boolean isRequireTarget() {
return requireTarget;
}
public void setRequireTarget(boolean requireTarget) {
this.requireTarget = requireTarget;
}
public boolean isRequireInvalid() {
return requireInvalid;
}
public void setRequireInvalid(boolean requireInvalid) {
this.requireInvalid = requireInvalid;
public record Outbox(boolean enabled, Integer maxRetryCount) {
public Outbox {
if (require(maxRetryCount, "outbox.maxRetryCount") < 0) {
throw new IllegalArgumentException("outbox.maxRetryCount must be >= 0");
}
}
}
public static class Sizing {
private String method = "SIGNAL_EXECUTION_RISK_DYNAMIC";
private boolean allowFullInitialEntry = true;
private int maxPlannedEntryLegs = 3;
private BigDecimal maxTotalPositionRatio = BigDecimal.ONE;
private BigDecimal maxSingleLegRatio = BigDecimal.ONE;
public record Release(boolean requireReviewForPaper, boolean requireReviewForLiveProbe, boolean activePointerCheckEnabled) {
}
public String getMethod() {
return method;
}
public void setMethod(String method) {
this.method = method;
}
public boolean isAllowFullInitialEntry() {
return allowFullInitialEntry;
}
public void setAllowFullInitialEntry(boolean allowFullInitialEntry) {
this.allowFullInitialEntry = allowFullInitialEntry;
}
public int getMaxPlannedEntryLegs() {
return maxPlannedEntryLegs;
}
public void setMaxPlannedEntryLegs(int maxPlannedEntryLegs) {
this.maxPlannedEntryLegs = maxPlannedEntryLegs;
}
public BigDecimal getMaxTotalPositionRatio() {
return maxTotalPositionRatio;
}
public void setMaxTotalPositionRatio(BigDecimal maxTotalPositionRatio) {
this.maxTotalPositionRatio = maxTotalPositionRatio;
}
public BigDecimal getMaxSingleLegRatio() {
return maxSingleLegRatio;
}
public void setMaxSingleLegRatio(BigDecimal maxSingleLegRatio) {
this.maxSingleLegRatio = maxSingleLegRatio;
public record Risk(BigDecimal maxDailyLossBps, BigDecimal maxTotalExposureRatio, BigDecimal minLiquidationBufferBps) {
public Risk {
maxDailyLossBps = nonNegative(maxDailyLossBps, "risk.maxDailyLossBps");
maxTotalExposureRatio = positive(maxTotalExposureRatio, "risk.maxTotalExposureRatio");
minLiquidationBufferBps = nonNegative(minLiquidationBufferBps, "risk.minLiquidationBufferBps");
}
}
public static class DataSource {
private String hashMode = "FULL_HASH_OR_SCHEMA_ROW_TIME_MISSING_SUMMARY";
public String getHashMode() {
return hashMode;
}
public void setHashMode(String hashMode) {
this.hashMode = hashMode;
public record PositionManager(BigDecimal maxSingleLegRatio, BigDecimal maxTotalPositionRatio) {
public PositionManager {
maxSingleLegRatio = positive(maxSingleLegRatio, "positionManager.maxSingleLegRatio");
maxTotalPositionRatio = positive(maxTotalPositionRatio, "positionManager.maxTotalPositionRatio");
}
}
}
@@ -1,7 +1,6 @@
package com.quantai.trader.controller;
public record TraderApiError(
String code,
String message
) {
import com.quantai.trader.enums.TraderErrorCode;
public record TraderApiError(TraderErrorCode code, String message) {
}
@@ -1,21 +1,19 @@
package com.quantai.trader.controller;
import com.quantai.trader.domain.TraderException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class TraderApiExceptionHandler {
@ExceptionHandler(TraderException.class)
public ResponseEntity<TraderApiError> handleTraderException(TraderException ex) {
return ResponseEntity.badRequest().body(new TraderApiError(ex.errorCode().name(), ex.getMessage()));
ResponseEntity<TraderApiError> traderException(TraderException exception) {
return ResponseEntity.badRequest().body(new TraderApiError(exception.code(), exception.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<TraderApiError> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new TraderApiError("TRADER_NOT_FOUND", ex.getMessage()));
ResponseEntity<TraderApiError> illegalArgument(IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(new TraderApiError(com.quantai.trader.enums.TraderErrorCode.TRADER_REQUEST_INVALID, exception.getMessage()));
}
}
@@ -1,48 +1,42 @@
package com.quantai.trader.controller;
import com.quantai.trader.config.TraderProperties;
import com.quantai.trader.domain.FeedbackValidator;
import com.quantai.trader.domain.TraderAppFeedback;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.enums.TraderErrorCode;
import com.quantai.trader.feedback.TraderFeedbackRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/trader/feedback")
public class TraderFeedbackController {
private static final Logger log = LoggerFactory.getLogger(TraderFeedbackController.class);
private final TraderProperties properties;
private final FeedbackValidator feedbackValidator;
private final TraderFeedbackRepository feedbackRepository;
public TraderFeedbackController(TraderProperties properties) {
public TraderFeedbackController(TraderProperties properties, FeedbackValidator feedbackValidator,
TraderFeedbackRepository feedbackRepository) {
this.properties = properties;
this.feedbackValidator = feedbackValidator;
this.feedbackRepository = feedbackRepository;
}
@PostMapping
public ResponseEntity<?> feedback(@RequestBody TraderFeedbackRequest request) {
if (!properties.getIntegration().isHttpFeedbackEnabled()) {
log.info(
"event=trader.feedback.rejected runId={} cycleId={} actionId={} reason={}",
request.runId(),
request.cycleId(),
request.actionId(),
TraderErrorCode.TRADER_FEEDBACK_DISABLED
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new TraderApiError(
TraderErrorCode.TRADER_FEEDBACK_DISABLED.name(),
"P0 feedback endpoint is disabled; future App main channel is trader-core jar"
));
@PostMapping("/api/trader/feedback")
public Map<String, Object> feedback(@RequestBody TraderAppFeedback feedback) {
if (!properties.feedback().httpEnabled()) {
throw new TraderException(TraderErrorCode.TRADER_FEEDBACK_INVALID, "HTTP feedback is disabled in P0");
}
return ResponseEntity.accepted().body(Map.of(
"status", "ACCEPTED_CONTRACT_ONLY",
"runId", request.runId(),
"actionId", request.actionId()
));
feedbackValidator.validateP0(feedback);
feedbackRepository.insert(feedback);
log.info("event=trader.feedback.accepted runId={} cycleId={} actionId={} source={}",
feedback.runId(), feedback.cycleId(), feedback.actionId(), feedback.feedbackSource());
return Map.of("accepted", true, "feedbackId", feedback.feedbackId());
}
}
@@ -1,26 +0,0 @@
package com.quantai.trader.controller;
import com.quantai.trader.enums.TraderFeedbackSource;
import com.quantai.trader.enums.TraderFeedbackType;
import java.math.BigDecimal;
import java.util.Map;
public record TraderFeedbackRequest(
String runId,
String cycleId,
String actionId,
TraderFeedbackType feedbackType,
TraderFeedbackSource feedbackSource,
boolean realFill,
String proxyMethod,
String simulatorVersion,
String orderId,
String positionId,
BigDecimal filledPrice,
BigDecimal filledQuantity,
BigDecimal fee,
BigDecimal slippageBps,
Map<String, Object> rawFeedback
) {
}
@@ -1,33 +1,28 @@
package com.quantai.trader.controller;
import com.quantai.trader.config.TraderProperties;
import com.quantai.trader.playbook.TraderPlaybookCatalog;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/trader")
public class TraderHealthController {
private final TraderProperties properties;
private final TraderPlaybookCatalog catalog;
public TraderHealthController(TraderProperties properties, TraderPlaybookCatalog catalog) {
public TraderHealthController(TraderProperties properties) {
this.properties = properties;
this.catalog = catalog;
}
@GetMapping("/health")
@GetMapping("/api/trader/health")
public Map<String, Object> health() {
return Map.of(
"service", properties.getServiceName(),
"runMode", properties.getRunMode(),
"symbol", properties.getSymbol(),
"playbookCount", catalog.list().size(),
"httpFeedbackEnabled", properties.getIntegration().isHttpFeedbackEnabled()
);
"status", "UP",
"runMode", properties.runMode(),
"executionMode", properties.execution().mode(),
"modelBundleVersion", properties.artifact().modelBundleVersion(),
"calibrationBundleVersion", properties.artifact().calibrationBundleVersion(),
"pmConfigVersion", properties.artifact().pmConfigVersion(),
"tradingEnabled", properties.runtime().tradingEnabled());
}
}
@@ -1,52 +0,0 @@
package com.quantai.trader.controller;
import com.quantai.trader.playbook.TraderPlaybookCatalog;
import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/trader/playbooks")
public class TraderPlaybookController {
private final TraderPlaybookCatalog catalog;
public TraderPlaybookController(TraderPlaybookCatalog catalog) {
this.catalog = catalog;
}
@GetMapping
public List<PlaybookResponse> list() {
return catalog.list().stream().map(PlaybookResponse::from).toList();
}
@GetMapping("/{playbookId}")
public PlaybookResponse get(@PathVariable String playbookId) {
return PlaybookResponse.from(catalog.require(playbookId));
}
public record PlaybookResponse(
String playbookId,
String playbookVersion,
String family,
String variant,
String definitionHashSha256,
List<String> outputActions
) {
static PlaybookResponse from(TraderPlaybookDefinitionSnapshot snapshot) {
return new PlaybookResponse(
snapshot.playbookId(),
snapshot.playbookVersion(),
snapshot.family(),
snapshot.variant(),
snapshot.definitionHashSha256(),
snapshot.definition().outputActions()
);
}
}
}
@@ -1,52 +1,22 @@
package com.quantai.trader.controller;
import com.quantai.trader.domain.TraderReplayReport;
import com.quantai.trader.persistence.ReplayReportRepository;
import com.quantai.trader.replay.ReplayRun;
import com.quantai.trader.replay.ReplayRunConfig;
import com.quantai.trader.replay.ReplayRunResponse;
import com.quantai.trader.replay.ReplayRunService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import com.quantai.trader.replay.ReplayMarketEvent;
import com.quantai.trader.replay.TraderCycleResult;
import com.quantai.trader.replay.TraderP0CycleRunner;
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;
@RestController
@RequestMapping("/api/trader/replay/runs")
public class TraderReplayController {
private final TraderP0CycleRunner runner;
private final ReplayRunService replayRunService;
private final ReplayReportRepository reportRepository;
public TraderReplayController(ReplayRunService replayRunService, ReplayReportRepository reportRepository) {
this.replayRunService = replayRunService;
this.reportRepository = reportRepository;
public TraderReplayController(TraderP0CycleRunner runner) {
this.runner = runner;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ReplayRunResponse create(@RequestBody ReplayRunConfig config) {
return replayRunService.createRun(config);
}
@GetMapping("/{runId}")
public ReplayRun get(@PathVariable String runId) {
return replayRunService.find(runId)
.orElseThrow(() -> new IllegalArgumentException("replay run not found: " + runId));
}
@PostMapping("/{runId}/cancel")
public ReplayRun cancel(@PathVariable String runId) {
return replayRunService.cancel(runId);
}
@GetMapping("/{runId}/report")
public TraderReplayReport report(@PathVariable String runId) {
return reportRepository.findByRunId(runId)
.orElseThrow(() -> new IllegalArgumentException("replay report not found: " + runId));
@PostMapping("/api/trader/replay/cycles")
public TraderCycleResult runOneCycle(@RequestBody ReplayMarketEvent event) {
return runner.runCycle(event);
}
}
@@ -0,0 +1,23 @@
package com.quantai.trader.core;
import com.quantai.trader.domain.*;
import java.time.Instant;
import java.util.Map;
public record TraderCoreDecisionRequest(
String requestId,
String runId,
String cycleId,
String symbol,
Instant eventTime,
TraderMarketSnapshot snapshot,
TraderPositionState positionState,
TraderAccountState accountState,
TraderExecutionState executionState,
String modelBundleVersion,
String calibrationBundleVersion,
String pmConfigVersion,
Map<String, Object> requestContextJson
) {
}
@@ -0,0 +1,19 @@
package com.quantai.trader.core;
import com.quantai.trader.domain.TraderAction;
import java.util.Map;
public record TraderCoreDecisionResponse(
String responseId,
String requestId,
String cycleId,
String modelOutputId,
String pmDecisionId,
String riskDecisionId,
boolean actionAllowed,
TraderAction action,
String blocker,
Map<String, Object> responseContextJson
) {
}
@@ -0,0 +1,24 @@
package com.quantai.trader.core;
import com.quantai.trader.enums.FeedbackSource;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderCoreFeedbackEvent(
String feedbackId,
String actionId,
FeedbackSource feedbackSource,
boolean realFill,
String orderId,
String orderStatus,
BigDecimal filledPrice,
BigDecimal filledQuantity,
BigDecimal fee,
BigDecimal slippageBps,
String rejectReason,
Instant eventTime,
Map<String, Object> rawFeedbackJson
) {
}
@@ -0,0 +1,11 @@
package com.quantai.trader.core;
public record TraderCoreHealth(
boolean ready,
String runMode,
String modelBundleVersion,
String calibrationBundleVersion,
String pmConfigVersion,
String blocker
) {
}
@@ -0,0 +1,30 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import com.quantai.trader.enums.PositionSide;
import java.math.BigDecimal;
public record ContinueOutput(
BigDecimal longContinueProb,
BigDecimal shortContinueProb,
BigDecimal longExpectedContinueEdgeBps,
BigDecimal shortExpectedContinueEdgeBps
) {
public ContinueOutput {
longContinueProb = probability(longContinueProb, "continue.longContinueProb");
shortContinueProb = probability(shortContinueProb, "continue.shortContinueProb");
longExpectedContinueEdgeBps = required(longExpectedContinueEdgeBps, "continue.longExpectedContinueEdgeBps");
shortExpectedContinueEdgeBps = required(shortExpectedContinueEdgeBps, "continue.shortExpectedContinueEdgeBps");
}
public BigDecimal continueEdgeBpsFor(PositionSide side) {
if (side == PositionSide.LONG) {
return longExpectedContinueEdgeBps;
}
if (side == PositionSide.SHORT) {
return shortExpectedContinueEdgeBps;
}
throw new IllegalArgumentException("continue edge requires LONG or SHORT side");
}
}
@@ -0,0 +1,29 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import java.math.BigDecimal;
public record DirectionOutput(
BigDecimal longProb,
BigDecimal shortProb,
BigDecimal neutralProb
) {
public DirectionOutput {
longProb = probability(longProb, "direction.longProb");
shortProb = probability(shortProb, "direction.shortProb");
neutralProb = probability(neutralProb, "direction.neutralProb");
BigDecimal sum = longProb.add(shortProb).add(neutralProb);
if (sum.subtract(BigDecimal.ONE).abs().compareTo(new BigDecimal("0.000001")) > 0) {
throw new IllegalArgumentException("direction probabilities must sum to 1");
}
}
public BigDecimal margin() {
return longProb.subtract(shortProb).abs();
}
public BigDecimal confidence() {
return longProb.max(shortProb).max(neutralProb);
}
}
@@ -0,0 +1,30 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import com.quantai.trader.enums.PositionSide;
import java.math.BigDecimal;
public record EntryOutput(
BigDecimal longEntryProb,
BigDecimal shortEntryProb,
BigDecimal longExpectedNetEdgeBps,
BigDecimal shortExpectedNetEdgeBps
) {
public EntryOutput {
longEntryProb = probability(longEntryProb, "entry.longEntryProb");
shortEntryProb = probability(shortEntryProb, "entry.shortEntryProb");
longExpectedNetEdgeBps = required(longExpectedNetEdgeBps, "entry.longExpectedNetEdgeBps");
shortExpectedNetEdgeBps = required(shortExpectedNetEdgeBps, "entry.shortExpectedNetEdgeBps");
}
public BigDecimal netEdgeBpsFor(PositionSide side) {
if (side == PositionSide.LONG) {
return longExpectedNetEdgeBps;
}
if (side == PositionSide.SHORT) {
return shortExpectedNetEdgeBps;
}
throw new IllegalArgumentException("entry edge requires LONG or SHORT side");
}
}
@@ -1,21 +0,0 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.util.Map;
public record ExecutionDecision(
boolean pass,
BigDecimal executionQualityScore,
String reason,
String blocker,
Map<String, Object> details
) {
public ExecutionDecision {
details = Maps.immutable(details);
}
public boolean blocked() {
return !pass;
}
}
@@ -0,0 +1,42 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Set;
public record ExitOutput(
BigDecimal longExitProb,
BigDecimal shortExitProb,
BigDecimal longAdverseMoveBps,
BigDecimal shortAdverseMoveBps,
Map<String, BigDecimal> exitReasonScores
) {
private static final Set<String> REQUIRED_REASON_KEYS = Set.of(
"adverse_move_prob", "reversal_prob", "stop_hit_prob", "stagnation_prob");
public ExitOutput {
longExitProb = probability(longExitProb, "exit.longExitProb");
shortExitProb = probability(shortExitProb, "exit.shortExitProb");
longAdverseMoveBps = nonNegative(longAdverseMoveBps, "exit.longAdverseMoveBps");
shortAdverseMoveBps = nonNegative(shortAdverseMoveBps, "exit.shortAdverseMoveBps");
exitReasonScores = checkedProbabilities(exitReasonScores, "exit.exitReasonScores");
}
public BigDecimal reasonScore(String key) {
return exitReasonScores.get(requiredText(key, "exit reason key"));
}
private static Map<String, BigDecimal> checkedProbabilities(Map<String, BigDecimal> scores, String field) {
Map<String, BigDecimal> source = scores == null ? Map.of() : scores;
if (!source.keySet().containsAll(REQUIRED_REASON_KEYS)) {
throw new IllegalArgumentException(field + " must contain " + REQUIRED_REASON_KEYS);
}
source.forEach((key, value) -> {
requiredText(key, field + ".key");
probability(value, field + "." + key);
});
return Map.copyOf(source);
}
}
@@ -0,0 +1,14 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderErrorCode;
import org.springframework.stereotype.Component;
@Component
public class FeedbackValidator {
public void validateP0(TraderAppFeedback feedback) {
if (!feedback.feedbackSource().p0Allowed() || feedback.realFill()) {
throw new TraderException(TraderErrorCode.TRADER_FEEDBACK_INVALID,
"P0 rejects PAPER_APP/REAL_APP and any realFill feedback");
}
}
}
@@ -1,16 +0,0 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import java.util.Map;
public record ManagementDecision(
TraderActionType actionType,
String reason,
Map<String, Object> details
) {
public ManagementDecision {
details = Maps.immutable(details);
}
}
@@ -1,17 +0,0 @@
package com.quantai.trader.domain;
import java.util.LinkedHashMap;
import java.util.Map;
final class Maps {
private Maps() {
}
static Map<String, Object> immutable(Map<String, Object> value) {
if (value == null || value.isEmpty()) {
return Map.of();
}
return Map.copyOf(new LinkedHashMap<>(value));
}
}
@@ -0,0 +1,10 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.requiredText;
public record OpenOrderState(String orderId, String status) {
public OpenOrderState {
orderId = requiredText(orderId, "orderId");
status = requiredText(status, "status");
}
}
@@ -1,25 +0,0 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderSide;
import java.time.Instant;
import java.util.Map;
public record PlaybookCandidate(
String runId,
String cycleId,
String candidateId,
String playbookId,
String playbookVersion,
TraderSide side,
String variant,
Instant candidateTime,
TraderPricePlan pricePlan,
int maxPlannedEntryLegs,
Map<String, Object> setupEvidence
) {
public PlaybookCandidate {
setupEvidence = Maps.immutable(setupEvidence);
}
}
@@ -0,0 +1,25 @@
package com.quantai.trader.domain;
import java.util.Objects;
public record PositionManagerInput(
TraderDecisionCycle cycle,
TraderMarketSnapshot snapshot,
TraderModelOutput modelOutput,
TraderPricePlanContext pricePlanContext,
TraderPositionState positionState,
TraderAccountState accountState,
TraderExecutionState executionState,
TraderPmConfig pmConfig
) {
public PositionManagerInput {
cycle = Objects.requireNonNull(cycle, "cycle is required");
snapshot = Objects.requireNonNull(snapshot, "snapshot is required");
modelOutput = Objects.requireNonNull(modelOutput, "modelOutput is required");
pricePlanContext = Objects.requireNonNull(pricePlanContext, "pricePlanContext is required");
positionState = Objects.requireNonNull(positionState, "positionState is required");
accountState = Objects.requireNonNull(accountState, "accountState is required");
executionState = Objects.requireNonNull(executionState, "executionState is required");
pmConfig = Objects.requireNonNull(pmConfig, "pmConfig is required");
}
}
@@ -1,14 +0,0 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
public record PositionSizingPlan(
int plannedLegCount,
BigDecimal initialLegRatio,
BigDecimal nextLegRatio,
String sizingMethod,
BigDecimal signalStrengthScore,
BigDecimal executionQualityScore,
BigDecimal riskGateScore
) {
}
@@ -1,20 +0,0 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.util.Map;
public record RiskDecision(
boolean allowAction,
String blocker,
BigDecimal riskGateScore,
Map<String, Object> details
) {
public RiskDecision {
details = Maps.immutable(details);
}
public boolean blocked() {
return !allowAction;
}
}
@@ -0,0 +1,69 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import com.quantai.trader.enums.PositionSide;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Set;
public record RiskOutput(
BigDecimal marketRiskProb,
BigDecimal longPositionRiskProb,
BigDecimal shortPositionRiskProb,
BigDecimal marketPathRiskBps,
BigDecimal longPositionPathRiskBps,
BigDecimal shortPositionPathRiskBps,
Map<String, BigDecimal> riskReasonScores
) {
private static final Set<String> REQUIRED_REASON_KEYS = Set.of(
"market_drawdown_prob", "volatility_expansion_prob", "spike_prob",
"liquidity_deterioration_prob", "position_drawdown_prob");
public RiskOutput {
marketRiskProb = probability(marketRiskProb, "risk.marketRiskProb");
longPositionRiskProb = probability(longPositionRiskProb, "risk.longPositionRiskProb");
shortPositionRiskProb = probability(shortPositionRiskProb, "risk.shortPositionRiskProb");
marketPathRiskBps = nonNegative(marketPathRiskBps, "risk.marketPathRiskBps");
longPositionPathRiskBps = nonNegative(longPositionPathRiskBps, "risk.longPositionPathRiskBps");
shortPositionPathRiskBps = nonNegative(shortPositionPathRiskBps, "risk.shortPositionPathRiskBps");
riskReasonScores = checkedProbabilities(riskReasonScores, "risk.riskReasonScores");
}
public BigDecimal reasonScore(String key) {
return riskReasonScores.get(requiredText(key, "risk reason key"));
}
public BigDecimal sideRiskProbFor(PositionSide side) {
if (side == PositionSide.LONG) {
return longPositionRiskProb;
}
if (side == PositionSide.SHORT) {
return shortPositionRiskProb;
}
throw new IllegalArgumentException("position risk requires LONG or SHORT side");
}
public BigDecimal positionPathRiskBpsFor(PositionSide side) {
if (side == PositionSide.LONG) {
return longPositionPathRiskBps;
}
if (side == PositionSide.SHORT) {
return shortPositionPathRiskBps;
}
throw new IllegalArgumentException("position path risk requires LONG or SHORT side");
}
private static Map<String, BigDecimal> checkedProbabilities(Map<String, BigDecimal> scores, String field) {
Map<String, BigDecimal> source = scores == null ? Map.of() : scores;
if (!source.keySet().containsAll(REQUIRED_REASON_KEYS)) {
throw new IllegalArgumentException(field + " must contain " + REQUIRED_REASON_KEYS);
}
source.forEach((key, value) -> {
requiredText(key, field + ".key");
probability(value, field + "." + key);
});
return Map.copyOf(source);
}
}
@@ -1,27 +0,0 @@
package com.quantai.trader.domain;
import java.util.Map;
public record StageDecision(
boolean pass,
String reason,
String blocker,
Map<String, Object> details
) {
public StageDecision {
details = Maps.immutable(details);
}
public boolean blocked() {
return !pass;
}
public static StageDecision pass(String reason) {
return new StageDecision(true, reason, null, Map.of());
}
public static StageDecision block(String reason, String blocker) {
return new StageDecision(false, reason, blocker, Map.of());
}
}
@@ -0,0 +1,27 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import java.math.BigDecimal;
public record TraderAccountState(
String accountStateId,
String runId,
String cycleId,
BigDecimal dailyDrawdownBps,
BigDecimal portfolioExposureRatio,
BigDecimal remainingSymbolCapacityRatio,
int consecutiveLosses
) {
public TraderAccountState {
accountStateId = requiredText(accountStateId, "accountStateId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
dailyDrawdownBps = nonNegative(dailyDrawdownBps, "dailyDrawdownBps");
portfolioExposureRatio = nonNegative(portfolioExposureRatio, "portfolioExposureRatio");
remainingSymbolCapacityRatio = nonNegative(remainingSymbolCapacityRatio, "remainingSymbolCapacityRatio");
if (consecutiveLosses < 0) {
throw new IllegalArgumentException("consecutiveLosses must be >= 0");
}
}
}
@@ -1,30 +1,60 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.enums.TraderSide;
import static com.quantai.trader.util.TraderNumbers.*;
import com.quantai.trader.enums.PositionSide;
import com.quantai.trader.enums.TraderActionType;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
public record TraderAction(
String actionId,
String runId,
String cycleId,
String actionId,
String modelOutputId,
String pmDecisionId,
String riskDecisionId,
TraderActionType actionType,
String playbookId,
String playbookVersion,
String symbol,
TraderSide side,
BigDecimal price,
PositionSide side,
String pricePlanId,
String pricePlanConfigHash,
BigDecimal positionRatio,
BigDecimal quantity,
Instant actionTime,
BigDecimal stopPrice,
BigDecimal targetPrice,
boolean reduceOnly,
String idempotencyKey,
String reason,
Map<String, Object> actionContext,
String sendStatus
Map<String, Object> actionContextJson
) {
public TraderAction {
actionContext = Maps.immutable(actionContext);
actionId = requiredText(actionId, "actionId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
modelOutputId = requiredText(modelOutputId, "modelOutputId");
pmDecisionId = requiredText(pmDecisionId, "pmDecisionId");
riskDecisionId = requiredText(riskDecisionId, "riskDecisionId");
actionType = Objects.requireNonNull(actionType, "actionType is required");
symbol = requiredText(symbol, "symbol");
side = Objects.requireNonNull(side, "side is required");
idempotencyKey = requiredText(idempotencyKey, "idempotencyKey");
reason = requiredText(reason, "reason");
actionContextJson = Map.copyOf(actionContextJson == null ? Map.of() : actionContextJson);
if (actionType.increasesExposure()) {
pricePlanId = requiredText(pricePlanId, "pricePlanId");
pricePlanConfigHash = requiredText(pricePlanConfigHash, "pricePlanConfigHash");
positionRatio = positive(positionRatio, "positionRatio");
}
if (actionType == TraderActionType.REDUCE_LONG || actionType == TraderActionType.REDUCE_SHORT) {
positionRatio = positive(positionRatio, "positionRatio");
if (positionRatio.compareTo(ONE) > 0) {
throw new IllegalArgumentException("positionRatio must be <= 1 for reduce action");
}
}
if (actionType.reducesExposure() && !reduceOnly) {
throw new IllegalArgumentException("reduce/close action must be reduceOnly");
}
}
}
@@ -0,0 +1,54 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.PositionSide;
import com.quantai.trader.enums.TraderActionType;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Map;
@Component
public class TraderActionFactory {
public TraderAction create(TraderPositionManagerDecision pmDecision, TraderRiskDecision riskDecision, String symbol) {
TraderActionType finalAction = riskDecision.finalAction();
PositionSide side = sideFor(finalAction, pmDecision.side());
return new TraderAction(
"action_" + pmDecision.cycleId(),
pmDecision.runId(),
pmDecision.cycleId(),
pmDecision.modelOutputId(),
pmDecision.pmDecisionId(),
riskDecision.riskDecisionId(),
finalAction,
symbol,
side,
finalAction.increasesExposure() ? pmDecision.pricePlanId() : null,
finalAction.increasesExposure() ? pmDecision.pricePlanConfigHash() : null,
ratioFor(finalAction, pmDecision),
null,
pmDecision.stopPrice(),
pmDecision.targetPrice(),
finalAction.reducesExposure(),
"idem_" + pmDecision.runId() + "_" + pmDecision.cycleId() + "_" + finalAction,
riskDecision.allowAction() ? pmDecision.reason() : riskDecision.blocker(),
Map.of("riskAllowed", riskDecision.allowAction()));
}
private BigDecimal ratioFor(TraderActionType action, TraderPositionManagerDecision pmDecision) {
return switch (action) {
case OPEN_LONG, OPEN_SHORT -> pmDecision.targetPositionRatio();
case ADD_LONG, ADD_SHORT -> pmDecision.addRatio();
case REDUCE_LONG, REDUCE_SHORT -> pmDecision.reduceRatio();
case WAIT, HOLD, CLOSE_LONG, CLOSE_SHORT, MOVE_STOP, CANCEL -> null;
};
}
private PositionSide sideFor(TraderActionType action, PositionSide pmSide) {
return switch (action) {
case OPEN_LONG, ADD_LONG, REDUCE_LONG, CLOSE_LONG -> PositionSide.LONG;
case OPEN_SHORT, ADD_SHORT, REDUCE_SHORT, CLOSE_SHORT -> PositionSide.SHORT;
case WAIT, CANCEL -> PositionSide.NONE;
case HOLD, MOVE_STOP -> pmSide;
};
}
}
@@ -1,23 +1,21 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderFeedbackSource;
import com.quantai.trader.enums.TraderFeedbackType;
import static com.quantai.trader.util.TraderNumbers.*;
import com.quantai.trader.enums.FeedbackSource;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
public record TraderAppFeedback(
String feedbackId,
String runId,
String cycleId,
String actionId,
TraderFeedbackType feedbackType,
TraderFeedbackSource feedbackSource,
String proxyMethod,
String simulatorVersion,
FeedbackSource feedbackSource,
boolean realFill,
String orderId,
String positionId,
String orderStatus,
Instant appReceivedTime,
Instant exchangeAckTime,
@@ -26,19 +24,24 @@ public record TraderAppFeedback(
BigDecimal filledQuantity,
BigDecimal fee,
BigDecimal slippageBps,
String closeReason,
String closeSignalSource,
String exchangeErrorCode,
String platformErrorCode,
Map<String, Object> rawFeedback
String rejectReason,
Map<String, Object> rawFeedbackJson
) {
public TraderAppFeedback {
rawFeedback = Maps.immutable(rawFeedback);
boolean sourceCanBeReal = feedbackSource == TraderFeedbackSource.PAPER_APP
|| feedbackSource == TraderFeedbackSource.REAL_APP;
if (realFill != sourceCanBeReal) {
throw new IllegalArgumentException("feedback_source and realFill are inconsistent");
feedbackId = requiredText(feedbackId, "feedbackId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
actionId = requiredText(actionId, "actionId");
feedbackSource = Objects.requireNonNull(feedbackSource, "feedbackSource is required");
if (realFill && !feedbackSource.canBeRealFill()) {
throw new IllegalArgumentException("realFill requires PAPER_APP or REAL_APP");
}
if (!realFill && feedbackSource.canBeRealFill()) {
throw new IllegalArgumentException("PAPER_APP/REAL_APP feedback must be realFill");
}
if (filledQuantity != null && filledQuantity.compareTo(ZERO) > 0) {
filledPrice = positive(filledPrice, "filledPrice");
}
rawFeedbackJson = Map.copyOf(rawFeedbackJson == null ? Map.of() : rawFeedbackJson);
}
}
@@ -1,48 +0,0 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderErrorCode;
import java.time.Instant;
import java.util.Map;
public record TraderDataSourceManifest(
String sourceId,
String symbol,
String sourceType,
String exchange,
String granularity,
String sourcePath,
String contentHashSha256,
String schemaHashSha256,
Instant dataFrom,
Instant dataTo,
Instant minEventTime,
Instant maxEventTime,
String timezone,
Long rowCount,
Map<String, Object> missingSummary,
String qualityStatus
) {
public TraderDataSourceManifest {
missingSummary = Maps.immutable(missingSummary);
boolean hasFullHash = contentHashSha256 != null && !contentHashSha256.isBlank();
boolean hasSchemaTrace = schemaHashSha256 != null
&& !schemaHashSha256.isBlank()
&& rowCount != null
&& minEventTime != null
&& maxEventTime != null;
if (sourceId == null || sourceId.isBlank() || sourcePath == null || sourcePath.isBlank()) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "data source id and path are required");
}
if (timezone == null || timezone.isBlank()) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "data source timezone is required");
}
if (!hasFullHash && !hasSchemaTrace) {
throw new TraderException(
TraderErrorCode.TRADER_DATA_SOURCE_MISSING,
"data source requires content hash or schema/row/time lineage"
);
}
}
}
@@ -1,37 +1,29 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderRunMode;
import com.quantai.trader.enums.TraderState;
import static com.quantai.trader.util.TraderNumbers.requiredText;
import com.quantai.trader.enums.TraderRunMode;
import java.time.Instant;
import java.util.Objects;
public record TraderDecisionCycle(
String runId,
String cycleId,
String snapshotId,
String symbol,
String playbookId,
String playbookVersion,
TraderState state,
Instant cycleTime,
TraderRunMode runMode,
String decisionStatus,
String blocker
String modelBundleVersion,
String calibrationBundleVersion,
String pmConfigVersion
) {
public TraderDecisionCycle withState(TraderState nextState, String nextStatus, String nextBlocker) {
return new TraderDecisionCycle(
runId,
cycleId,
snapshotId,
symbol,
playbookId,
playbookVersion,
nextState,
cycleTime,
runMode,
nextStatus,
nextBlocker
);
public TraderDecisionCycle {
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
symbol = requiredText(symbol, "symbol");
cycleTime = Objects.requireNonNull(cycleTime, "cycleTime is required");
runMode = Objects.requireNonNull(runMode, "runMode is required");
modelBundleVersion = requiredText(modelBundleVersion, "modelBundleVersion");
calibrationBundleVersion = requiredText(calibrationBundleVersion, "calibrationBundleVersion");
pmConfigVersion = requiredText(pmConfigVersion, "pmConfigVersion");
}
}
@@ -1,39 +0,0 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import java.math.BigDecimal;
public record TraderEntryPlan(
String runId,
String cycleId,
String actionId,
String entryLegId,
String candidateId,
TraderActionType entryAction,
int plannedLegIndex,
int plannedLegCount,
BigDecimal plannedLegRatio,
String sizingMethod,
BigDecimal signalStrengthScore,
BigDecimal executionQualityScore,
BigDecimal riskGateScore,
BigDecimal entryPrice,
BigDecimal invalidPrice,
BigDecimal stopPrice,
BigDecimal targetPrice,
BigDecimal partialTakeProfitPrice,
long maxEntryWaitMs,
long maxHoldMs,
String reason
) {
public boolean completeForEntry() {
return entryPrice != null
&& invalidPrice != null
&& stopPrice != null
&& targetPrice != null
&& maxEntryWaitMs > 0
&& maxHoldMs > 0;
}
}
@@ -1,21 +1,29 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.requiredText;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
public record TraderEvidence(
String evidenceId,
String runId,
String cycleId,
String evidenceId,
String stage,
boolean pass,
String reason,
String blocker,
Instant evidenceTime,
Map<String, Object> details
Map<String, Object> detailsJson
) {
public TraderEvidence {
details = Maps.immutable(details);
evidenceId = requiredText(evidenceId, "evidenceId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
stage = requiredText(stage, "stage");
reason = requiredText(reason, "reason");
evidenceTime = Objects.requireNonNull(evidenceTime, "evidenceTime is required");
detailsJson = Map.copyOf(detailsJson == null ? Map.of() : detailsJson);
}
}
@@ -3,15 +3,14 @@ package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderErrorCode;
public class TraderException extends RuntimeException {
private final TraderErrorCode code;
private final TraderErrorCode errorCode;
public TraderException(TraderErrorCode errorCode, String message) {
public TraderException(TraderErrorCode code, String message) {
super(message);
this.errorCode = errorCode;
this.code = code;
}
public TraderErrorCode errorCode() {
return errorCode;
public TraderErrorCode code() {
return code;
}
}
@@ -0,0 +1,46 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import java.math.BigDecimal;
import java.util.List;
public record TraderExecutionState(
String executionStateId,
String runId,
String cycleId,
String symbol,
List<OpenOrderState> openOrders,
BigDecimal expectedSlippageBps,
long exchangeLatencyMs,
int apiErrorCount,
BigDecimal makerFeeBps,
BigDecimal takerFeeBps,
BigDecimal minNotional,
BigDecimal priceTickSize,
BigDecimal lotSizeStepSize,
BigDecimal marketLotSizeStepSize,
BigDecimal liquidityCapacityRatio
) {
public TraderExecutionState {
executionStateId = requiredText(executionStateId, "executionStateId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
symbol = requiredText(symbol, "symbol");
openOrders = List.copyOf(openOrders == null ? List.of() : openOrders);
expectedSlippageBps = nonNegative(expectedSlippageBps, "expectedSlippageBps");
if (exchangeLatencyMs < 0) {
throw new IllegalArgumentException("exchangeLatencyMs must be >= 0");
}
if (apiErrorCount < 0) {
throw new IllegalArgumentException("apiErrorCount must be >= 0");
}
makerFeeBps = nonNegative(makerFeeBps, "makerFeeBps");
takerFeeBps = nonNegative(takerFeeBps, "takerFeeBps");
minNotional = positive(minNotional, "minNotional");
priceTickSize = positive(priceTickSize, "priceTickSize");
lotSizeStepSize = positive(lotSizeStepSize, "lotSizeStepSize");
marketLotSizeStepSize = positive(marketLotSizeStepSize, "marketLotSizeStepSize");
liquidityCapacityRatio = nonNegative(liquidityCapacityRatio, "liquidityCapacityRatio");
}
}
@@ -1,26 +0,0 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderManagementAction(
String runId,
String cycleId,
String actionId,
String managementActionId,
String positionId,
TraderActionType managementActionType,
Instant actionTime,
BigDecimal beforeRiskBps,
BigDecimal afterRiskBps,
String reason,
Map<String, Object> details
) {
public TraderManagementAction {
details = Maps.immutable(details);
}
}
@@ -1,19 +0,0 @@
package com.quantai.trader.domain;
import java.time.Instant;
import java.util.Map;
public record TraderMarketEvent(
String runId,
String eventId,
String symbol,
Instant eventTime,
String source,
String sourcePath,
Map<String, Object> payload
) {
public TraderMarketEvent {
payload = Maps.immutable(payload);
}
}
@@ -1,29 +1,44 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderMarketSnapshot(
String snapshotId,
String runId,
String cycleId,
String snapshotId,
String symbol,
Instant snapshotTime,
String featureVersion,
Map<String, Object> contextFeatures,
Map<String, Object> setupFeatures,
Map<String, Object> triggerFeatures,
Map<String, Object> executionFeatures,
Map<String, Object> dataQuality,
Map<String, Object> labelInputs
BigDecimal markPrice,
BigDecimal indexPrice,
BigDecimal spreadBps,
BigDecimal fundingRateBps,
BigDecimal depthNotional5Bps,
BigDecimal depthNotional10Bps,
BigDecimal depthNotional25Bps,
boolean dataReady,
Map<String, Object> featureJson,
Map<String, Object> dataQualityJson
) {
public TraderMarketSnapshot {
contextFeatures = Maps.immutable(contextFeatures);
setupFeatures = Maps.immutable(setupFeatures);
triggerFeatures = Maps.immutable(triggerFeatures);
executionFeatures = Maps.immutable(executionFeatures);
dataQuality = Maps.immutable(dataQuality);
labelInputs = Maps.immutable(labelInputs);
snapshotId = requiredText(snapshotId, "snapshotId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
symbol = requiredText(symbol, "symbol");
snapshotTime = java.util.Objects.requireNonNull(snapshotTime, "snapshotTime is required");
featureVersion = requiredText(featureVersion, "featureVersion");
markPrice = positive(markPrice, "markPrice");
indexPrice = positive(indexPrice, "indexPrice");
spreadBps = nonNegative(spreadBps, "spreadBps");
fundingRateBps = required(fundingRateBps, "fundingRateBps");
depthNotional5Bps = nonNegative(depthNotional5Bps, "depthNotional5Bps");
depthNotional10Bps = nonNegative(depthNotional10Bps, "depthNotional10Bps");
depthNotional25Bps = nonNegative(depthNotional25Bps, "depthNotional25Bps");
featureJson = Map.copyOf(featureJson == null ? Map.of() : featureJson);
dataQualityJson = Map.copyOf(dataQualityJson == null ? Map.of() : dataQualityJson);
}
}
@@ -1,20 +0,0 @@
package com.quantai.trader.domain;
import java.time.Instant;
import java.util.Map;
public record TraderModelManifest(
String modelName,
String modelVersion,
String featureVersion,
String labelVersion,
String artifactPath,
Instant trainedAt,
Map<String, Object> metrics,
String status
) {
public TraderModelManifest {
metrics = Maps.immutable(metrics);
}
}
@@ -1,20 +1,29 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
import static com.quantai.trader.util.TraderNumbers.*;
import java.util.Objects;
public record TraderModelOutput(
String modelName,
String modelVersion,
BigDecimal score,
BigDecimal uncertainty,
BigDecimal oodScore,
Instant predictedAt,
Map<String, Object> details
String modelOutputId,
String runId,
String cycleId,
TraderModelOutputMetadata metadata,
DirectionOutput direction,
EntryOutput entry,
ContinueOutput continuation,
ExitOutput exit,
RiskOutput risk
) {
public TraderModelOutput {
details = Maps.immutable(details);
modelOutputId = requiredText(modelOutputId, "modelOutputId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
metadata = Objects.requireNonNull(metadata, "metadata is required");
direction = Objects.requireNonNull(direction, "direction is required");
entry = Objects.requireNonNull(entry, "entry is required");
continuation = Objects.requireNonNull(continuation, "continuation is required");
exit = Objects.requireNonNull(exit, "exit is required");
risk = Objects.requireNonNull(risk, "risk is required");
}
}
@@ -0,0 +1,39 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import java.math.BigDecimal;
import java.util.Map;
public record TraderModelOutputMetadata(
String modelBundleVersion,
String calibrationBundleVersion,
Map<String, String> modelVersions,
Map<String, String> calibrationVersions,
String featureSchemaHash,
String featureOrderHash,
String outputSchemaHash,
BigDecimal uncertainty,
BigDecimal oodScore
) {
public TraderModelOutputMetadata {
modelBundleVersion = requiredText(modelBundleVersion, "metadata.modelBundleVersion");
calibrationBundleVersion = requiredText(calibrationBundleVersion, "metadata.calibrationBundleVersion");
modelVersions = checkedTextMap(modelVersions, "metadata.modelVersions");
calibrationVersions = checkedTextMap(calibrationVersions, "metadata.calibrationVersions");
featureSchemaHash = requiredText(featureSchemaHash, "metadata.featureSchemaHash");
featureOrderHash = requiredText(featureOrderHash, "metadata.featureOrderHash");
outputSchemaHash = requiredText(outputSchemaHash, "metadata.outputSchemaHash");
uncertainty = probability(uncertainty, "metadata.uncertainty");
oodScore = probability(oodScore, "metadata.oodScore");
}
private static Map<String, String> checkedTextMap(Map<String, String> values, String field) {
Map<String, String> source = values == null ? Map.of() : values;
source.forEach((key, value) -> {
requiredText(key, field + ".key");
requiredText(value, field + "." + key);
});
return Map.copyOf(source);
}
}
@@ -0,0 +1,130 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import java.math.BigDecimal;
public record TraderPmConfig(
String pmConfigVersion,
OpenRuleConfig open,
AddRuleConfig add,
ExitRuleConfig exit,
SizingConfig sizing
) {
public TraderPmConfig {
pmConfigVersion = requiredText(pmConfigVersion, "pmConfigVersion");
open = java.util.Objects.requireNonNull(open, "open config is required");
add = java.util.Objects.requireNonNull(add, "add config is required");
exit = java.util.Objects.requireNonNull(exit, "exit config is required");
sizing = java.util.Objects.requireNonNull(sizing, "sizing config is required");
}
public record OpenRuleConfig(
BigDecimal longOpenProb,
BigDecimal shortOpenProb,
BigDecimal minLongEntryProb,
BigDecimal minShortEntryProb,
BigDecimal maxMarketRiskProb,
BigDecimal minExpectedEdgeBps,
BigDecimal minDirectionMargin,
BigDecimal minLiquidityCapacityRatio,
BigDecimal maxOodScore
) {
public OpenRuleConfig {
longOpenProb = probability(longOpenProb, "open.longOpenProb");
shortOpenProb = probability(shortOpenProb, "open.shortOpenProb");
minLongEntryProb = probability(minLongEntryProb, "open.minLongEntryProb");
minShortEntryProb = probability(minShortEntryProb, "open.minShortEntryProb");
maxMarketRiskProb = probability(maxMarketRiskProb, "open.maxMarketRiskProb");
minExpectedEdgeBps = required(minExpectedEdgeBps, "open.minExpectedEdgeBps");
minDirectionMargin = nonNegative(minDirectionMargin, "open.minDirectionMargin");
minLiquidityCapacityRatio = nonNegative(minLiquidityCapacityRatio, "open.minLiquidityCapacityRatio");
maxOodScore = probability(maxOodScore, "open.maxOodScore");
}
}
public record AddRuleConfig(
BigDecimal minLongProb,
BigDecimal minShortProb,
BigDecimal minContinueProb,
BigDecimal minEntryProb,
BigDecimal maxExitProb,
BigDecimal maxMarketRiskProb,
BigDecimal maxPositionRiskProb,
BigDecimal minExpectedEdgeBps,
BigDecimal minContinueVsExitEdgeBps,
BigDecimal minLiquidityCapacityRatio,
BigDecimal minPostTradeLiquidationBufferBps,
int maxAddCount,
long cooldownMinutes
) {
public AddRuleConfig {
minLongProb = probability(minLongProb, "add.minLongProb");
minShortProb = probability(minShortProb, "add.minShortProb");
minContinueProb = probability(minContinueProb, "add.minContinueProb");
minEntryProb = probability(minEntryProb, "add.minEntryProb");
maxExitProb = probability(maxExitProb, "add.maxExitProb");
maxMarketRiskProb = probability(maxMarketRiskProb, "add.maxMarketRiskProb");
maxPositionRiskProb = probability(maxPositionRiskProb, "add.maxPositionRiskProb");
minExpectedEdgeBps = required(minExpectedEdgeBps, "add.minExpectedEdgeBps");
minContinueVsExitEdgeBps = required(minContinueVsExitEdgeBps, "add.minContinueVsExitEdgeBps");
minLiquidityCapacityRatio = nonNegative(minLiquidityCapacityRatio, "add.minLiquidityCapacityRatio");
minPostTradeLiquidationBufferBps = nonNegative(minPostTradeLiquidationBufferBps, "add.minPostTradeLiquidationBufferBps");
if (maxAddCount < 0 || cooldownMinutes < 0) {
throw new IllegalArgumentException("add count and cooldown must be >= 0");
}
}
}
public record ExitRuleConfig(
BigDecimal closeExitProb,
BigDecimal closePositionRiskProb,
BigDecimal closeMarketRiskProb,
BigDecimal closeContinueMax,
BigDecimal reduceAdverseMoveProb,
BigDecimal reduceContinueMin,
BigDecimal reduceContinueMax,
BigDecimal minProfitForReduceBps,
BigDecimal maxPositionPathRiskBps
) {
public ExitRuleConfig {
closeExitProb = probability(closeExitProb, "exit.closeExitProb");
closePositionRiskProb = probability(closePositionRiskProb, "exit.closePositionRiskProb");
closeMarketRiskProb = probability(closeMarketRiskProb, "exit.closeMarketRiskProb");
closeContinueMax = probability(closeContinueMax, "exit.closeContinueMax");
reduceAdverseMoveProb = probability(reduceAdverseMoveProb, "exit.reduceAdverseMoveProb");
reduceContinueMin = probability(reduceContinueMin, "exit.reduceContinueMin");
reduceContinueMax = probability(reduceContinueMax, "exit.reduceContinueMax");
minProfitForReduceBps = nonNegative(minProfitForReduceBps, "exit.minProfitForReduceBps");
maxPositionPathRiskBps = nonNegative(maxPositionPathRiskBps, "exit.maxPositionPathRiskBps");
}
}
public record SizingConfig(
BigDecimal baseRatio,
BigDecimal minInitialRatio,
BigDecimal maxSingleLegRatio,
BigDecimal minAddRatio,
BigDecimal maxAddRatio,
BigDecimal maxTotalPositionRatio,
BigDecimal minEdgeBps,
BigDecimal maxLossPerTradeBps,
BigDecimal maxLiquidityUsageRatio,
BigDecimal uncertaintyPenaltyMultiplier,
BigDecimal minPostTradeLiquidationBufferBps
) {
public SizingConfig {
baseRatio = positive(baseRatio, "sizing.baseRatio");
minInitialRatio = nonNegative(minInitialRatio, "sizing.minInitialRatio");
maxSingleLegRatio = positive(maxSingleLegRatio, "sizing.maxSingleLegRatio");
minAddRatio = nonNegative(minAddRatio, "sizing.minAddRatio");
maxAddRatio = nonNegative(maxAddRatio, "sizing.maxAddRatio");
maxTotalPositionRatio = positive(maxTotalPositionRatio, "sizing.maxTotalPositionRatio");
minEdgeBps = required(minEdgeBps, "sizing.minEdgeBps");
maxLossPerTradeBps = positive(maxLossPerTradeBps, "sizing.maxLossPerTradeBps");
maxLiquidityUsageRatio = positive(maxLiquidityUsageRatio, "sizing.maxLiquidityUsageRatio");
uncertaintyPenaltyMultiplier = nonNegative(uncertaintyPenaltyMultiplier, "sizing.uncertaintyPenaltyMultiplier");
minPostTradeLiquidationBufferBps = nonNegative(minPostTradeLiquidationBufferBps, "sizing.minPostTradeLiquidationBufferBps");
}
}
}
@@ -1,21 +0,0 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import java.math.BigDecimal;
import java.time.Instant;
public record TraderPositionLeg(
String runId,
String cycleId,
String positionId,
String legId,
TraderActionType actionType,
BigDecimal quantity,
BigDecimal price,
BigDecimal legRatio,
BigDecimal riskDeltaBps,
Instant actionTime,
String reason
) {
}
@@ -0,0 +1,48 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import com.quantai.trader.enums.PositionSide;
import com.quantai.trader.enums.TraderActionType;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Objects;
public record TraderPositionManagerDecision(
String pmDecisionId,
String runId,
String cycleId,
String modelOutputId,
String positionStateId,
String accountStateId,
String executionStateId,
TraderActionType candidateAction,
PositionSide side,
String pricePlanId,
String pricePlanConfigHash,
BigDecimal targetPositionRatio,
BigDecimal addRatio,
BigDecimal reduceRatio,
BigDecimal stopPrice,
BigDecimal targetPrice,
String reason,
Map<String, Object> decisionJson
) {
public TraderPositionManagerDecision {
pmDecisionId = requiredText(pmDecisionId, "pmDecisionId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
modelOutputId = requiredText(modelOutputId, "modelOutputId");
positionStateId = requiredText(positionStateId, "positionStateId");
accountStateId = requiredText(accountStateId, "accountStateId");
executionStateId = requiredText(executionStateId, "executionStateId");
candidateAction = Objects.requireNonNull(candidateAction, "candidateAction is required");
side = Objects.requireNonNull(side, "side is required");
reason = requiredText(reason, "reason");
decisionJson = Map.copyOf(decisionJson == null ? Map.of() : decisionJson);
if (candidateAction.increasesExposure()) {
pricePlanId = requiredText(pricePlanId, "pricePlanId");
pricePlanConfigHash = requiredText(pricePlanConfigHash, "pricePlanConfigHash");
}
}
}
@@ -1,43 +0,0 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderSide;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderPositionPath(
String runId,
String cycleId,
String actionId,
String positionId,
TraderSide side,
Instant entryTime,
Instant lastEventTime,
BigDecimal entryPrice,
BigDecimal currentPrice,
BigDecimal mfeBps,
BigDecimal maeBps,
Long timeToTargetMs,
Long timeToInvalidMs,
boolean targetBeforeStop,
boolean stagnationTimeoutHit,
boolean proxyOnly,
boolean reduceSeen,
int filledLegCount,
BigDecimal totalPositionRatio,
Map<String, Object> pathSummary
) {
public TraderPositionPath {
pathSummary = Maps.immutable(pathSummary);
}
public boolean opened() {
return positionId != null && filledLegCount > 0;
}
public boolean fullSize() {
return totalPositionRatio != null && totalPositionRatio.compareTo(BigDecimal.ONE) >= 0;
}
}
@@ -0,0 +1,47 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import com.quantai.trader.enums.PositionSide;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Objects;
public record TraderPositionState(
String positionStateId,
String runId,
String cycleId,
String symbol,
PositionSide side,
BigDecimal positionRatio,
BigDecimal averageEntryPrice,
BigDecimal currentPrice,
BigDecimal unrealizedPnlBps,
BigDecimal liquidationBufferBps,
int addCount,
BigDecimal remainingAddCapacity,
Instant lastAddTime
) {
public TraderPositionState {
positionStateId = requiredText(positionStateId, "positionStateId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
symbol = requiredText(symbol, "symbol");
side = Objects.requireNonNull(side, "side is required");
positionRatio = nonNegative(positionRatio, "positionRatio");
currentPrice = positive(currentPrice, "currentPrice");
if (side == PositionSide.NONE && positionRatio.compareTo(ZERO) != 0) {
throw new IllegalArgumentException("flat position must have zero ratio");
}
if (side != PositionSide.NONE) {
averageEntryPrice = positive(averageEntryPrice, "averageEntryPrice");
}
unrealizedPnlBps = required(unrealizedPnlBps, "unrealizedPnlBps");
liquidationBufferBps = nonNegative(liquidationBufferBps, "liquidationBufferBps");
remainingAddCapacity = nonNegative(remainingAddCapacity, "remainingAddCapacity");
}
public boolean isFlat() {
return side == PositionSide.NONE;
}
}
@@ -1,23 +0,0 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
public record TraderPricePlan(
BigDecimal entryPrice,
BigDecimal invalidPrice,
BigDecimal stopPrice,
BigDecimal targetPrice,
BigDecimal partialTakeProfitPrice,
long maxEntryWaitMs,
long maxHoldMs
) {
public boolean completeForEntry() {
return entryPrice != null
&& invalidPrice != null
&& stopPrice != null
&& targetPrice != null
&& maxEntryWaitMs > 0
&& maxHoldMs > 0;
}
}
@@ -0,0 +1,25 @@
package com.quantai.trader.domain;
import static com.quantai.trader.util.TraderNumbers.*;
import java.math.BigDecimal;
public record TraderPricePlanContext(
String pricePlanId,
String pricePlanConfigHash,
BigDecimal stopDistanceBps,
BigDecimal targetDistanceBps,
int maxHoldMinutes,
BigDecimal costBps
) {
public TraderPricePlanContext {
pricePlanId = requiredText(pricePlanId, "pricePlan.pricePlanId");
pricePlanConfigHash = requiredText(pricePlanConfigHash, "pricePlan.pricePlanConfigHash");
stopDistanceBps = positive(stopDistanceBps, "pricePlan.stopDistanceBps");
targetDistanceBps = positive(targetDistanceBps, "pricePlan.targetDistanceBps");
if (maxHoldMinutes <= 0) {
throw new IllegalArgumentException("pricePlan.maxHoldMinutes must be > 0");
}
costBps = nonNegative(costBps, "pricePlan.costBps");
}
}
@@ -1,30 +0,0 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Map;
public record TraderReplayReport(
String runId,
String reportId,
String symbol,
String playbookId,
String playbookVersion,
int candidateEvents,
int monthsCovered,
BigDecimal baseNetReturnBps1x,
BigDecimal leveragedNetReturnBps10x,
BigDecimal holdoutReturnBps10x,
Map<String, Object> strictVsLoose,
List<String> failureRisks,
String conclusion,
String reportPath,
Instant createdAt
) {
public TraderReplayReport {
strictVsLoose = Maps.immutable(strictVsLoose);
failureRisks = failureRisks == null ? List.of() : List.copyOf(failureRisks);
}
}
@@ -1,32 +1,32 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import static com.quantai.trader.util.TraderNumbers.requiredText;
import java.math.BigDecimal;
import java.time.Instant;
import com.quantai.trader.enums.TraderActionType;
import java.util.Map;
import java.util.Objects;
public record TraderRiskDecision(
String riskDecisionId,
String runId,
String cycleId,
String actionId,
String accountStateId,
TraderActionType actionType,
BigDecimal leverageScreen,
BigDecimal plannedTotalPositionRatio,
BigDecimal maxLossBps,
BigDecimal liquidationBufferBps,
BigDecimal expectedValueBps1x,
BigDecimal expectedValueBps10x,
BigDecimal uncertainty,
BigDecimal oodScore,
String pmDecisionId,
boolean allowAction,
TraderActionType originalAction,
TraderActionType finalAction,
String blocker,
Map<String, Object> decision,
Instant createdAt
Map<String, Object> decisionJson
) {
public TraderRiskDecision {
decision = Maps.immutable(decision);
riskDecisionId = requiredText(riskDecisionId, "riskDecisionId");
runId = requiredText(runId, "runId");
cycleId = requiredText(cycleId, "cycleId");
pmDecisionId = requiredText(pmDecisionId, "pmDecisionId");
originalAction = Objects.requireNonNull(originalAction, "originalAction is required");
finalAction = Objects.requireNonNull(finalAction, "finalAction is required");
if (!allowAction) {
blocker = requiredText(blocker, "blocker");
}
decisionJson = Map.copyOf(decisionJson == null ? Map.of() : decisionJson);
}
}
@@ -1,27 +0,0 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderTrainingSample(
String runId,
String cycleId,
String sampleId,
String actionId,
String positionId,
String featureVersion,
String labelVersion,
Instant sampleTime,
Map<String, Object> features,
Map<String, Object> labels,
BigDecimal netReturnBps1x,
BigDecimal netReturnBps10x,
boolean proxyOnly
) {
public TraderTrainingSample {
features = Maps.immutable(features);
labels = Maps.immutable(labels);
}
}
@@ -1,21 +0,0 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.util.Map;
public record TriggerDecision(
boolean pass,
BigDecimal signalStrengthScore,
String reason,
String blocker,
Map<String, Object> details
) {
public TriggerDecision {
details = Maps.immutable(details);
}
public boolean blocked() {
return !pass;
}
}
@@ -1,22 +0,0 @@
package com.quantai.trader.domain;
import java.time.Instant;
import java.util.Map;
public record TriggerEvent(
String runId,
String cycleId,
String candidateId,
String triggerId,
Instant triggerTime,
String triggerTimeframe,
String featureVersion,
Map<String, Object> triggerEvidence,
Map<String, Object> markout
) {
public TriggerEvent {
triggerEvidence = Maps.immutable(triggerEvidence);
markout = Maps.immutable(markout);
}
}
@@ -0,0 +1,16 @@
package com.quantai.trader.enums;
public enum FeedbackSource {
REPLAY_SIMULATOR,
SHADOW_APP,
PAPER_APP,
REAL_APP;
public boolean p0Allowed() {
return this == REPLAY_SIMULATOR || this == SHADOW_APP;
}
public boolean canBeRealFill() {
return this == PAPER_APP || this == REAL_APP;
}
}
@@ -0,0 +1,15 @@
package com.quantai.trader.enums;
public enum PositionSide {
NONE,
LONG,
SHORT;
public boolean isLong() {
return this == LONG;
}
public boolean isShort() {
return this == SHORT;
}
}
@@ -1,10 +0,0 @@
package com.quantai.trader.enums;
public enum ReplayRunStatus {
CREATED,
RUNNING,
CANCEL_REQUESTED,
CANCELLED,
COMPLETED,
FAILED
}
@@ -2,12 +2,23 @@ package com.quantai.trader.enums;
public enum TraderActionType {
WAIT,
OPEN_INITIAL,
OPEN_PLANNED_LEG,
OPEN_LONG,
OPEN_SHORT,
ADD_LONG,
ADD_SHORT,
HOLD,
REDUCE,
REDUCE_LONG,
REDUCE_SHORT,
MOVE_STOP,
CLOSE,
CANCEL,
REQUOTE
CLOSE_LONG,
CLOSE_SHORT,
CANCEL;
public boolean increasesExposure() {
return this == OPEN_LONG || this == OPEN_SHORT || this == ADD_LONG || this == ADD_SHORT;
}
public boolean reducesExposure() {
return this == REDUCE_LONG || this == REDUCE_SHORT || this == CLOSE_LONG || this == CLOSE_SHORT;
}
}
@@ -1,13 +1,19 @@
package com.quantai.trader.enums;
public enum TraderErrorCode {
TRADER_PLAYBOOK_VERSION_CONFLICT,
TRADER_DATA_SOURCE_MISSING,
TRADER_DATA_QUALITY_FAILED,
TRADER_ENTRY_PLAN_INCOMPLETE,
TRADER_ILLEGAL_ACTION_TRANSITION,
TRADER_PLANNED_LEG_AFTER_REDUCE,
TRADER_DATA_NOT_READY,
TRADER_MODEL_ARTIFACT_MISSING,
TRADER_CALIBRATION_MISMATCH,
TRADER_PM_CONFIG_MISMATCH,
TRADER_MODEL_OUTPUT_INVALID,
TRADER_RISK_BLOCKED,
TRADER_FEEDBACK_DISABLED,
TRADER_SAMPLE_EXPORT_FAILED
TRADER_EXECUTION_BLOCKED,
TRADER_FEEDBACK_INVALID,
TRADER_REQUEST_INVALID,
TRADER_P0_MODE_BLOCKED,
TRADER_KILL_SWITCH_ACTIVE,
TRADER_RUNTIME_CONTROL_BLOCKED,
TRADER_OUTBOX_BLOCKED,
TRADER_ACTIVE_POINTER_MISMATCH,
TRADER_PERSISTENCE_FAILED
}
@@ -0,0 +1,12 @@
package com.quantai.trader.enums;
public enum TraderExecutionMode {
REPLAY_SIM,
SHADOW,
PAPER,
REAL;
public boolean p0Allowed() {
return this == REPLAY_SIM || this == SHADOW;
}
}
@@ -1,8 +0,0 @@
package com.quantai.trader.enums;
public enum TraderFeedbackSource {
MARKET_PROXY,
SHADOW_APP,
PAPER_APP,
REAL_APP
}
@@ -1,8 +0,0 @@
package com.quantai.trader.enums;
public enum TraderFeedbackType {
FILL_EVENT,
CANCEL_EVENT,
CLOSE_EVENT,
REJECT_EVENT
}
@@ -1,7 +0,0 @@
package com.quantai.trader.enums;
public enum TraderPlaybookId {
BREAKOUT_RETEST_CONTINUATION,
SUPPORT_PULLBACK_CONTINUATION,
FALSE_BREAK_RECLAIM
}
@@ -1,7 +1,12 @@
package com.quantai.trader.enums;
public enum TraderRunMode {
REPLAY,
REPLAY_SIM,
SHADOW,
PAPER
PAPER,
REAL;
public boolean p0Allowed() {
return this == REPLAY_SIM || this == SHADOW;
}
}
@@ -1,6 +0,0 @@
package com.quantai.trader.enums;
public enum TraderSide {
LONG,
SHORT
}
@@ -1,18 +0,0 @@
package com.quantai.trader.enums;
public enum TraderState {
CONTEXT_CHECK,
SETUP_ARMED,
TRIGGER_WAIT,
ENTRY_PLANNED,
ENTRY_SENT_SHADOW,
ENTRY_FILLED_PROXY,
ENTRY_MISSED_PROXY,
PLANNED_LEG_WAIT,
PLANNED_LEG_SENT_SHADOW,
PLANNED_LEG_FILLED_PROXY,
PLANNED_LEG_MISSED_PROXY,
MANAGING,
SAMPLE_EXPORTED,
BLOCKED
}
@@ -1,50 +1,30 @@
package com.quantai.trader.evidence;
import com.quantai.trader.domain.StageDecision;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderEvidence;
import com.quantai.trader.persistence.TraderEvidenceRepository;
import com.quantai.trader.util.Ids;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@Component
public class EvidenceAppender {
private static final Logger log = LoggerFactory.getLogger(EvidenceAppender.class);
private final AtomicLong sequence = new AtomicLong();
private final TraderEvidenceRepository repository;
public EvidenceAppender(TraderEvidenceRepository repository) {
this.repository = repository;
}
public TraderEvidence append(TraderDecisionCycle cycle, String stage, StageDecision decision) {
TraderEvidence evidence = new TraderEvidence(
cycle.runId(),
cycle.cycleId(),
Ids.evidenceId(cycle, stage),
stage,
decision.pass(),
decision.reason(),
decision.blocker(),
cycle.cycleTime(),
decision.details()
);
repository.insert(evidence);
log.info(
"event=trader.evidence runId={} cycleId={} symbol={} playbookId={} playbookVersion={} state={} stage={} pass={} reason={} blocker={}",
cycle.runId(),
cycle.cycleId(),
cycle.symbol(),
cycle.playbookId(),
cycle.playbookVersion(),
cycle.state(),
stage,
decision.pass(),
decision.reason(),
decision.blocker()
);
return evidence;
public TraderEvidence append(String runId, String cycleId, String stage, boolean pass, String reason, String blocker, Map<String, Object> details) {
TraderEvidence item = new TraderEvidence("evidence_" + cycleId + "_" + sequence.getAndIncrement(), runId, cycleId,
stage, pass, reason, blocker, Instant.now(), details);
repository.insert(item);
log.info("event=trader.evidence.appended runId={} cycleId={} stage={} pass={} reason={} blocker={}",
runId, cycleId, stage, pass, reason, blocker);
return item;
}
}
@@ -0,0 +1,32 @@
package com.quantai.trader.evidence;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quantai.trader.domain.TraderEvidence;
import com.quantai.trader.persistence.TraderJsonCodec;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
@Repository
public class JdbcTraderEvidenceRepository implements TraderEvidenceRepository {
private final JdbcTemplate jdbcTemplate;
private final TraderJsonCodec jsonCodec;
public JdbcTraderEvidenceRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.jsonCodec = new TraderJsonCodec(objectMapper);
}
@Override
public void insert(TraderEvidence evidence) {
jdbcTemplate.update("""
insert into trader_evidence
(run_id, cycle_id, evidence_id, stage, pass, reason, blocker, evidence_time, details_json)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
evidence.runId(), evidence.cycleId(), evidence.evidenceId(), evidence.stage(), evidence.pass(),
evidence.reason(), evidence.blocker(), Timestamp.from(evidence.evidenceTime()),
jsonCodec.toJson(evidence.detailsJson()));
}
}
@@ -1,12 +1,7 @@
package com.quantai.trader.persistence;
package com.quantai.trader.evidence;
import com.quantai.trader.domain.TraderEvidence;
import java.util.List;
public interface TraderEvidenceRepository {
void insert(TraderEvidence evidence);
List<TraderEvidence> findByCycleId(String runId, String cycleId);
}
@@ -1,31 +0,0 @@
package com.quantai.trader.execution;
import com.quantai.trader.domain.ExecutionDecision;
import com.quantai.trader.domain.TraderEntryPlan;
import com.quantai.trader.domain.TraderMarketSnapshot;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Map;
@Component
public class ExecutionQualityGate {
public ExecutionDecision evaluate(TraderMarketSnapshot snapshot, TraderEntryPlan entryPlan) {
BigDecimal score = entryPlan.executionQualityScore() == null
? new BigDecimal("0.50")
: entryPlan.executionQualityScore();
if (!entryPlan.completeForEntry()) {
return new ExecutionDecision(false, score, "ENTRY_PLAN_INCOMPLETE", "TRADER_ENTRY_PLAN_INCOMPLETE", Map.of());
}
if (score.compareTo(new BigDecimal("0.20")) < 0) {
return new ExecutionDecision(false, score, "EXECUTION_QUALITY_TOO_LOW", "TRADER_RISK_BLOCKED", Map.of(
"executionQualityScore", score
));
}
return new ExecutionDecision(true, score, "EXECUTION_PROXY_PASS", null, Map.of(
"executionQualityScore", score,
"proxyOnly", true
));
}
}
@@ -1,113 +0,0 @@
package com.quantai.trader.execution;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.PositionSizingPlan;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderEntryPlan;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.domain.TraderPricePlan;
import com.quantai.trader.domain.TriggerDecision;
import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.enums.TraderErrorCode;
import com.quantai.trader.risk.TraderPositionSizer;
import com.quantai.trader.util.Ids;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Optional;
@Component
public class TraderEntryPlanner {
private final TraderPositionSizer positionSizer;
public TraderEntryPlanner(TraderPositionSizer positionSizer) {
this.positionSizer = positionSizer;
}
public TraderEntryPlan planInitialEntry(
TraderDecisionCycle cycle,
PlaybookCandidate candidate,
TriggerDecision trigger
) {
TraderPricePlan pricePlan = candidate.pricePlan();
validatePricePlan(pricePlan);
PositionSizingPlan sizingPlan = positionSizer.sizeInitialPlan(cycle, candidate, trigger, pricePlan);
return new TraderEntryPlan(
cycle.runId(),
cycle.cycleId(),
Ids.actionId(cycle, 1),
Ids.entryLegId(cycle, 0),
candidate.candidateId(),
TraderActionType.OPEN_INITIAL,
0,
sizingPlan.plannedLegCount(),
sizingPlan.initialLegRatio(),
sizingPlan.sizingMethod(),
sizingPlan.signalStrengthScore(),
sizingPlan.executionQualityScore(),
sizingPlan.riskGateScore(),
pricePlan.entryPrice(),
pricePlan.invalidPrice(),
pricePlan.stopPrice(),
pricePlan.targetPrice(),
pricePlan.partialTakeProfitPrice(),
pricePlan.maxEntryWaitMs(),
pricePlan.maxHoldMs(),
"INITIAL_ENTRY_FROM_PLAYBOOK_TRIGGER"
);
}
public Optional<TraderEntryPlan> planNextDeclaredLeg(
TraderDecisionCycle cycle,
PlaybookCandidate candidate,
TraderPositionPath path
) {
if (path == null || !path.opened() || path.reduceSeen() || path.fullSize()) {
return Optional.empty();
}
int nextIndex = path.filledLegCount();
if (nextIndex >= candidate.maxPlannedEntryLegs()) {
return Optional.empty();
}
TraderPricePlan pricePlan = candidate.pricePlan();
validatePricePlan(pricePlan);
PositionSizingPlan sizingPlan = positionSizer.sizeNextPlannedLeg(cycle, candidate, path, pricePlan, nextIndex);
if (sizingPlan.nextLegRatio().compareTo(BigDecimal.ZERO) <= 0) {
return Optional.empty();
}
return Optional.of(new TraderEntryPlan(
cycle.runId(),
cycle.cycleId(),
Ids.actionId(cycle, nextIndex + 1),
Ids.entryLegId(cycle, nextIndex),
candidate.candidateId(),
TraderActionType.OPEN_PLANNED_LEG,
nextIndex,
sizingPlan.plannedLegCount(),
sizingPlan.nextLegRatio(),
sizingPlan.sizingMethod(),
sizingPlan.signalStrengthScore(),
sizingPlan.executionQualityScore(),
sizingPlan.riskGateScore(),
pricePlan.entryPrice(),
pricePlan.invalidPrice(),
pricePlan.stopPrice(),
pricePlan.targetPrice(),
pricePlan.partialTakeProfitPrice(),
pricePlan.maxEntryWaitMs(),
pricePlan.maxHoldMs(),
"DECLARED_PLANNED_ENTRY_LEG"
));
}
private void validatePricePlan(TraderPricePlan pricePlan) {
if (pricePlan == null || !pricePlan.completeForEntry()) {
throw new TraderException(
TraderErrorCode.TRADER_ENTRY_PLAN_INCOMPLETE,
"entry/invalid/stop/target/maxHold are required before an entry action"
);
}
}
}
@@ -0,0 +1,144 @@
package com.quantai.trader.feature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quantai.trader.artifact.TraderArtifactBundle;
import com.quantai.trader.artifact.TraderModelManifest;
import com.quantai.trader.config.TraderProperties;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.enums.TraderErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.StreamSupport;
@Component
public class TraderFeatureVectorBuilder {
private static final Logger log = LoggerFactory.getLogger(TraderFeatureVectorBuilder.class);
private static final int REQUIRED_FEATURE_COUNT = 54;
private final TraderProperties properties;
private final ObjectMapper objectMapper;
private final ConcurrentMap<String, List<String>> featureOrderCache = new ConcurrentHashMap<>();
public TraderFeatureVectorBuilder(TraderProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
public float[] build(TraderMarketSnapshot snapshot, TraderArtifactBundle bundle) {
TraderModelManifest referenceManifest = referenceManifest(bundle);
if (!referenceManifest.featureVersion().equals(snapshot.featureVersion())) {
log.warn("event=trader.features.version_mismatch runId={} cycleId={} snapshotFeatureVersion={} modelFeatureVersion={}",
snapshot.runId(), snapshot.cycleId(), snapshot.featureVersion(), referenceManifest.featureVersion());
throw modelInputException("snapshot feature version does not match model feature version: "
+ snapshot.featureVersion() + " != " + referenceManifest.featureVersion());
}
List<String> featureOrder = featureOrder(referenceManifest);
rejectUnexpectedFeatures(snapshot, featureOrder);
float[] values = new float[featureOrder.size()];
for (int index = 0; index < featureOrder.size(); index++) {
String featureName = featureOrder.get(index);
Object rawValue = snapshot.featureJson().get(featureName);
values[index] = toFloat(snapshot, rawValue, featureName, index);
}
log.debug("event=trader.features.vector_built runId={} cycleId={} featureVersion={} featureCount={} featureOrderHash={}",
snapshot.runId(), snapshot.cycleId(), snapshot.featureVersion(), values.length, referenceManifest.featureOrderHash());
return values;
}
public List<String> featureOrder(TraderArtifactBundle bundle) {
return featureOrder(referenceManifest(bundle));
}
private TraderModelManifest referenceManifest(TraderArtifactBundle bundle) {
return bundle.modelManifests().stream()
.filter(manifest -> "DIRECTION".equals(manifest.modelType()))
.findFirst()
.orElseThrow(() -> modelInputException("DIRECTION model manifest is required for feature order"));
}
private List<String> featureOrder(TraderModelManifest manifest) {
String cacheKey = manifest.featureOrderHash() + "|" + manifest.featureOrderPath();
// 特征顺序是模型包契约的一部分,按 hash 缓存,避免每轮重复读文件。
return featureOrderCache.computeIfAbsent(cacheKey, ignored -> readFeatureOrder(manifest));
}
private List<String> readFeatureOrder(TraderModelManifest manifest) {
Path path = Path.of(properties.artifact().artifactRoot()).resolve(manifest.featureOrderPath());
if (!Files.isRegularFile(path)) {
throw modelInputException("feature_order.json is missing: " + path);
}
try {
JsonNode root = objectMapper.readTree(path.toFile());
if (!root.isArray() || root.size() != REQUIRED_FEATURE_COUNT) {
throw modelInputException("feature_order.json must contain exactly " + REQUIRED_FEATURE_COUNT + " fields: " + path);
}
List<String> order = StreamSupport.stream(root.spliterator(), false)
.map(JsonNode::asText)
.toList();
Set<String> unique = new LinkedHashSet<>(order);
if (unique.size() != order.size() || order.stream().anyMatch(String::isBlank)) {
throw modelInputException("feature_order.json contains duplicate or blank feature names: " + path);
}
log.info("event=trader.features.order_loaded featureOrderPath={} featureOrderHash={} featureCount={}",
manifest.featureOrderPath(), manifest.featureOrderHash(), order.size());
return order;
} catch (IOException exception) {
throw modelInputException("feature_order.json cannot be read: " + path);
}
}
private void rejectUnexpectedFeatures(TraderMarketSnapshot snapshot, List<String> featureOrder) {
Set<String> allowed = Set.copyOf(featureOrder);
List<String> unexpected = snapshot.featureJson().keySet().stream()
.filter(key -> !allowed.contains(key))
.sorted()
.toList();
if (!unexpected.isEmpty()) {
log.warn("event=trader.features.unexpected_fields runId={} cycleId={} unexpectedFields={}",
snapshot.runId(), snapshot.cycleId(), unexpected);
throw modelInputException("snapshot featureJson contains fields outside feature_order.json: " + unexpected);
}
}
private float toFloat(TraderMarketSnapshot snapshot, Object rawValue, String featureName, int index) {
if (rawValue == null) {
log.warn("event=trader.features.missing runId={} cycleId={} featureIndex={} featureName={}",
snapshot.runId(), snapshot.cycleId(), index + 1, featureName);
throw modelInputException("snapshot feature is missing: " + featureName);
}
double value;
if (rawValue instanceof BigDecimal decimal) {
value = decimal.doubleValue();
} else if (rawValue instanceof Number number) {
value = number.doubleValue();
} else {
log.warn("event=trader.features.non_numeric runId={} cycleId={} featureIndex={} featureName={} valueType={}",
snapshot.runId(), snapshot.cycleId(), index + 1, featureName, rawValue.getClass().getName());
throw modelInputException("snapshot feature must be numeric: " + featureName);
}
if (!Double.isFinite(value)) {
log.warn("event=trader.features.non_finite runId={} cycleId={} featureIndex={} featureName={} value={}",
snapshot.runId(), snapshot.cycleId(), index + 1, featureName, value);
throw modelInputException("snapshot feature must be finite: " + featureName);
}
return (float) value;
}
private static TraderException modelInputException(String message) {
return new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, message);
}
}
@@ -0,0 +1,38 @@
package com.quantai.trader.feedback;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quantai.trader.domain.TraderAppFeedback;
import com.quantai.trader.persistence.TraderJsonCodec;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
@Repository
public class JdbcTraderFeedbackRepository implements TraderFeedbackRepository {
private final JdbcTemplate jdbcTemplate;
private final TraderJsonCodec jsonCodec;
public JdbcTraderFeedbackRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.jsonCodec = new TraderJsonCodec(objectMapper);
}
@Override
public void insert(TraderAppFeedback feedback) {
jdbcTemplate.update("""
insert into trader_app_feedback
(run_id, cycle_id, feedback_id, action_id, feedback_source, is_real_fill,
order_id, order_status, app_received_time, exchange_ack_time, filled_time,
filled_price, filled_quantity, fee, slippage_bps, reject_reason, raw_feedback_json)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
feedback.runId(), feedback.cycleId(), feedback.feedbackId(), feedback.actionId(),
feedback.feedbackSource().name(), feedback.realFill(), feedback.orderId(), feedback.orderStatus(),
feedback.appReceivedTime() == null ? null : Timestamp.from(feedback.appReceivedTime()),
feedback.exchangeAckTime() == null ? null : Timestamp.from(feedback.exchangeAckTime()),
feedback.filledTime() == null ? null : Timestamp.from(feedback.filledTime()),
feedback.filledPrice(), feedback.filledQuantity(), feedback.fee(), feedback.slippageBps(),
feedback.rejectReason(), jsonCodec.toJson(feedback.rawFeedbackJson()));
}
}
@@ -0,0 +1,7 @@
package com.quantai.trader.feedback;
import com.quantai.trader.domain.TraderAppFeedback;
public interface TraderFeedbackRepository {
void insert(TraderAppFeedback feedback);
}
@@ -1,25 +0,0 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("trader_evidence")
public class TraderEvidenceEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String runId;
private String cycleId;
private String evidenceId;
private String stage;
private Boolean pass;
private String reason;
private String blocker;
private LocalDateTime evidenceTime;
private String detailsJson;
private LocalDateTime createdAt;
}
@@ -1,27 +0,0 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("trader_playbook_definition")
public class TraderPlaybookDefinitionEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String playbookId;
private String playbookVersion;
private String family;
private String variant;
private String sideMode;
private String sourcePath;
private String definitionHashSha256;
private String definitionJson;
private LocalDateTime loadedAt;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -1,35 +0,0 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("trader_replay_report")
public class TraderReplayReportEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String runId;
private String reportId;
private String symbol;
private String playbookId;
private String playbookVersion;
private Integer candidateEvents;
private Integer monthsCovered;
@TableField("base_net_return_bps_1x")
private BigDecimal baseNetReturnBps1x;
@TableField("leveraged_net_return_bps_10x")
private BigDecimal leveragedNetReturnBps10x;
@TableField("holdout_return_bps_10x")
private BigDecimal holdoutReturnBps10x;
private String strictVsLooseJson;
private String failureRisksJson;
private String conclusion;
private String reportPath;
private LocalDateTime createdAt;
}
@@ -1,33 +0,0 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("trader_replay_run")
public class TraderReplayRunEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String runId;
private String runMode;
private String symbol;
private String playbookId;
private String playbookVersion;
private String playbookDefinitionHash;
private LocalDateTime dataFrom;
private LocalDateTime dataTo;
private String featureVersion;
private String labelVersion;
private String dataSourceManifestJson;
private String status;
private String configJson;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private String failureReason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

Some files were not shown because too many files have changed in this diff Show More