Persist trader V4 P0 decision trace
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,5 +11,6 @@ public enum TraderErrorCode {
|
|||||||
TRADER_FEEDBACK_INVALID,
|
TRADER_FEEDBACK_INVALID,
|
||||||
TRADER_P0_MODE_BLOCKED,
|
TRADER_P0_MODE_BLOCKED,
|
||||||
TRADER_KILL_SWITCH_ACTIVE,
|
TRADER_KILL_SWITCH_ACTIVE,
|
||||||
TRADER_ACTIVE_POINTER_MISMATCH
|
TRADER_ACTIVE_POINTER_MISMATCH,
|
||||||
|
TRADER_PERSISTENCE_FAILED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,25 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class EvidenceAppender {
|
public class EvidenceAppender {
|
||||||
private static final Logger log = LoggerFactory.getLogger(EvidenceAppender.class);
|
private static final Logger log = LoggerFactory.getLogger(EvidenceAppender.class);
|
||||||
private final CopyOnWriteArrayList<TraderEvidence> evidence = new CopyOnWriteArrayList<>();
|
private final AtomicLong sequence = new AtomicLong();
|
||||||
|
private final TraderEvidenceRepository repository;
|
||||||
|
|
||||||
|
public EvidenceAppender(TraderEvidenceRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
public TraderEvidence append(String runId, String cycleId, String stage, boolean pass, String reason, String blocker, Map<String, Object> details) {
|
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 + "_" + evidence.size(), runId, cycleId,
|
TraderEvidence item = new TraderEvidence("evidence_" + cycleId + "_" + sequence.getAndIncrement(), runId, cycleId,
|
||||||
stage, pass, reason, blocker, Instant.now(), details);
|
stage, pass, reason, blocker, Instant.now(), details);
|
||||||
evidence.add(item);
|
repository.insert(item);
|
||||||
log.info("event=trader.evidence.appended runId={} cycleId={} stage={} pass={} reason={} blocker={}",
|
log.info("event=trader.evidence.appended runId={} cycleId={} stage={} pass={} reason={} blocker={}",
|
||||||
runId, cycleId, stage, pass, reason, blocker);
|
runId, cycleId, stage, pass, reason, blocker);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<TraderEvidence> all() {
|
|
||||||
return new ArrayList<>(evidence);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.quantai.trader.evidence;
|
||||||
|
|
||||||
|
import com.quantai.trader.domain.TraderEvidence;
|
||||||
|
|
||||||
|
public interface TraderEvidenceRepository {
|
||||||
|
void insert(TraderEvidence evidence);
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package com.quantai.trader.outbox;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public class InMemoryOutboxRepository {
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(InMemoryOutboxRepository.class);
|
|
||||||
private final CopyOnWriteArrayList<TraderOutboxEvent> events = new CopyOnWriteArrayList<>();
|
|
||||||
|
|
||||||
public void insert(TraderOutboxEvent event) {
|
|
||||||
boolean duplicate = events.stream().anyMatch(existing -> existing.destination().equals(event.destination())
|
|
||||||
&& existing.idempotencyKey().equals(event.idempotencyKey()));
|
|
||||||
if (duplicate) {
|
|
||||||
throw new IllegalArgumentException("duplicate outbox idempotency key: " + event.idempotencyKey());
|
|
||||||
}
|
|
||||||
events.add(event);
|
|
||||||
log.info("event=trader.outbox.inserted runId={} cycleId={} destination={} aggregateType={} aggregateId={} status={}",
|
|
||||||
event.runId(), event.cycleId(), event.destination(), event.aggregateType(), event.aggregateId(), event.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<TraderOutboxEvent> all() {
|
|
||||||
return new ArrayList<>(events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.quantai.trader.outbox;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.quantai.trader.persistence.TraderJsonCodec;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class JdbcTraderOutboxRepository implements TraderOutboxRepository {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(JdbcTraderOutboxRepository.class);
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final TraderJsonCodec jsonCodec;
|
||||||
|
|
||||||
|
public JdbcTraderOutboxRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.jsonCodec = new TraderJsonCodec(objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insert(TraderOutboxEvent event) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
insert into trader_outbox
|
||||||
|
(outbox_id, run_id, cycle_id, aggregate_type, aggregate_id, event_type, destination,
|
||||||
|
payload_json, idempotency_key, status, created_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
event.outboxId(), event.runId(), event.cycleId(), event.aggregateType(), event.aggregateId(),
|
||||||
|
event.eventType(), event.destination(), jsonCodec.toJson(event.payloadJson()),
|
||||||
|
event.idempotencyKey(), event.status(), Timestamp.from(event.createdAt()));
|
||||||
|
log.info("event=trader.outbox.inserted runId={} cycleId={} destination={} aggregateType={} aggregateId={} status={}",
|
||||||
|
event.runId(), event.cycleId(), event.destination(), event.aggregateType(), event.aggregateId(), event.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.quantai.trader.outbox;
|
||||||
|
|
||||||
|
public interface TraderOutboxRepository {
|
||||||
|
void insert(TraderOutboxEvent event);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package com.quantai.trader.persistence;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.quantai.trader.config.TraderProperties;
|
||||||
|
import com.quantai.trader.domain.*;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class JdbcTraderDecisionTraceWriter implements TraderDecisionTraceWriter {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(JdbcTraderDecisionTraceWriter.class);
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final TraderJsonCodec jsonCodec;
|
||||||
|
private final TraderProperties properties;
|
||||||
|
|
||||||
|
public JdbcTraderDecisionTraceWriter(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper, TraderProperties properties) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.jsonCodec = new TraderJsonCodec(objectMapper);
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void persistCycleTrace(TraderDecisionCycle cycle,
|
||||||
|
TraderMarketSnapshot snapshot,
|
||||||
|
TraderModelOutput modelOutput,
|
||||||
|
TraderPositionState positionState,
|
||||||
|
TraderPositionManagerDecision pmDecision,
|
||||||
|
TraderRiskDecision riskDecision,
|
||||||
|
TraderAction action) {
|
||||||
|
upsertRun(cycle);
|
||||||
|
insertCycle(cycle, positionState, riskDecision);
|
||||||
|
insertModelOutput(modelOutput, snapshot);
|
||||||
|
insertPmDecision(pmDecision);
|
||||||
|
insertRiskDecision(riskDecision);
|
||||||
|
insertAction(action);
|
||||||
|
log.info("event=trader.trace.persisted runId={} cycleId={} action={} riskAllowed={}",
|
||||||
|
cycle.runId(), cycle.cycleId(), action.actionType(), riskDecision.allowAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
void upsertRun(TraderDecisionCycle cycle) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
insert into trader_run
|
||||||
|
(run_id, run_mode, symbol, model_bundle_version, calibration_bundle_version,
|
||||||
|
pm_config_version, execution_mode, status, config_json, started_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
on duplicate key update
|
||||||
|
model_bundle_version = values(model_bundle_version),
|
||||||
|
calibration_bundle_version = values(calibration_bundle_version),
|
||||||
|
pm_config_version = values(pm_config_version),
|
||||||
|
execution_mode = values(execution_mode),
|
||||||
|
status = values(status),
|
||||||
|
updated_at = current_timestamp(3)
|
||||||
|
""",
|
||||||
|
cycle.runId(), cycle.runMode().name(), cycle.symbol(), cycle.modelBundleVersion(),
|
||||||
|
cycle.calibrationBundleVersion(), cycle.pmConfigVersion(), properties.execution().mode().name(),
|
||||||
|
"RUNNING", jsonCodec.toJson(Map.of(
|
||||||
|
"serviceName", properties.serviceName(),
|
||||||
|
"artifactRoot", properties.artifact().artifactRoot(),
|
||||||
|
"redisKeyPrefix", properties.runtime().redisKeyPrefix())),
|
||||||
|
Timestamp.from(cycle.cycleTime()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void insertCycle(TraderDecisionCycle cycle, TraderPositionState positionState, TraderRiskDecision riskDecision) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
insert into trader_decision_cycle
|
||||||
|
(run_id, cycle_id, symbol, cycle_time, state, decision_status, blocker)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
cycle.runId(), cycle.cycleId(), cycle.symbol(), Timestamp.from(cycle.cycleTime()),
|
||||||
|
positionState.isFlat() ? "FLAT" : "POSITION",
|
||||||
|
riskDecision.allowAction() ? "ACTION_ALLOWED" : "ACTION_BLOCKED",
|
||||||
|
riskDecision.blocker());
|
||||||
|
}
|
||||||
|
|
||||||
|
void insertModelOutput(TraderModelOutput modelOutput, TraderMarketSnapshot snapshot) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
insert into trader_model_output
|
||||||
|
(run_id, cycle_id, model_output_id, model_bundle_version, calibration_bundle_version,
|
||||||
|
direction_json, entry_json, continue_json, exit_json, risk_json,
|
||||||
|
uncertainty, ood_score, usable, blocker)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
modelOutput.runId(), modelOutput.cycleId(), modelOutput.modelOutputId(),
|
||||||
|
modelOutput.modelBundleVersion(), modelOutput.calibrationBundleVersion(),
|
||||||
|
jsonCodec.toJson(modelOutput.direction()), jsonCodec.toJson(modelOutput.entry()),
|
||||||
|
jsonCodec.toJson(modelOutput.continuation()), jsonCodec.toJson(modelOutput.exit()),
|
||||||
|
jsonCodec.toJson(modelOutput.risk()), modelOutput.uncertainty(), modelOutput.oodScore(),
|
||||||
|
snapshot.dataReady(), snapshot.dataReady() ? null : "DATA_NOT_READY");
|
||||||
|
}
|
||||||
|
|
||||||
|
void insertPmDecision(TraderPositionManagerDecision decision) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
insert into trader_position_manager_decision
|
||||||
|
(run_id, cycle_id, pm_decision_id, model_output_id, position_state_id, account_state_id,
|
||||||
|
execution_state_id, candidate_action, side, price_plan_id, price_plan_config_hash,
|
||||||
|
target_position_ratio, add_ratio, reduce_ratio, stop_price, target_price, reason, decision_json)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
decision.runId(), decision.cycleId(), decision.pmDecisionId(), decision.modelOutputId(),
|
||||||
|
decision.positionStateId(), decision.accountStateId(), decision.executionStateId(),
|
||||||
|
decision.candidateAction().name(), decision.side().name(), decision.pricePlanId(),
|
||||||
|
decision.pricePlanConfigHash(), decision.targetPositionRatio(), decision.addRatio(),
|
||||||
|
decision.reduceRatio(), decision.stopPrice(), decision.targetPrice(), decision.reason(),
|
||||||
|
jsonCodec.toJson(decision.decisionJson()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void insertRiskDecision(TraderRiskDecision decision) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
insert into trader_risk_decision
|
||||||
|
(run_id, cycle_id, risk_decision_id, pm_decision_id, original_action, final_action,
|
||||||
|
allow_action, blocker, decision_json)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
decision.runId(), decision.cycleId(), decision.riskDecisionId(), decision.pmDecisionId(),
|
||||||
|
decision.originalAction().name(), decision.finalAction().name(), decision.allowAction(),
|
||||||
|
decision.blocker(), jsonCodec.toJson(decision.decisionJson()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void insertAction(TraderAction action) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
insert into trader_action
|
||||||
|
(run_id, cycle_id, action_id, model_output_id, pm_decision_id, risk_decision_id,
|
||||||
|
action_type, symbol, side, price_plan_id, price_plan_config_hash, position_ratio,
|
||||||
|
quantity, stop_price, target_price, reduce_only, idempotency_key, send_status,
|
||||||
|
reason, action_context_json)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
action.runId(), action.cycleId(), action.actionId(), action.modelOutputId(),
|
||||||
|
action.pmDecisionId(), action.riskDecisionId(), action.actionType().name(),
|
||||||
|
action.symbol(), action.side().name(), action.pricePlanId(), action.pricePlanConfigHash(),
|
||||||
|
action.positionRatio(), action.quantity(), action.stopPrice(), action.targetPrice(),
|
||||||
|
action.reduceOnly(), action.idempotencyKey(), "PENDING", action.reason(),
|
||||||
|
jsonCodec.toJson(action.actionContextJson()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.quantai.trader.persistence;
|
||||||
|
|
||||||
|
import com.quantai.trader.domain.*;
|
||||||
|
|
||||||
|
public interface TraderDecisionTraceWriter {
|
||||||
|
void persistCycleTrace(TraderDecisionCycle cycle,
|
||||||
|
TraderMarketSnapshot snapshot,
|
||||||
|
TraderModelOutput modelOutput,
|
||||||
|
TraderPositionState positionState,
|
||||||
|
TraderPositionManagerDecision pmDecision,
|
||||||
|
TraderRiskDecision riskDecision,
|
||||||
|
TraderAction action);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.quantai.trader.persistence;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.quantai.trader.domain.TraderException;
|
||||||
|
import com.quantai.trader.enums.TraderErrorCode;
|
||||||
|
|
||||||
|
public class TraderJsonCodec {
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public TraderJsonCodec(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toJson(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new TraderException(TraderErrorCode.TRADER_PERSISTENCE_FAILED,
|
||||||
|
"failed to serialize trader persistence payload");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import com.quantai.trader.domain.*;
|
|||||||
import com.quantai.trader.enums.PositionSide;
|
import com.quantai.trader.enums.PositionSide;
|
||||||
import com.quantai.trader.evidence.EvidenceAppender;
|
import com.quantai.trader.evidence.EvidenceAppender;
|
||||||
import com.quantai.trader.model.TraderModelService;
|
import com.quantai.trader.model.TraderModelService;
|
||||||
import com.quantai.trader.outbox.InMemoryOutboxRepository;
|
import com.quantai.trader.outbox.TraderOutboxRepository;
|
||||||
import com.quantai.trader.outbox.TraderOutboxEvent;
|
import com.quantai.trader.outbox.TraderOutboxEvent;
|
||||||
|
import com.quantai.trader.persistence.TraderDecisionTraceWriter;
|
||||||
import com.quantai.trader.position.TraderPositionManager;
|
import com.quantai.trader.position.TraderPositionManager;
|
||||||
import com.quantai.trader.risk.RiskGateInput;
|
import com.quantai.trader.risk.RiskGateInput;
|
||||||
import com.quantai.trader.risk.RiskLimits;
|
import com.quantai.trader.risk.RiskLimits;
|
||||||
@@ -32,7 +33,8 @@ public class TraderP0CycleRunner {
|
|||||||
private final TraderRiskGate riskGate;
|
private final TraderRiskGate riskGate;
|
||||||
private final TraderActionFactory actionFactory;
|
private final TraderActionFactory actionFactory;
|
||||||
private final EvidenceAppender evidenceAppender;
|
private final EvidenceAppender evidenceAppender;
|
||||||
private final InMemoryOutboxRepository outboxRepository;
|
private final TraderDecisionTraceWriter traceWriter;
|
||||||
|
private final TraderOutboxRepository outboxRepository;
|
||||||
|
|
||||||
public TraderP0CycleRunner(TraderProperties properties,
|
public TraderP0CycleRunner(TraderProperties properties,
|
||||||
TraderArtifactLoader artifactLoader,
|
TraderArtifactLoader artifactLoader,
|
||||||
@@ -41,7 +43,8 @@ public class TraderP0CycleRunner {
|
|||||||
TraderRiskGate riskGate,
|
TraderRiskGate riskGate,
|
||||||
TraderActionFactory actionFactory,
|
TraderActionFactory actionFactory,
|
||||||
EvidenceAppender evidenceAppender,
|
EvidenceAppender evidenceAppender,
|
||||||
InMemoryOutboxRepository outboxRepository) {
|
TraderDecisionTraceWriter traceWriter,
|
||||||
|
TraderOutboxRepository outboxRepository) {
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.artifactLoader = artifactLoader;
|
this.artifactLoader = artifactLoader;
|
||||||
this.modelService = modelService;
|
this.modelService = modelService;
|
||||||
@@ -49,6 +52,7 @@ public class TraderP0CycleRunner {
|
|||||||
this.riskGate = riskGate;
|
this.riskGate = riskGate;
|
||||||
this.actionFactory = actionFactory;
|
this.actionFactory = actionFactory;
|
||||||
this.evidenceAppender = evidenceAppender;
|
this.evidenceAppender = evidenceAppender;
|
||||||
|
this.traceWriter = traceWriter;
|
||||||
this.outboxRepository = outboxRepository;
|
this.outboxRepository = outboxRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +73,7 @@ public class TraderP0CycleRunner {
|
|||||||
pmInput.executionState(), snapshot, riskLimits()));
|
pmInput.executionState(), snapshot, riskLimits()));
|
||||||
evidenceAppender.append(cycle.runId(), cycle.cycleId(), "RISK_DECISION", riskDecision.allowAction(), riskDecision.allowAction() ? "RISK_PASS" : riskDecision.blocker(), riskDecision.blocker(), Map.of());
|
evidenceAppender.append(cycle.runId(), cycle.cycleId(), "RISK_DECISION", riskDecision.allowAction(), riskDecision.allowAction() ? "RISK_PASS" : riskDecision.blocker(), riskDecision.blocker(), Map.of());
|
||||||
TraderAction action = actionFactory.create(pmDecision, riskDecision, event.symbol());
|
TraderAction action = actionFactory.create(pmDecision, riskDecision, event.symbol());
|
||||||
|
traceWriter.persistCycleTrace(cycle, snapshot, modelOutput, pmInput.positionState(), pmDecision, riskDecision, action);
|
||||||
outboxRepository.insert(new TraderOutboxEvent("outbox_" + action.actionId(), action.runId(), action.cycleId(),
|
outboxRepository.insert(new TraderOutboxEvent("outbox_" + action.actionId(), action.runId(), action.cycleId(),
|
||||||
"TRADER_ACTION", action.actionId(), "ACTION_CREATED", properties.runMode().name() + "_RECORDER",
|
"TRADER_ACTION", action.actionId(), "ACTION_CREATED", properties.runMode().name() + "_RECORDER",
|
||||||
Map.of("actionType", action.actionType().name()), action.idempotencyKey(), "PENDING", Instant.now()));
|
Map.of("actionType", action.actionType().name()), action.idempotencyKey(), "PENDING", Instant.now()));
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.quantai.trader.evidence;
|
|||||||
import com.quantai.trader.domain.TraderEvidence;
|
import com.quantai.trader.domain.TraderEvidence;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -10,7 +12,8 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
class EvidenceAppenderTest {
|
class EvidenceAppenderTest {
|
||||||
@Test
|
@Test
|
||||||
void appendsEvidenceWithStageReasonAndDetails() {
|
void appendsEvidenceWithStageReasonAndDetails() {
|
||||||
EvidenceAppender appender = new EvidenceAppender();
|
RecordingEvidenceRepository repository = new RecordingEvidenceRepository();
|
||||||
|
EvidenceAppender appender = new EvidenceAppender(repository);
|
||||||
|
|
||||||
TraderEvidence item = appender.append("run-1", "cycle-1", "PM_DECISION", true,
|
TraderEvidence item = appender.append("run-1", "cycle-1", "PM_DECISION", true,
|
||||||
"OPEN_LONG_PM_PASS", null, Map.of("action", "OPEN_LONG"));
|
"OPEN_LONG_PM_PASS", null, Map.of("action", "OPEN_LONG"));
|
||||||
@@ -18,6 +21,19 @@ class EvidenceAppenderTest {
|
|||||||
assertThat(item.evidenceId()).isEqualTo("evidence_cycle-1_0");
|
assertThat(item.evidenceId()).isEqualTo("evidence_cycle-1_0");
|
||||||
assertThat(item.pass()).isTrue();
|
assertThat(item.pass()).isTrue();
|
||||||
assertThat(item.detailsJson()).containsEntry("action", "OPEN_LONG");
|
assertThat(item.detailsJson()).containsEntry("action", "OPEN_LONG");
|
||||||
assertThat(appender.all()).containsExactly(item);
|
assertThat(repository.items()).containsExactly(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingEvidenceRepository implements TraderEvidenceRepository {
|
||||||
|
private final List<TraderEvidence> items = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insert(TraderEvidence evidence) {
|
||||||
|
items.add(evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TraderEvidence> items() {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package com.quantai.trader.outbox;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
class InMemoryOutboxRepositoryTest {
|
|
||||||
@Test
|
|
||||||
void rejectsDuplicateDestinationAndIdempotencyKey() {
|
|
||||||
InMemoryOutboxRepository repository = new InMemoryOutboxRepository();
|
|
||||||
TraderOutboxEvent event = event("outbox-1", "SHADOW_RECORDER", "idem-1");
|
|
||||||
|
|
||||||
repository.insert(event);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> repository.insert(event("outbox-2", "SHADOW_RECORDER", "idem-1")))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
|
||||||
.hasMessageContaining("duplicate outbox idempotency key");
|
|
||||||
assertThat(repository.all()).containsExactly(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void allowsSameIdempotencyKeyForDifferentDestination() {
|
|
||||||
InMemoryOutboxRepository repository = new InMemoryOutboxRepository();
|
|
||||||
|
|
||||||
repository.insert(event("outbox-1", "REPLAY_SIM_RECORDER", "idem-1"));
|
|
||||||
repository.insert(event("outbox-2", "SHADOW_RECORDER", "idem-1"));
|
|
||||||
|
|
||||||
assertThat(repository.all()).hasSize(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TraderOutboxEvent event(String id, String destination, String idempotencyKey) {
|
|
||||||
return new TraderOutboxEvent(id, "run-1", "cycle-1", "TRADER_ACTION", "action-1",
|
|
||||||
"ACTION_CREATED", destination, Map.of("actionType", "OPEN_LONG"), idempotencyKey,
|
|
||||||
"PENDING", Instant.parse("2026-06-26T00:00:00Z"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.quantai.trader.outbox;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
class JdbcTraderOutboxRepositoryTest {
|
||||||
|
@Test
|
||||||
|
void insertsOutboxEventThroughJdbcRepository() {
|
||||||
|
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||||
|
JdbcTraderOutboxRepository repository = new JdbcTraderOutboxRepository(jdbcTemplate, new ObjectMapper());
|
||||||
|
|
||||||
|
repository.insert(new TraderOutboxEvent("outbox-1", "run-1", "cycle-1",
|
||||||
|
"TRADER_ACTION", "action-1", "ACTION_CREATED", "SHADOW_RECORDER",
|
||||||
|
Map.of("actionType", "OPEN_LONG"), "idem-1", "PENDING",
|
||||||
|
Instant.parse("2026-06-26T00:00:00Z")));
|
||||||
|
|
||||||
|
verify(jdbcTemplate).update(contains("insert into trader_outbox"), any(Object[].class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.quantai.trader.persistence;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.quantai.trader.domain.TraderActionFactory;
|
||||||
|
import com.quantai.trader.domain.TraderRiskDecision;
|
||||||
|
import com.quantai.trader.enums.PositionSide;
|
||||||
|
import com.quantai.trader.enums.TraderActionType;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static com.quantai.trader.TestFixtures.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
class JdbcTraderDecisionTraceWriterTest {
|
||||||
|
@Test
|
||||||
|
void persistsRunCycleModelPmRiskAndActionTrace() {
|
||||||
|
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||||
|
JdbcTraderDecisionTraceWriter writer = new JdbcTraderDecisionTraceWriter(jdbcTemplate, new ObjectMapper(), properties());
|
||||||
|
TraderRiskDecision riskDecision = new TraderRiskDecision("risk-1", "run-1", "cycle-1",
|
||||||
|
"pm-cycle-1", true, TraderActionType.OPEN_LONG, TraderActionType.OPEN_LONG, null, Map.of());
|
||||||
|
var action = new TraderActionFactory().create(pmDecision(TraderActionType.OPEN_LONG, PositionSide.LONG), riskDecision, "BTC-USDT-PERP");
|
||||||
|
|
||||||
|
writer.persistCycleTrace(cycle(), snapshot(), modelOutput(), flatPosition(),
|
||||||
|
pmDecision(TraderActionType.OPEN_LONG, PositionSide.LONG), riskDecision, action);
|
||||||
|
|
||||||
|
verify(jdbcTemplate, times(6)).update(anyString(), any(Object[].class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
package com.quantai.trader.replay;
|
package com.quantai.trader.replay;
|
||||||
|
|
||||||
import com.quantai.trader.artifact.TraderArtifactLoader;
|
import com.quantai.trader.artifact.TraderArtifactLoader;
|
||||||
import com.quantai.trader.domain.TraderActionFactory;
|
import com.quantai.trader.domain.*;
|
||||||
import com.quantai.trader.enums.TraderActionType;
|
import com.quantai.trader.enums.TraderActionType;
|
||||||
import com.quantai.trader.evidence.EvidenceAppender;
|
import com.quantai.trader.evidence.EvidenceAppender;
|
||||||
|
import com.quantai.trader.evidence.TraderEvidenceRepository;
|
||||||
import com.quantai.trader.model.DeterministicTraderModelService;
|
import com.quantai.trader.model.DeterministicTraderModelService;
|
||||||
import com.quantai.trader.outbox.InMemoryOutboxRepository;
|
import com.quantai.trader.outbox.TraderOutboxEvent;
|
||||||
|
import com.quantai.trader.outbox.TraderOutboxRepository;
|
||||||
|
import com.quantai.trader.persistence.TraderDecisionTraceWriter;
|
||||||
import com.quantai.trader.position.TraderPositionManager;
|
import com.quantai.trader.position.TraderPositionManager;
|
||||||
import com.quantai.trader.risk.TraderRiskGate;
|
import com.quantai.trader.risk.TraderRiskGate;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
import static com.quantai.trader.TestFixtures.T0;
|
import static com.quantai.trader.TestFixtures.T0;
|
||||||
@@ -19,8 +24,10 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
class TraderP0CycleRunnerTest {
|
class TraderP0CycleRunnerTest {
|
||||||
@Test
|
@Test
|
||||||
void runsReplayShadowCycleThroughModelPmRiskActionOutboxAndEvidence() {
|
void runsReplayShadowCycleThroughModelPmRiskActionOutboxAndEvidence() {
|
||||||
EvidenceAppender evidenceAppender = new EvidenceAppender();
|
RecordingEvidenceRepository evidenceRepository = new RecordingEvidenceRepository();
|
||||||
InMemoryOutboxRepository outboxRepository = new InMemoryOutboxRepository();
|
EvidenceAppender evidenceAppender = new EvidenceAppender(evidenceRepository);
|
||||||
|
RecordingTraceWriter traceWriter = new RecordingTraceWriter();
|
||||||
|
RecordingOutboxRepository outboxRepository = new RecordingOutboxRepository();
|
||||||
TraderP0CycleRunner runner = new TraderP0CycleRunner(
|
TraderP0CycleRunner runner = new TraderP0CycleRunner(
|
||||||
properties(),
|
properties(),
|
||||||
new TraderArtifactLoader(properties()),
|
new TraderArtifactLoader(properties()),
|
||||||
@@ -29,6 +36,7 @@ class TraderP0CycleRunnerTest {
|
|||||||
new TraderRiskGate(),
|
new TraderRiskGate(),
|
||||||
new TraderActionFactory(),
|
new TraderActionFactory(),
|
||||||
evidenceAppender,
|
evidenceAppender,
|
||||||
|
traceWriter,
|
||||||
outboxRepository);
|
outboxRepository);
|
||||||
|
|
||||||
TraderCycleResult result = runner.runFlatCycle(new ReplayMarketEvent(
|
TraderCycleResult result = runner.runFlatCycle(new ReplayMarketEvent(
|
||||||
@@ -37,16 +45,19 @@ class TraderP0CycleRunnerTest {
|
|||||||
|
|
||||||
assertThat(result.action().actionType()).isEqualTo(TraderActionType.OPEN_LONG);
|
assertThat(result.action().actionType()).isEqualTo(TraderActionType.OPEN_LONG);
|
||||||
assertThat(result.action().reduceOnly()).isFalse();
|
assertThat(result.action().reduceOnly()).isFalse();
|
||||||
assertThat(outboxRepository.all()).hasSize(1);
|
assertThat(traceWriter.actions()).containsExactly(result.action());
|
||||||
assertThat(outboxRepository.all().getFirst().destination()).isEqualTo("SHADOW_RECORDER");
|
assertThat(outboxRepository.events()).hasSize(1);
|
||||||
assertThat(evidenceAppender.all()).extracting("stage")
|
assertThat(outboxRepository.events().getFirst().destination()).isEqualTo("SHADOW_RECORDER");
|
||||||
|
assertThat(evidenceRepository.items()).extracting("stage")
|
||||||
.containsExactly("MARKET_SNAPSHOT", "MODEL_OUTPUT", "PM_DECISION", "RISK_DECISION");
|
.containsExactly("MARKET_SNAPSHOT", "MODEL_OUTPUT", "PM_DECISION", "RISK_DECISION");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void recordsWaitActionWhenReplaySnapshotHasNoLiquidity() {
|
void recordsWaitActionWhenReplaySnapshotHasNoLiquidity() {
|
||||||
EvidenceAppender evidenceAppender = new EvidenceAppender();
|
RecordingEvidenceRepository evidenceRepository = new RecordingEvidenceRepository();
|
||||||
InMemoryOutboxRepository outboxRepository = new InMemoryOutboxRepository();
|
EvidenceAppender evidenceAppender = new EvidenceAppender(evidenceRepository);
|
||||||
|
RecordingTraceWriter traceWriter = new RecordingTraceWriter();
|
||||||
|
RecordingOutboxRepository outboxRepository = new RecordingOutboxRepository();
|
||||||
TraderP0CycleRunner runner = new TraderP0CycleRunner(
|
TraderP0CycleRunner runner = new TraderP0CycleRunner(
|
||||||
properties(),
|
properties(),
|
||||||
new TraderArtifactLoader(properties()),
|
new TraderArtifactLoader(properties()),
|
||||||
@@ -55,6 +66,7 @@ class TraderP0CycleRunnerTest {
|
|||||||
new TraderRiskGate(),
|
new TraderRiskGate(),
|
||||||
new TraderActionFactory(),
|
new TraderActionFactory(),
|
||||||
evidenceAppender,
|
evidenceAppender,
|
||||||
|
traceWriter,
|
||||||
outboxRepository);
|
outboxRepository);
|
||||||
|
|
||||||
TraderCycleResult result = runner.runFlatCycle(new ReplayMarketEvent(
|
TraderCycleResult result = runner.runFlatCycle(new ReplayMarketEvent(
|
||||||
@@ -63,6 +75,49 @@ class TraderP0CycleRunnerTest {
|
|||||||
|
|
||||||
assertThat(result.action().actionType()).isEqualTo(TraderActionType.WAIT);
|
assertThat(result.action().actionType()).isEqualTo(TraderActionType.WAIT);
|
||||||
assertThat(result.action().pricePlanId()).isNull();
|
assertThat(result.action().pricePlanId()).isNull();
|
||||||
assertThat(outboxRepository.all()).hasSize(1);
|
assertThat(traceWriter.actions()).containsExactly(result.action());
|
||||||
|
assertThat(outboxRepository.events()).hasSize(1);
|
||||||
|
assertThat(evidenceRepository.items()).hasSize(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingEvidenceRepository implements TraderEvidenceRepository {
|
||||||
|
private final List<TraderEvidence> items = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insert(TraderEvidence evidence) {
|
||||||
|
items.add(evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TraderEvidence> items() {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingOutboxRepository implements TraderOutboxRepository {
|
||||||
|
private final List<TraderOutboxEvent> events = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insert(TraderOutboxEvent event) {
|
||||||
|
events.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TraderOutboxEvent> events() {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingTraceWriter implements TraderDecisionTraceWriter {
|
||||||
|
private final List<TraderAction> actions = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void persistCycleTrace(TraderDecisionCycle cycle, TraderMarketSnapshot snapshot, TraderModelOutput modelOutput,
|
||||||
|
TraderPositionState positionState, TraderPositionManagerDecision pmDecision,
|
||||||
|
TraderRiskDecision riskDecision, TraderAction action) {
|
||||||
|
actions.add(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TraderAction> actions() {
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user