Skip to content
Go back

퀀트 플랫폼 개발기 #9 — 갭 필터를 비대칭으로 재설계하기

4편에서 KIS Open API 위에 cron 1회 실행 워커를 띄웠고, 그 뒤로 라이브 매매를 며칠 돌리면서 작은 결함들을 하나씩 잡아왔다. 이번 글은 그 중에서도 갭 필터 에 관한 회고다. 처음엔 “오늘 시가가 어제 종가 대비 ±5% 넘게 벌어졌으면 진입 스킵” 이라는 표준적인 정의로 잡았는데, 실제로 돌려보니 모멘텀 전략의 정신과 정면으로 충돌하는 케이스가 자주 나왔다. 무엇을 잘못 봤고, 어떻게 다시 설계했는지 정리했다.

시작 (왜)

라이브 매매 cron 은 매일 평일 15:15 — 정규장 마감 5분 전 — 에 한 번 돈다. 흐름은 단순하다.

  1. factor screener 가 어제 종가 기준 ranking 으로 후보를 뽑는다.
  2. 후보별로 KIS 현재가를 조회해 갭 필터 를 통과한 종목만 추린다.
  3. 통과한 종목을 지정가 + 안전 마진으로 매수.

여기서 갭 필터의 원래 의도는 “어제 종가 시그널과 진입가 사이의 괴리 차단”. 백테스트는 어제 종가에 산다고 가정하니까, 실제 진입가가 너무 다르면 검증 안 된 영역으로 들어가는 꼴이다. 안전장치로 양방향 ±5% 를 잡았다.

문제는 이게 두 가지로 잘못 만들어져 있었다.

두 결함이 합쳐지자 “오늘 +8% 오른 모멘텀 종목” 같이, 시그널과 시장이 동시에 OK 한 종목을 죄다 거르는 코드가 됐다.

무엇을 했나

갭 계산:   오늘 시가 / 어제 종가   →   현재가 / 어제 종가
매수 가격: screener 의 어제 종가    →   KIS 현재가
갭 임계:   양방향 ±5%               →   비대칭 -5% / +15%

세 가지를 동시에 바꿔야 의도가 맞았다. 따로따로 보면 각각 작은 결정 같지만 — 시점·시그널·진입가 reference 가 한 줄로 정렬돼야 의미가 산다.

핵심 결정 1 — 갭은 시가가 아니라 현재가로

처음 코드는 표준 정의 그대로였다.

# service/live/executor.py (변경 전)
gap_pct = (open_price / yesterday_close - 1) * 100
if abs(gap_pct) > GAP_FILTER_PCT:
    continue

학술이나 데이트레이딩 영역에서 Opening Gap 은 거의 항상 “오늘 시가 vs 어제 종가” 다. Gap-and-Go, Gap Fade 같은 09:30 진입 전략의 기본 정의. 표준 용어를 그대로 가져왔는데, 우리는 그 표준이 가정하는 시점 (정규장 시작 직후) 이 아니라 정규장 종료 5분 전 에 진입한다.

결과적으로 이런 케이스가 나왔다:

시가 갭만 보면 이 케이스를 못 잡는다. 그래서 분모 분자를 바꿨다.

# service/live/executor.py (변경 후)
current_price = float(quote.get("price") or 0)
return {
    "gap_pct": (current_price / yesterday_close - 1) * 100,
    ...
    "current_price": current_price,
}

학술 용어로는 intraday cumulative return 또는 signal-to-execution slippage 에 더 가깝다. 단어 자체는 여전히 “gap” 으로 유지하되 — 코드 주석에 “현재가 기준” 임을 명시했다. 이름보다는 의미가 일관되는 게 중요했다.

핵심 결정 2 — 매수 가격도 현재가로 통일

갭 계산만 고치고 끝났다고 생각했다가, dry-run 출력 보고 한 번 더 멈췄다.

🔺 BUY SK하이닉스 (000660.KS) qty=1 (gap=+1.20%, price≈₩1,835,000)
   🛡️  (dry-run) stop-loss @ ₩1,688,200 (limit ₩1,654,436)

price≈₩1,835,000 이 어디서 왔나 보니 screener 가 반환한 market_data.close — 즉 어제 종가 였다. qty 계산, +5% 안전 마진 limit, stop-loss reference 가 전부 이 값 기준이었다. 갭 필터만 현재가로 바꾸고 다른 데는 그대로 둔 셈.

