포스트

백테스트 엔진: look-ahead 없는 시뮬레이션 — Sharpe·MDD·PF 계산

백테스트의 핵심 원칙: look-ahead bias 금지

백테스트에서 가장 흔한 실수는 미래 데이터를 현재 결정에 사용하는 것이다.

1
2
3
4
5
6
잘못된 예:
  2023-01-10 에 이동평균을 계산할 때
  2023-01-11의 가격 데이터가 포함되면 → look-ahead bias

올바른 예:
  2023-01-10 의 MA(20) = 2022-12-16 ~ 2023-01-10 데이터로만 계산

이 실수가 있으면 “이미 결과를 알고 트레이딩하는” 전략이 만들어진다. 백테스트 수익률은 환상적이지만 실전에서는 참패한다.


시뮬레이션 구조

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Service
class QuantBacktestEngine(
    private val candleRepository: CandleRepository,
    private val indicatorEngine: IndicatorEngine,
    private val ruleEvaluator: RuleEvaluator,
) {
    fun run(request: BacktestRequest): BacktestResult {
        // 1. 기간 내 캔들 데이터 로드 (시작~종료)
        val candles = candleRepository.findRange(
            stockId   = request.stockId,
            from      = request.startDate,
            to        = request.endDate,
        )

        val trades = mutableListOf<SimulatedTrade>()
        var currentPosition: Position? = null

        // 2. 캔들을 시간순으로 순회 (look-ahead 없음: i번째는 0~i-1 데이터만 사용)
        candles.forEachIndexed { index, candle ->
            if (index < WARMUP_PERIODS) return@forEachIndexed  // EMA 워밍업 기간 스킵

            val pastCandles = candles.subList(0, index + 1)  // 현재까지의 데이터만

            if (currentPosition == null) {
                // 진입 조건 평가
                if (ruleEvaluator.evaluateEntryFromCandles(request.ruleSet, pastCandles)) {
                    currentPosition = Position(
                        entryPrice = candle.close,
                        entryDate  = candle.bucket,
                        quantity   = calculateQuantity(request.capital, candle.close),
                    )
                }
            } else {
                // 청산 조건 평가
                if (ruleEvaluator.evaluateExitFromCandles(request.ruleSet, pastCandles)) {
                    val trade = closePosition(currentPosition!!, candle)
                    trades.add(trade)
                    currentPosition = null
                }
            }
        }

        // 마지막 미청산 포지션 강제 청산
        currentPosition?.let { pos ->
            trades.add(closePosition(pos, candles.last()))
        }

        return calculateMetrics(trades, request)
    }
}

candles.subList(0, index + 1)이 핵심이다. 현재 캔들 이전의 데이터만 지표 계산에 사용해 look-ahead bias를 구조적으로 방지한다.


실제 비용 반영

실전 거래에는 비용이 있다. 이 비용을 반영하지 않으면 백테스트 수익률이 과대평가된다.

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
data class TradeCost(
    val commission: Double = 0.00015,  // 0.015% (한국 증권사 평균)
    val tax:        Double = 0.002,    // 0.2%  (주식 거래세, 매도 시)
    val slippage:   Double = 0.001,    // 0.1%  (실제 체결가 차이 추정)
)

fun closePosition(position: Position, candle: Candle): SimulatedTrade {
    val grossPnl = (candle.close - position.entryPrice) * position.quantity

    val sellAmount  = candle.close * position.quantity
    val commission  = sellAmount * TradeCost().commission * 2  // 매수·매도 각 1회
    val tax         = sellAmount * TradeCost().tax
    val slippageCost = sellAmount * TradeCost().slippage * 2

    val netPnl = grossPnl - commission - tax - slippageCost

    return SimulatedTrade(
        entryDate  = position.entryDate,
        exitDate   = candle.bucket,
        entryPrice = position.entryPrice,
        exitPrice  = candle.close * (1 - TradeCost().slippage),  // 슬리피지 적용
        quantity   = position.quantity,
        grossPnl   = grossPnl,
        netPnl     = netPnl,
        holdingDays = ChronoUnit.DAYS.between(position.entryDate, candle.bucket).toInt(),
    )
}

성과 지표 계산

