본문 바로가기
경제 금융 포털사이트 경제 금융 포털사이트

Numba로 틱 데이터 이벤트 루프 최적화하기, 벡터화가 어려운 백테스트는 어떻게 빠르게 만들까?

금알남 읽는 시간 약 16분
ai thumbnail minimal Numba로 틱 데이터 이벤트 루프 최적화하기 1789701510

Parquet이나 Polars를 적용했는데도 틱 백테스트가 느리다면 데이터 로딩이 아니라 실제 전략 실행 루프가 병목일 수 있습니다. 특히 이전 포지션과 주문 상태를 확인하면서 틱을 순서대로 처리하는 구조는 단순 벡터화만으로 해결하기 어렵습니다. 아래 글에 Numba를 적용하기 좋은 구간과 적용 전에 확인해야 할 부분을 정리했습니다.

Parquet까지 바꿨는데 왜 백테스트는 여전히 느릴까?

앞서 CSV 데이터를 Parquet으로 바꾸고 필요한 컬럼만 읽도록 최적화했다고 가정해보겠습니다.

Polars나 DuckDB까지 적용해서 데이터 로딩 속도도 상당히 개선했습니다.

그런데 이상합니다.

데이터는 금방 읽히는데 백테스트를 시작하면 또 오래 걸립니다.

그렇다면 이제 파일 I/O가 아니라 전략 실행 로직을 봐야 합니다.

특히 틱 데이터 기반 백테스팅에서는 다음과 같은 코드가 반복적으로 실행되는 경우가 많습니다.

  1. 현재 틱 가격을 확인한다.
  2. 현재 포지션을 확인한다.
  3. 진입 조건을 검사한다.
  4. 주문 체결 여부를 판단한다.
  5. 평균단가를 갱신한다.
  6. 손절·익절 조건을 확인한다.
  7. 수수료와 슬리피지를 계산한다.
  8. 다음 틱으로 이동한다.

문제는 이 과정이 틱마다 반복된다는 것입니다.

그리고 많은 경우 현재 틱의 계산 결과가 다음 틱의 상태에 영향을 줍니다.

예를 들어 현재 포지션이 없는 상태에서 매수 조건이 발생하면 다음 틱부터는 진입 조건이 아니라 청산 조건을 확인해야 합니다.

즉 이런 전략은 단순히 데이터 컬럼 전체에 하나의 수식을 적용하는 문제와 성격이 다릅니다.

그래서 여기서 이런 질문이 나옵니다.

“Pandas 벡터화가 빠르다는데 왜 그냥 전부 벡터화하지 않을까?”

가능한 부분은 벡터화하는 것이 좋습니다.

하지만 모든 백테스트가 완전히 벡터화될 수 있는 것은 아닙니다.

바로 이 지점에서 Numba를 검토할 가치가 생깁니다.

벡터화가 어려운 이벤트 기반 백테스트

예를 들어 아주 단순한 조건을 생각해보겠습니다.

가격이 이동평균선을 상향 돌파하면 매수하고,

진입 가격에서 일정 조건에 도달하면 청산한다고 가정합니다.

단순한 매수 신호 자체는 벡터화하기 어렵지 않습니다.

price > moving_average

같은 조건은 전체 배열에 한 번에 계산할 수 있습니다.

하지만 실제 주문 시뮬레이션으로 넘어가면 문제가 달라집니다.

매수 신호가 발생했더라도 이미 포지션을 보유하고 있다면 추가 매수를 하지 않을 수도 있습니다.

현재 포지션의 진입 가격도 알아야 합니다.

이전 틱에서 주문이 발생했지만 현재 틱에서 체결되는 구조라면 미체결 주문 상태도 기억해야 합니다.

손절 조건과 익절 조건이 동시에 충족되는 경우 처리 순서를 정해야 할 수도 있습니다.

즉 현재 상태를 기억하면서 순서대로 계산해야 합니다.

이것이 대표적인 stateful calculation, 즉 상태 의존 계산입니다.

이런 로직을 억지로 완전 벡터화하면 코드가 오히려 복잡해지거나 실제 거래 순서를 잘못 표현할 가능성이 있습니다.

Numba로 틱 데이터 이벤트 루프 최적화하기, 벡터화가 어려운 백테스트는 어떻게 빠르게 만들까?

따라서 현실적인 방법은 두 부분을 분리하는 것입니다.

