164 lines
6.4 KiB
Java
164 lines
6.4 KiB
Java
|
|
package com.quantai.trader.risk;
|
||
|
|
|
||
|
|
import com.quantai.trader.config.TraderProperties;
|
||
|
|
import com.quantai.trader.domain.ExecutionDecision;
|
||
|
|
import com.quantai.trader.domain.ManagementDecision;
|
||
|
|
import com.quantai.trader.domain.RiskDecision;
|
||
|
|
import com.quantai.trader.domain.TraderAction;
|
||
|
|
import com.quantai.trader.domain.TraderDecisionCycle;
|
||
|
|
import com.quantai.trader.domain.TraderEntryPlan;
|
||
|
|
import com.quantai.trader.domain.TraderPositionPath;
|
||
|
|
import com.quantai.trader.domain.TraderRiskDecision;
|
||
|
|
import com.quantai.trader.enums.TraderErrorCode;
|
||
|
|
import com.quantai.trader.persistence.TraderRiskDecisionRepository;
|
||
|
|
import org.slf4j.Logger;
|
||
|
|
import org.slf4j.LoggerFactory;
|
||
|
|
import org.springframework.stereotype.Component;
|
||
|
|
|
||
|
|
import java.math.BigDecimal;
|
||
|
|
import java.math.MathContext;
|
||
|
|
import java.math.RoundingMode;
|
||
|
|
import java.time.Instant;
|
||
|
|
import java.util.Map;
|
||
|
|
|
||
|
|
@Component
|
||
|
|
public class TraderRiskGate {
|
||
|
|
|
||
|
|
private static final Logger log = LoggerFactory.getLogger(TraderRiskGate.class);
|
||
|
|
private static final MathContext MC = new MathContext(10, RoundingMode.HALF_UP);
|
||
|
|
private final TraderProperties properties;
|
||
|
|
private final TraderRiskDecisionRepository repository;
|
||
|
|
|
||
|
|
public TraderRiskGate(TraderProperties properties, TraderRiskDecisionRepository repository) {
|
||
|
|
this.properties = properties;
|
||
|
|
this.repository = repository;
|
||
|
|
}
|
||
|
|
|
||
|
|
public RiskDecision evaluate(TraderDecisionCycle cycle, TraderEntryPlan entryPlan, ExecutionDecision execution) {
|
||
|
|
RiskDecision result = decide(entryPlan, execution);
|
||
|
|
TraderRiskDecision persisted = new TraderRiskDecision(
|
||
|
|
cycle.runId(),
|
||
|
|
cycle.cycleId(),
|
||
|
|
entryPlan.actionId(),
|
||
|
|
null,
|
||
|
|
entryPlan.entryAction(),
|
||
|
|
properties.getRisk().getLeverageScreen(),
|
||
|
|
entryPlan.plannedLegRatio(),
|
||
|
|
maxLossBps(entryPlan),
|
||
|
|
null,
|
||
|
|
null,
|
||
|
|
null,
|
||
|
|
null,
|
||
|
|
null,
|
||
|
|
result.allowAction(),
|
||
|
|
result.blocker(),
|
||
|
|
result.details(),
|
||
|
|
Instant.now()
|
||
|
|
);
|
||
|
|
repository.insert(persisted);
|
||
|
|
log.info(
|
||
|
|
"event=trader.risk.decision runId={} cycleId={} symbol={} playbookId={} playbookVersion={} state={} actionId={} actionType={} allowAction={} blocker={}",
|
||
|
|
cycle.runId(),
|
||
|
|
cycle.cycleId(),
|
||
|
|
cycle.symbol(),
|
||
|
|
cycle.playbookId(),
|
||
|
|
cycle.playbookVersion(),
|
||
|
|
cycle.state(),
|
||
|
|
entryPlan.actionId(),
|
||
|
|
entryPlan.entryAction(),
|
||
|
|
result.allowAction(),
|
||
|
|
result.blocker()
|
||
|
|
);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
public RiskDecision evaluateManagement(
|
||
|
|
TraderDecisionCycle cycle,
|
||
|
|
TraderAction action,
|
||
|
|
TraderPositionPath path,
|
||
|
|
ManagementDecision management
|
||
|
|
) {
|
||
|
|
if (path == null || path.totalPositionRatio() == null) {
|
||
|
|
throw new IllegalArgumentException("management risk evaluation requires an opened position path");
|
||
|
|
}
|
||
|
|
BigDecimal ratio = path.totalPositionRatio();
|
||
|
|
RiskDecision result = new RiskDecision(true, null, BigDecimal.ONE, Map.of(
|
||
|
|
"positionId", path.positionId(),
|
||
|
|
"managementReason", management.reason(),
|
||
|
|
"plannedTotalPositionRatio", ratio
|
||
|
|
));
|
||
|
|
TraderRiskDecision persisted = new TraderRiskDecision(
|
||
|
|
cycle.runId(),
|
||
|
|
cycle.cycleId(),
|
||
|
|
action.actionId(),
|
||
|
|
null,
|
||
|
|
action.actionType(),
|
||
|
|
properties.getRisk().getLeverageScreen(),
|
||
|
|
ratio,
|
||
|
|
BigDecimal.ZERO,
|
||
|
|
null,
|
||
|
|
null,
|
||
|
|
null,
|
||
|
|
null,
|
||
|
|
null,
|
||
|
|
true,
|
||
|
|
null,
|
||
|
|
result.details(),
|
||
|
|
Instant.now()
|
||
|
|
);
|
||
|
|
repository.insert(persisted);
|
||
|
|
log.info(
|
||
|
|
"event=trader.risk.decision runId={} cycleId={} symbol={} playbookId={} playbookVersion={} state={} actionId={} actionType={} allowAction={} blocker={}",
|
||
|
|
cycle.runId(),
|
||
|
|
cycle.cycleId(),
|
||
|
|
cycle.symbol(),
|
||
|
|
cycle.playbookId(),
|
||
|
|
cycle.playbookVersion(),
|
||
|
|
cycle.state(),
|
||
|
|
action.actionId(),
|
||
|
|
action.actionType(),
|
||
|
|
true,
|
||
|
|
null
|
||
|
|
);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private RiskDecision decide(TraderEntryPlan entryPlan, ExecutionDecision execution) {
|
||
|
|
if (!entryPlan.completeForEntry()) {
|
||
|
|
return block(TraderErrorCode.TRADER_ENTRY_PLAN_INCOMPLETE.name(), Map.of("reason", "entry plan incomplete"));
|
||
|
|
}
|
||
|
|
if (execution.blocked()) {
|
||
|
|
return block(execution.blocker(), Map.of("reason", execution.reason()));
|
||
|
|
}
|
||
|
|
if (entryPlan.plannedLegRatio().compareTo(BigDecimal.ZERO) <= 0
|
||
|
|
|| entryPlan.plannedLegRatio().compareTo(properties.getSizing().getMaxSingleLegRatio()) > 0
|
||
|
|
|| entryPlan.plannedLegRatio().compareTo(properties.getSizing().getMaxTotalPositionRatio()) > 0) {
|
||
|
|
return block(TraderErrorCode.TRADER_RISK_BLOCKED.name(), Map.of(
|
||
|
|
"reason", "planned position ratio exceeds P0 risk constraints",
|
||
|
|
"plannedLegRatio", entryPlan.plannedLegRatio()
|
||
|
|
));
|
||
|
|
}
|
||
|
|
return new RiskDecision(true, null, entryPlan.riskGateScore(), Map.of(
|
||
|
|
"plannedLegRatio", entryPlan.plannedLegRatio(),
|
||
|
|
"leverageScreen", properties.getRisk().getLeverageScreen(),
|
||
|
|
"requireOneXNotNegative", properties.getRisk().isRequireOneXNotNegative()
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
private RiskDecision block(String blocker, Map<String, Object> details) {
|
||
|
|
return new RiskDecision(false, blocker, BigDecimal.ZERO, details);
|
||
|
|
}
|
||
|
|
|
||
|
|
private BigDecimal maxLossBps(TraderEntryPlan entryPlan) {
|
||
|
|
if (entryPlan.entryPrice() == null || entryPlan.stopPrice() == null || entryPlan.entryPrice().compareTo(BigDecimal.ZERO) <= 0) {
|
||
|
|
return BigDecimal.ZERO;
|
||
|
|
}
|
||
|
|
return entryPlan.entryPrice()
|
||
|
|
.subtract(entryPlan.stopPrice(), MC)
|
||
|
|
.abs()
|
||
|
|
.divide(entryPlan.entryPrice(), MC)
|
||
|
|
.multiply(BigDecimal.valueOf(10_000), MC)
|
||
|
|
.setScale(8, RoundingMode.HALF_UP);
|
||
|
|
}
|
||
|
|
}
|