# screener (factor)
SELECT ..., md.close AS price FROM market_data md ...
# executor — buy phase (변경 전)
prices = [float(c.get("price") or 0) for c, _ in selected]
qty = cash // price
limit_price = price * (1 + BUY_LIMIT_MARGIN_PCT / 100)
record_entry(entry_price=price, ...)

이러면 안 된다. 오늘 +3% 오른 종목은 어제 종가 기준 qty 가 한 주 더 잡혀서 자본 부족이 나거나, 어제 종가 + 5% 가 현재가보다 낮아서 limit 가 안 채워질 수 있다. 가장 결정적인 건 stop-loss reference 가 어긋난다는 점. entry_price=어제 종가 로 박힌 trade row 의 stop trigger 는 실제 체결가 기준 -X% 가 아니다.

해법은 한 줄이었다. 갭 필터 통과 직후 screener 의 price 를 현재가로 override.

# executor — gap filter 통과 직후
c["price"] = info["current_price"]
selected.append((c, gap))

이 한 줄로 qty · limit · record_entry 모두 현재가 기준으로 정렬됐다.

핵심 결정 3 — 양방향 → 비대칭

세 번째 결함은 dry-run 결과 보고 알아챘다.

🚫 SK하이닉스 (000660.KS): 현재가 갭 +7.68% (>±5.0%) → 스킵
🚫 신세계   (004170.KS): 현재가 갭 +9.29% (>±5.0%) → 스킵
🚫 현대차   (005380.KS): 현재가 갭 +9.91% (>±5.0%) → 스킵
🚫 LG이노텍 (011070.KS): 현재가 갭 +8.19% (>±5.0%) → 스킵
🚫 현대모비스(012330.KS): 현재가 갭 +18.43% (>±5.0%) → 스킵

전부 상승 갭. factor screener 는 이미 모멘텀 상위 종목으로 후보를 좁혔는데, 시장이 시그널을 확인하듯 같은 방향으로 움직인 종목을 모조리 거르고 있다. 모멘텀 전략에 양방향 대칭 갭 필터는 모순이다.

학술 쪽도 같은 결을 말한다.

하지만 무한 허용도 위험하다. 일중 +15% 이상 폭등 종목은 단기 차익 실현 매물에 노출되고, 다음 영업일 평균 수익률이 약하게 음수인 경우가 많다 (mean reversion zone). 그래서 비대칭 으로 잡았다.

# 하방: 시그널 부정 (악재 가능) 이라 좁게
# 상방: 모멘텀 가속 (시장이 시그널 확인) 이라 넓게 허용
# 다만 +15%+ 폭등은 평균회귀 위험으로 상한
GAP_DOWN_LIMIT_PCT = 5.0
GAP_UP_LIMIT_PCT = 15.0

if gap < -GAP_DOWN_LIMIT_PCT or gap > GAP_UP_LIMIT_PCT:
    print(f"   🚫 {label}: 현재가 갭 {gap:+.2f}% "
          f"(허용 -{GAP_DOWN_LIMIT_PCT}% ~ +{GAP_UP_LIMIT_PCT}%) → 스킵")
    continue

같은 dry-run 환경에서 이제 +7.68%, +9.29%, +9.91%, +8.19% 는 통과하고 +18.43% 만 막힌다. 모멘텀 가속 케이스를 놓치지 않으면서, 극단적 폭등의 평균회귀 위험은 거른다.

회고

표준 용어를 그대로 가져올 때, 그 용어가 가정한 맥락 도 같이 따라오는지 확인하자.

이번에 잘못된 출발점은 “Opening Gap” 이라는 단어였다. 학술 정의 자체는 정확했지만, 그 정의가 가정한 진입 시점 (09:30 직후) 과 우리 라이브 cron 의 시점 (15:15) 이 달랐다. 정의는 동일했지만 의미가 다른 변수 였던 것.

남는 한 줄 교훈은 단순하다 — 시점·시그널·진입가 reference 가 한 라인에 정렬돼야 한다. 갭 계산은 현재가, 매수 가격은 어제 종가, stop reference 는 또 다른 값이 섞이면 그 어떤 임계값을 잡아도 의도와 다른 동작이 나온다.

다음 글에선 어제 작업한 KIS 스탑지정가 (ORD_DVSN=22) 자동화 — 호가 단위 정렬, paper 모드 미지원, 매일 09:00 재발사 같은 — 디테일을 정리할 예정.

링크


Share this post on:

Comments


Previous Post
Self-hosted GitLab에서 Renovate Bot으로 보안 취약점 패치 자동화하기
Next Post
퀀트 플랫폼 개발기 #8 — 백테스트 · 스크리너 · 자동탐색 최적화하기