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 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); } }