포스트

주문 전 동기 리스크 게이트 설계 — VaR, 집중도, 일일손실 5가지 규칙

왜 모의투자에도 리스크 한도가 필요한가

모의투자는 가상 돈을 쓴다. 잃어도 실제 손해는 없다. 그런데 왜 리스크 한도가 필요할까?

사용자가 투자 전략을 연습하는 과정에서 나쁜 습관을 체계적으로 방지하지 않으면 학습 효과가 떨어진다. “어차피 가상이니까”라는 심리로 현실에서는 불가능한 집중 투자나 손실 무시 패턴이 굳어진다.

리스크 한도는 교육적 장치다. “이 주문은 실제 시장에서 위험하다”는 것을 체결 전에 명시적으로 알려준다.


아키텍처: 동기 게이트

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /api/matching/orders
  │
  ▼
RiskChecker.preCheck()  ← 동기 실행, 통과 못하면 즉시 REJECTED 반환
  │
  ├── DailyLossLimitRule
  ├── ConcentrationRule
  ├── VaRLimitRule
  ├── PositionCountRule
  └── TradingFrequencyRule
  │
  ▼ (모든 규칙 통과 시)
OrderBook.submit(order)  ← 체결 엔진

리스크 체크는 동기로 실행된다. 비동기로 하면 체결이 발생한 후 리스크 초과를 발견하는 타이밍 문제가 생긴다. 동기 게이트는 주문 체결 전에 반드시 통과해야 한다.


5가지 리스크 규칙

1. DailyLossLimitRule — 일일 손실 한도

오늘 실현된 손실이 총 자산의 N% 이상이면 추가 주문을 차단한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class DailyLossLimitRule(
    private val jdbcTemplate: JdbcTemplate,
    private val limitPct: Double = 0.03,  // 기본 3%
) : RiskRule {

    override fun check(userId: Long, order: OrderRequest, context: RiskContext): RuleResult {
        val todayLoss = jdbcTemplate.queryForObject("""
            SELECT COALESCE(SUM(realized_pnl), 0)
            FROM fills f
            JOIN orders o ON o.id = f.order_id
            WHERE o.user_id = ?
              AND DATE(f.filled_at) = CURRENT_DATE
              AND f.realized_pnl < 0
        """, BigDecimal::class.java, userId) ?: BigDecimal.ZERO

        val totalAsset = context.totalAsset
        val lossRatio = todayLoss.abs().toDouble() / totalAsset.toDouble()

        return if (lossRatio >= limitPct) {
            RuleResult.blocked(
                rule = "DAILY_LOSS",
                detail = "오늘 손실 ${String.format("%.1f", lossRatio * 100)}% / 한도 ${limitPct * 100}%",
                current = lossRatio * 100,
                limit = limitPct * 100
            )
        } else {
            RuleResult.passed("DAILY_LOSS", lossRatio * 100, limitPct * 100)
        }
    }
}

2. ConcentrationRule — 집중도 한도

주문 후 특정 종목의 비중이 포트폴리오의 N%를 초과하면 차단한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ConcentrationRule(
    private val limitPct: Double = 0.30,  // 기본 30%
) : RiskRule {

    override fun check(userId: Long, order: OrderRequest, context: RiskContext): RuleResult {
        val currentValue  = context.holdingsByStock[order.stockId] ?: BigDecimal.ZERO
        val orderValue    = order.estimatedValue  // 주문 예상 금액
        val projectedValue = currentValue + orderValue
        val projectedPct   = projectedValue.toDouble() / context.totalAsset.toDouble()

        return if (projectedPct >= limitPct) {
            RuleResult.blocked("CONCENTRATION",
                "${order.symbol} 주문 후 비중 ${String.format("%.1f", projectedPct * 100)}% / 한도 ${limitPct * 100}%",
                projectedPct * 100, limitPct * 100)
        } else {
            RuleResult.passed("CONCENTRATION", projectedPct * 100, limitPct * 100)
        }
    }
}

3. VaRLimitRule — VaR 한도

