Taewon Park - Portfolio

백테스팅 엔진 성능 최적화

이동평균·RSI·볼린저밴드·캔들패턴 등 기술적 지표를 조합해 전략을 만들고, 과거 시세로 바로 백테스트해 성과를 시각화하는 서비스입니다. React(Vite) 프런트와 FastAPI+Celery 백엔드, VectorBT·Numba 기반 시뮬레이션 엔진으로 구성했으며, 캔들 데이터는 Redis에 선제 적재해 요청 경로에서 거래소 API 의존을 제거했습니다. 단계별 프로파일링으로 병목을 분해해 엔드투엔드 30~35초를 6.4초까지 단축했습니다.

기간
2025년 6월 ~ (개인 프로젝트, 진행 중)
역할
개인 프로젝트 · 풀스택 / 인프라 · 기여도 100% · 설계/구현/배포 전담
스택
React · FastAPI · Celery · Redis · VectorBT · Numba · Docker · GitHub Actions
One-More-Coin · 백테스팅 플랫폼 화면

문제 상황

수만 캔들 규모 백테스트가 요청당 30~35초. 사용자는 버튼을 누르고 반 분을 기다려야 했다.

추측 대신 엔진에 단계별 계측을 심어, 요청 1회의 시간을 데이터 수집 / 시그널 생성 / 시뮬레이션 / 결과 분석으로 분해했다.

병목은 한 곳이 아니었다. 데이터 I/O, 시뮬레이션 루프, 지표 계산, JIT 컴파일에 흩어져 있었고 성격이 전부 달랐다. 단일 처방이 불가능했기 때문에, 이후 최적화는 병목마다 다른 도구로 대응하는 방식으로 진행했다.

해결

네 갈래 병목에 각각 다른 방법을 적용했다.

데이터 I/O

Redis 선제 적재로 요청 경로에서 거래소 API 제거

거래소 API는 호출당 최대 1,000개인데 백테스트 1회에 필요한 캔들은 수만 개다. 요청마다 수십 회의 페이지네이션 왕복과 rate limit 리스크가 생긴다. 서버 기동 시 초기 적재 + 매일 크론 증분 갱신(APScheduler)으로 옮겨, 요청 시점에는 Redis에서 바로 읽게 했다.

RDB가 아니라 인메모리를 고른 이유는 네 가지다.

  • 접근 패턴이 "심볼+타임프레임 연속 구간 조회"뿐 — 조인·조건 검색·행 갱신이 없다
  • 확정 캔들은 append-only라 트랜잭션 정합성이 필요 없다
  • 원본이 거래소에 있어 유실돼도 재적재로 복구된다
  • Celery 브로커로 Redis가 이미 스택에 있어 운영 컴포넌트 추가 비용이 0이다

1h를 1d로 합산하지 않고 타임프레임별 원본을 각각 저장했다. 저장량은 늘지만 집계 오차가 원천 차단된다.

시뮬레이션

커스텀 로직을 유지한 채 네이티브 속도로

복리 재투자와 코인별 최소주문단위 버림은 캔들 단위 상태 의존 로직이라 순수 벡터 연산으로 표현되지 않는다. 파이썬 루프로 돌리면 수만 캔들에서 수십 초가 걸린다.

VectorBT의 from_order_func에 직접 작성한 @njit 주문 함수를 주입해, 커스텀 로직을 유지한 채 시뮬레이션 전체를 네이티브 코드로 실행했다.

  • 진입 시 현금 전액을 코인 정밀도 단위로 버림 처리 (정수 연산으로 부동소수점 오차 방지)
  • 신호를 1기간 shift해 "어제 신호 → 오늘 시가 진입"을 시뮬레이션
  • 주문 함수는 반드시 모듈 레벨에 정의 — 클로저로 두면 요청마다 재컴파일된다
지표

전부가 아니라 순차 의존 구간만 Numba화

SMA·볼린저밴드는 pandas rolling으로 충분히 빠르다. 반면 RSI의 Wilder 스무딩과 EMA는 직전 계산값을 재귀 참조하는 데다 TradingView와 동일한 시딩 방식이 필요해 라이브러리 벡터 연산으로 대체할 수 없다.

이 두 구간만 @jit(nopython=True)로 처리했다. 전부를 Numba화하지 않은 이유는 코드 복잡도 — 최적화는 병목에만 적용해 유지보수 비용 증가를 최소화했다. 모든 지표는 Pine Script의 계산 방식(ta.rma, ta.ema)과 동일한 공식으로 구현하고 값 일치를 검증했다.

JIT 컴파일

컴파일 비용을 사용자 경로 밖으로

Numba는 첫 호출 시 수십 초의 컴파일 비용을 낸다. 그대로 두면 첫 사용자가 그 비용을 지불한다.

  • 디스크 캐시를 Docker named volume으로 backend·worker 컨테이너가 공유
  • 기동 워밍업으로 실전 호출 경로(from_order_func 포함)를 미리 컴파일
  • Celery는 prefork 이전 부모 프로세스에서 워밍업해 자식 워커 전체가 결과를 fork로 상속 — 자식마다 워밍업하면 concurrency 수만큼 중복 컴파일된다
  • Numba는 int와 float을 다른 시그니처로 보므로, 워밍업 인자 타입을 실제 호출과 정확히 일치시켜야 효과가 있다

결과

16.1s → 0.1s
시뮬레이션 단계 · 160배
30~35s → 6.4s
엔드투엔드 · 5배
100%
TradingView 캔들·지표값 일치

BTC/USDT 1h 약 27,000캔들 기준 실측이며, 최적화 전후 결과값 동일성을 검증해 성능과 정확도를 함께 확인했다.