벡터화가 가능한 부분

  • 이동평균
  • 변동성
  • 거래량 조건
  • 단순 지표 계산
  • 사전 시그널 생성

순차 계산이 필요한 부분

  • 포지션 상태
  • 주문 상태
  • 체결 처리
  • 평균단가
  • 손절·익절
  • 수수료
  • 자산 변화

첫 번째 영역은 Pandas, NumPy, Polars 등을 활용할 수 있습니다.

두 번째 영역에서는 빠른 반복 처리가 중요해지고, Numba를 검토할 수 있습니다.

Numba는 Python 함수를 JIT, 즉 Just-In-Time 방식으로 컴파일해 네이티브 머신 코드로 실행할 수 있도록 설계된 도구입니다.

Pandas 공식 성능 가이드에서도 Numba는 NumPy 배열을 사용하는 수치 계산을 가속하는 방식으로 활용할 수 있으며, Series.to_numpy() 등으로 배열을 전달해 JIT 컴파일된 함수에서 사용하는 방법을 설명하고 있습니다.

핵심은 DataFrame 전체를 그대로 Numba에 넣는 것보다 계산 핵심부를 NumPy 배열 중심으로 단순화하는 것입니다.

Numba는 어디에 적용해야 효과적일까?

Numba를 사용한다고 모든 Python 코드가 자동으로 빨라지는 것은 아닙니다.

오히려 적용 위치가 중요합니다.

예를 들어 백테스트 구조가 다음과 같다고 해보겠습니다.

Parquet 파일

Polars/Pandas 전처리

NumPy 배열 변환

전략 이벤트 루프

결과 DataFrame 생성

이 구조라면 Numba가 담당하기 좋은 곳은 가운데 있는 전략 이벤트 루프입니다.

예를 들어 개념적으로는 이런 계산입니다.

from numba import njit

@njit
def run_backtest(price, signal):
    position = 0
    entry_price = 0.0
    pnl = 0.0

    for i in range(len(price)):
        if position == 0 and signal[i] == 1:
            position = 1
            entry_price = price[i]

        elif position == 1 and signal[i] == -1:
            pnl += price[i] - entry_price
            position = 0

    return pnl

실제 백테스트는 이것보다 훨씬 복잡하겠지만 구조에서 봐야 할 핵심은 하나입니다.

DataFrame.iterrows()를 이용해 행을 하나씩 처리하는 대신 필요한 데이터를 연속적인 NumPy 배열로 전달하고 반복 계산을 컴파일된 함수 안에서 수행하는 방식입니다.

Numba 공식 문서에서도 NumPy 배열은 Numba가 효율적으로 처리하기 좋은 데이터 구조이며, 가능한 경우 배열 인덱싱을 직접 메모리 접근으로 변환할 수 있다고 설명합니다.

특히 다음과 같은 계산은 검토할 가치가 있습니다.

  • 대량의 가격 배열 반복
  • 포지션 상태 업데이트
  • 주문 체결 조건
  • 손익 계산
  • trailing stop
  • stop loss / take profit
  • 수수료 계산
  • rolling custom calculation
  • Monte Carlo 반복 계산
  • 다수 파라미터 조합의 반복 연산

하지만 반대로 이런 부분까지 무조건 Numba에 집어넣으려고 하면 코드가 복잡해질 수 있습니다.

문자열 처리, 동적인 Python 객체, 복잡한 클래스 구조, 지원되지 않는 라이브러리 호출이 함수 안에 많이 포함되어 있다면 Numba의 장점을 활용하기 어려울 수 있습니다.

그래서 Numba를 적용할 때 중요한 기준은

“이 함수가 숫자 배열을 반복해서 계산하는 함수인가?”

입니다.

그렇다면 좋은 후보일 가능성이 높습니다.

Numba를 적용했는데 안 빨라지는 이유

Numba를 처음 사용하면 의외의 상황을 만날 수 있습니다.

분명 @njit을 붙였는데 처음 실행은 오히려 더 오래 걸립니다.

이것은 이상한 현상이 아닙니다.

JIT 방식은 처음 실행할 때 코드를 컴파일해야 하기 때문입니다.

Pandas 공식 문서에서도 Numba 기반 함수는 첫 번째 실행에서 컴파일 비용이 발생하기 때문에 작은 데이터에서는 성능상 이점이 크지 않을 수 있다고 설명합니다. 반복 실행에서는 이미 컴파일된 함수를 재사용하면서 상황이 달라질 수 있습니다.