Value at Risk(VaR)는 주어진 신뢰 수준에서 최대 예상 손실이다. “95% 신뢰 수준으로 하루 최대 손실이 총 자산의 5%를 넘지 않는다”는 식이다.

monticker는 역사적 시뮬레이션 방식을 사용한다. 보유 종목들의 과거 일별 수익률에서 하위 5th 백분위를 VaR로 계산한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class VaRLimitRule(
    private val jdbcTemplate: JdbcTemplate,
    private val limitPct: Double = 0.05,
    private val confidenceLevel: Double = 0.95,
) : RiskRule {

    override fun check(userId: Long, order: OrderRequest, context: RiskContext): RuleResult {
        // 보유 종목별 최근 250일 일별 수익률 수집
        val returns = fetchDailyReturns(context.holdingStockIds)
        if (returns.isEmpty()) return RuleResult.passed("VAR", 0.0, limitPct * 100)

        // 포트폴리오 수익률 = 비중 가중 합산
        val portfolioReturns = calculatePortfolioReturns(returns, context.weights)

        // 하위 (1-confidenceLevel) 백분위 = VaR
        portfolioReturns.sort()
        val varIndex = ((1 - confidenceLevel) * portfolioReturns.size).toInt()
        val varValue = portfolioReturns[varIndex].abs()

        return if (varValue >= limitPct) {
            RuleResult.blocked("VAR",
                "95% VaR ${String.format("%.1f", varValue * 100)}% / 한도 ${limitPct * 100}%",
                varValue * 100, limitPct * 100)
        } else {
            RuleResult.passed("VAR", varValue * 100, limitPct * 100)
        }
    }
}

4. PositionCountRule — 보유 종목 수 한도

과도한 분산은 관리가 어렵다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class PositionCountRule(
    private val maxPositions: Int = 10
) : RiskRule {

    override fun check(userId: Long, order: OrderRequest, context: RiskContext): RuleResult {
        val currentCount = context.holdingsByStock.size
        val isNewPosition = !context.holdingsByStock.containsKey(order.stockId)

        return if (isNewPosition && currentCount >= maxPositions) {
            RuleResult.blocked("POSITION_COUNT",
                "보유 종목 ${currentCount}개 / 한도 ${maxPositions}개",
                currentCount.toDouble(), maxPositions.toDouble())
        } else {
            RuleResult.passed("POSITION_COUNT", currentCount.toDouble(), maxPositions.toDouble())
        }
    }
}

5. TradingFrequencyRule — 주문 빈도 한도

1시간 내 주문 횟수를 제한해 충동 매매 패턴을 방지한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class TradingFrequencyRule(
    private val jdbcTemplate: JdbcTemplate,
    private val maxOrdersPerHour: Int = 5,
) : RiskRule {

    override fun check(userId: Long, order: OrderRequest, context: RiskContext): RuleResult {
        val recentCount = jdbcTemplate.queryForObject("""
            SELECT COUNT(*) FROM orders
            WHERE user_id = ?
              AND created_at >= NOW() - INTERVAL '1 hour'
              AND status NOT IN ('CANCELLED')
        """, Int::class.java, userId) ?: 0

        return if (recentCount >= maxOrdersPerHour) {
            RuleResult.blocked("TRADING_FREQUENCY",
                "1시간 내 주문 ${recentCount}회 / 한도 ${maxOrdersPerHour}회",
                recentCount.toDouble(), maxOrdersPerHour.toDouble())
        } else {
            RuleResult.passed("TRADING_FREQUENCY", recentCount.toDouble(), maxOrdersPerHour.toDouble())
        }
    }
}

RiskCheckResult — 응답 형식

1
2
3
4
5
6
7
8
data class RiskCheckResult(
    val approved: Boolean,
    val checks: List<RuleResult>,
    val blockedBy: String?,
    val severity: Severity,
)

enum class Severity { INFO, WARNING, BLOCKED }

