Skip to content
Go back

퀀트 플랫폼 개발기 #8 — 백테스트 · 스크리너 · 자동탐색 최적화하기

시작

운영 흐름까지 정돈하고 나니 다음 차례는 응답속도였다. 시뮬레이션 한 번 돌리면 백테스트가 30초, 스크리너가 60초, 자동탐색은 4분 가까이 걸렸다. UI 에 progress bar 가 붙어 있어도 60초 동안 “준비 중…” 만 떠 있으면 사용자는 그냥 망가진 화면으로 인식한다. 분 단위 응답은 진짜로 못 쓴다.

분명 데이터는 정상이고 인덱스도 만들어 둔 상태였다. 어디서 시간이 새는지 짚지 않은 채로 추측만 던지면 끝없이 헛수고를 하게 된다. 결국 EXPLAIN ANALYZE 부터 되감아 보기로 했다.

무엇을 했나

핵심 결정 · 시행착오

1. Plan time 12 초의 정체

EXPLAIN ANALYZE 한 줄을 보고 진짜 원인이 보였다.

Planning Time: 12768.163 ms
Execution Time: 125.523 ms
Chunks excluded during startup: 3339

쿼리 실행은 0.1 초인데 실행 계획 만드는 데 12 초가 걸리고 있었다. TimescaleDB 가 매 plan 마다 3339 개 chunk 의 metadata 를 다 검사한 다음 60 일 윈도에 해당하는 것만 빼두는 식으로 동작하는데, chunk 가 많으면 그 검사 자체가 비싸진다.

timescaledb_information.chunks 를 보니 시간 범위가 1962-03-08 부터 7일 단위로 잘려있었다. yfinance 가 미국 종목 (S&P 500 같은 것) 의 1962 년 데이터까지 같이 주니, hypertable 이 그 시점부터 7일 chunk 를 만들어 둔 상태였다. 18 년 데이터인데 64 년치 칸막이가 펼쳐져 있고, 대부분이 빈 chunk 였다.

해결은 hypertable 자체를 통째로 다시 만드는 쪽이 깨끗했다. swap 패턴으로 다운타임을 짧게.

-- 90일 chunk_time_interval 로 새 hypertable 생성
CREATE TABLE factors_new (LIKE factors INCLUDING ALL);
SELECT create_hypertable('factors_new', 'time',
    chunk_time_interval => INTERVAL '90 days');

-- 2005년 이후만 옮겨 옛 빈 chunks 자연 제거
INSERT INTO factors_new SELECT * FROM factors WHERE time >= '2005-01-01';

-- atomic swap
BEGIN;
ALTER TABLE factors RENAME TO factors_old;
ALTER TABLE factors_new RENAME TO factors;
COMMIT;

3340 chunks 가 87 chunks 로 줄었다. plan time 은 12 초에서 100 ms 미만으로 떨어졌다.

chunk 가 너무 잘게 쪼개지면 chunk pruning 의 이점이 plan time 비용에 먹힌다.

2. Python hot loop 가 매 iter 마다 200M ops

DB 쪽을 정리하니 이번엔 Python 쪽이 보였다. 백테스트의 메인 loop 은 거래일을 750 일 도는데 그 안에서 매번 이런 코드가 돌고 있었다.

for today_idx, day in enumerate(trading_days):
    day_factors = factors_df[factors_df["date"] == day].set_index("symbol")
    # ... exit 평가 + 진입 후보 ...

factors_df 자체가 350 종목 × 750 일 ≈ 262K row. 매 iter 마다 boolean mask 만들고 set_index 까지 하니 750 × 262K ≈ 200M 개 비교 연산이다. groupby 한 번이면 끝나는 일을 매번 다시 한다는 뜻이다.

factors_by_date = {
    d: g.drop(columns="date").set_index("symbol")
    for d, g in factors_df.groupby("date")
}
# loop 안:
day_factors = factors_by_date.get(day, empty_factor_df)

같은 자리에서 closes.loc[day, sym] 도 매 종목마다 호출되고 있어서 row 한 번만 캐싱하도록 같이 바꿨다. mac 기준으로 백테스트가 16 초에서 24 ms 로 떨어졌다.

3. pd.read_sql 이 row-by-row Python conversion 을 하고 있었다

자동탐색 쪽은 universe × 10 년 데이터를 한 번에 끌어온다. SQL 자체는 4 초 정도였는데 시작 지연이 30 초였다. 차이는 어디서?

pd.read_sql 은 cursor 에서 row 하나씩 가져와 Python tuple 로 변환한 뒤 DataFrame 을 만든다. 1.26 M row × 7 column 이면 그것만으로 수십 초가 간다. connectorx 는 PostgreSQL native binary protocol 로 직접 받아 arrow 로 만들고, multi-thread 로 fetch 까지 한다.

# 기존
df = pd.read_sql(query, conn, params=params)

# 교체
df = cx.read_sql(CONNECTORX_URL, sql_with_inlined_params,
                 return_type="pandas")

connectorx 는 named parameter 를 받지 않아서 universe symbols 와 ISO 날짜를 SQL 에 직접 인라인했다. universe 는 내부 통제 데이터라 작은 따옴표 escape 정도면 안전했다.

4. lightweight-charts 의 첫 paint 가 사라진다

백엔드를 다 정리하고 나니 이번엔 차트가 안 그려졌다. API 응답은 정상으로 도착해 종목 리스트와 메트릭은 다 떴는데 누적 수익률 차트 자리가 흰 박스로 비어 있었다.

신기한 건 console.log 를 넣어 컴포넌트를 강제로 re-render 시키면 그 순간 차트가 나타났다. 즉 데이터는 라이브러리에 들어갔는데 첫 paint 만 누락되고 있었다.

비교를 위해 다른 차트 (stock-chart) 를 열어 보니 그건 정상. 두 컴포넌트의 차이는 컨테이너 div 의 mount 패턴이었다.

// 문제: 결과가 도착해야 컨테이너가 mount 됨
{!isLoading && result && result.equity.length > 0 && (
  <div ref={containerRef} className="h-[360px] w-full" />
)}

조건부 렌더라 매 시뮬레이션마다 컨테이너 div 가 mount/unmount 되고, lightweight-charts 가 새 div 의 layout 을 ResizeObserver 로 잡기 전에 차트를 그려 버린 게 원인이었다. stock-chart 처럼 컨테이너는 항상 두고 상태 표시만 overlay 로 띄우니 첫 paint 가 정상으로 돌아왔다.

<div className="relative h-[360px] w-full">
  <div ref={containerRef}
       className={`h-full w-full ${showChart ? '' : 'invisible'}`} />
  {isLoading && <SpinnerOverlay />}
  {overlayMessage && <PlaceholderOverlay text={overlayMessage} />}
</div>

같은 김에 lightweight-charts 4 → 5 도 올렸다 (addLineSeriesaddSeries(LineSeries, ...)).

회고

분 단위 응답이 떠 있을 땐 보통 한 군데가 아니라 layer 마다 조금씩 새고 있다.

링크


Share this post on:

Comments


Previous Post
퀀트 플랫폼 개발기 #9 — 갭 필터를 비대칭으로 재설계하기
Next Post
퀀트 플랫폼 개발기 #7 — Tailscale + GitHub Actions runner 로 운영 흐름 만들기