총 수익률과 연환산 수익률

1
2
3
val totalReturn = trades.sumOf { it.netPnl } / request.capital
val years = ChronoUnit.DAYS.between(request.startDate, request.endDate) / 365.0
val annualReturn = (1 + totalReturn).pow(1.0 / years) - 1

최대낙폭 (MDD, Maximum Drawdown)

1
2
3
4
5
6
7
8
9
10
11
12
13
fun calculateMdd(trades: List<SimulatedTrade>, capital: Double): Double {
    var peak = capital
    var mdd = 0.0
    var equity = capital

    trades.forEach { trade ->
        equity += trade.netPnl
        if (equity > peak) peak = equity
        val drawdown = (peak - equity) / peak
        if (drawdown > mdd) mdd = drawdown
    }
    return mdd
}

MDD는 가장 높은 고점에서 가장 낮은 저점까지의 하락률이다. 전략의 최악 구간을 나타낸다.

샤프 비율 (Sharpe Ratio)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun calculateSharpe(returns: List<Double>, riskFreeRate: Double = 0.035): Double {
    if (returns.size < 2) return 0.0
    val avgReturn = returns.average()
    val std = returns.standardDeviation()
    if (std == 0.0) return 0.0

    val dailyRiskFree = (1 + riskFreeRate).pow(1.0 / 252) - 1
    val annualized = Math.sqrt(252.0)

    return (avgReturn - dailyRiskFree) / std * annualized
}

fun List<Double>.standardDeviation(): Double {
    val mean = average()
    return Math.sqrt(map { (it - mean).pow(2) }.average())
}

샤프 비율이 1.0 이상이면 위험 대비 수익이 양호, 2.0 이상이면 우수한 전략으로 본다.

수익 인수 (Profit Factor)

1
2
3
val grossWin  = trades.filter { it.netPnl > 0 }.sumOf { it.netPnl }
val grossLoss = trades.filter { it.netPnl < 0 }.sumOf { -it.netPnl }
val profitFactor = if (grossLoss == 0.0) Double.MAX_VALUE else grossWin / grossLoss

PF > 1.5 이면 손익 비율이 양호하다.


백테스트 결과 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
data class BacktestResult(
    val ruleSetId       : Long,
    val stockId         : Long,
    val startDate       : LocalDate,
    val endDate         : LocalDate,
    val tradeCount      : Int,
    val winRate         : Double,          // 승률
    val totalReturn     : Double,          // 총 수익률
    val annualReturn    : Double,          // 연환산 수익률
    val mdd             : Double,          // 최대낙폭
    val sharpeRatio     : Double,
    val profitFactor    : Double,
    val avgHoldingDays  : Double,          // 평균 보유 기간
    val benchmarkReturn : Double,          // 같은 기간 KOSPI 수익률
    val reliabilityGrade: ReliabilityGrade, // A/B/C/D
    val phasePerformance: Map<MarketRegime, PhaseStats>, // 국면별 성과
)

국면별 성과 분해

같은 전략이라도 상승장·하락장·횡보장에서 성과가 다르다. 이를 분리해 보여준다.

1
2
3
4
5
6
7
이 전략의 국면별 성과:

  BULL  구간 (2023.01~2023.07):  수익률 +18.2%,  MDD -4.1%
  BEAR  구간 (2022.01~2022.10):  수익률  -8.4%,  MDD -22.3%
  SIDEWAYS 구간 (2024.03~):      수익률  +1.1%,  MDD  -6.7%

⚠️ 경고: 이 전략은 하락장에서 MDD가 5배 이상 확대됩니다.

정리

  • candles.subList(0, index + 1)로 현재 시점 이전 데이터만 사용해 look-ahead bias를 방지한다.
  • 커미션(0.015%) + 세금(0.2%) + 슬리피지(0.1%)를 반영해야 실전 수준의 수익률이 나온다.
  • MDD·샤프 비율·수익 인수는 수익률만큼 중요한 지표다. 수익률 좋은 전략이 MDD도 크면 실전에서 못 버틴다.

다음 편에서는 전략의 룰셋을 SHA-256으로 보호하고, 구독자에게 신호만 제공하는 전략 보호 시스템을 다룬다.

  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 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다