API 응답 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "approved": false,
  "blockedBy": "일일 손실 한도 초과",
  "severity": "BLOCKED",
  "checks": [
    {
      "rule": "DAILY_LOSS",
      "passed": false,
      "detail": "오늘 손실 3.4% / 한도 3.0%",
      "current": 3.4,
      "limit": 3.0
    },
    {
      "rule": "CONCENTRATION",
      "passed": true,
      "detail": "삼성전자 비중 22.1% / 한도 30.0%",
      "current": 22.1,
      "limit": 30.0
    }
  ]
}

Dry-run API

실제 주문 없이 리스크 결과만 확인할 수 있다. 프론트엔드에서 “주문 전 리스크 확인” 버튼에 연결한다.

1
2
3
4
5
6
7
8
9
POST /api/risk/check
Content-Type: application/json

{
  "stockId": 1,
  "side": "BUY",
  "quantity": 100,
  "orderType": "MARKET"
}

정리

  • 리스크 게이트는 체결 엔진 에서 동기로 실행된다. 비동기로 하면 타이밍 문제가 생긴다.
  • 5가지 규칙(일일손실·집중도·VaR·종목수·주문빈도)이 독립적인 클래스로 구현되어 새 규칙 추가가 쉽다.
  • Dry-run API로 주문 전에 사용자가 리스크 결과를 미리 확인할 수 있다.

다음 시리즈(Series 4)에서는 체결 결과를 어떻게 원장에 기록하는지, 이벤트 소싱 패턴으로 잔고를 저장하지 않고 재구성하는 방법을 다룬다.

  1. 1 가격이 아니라 이벤트를 팔자 — monticker 설계 철학
  2. 2 모듈식 모놀리스를 선택한 이유 — MSA의 유혹을 거부하기
  3. 3 TimescaleDB를 시계열 DB로 고른 이유 — Hypertable과 연속 집계
  4. 4 Go goroutine으로 202개 종목 동시 수집하기 — Market Gateway 설계
  5. 5 Kafka로 시세 파이프라인 분리하기 — 토픽 설계와 at-least-once
  6. 6 Netty로 수만 연결에 시세 브로드캐스트하기 — NioEventLoopGroup 리액터 패턴
  7. 7 EMA 기반 이상 탐지 — 가격 급등과 거래량 서지 실시간 감지
  8. 8 TreeMap으로 CLOB 호가창 구현하기 — 가격/시간 우선 매칭과 슬리피지
  9. 9 주문 전 동기 리스크 게이트 설계 — VaR, 집중도, 일일손실 5가지 규칙
  10. 10 잔고를 저장하지 말고 재구성하라 — 이벤트 소싱 원장 설계
  11. 11 감정 태그 × 수익률 — 투자 습관을 데이터로 기록하기
  12. 12 룰 엔진: RSI·MACD 조건식을 JSON DSL로 — Quant Lab 설계
  13. 13 백테스트 엔진: look-ahead 없는 시뮬레이션 — Sharpe·MDD·PF 계산
  14. 14 전략 지문(SHA-256)으로 룰셋 보호하기 — 서버 사이드 실행과 역공학 방어
  15. 15 Markowitz 최적화를 솔버 없이 구현하기 — 프로젝션 경사하강법
  16. 16 Kelly Criterion: 수학적 파산 방지 베팅 비율 — Half Kelly와 백테스트 연동
  17. 17 ZigZag + 패턴 템플릿 매칭으로 차트 패턴 감지 — 헤드앤숄더·이중바닥
  18. 18 ADX로 시장 국면 분류하기 — BULL·BEAR·SIDEWAYS·HIGH_VOL
  19. 19 손익통산으로 세금 줄이기 — Tax-Loss Harvesting 시뮬레이션
  20. 20 MockK로 JdbcTemplate 목킹하기 — 311개 테스트 작성 경험
  21. 21 OpenTelemetry + Jaeger로 분산 추적 — 시세 파이프라인 지연 측정
  22. 22 Circuit Breaker로 외부 API 장애 격리 — Resilience4j + KIS·Yahoo 폴백 체인
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다