따라서 벤치마크를 할 때는

첫 실행 시간

컴파일 이후 반복 실행 시간

을 분리해서 보는 것이 좋습니다.

백테스트 연구에서는 이 차이가 중요합니다.

왜냐하면 전략을 한 번만 실행하는 것이 아니라 파라미터를 바꾸면서 수십 번, 수백 번 반복하는 일이 있기 때문입니다.

두 번째로 확인해야 할 것은 nopython 방식으로 정상적으로 컴파일되고 있는지입니다.

Numba 공식 문제 해결 문서에 따르면 Python 객체를 그대로 처리하는 object mode는 일반 Python 실행에 비해 성능 향상이 거의 없을 수 있습니다.

그래서 최근에는 다음처럼 쓰는 방식을 많이 볼 수 있습니다.

@njit
def backtest(...):
    ...

njit은 사실상 jit(nopython=True)를 사용하기 위한 편리한 형태입니다.

세 번째는 작은 함수에 무조건 적용하는 경우입니다.

몇백 개 데이터에 간단한 덧셈 몇 번 하는 함수라면 JIT 컴파일 비용이 더 클 수 있습니다.

반면 수백만 개 틱을 반복하거나 같은 함수를 계속 호출한다면 이야기가 달라질 수 있습니다.

네 번째는 메모리 할당입니다.

루프 안에서 계속 배열이나 객체를 새로 만들면 계산 자체가 빨라도 메모리 할당 비용이 커질 수 있습니다.

가능하다면 필요한 결과 배열을 미리 생성하고 반복문에서 값을 채우는 방식을 고려할 수 있습니다.

결국 @njit 하나 붙이는 것이 최적화가 아닙니다.

컴파일하기 좋은 형태로 계산 구조를 정리하는 것이 핵심입니다.

틱 백테스팅은 이렇게 역할을 나누면 관리하기 좋다

대규모 틱 백테스트 시스템을 하나의 도구로 전부 해결하려고 할 필요는 없습니다.

각 도구가 잘하는 역할을 분리하는 방식이 현실적입니다.

예를 들어 이런 구조를 생각해볼 수 있습니다.

1. 데이터 저장

Parquet

원본 CSV는 별도로 보관하고 반복 분석용 데이터는 Parquet으로 관리합니다.

2. 데이터 조회·전처리

Polars 또는 DuckDB

필요한 날짜, 종목, 컬럼만 가져옵니다.

3. 데이터 구조 변환

NumPy

전략 계산에 필요한 가격, 거래량, 시그널 등을 배열로 준비합니다.

4. 이벤트 시뮬레이션

Numba

포지션, 주문, 체결, 수수료와 손익을 순차적으로 처리합니다.

5. 결과 분석

Pandas 또는 Polars

거래 내역, 수익곡선, 최대낙폭, 승률 등을 계산하고 분석합니다.

이렇게 역할을 나누면

“Pandas와 Polars 중 누가 더 좋은가?”

같은 질문에서 벗어날 수 있습니다.

더 중요한 질문은

“각 단계에서 어떤 도구가 가장 적합한가?”

입니다.

그리고 Numba를 적용하기 전에 가장 먼저 해야 할 작업은 프로파일링입니다.

데이터 로딩이 전체 시간의 대부분을 차지하는데 이벤트 루프를 Numba로 바꿔봐야 전체 실행시간은 크게 달라지지 않을 수 있습니다.

반대로 데이터 로딩은 빠른데 Python 이벤트 루프가 대부분의 시간을 차지한다면 Numba를 검토할 이유가 커집니다.

그래서 실제 최적화에서는 다음 순서를 권합니다.

  1. 전체 실행시간을 측정한다.
  2. 데이터 로딩 시간을 분리한다.
  3. 지표 계산 시간을 분리한다.
  4. 이벤트 루프 시간을 분리한다.
  5. 가장 오래 걸리는 부분부터 하나씩 수정한다.
  6. 수정 전후 결과값이 동일한지 검증한다.

마지막 6번은 성능보다 더 중요할 수도 있습니다.

백테스트가 빨라졌더라도 주문 체결 순서가 달라졌다면 같은 백테스트가 아닙니다.

특히 틱 데이터에서는

  • 동일 timestamp의 처리 순서
  • 체결 가격
  • 수수료
  • 슬리피지
  • 주문 우선순위
  • 진입·청산 순서

같은 작은 차이가 최종 결과에 영향을 줄 수 있습니다.

따라서 최적화 전후에는 단순히 최종 수익만 비교하지 말고 거래 건수, 개별 체결 가격, 포지션 변화까지 샘플 구간에서 비교하는 것이 좋습니다.

결국 Numba의 목적은

“Python을 무조건 빠르게 만드는 것”

이 아닙니다.

더 정확하게 말하면,

“벡터화하기 어려운 대규모 수치 반복 계산을 Python 문법에 가까운 형태로 작성하면서 컴파일된 코드로 실행하는 것”

에 가깝습니다.

Parquet으로 데이터 로딩을 개선했고 Polars나 DuckDB로 전처리를 줄였는데도 백테스트가 느리다면, 이제 실제 전략 이벤트 루프가 어디에서 시간을 사용하는지 확인해보세요.

그 지점이 수백만 개 이상의 틱을 순차적으로 계산하는 Python 반복문이라면 Numba가 다음 최적화 후보가 될 수 있습니다.

Q1. Numba를 사용하면 모든 Python 백테스트가 빨라지나요?

그렇게 단정할 수 없습니다. Numba는 특히 NumPy 배열을 기반으로 한 수치 계산과 반복 루프에서 활용도가 높습니다. 데이터가 작거나 Python 객체와 지원되지 않는 기능이 많이 포함된 코드에서는 기대한 효과가 나타나지 않을 수 있습니다.

Q2. Pandas DataFrame을 그대로 Numba 함수에 넣으면 되나요?

일반적으로는 핵심 계산에 필요한 Series를 NumPy 배열로 변환해 전달하는 방식을 검토하는 것이 좋습니다. Pandas 공식 성능 가이드에서도 사용자 정의 Numba 함수에 DataFrame 또는 Series의 NumPy 표현을 전달하는 방식을 설명하고 있습니다.

Q3. parallel=True를 붙이면 더 빨라지나요?

항상 그렇지는 않습니다. 병렬화 가능한 계산인지, 데이터 크기가 충분한지, 스레드 오버헤드보다 이익이 큰지를 확인해야 합니다. Pandas 공식 문서에서도 병렬 옵션의 효과는 작업 형태에 따라 달라질 수 있음을 보여줍니다.

자료 출처

  • pandas 공식 문서, Enhancing Performance — Numba JIT, NumPy 배열 활용 및 컴파일 오버헤드 설명
  • Numba 공식 문서, NumPy 배열 및 지원 기능 — NumPy 배열 기반 코드 생성과 배열 접근 설명
  • Numba 공식 Troubleshooting 문서 — object mode와 nopython 방식의 성능 차이 안내
  • Numba 공식 User Manual — NoPython mode, loops, parallel 실행 등 성능 최적화 항목

※ 라이브러리 성능은 CPU, 메모리, 데이터 규모, 코드 구조, 라이브러리 버전 및 실행 환경에 따라 달라질 수 있습니다. 특정 예제나 공개 벤치마크의 결과가 개별 시스템에서도 동일하게 재현된다는 의미는 아닙니다.

※ 백테스트 결과는 과거 데이터와 가정된 체결 조건을 이용한 시뮬레이션입니다. 실제 투자에서는 시장 충격, 호가 잔량, 체결 지연, 슬리피지, 거래비용 등 추가 요인에 따라 결과가 달라질 수 있으며 과거 시뮬레이션 결과가 미래의 투자 성과를 보장하지 않습니다.

※ 본 글은 파이썬 데이터 처리 및 백테스팅 시스템에 관한 일반적인 기술 정보 제공을 목적으로 작성되었습니다. 특정 금융상품의 매수·매도 또는 투자 성과를 보장하거나 권유하는 내용이 아닙니다.

금융상품 관련 상담·광고 자료로 활용하는 경우에는 실제 업무 범위와 적용되는 금융소비자보호 관련 표시·광고 기준을 별도로 확인하시기 바랍니다.

금알남

금알남
함께 보면 좋은 글

댓글 0

첫 댓글을 남겨보세요.

광고 차단 알림

광고 클릭 제한을 초과하여 광고가 차단되었습니다.

단시간에 반복적인 광고 클릭은 시스템에 의해 감지되며, IP가 수집되어 사이트 관리자가 확인 가능합니다.