미장봇 ORB·FVG 전략을 코드로 — 상태머신과 시그널 엔진

지난 글에서는 유튜브에서 본 데이트레이딩 setup(첫 캔들 기준 ORB 돌파 + FVG 되돌림)이 어디서 왔는지를 다뤘다. 이번 글은 그 다음 질문이다. “차트를 눈으로 보며 판단하던 규칙을, 어떻게 컴퓨터가 매일 똑같이 실행하는 코드로 옮길 것인가.” 미장봇(한국에서 KIS API로 미국 ETF를 자동매매하는 개인 프로젝트)의 상태머신과 시그널 엔진을 실제 코드로 따라간다.

🛠️ 이 글의 성격 — 투자 권유가 아니라, 자동매매 시스템을 만들며 남긴 엔지니어링 기록입니다. 모든 수치·코드는 실제 구현과 실거래 로그에 근거합니다.

신호는 어디서 보고, 주문은 어디에 넣는가

코드로 옮기기 전에 정리해야 할 원칙이 하나 있다. 신호를 읽는 차트실제 주문을 넣는 종목이 다르다는 점이다. 리테일은 지수 선물(NQ)을 직접 매매하기 어렵기 때문에, 지수의 방향을 QQQ에서 읽고 레버리지 ETF로 실행하는 구조를 택했다.

차트 용도 실행
TQQQ 5분봉 상승 setup의 진입가·손절·목표 계산 ✅ TQQQ 매수
QQQ 5분봉 하락 setup 신호 추출(인버스 매핑) ✅ SQQQ 매수
QQQ 일봉 · ^VIX 일봉 그날 매매 여부(Daily Bias·변동성 필터) — 판단용

QQQ가 하락 방향으로 신호를 주면, 그것을 인버스 ETF인 SQQQ의 상승 매수로 뒤집어 실행한다. 덕분에 “롱만 매수하는 봇”이 사실상 양방향 매매를 하게 된다.

봇의 뼈대 — 하루를 6개 상태로 쪼개기

자동매매 봇의 핵심은 “지금이 하루 중 어느 국면인가”를 명확히 아는 것이다. 미장봇은 하루를 6개 상태로 나눈 상태머신으로 돌아간다. 메인 루프는 시간을 확인하며 상태를 한 방향으로만 전이시킨다.

mermaid diagram

🔁 흐름 요약: 대기 → 장 시작 전 필터(VIX·방향) → 첫 15분 ORB 박스 형성 → 25분간 신호 스캔 → 진입 시 손절·익절 감시 → 청산. 스캔 중 신호가 없으면 그날은 매매 0건으로 마감한다.

상태 핵심 동작
PRE_MARKET VIX 12~30 범위 확인, QQQ 일봉으로 Daily Bias 점수 계산, 공휴일 체크
ORB_FORMING 개장 첫 15분(9:30~9:44 ET) 3개 봉으로 ORB 고가·저가 확정
SCANNING 돌파+FVG 감지, 되돌림 진입 시그널 탐색(하루 최대 1건)
POSITION_OPEN 손절·익절 모니터링, 11:00 ET 손익분기 이동(BE move)

1단계 — ORB, 첫 15분을 두 개의 선으로

ORB(Opening Range)는 개장 직후 첫 3개 5분봉(9:30·9:35·9:40 ET)의 최고가·최저가다. 이 두 값이 그날 모든 판단의 기준선이 된다. 코드는 놀랄 만큼 단순하다.

Python

def calculate_orb(bars_5m):
    # 09:30~09:44 ET, 첫 3개 5분봉만 사용
    orb_bars = bars_5m.between_time("09:30", "09:44")
    if len(orb_bars) < 3:
        return None
    orb_high = orb_bars["High"].max()
    orb_low  = orb_bars["Low"].min()
    return OpeningRange(high=orb_high, low=orb_low,
                        range_size=orb_high - orb_low)

단순한 만큼 위험도 있다. 개장 초 변동성이 폭발하면 ORB 폭이 비정상적으로 넓어져, 손절 폭이 커지고 R:R이 무너진다. 그래서 20일 평균 일중 변동폭(ADR) 대비 ORB가 1.5배를 넘으면 그 종목은 그날 제외한다.

Python

def is_orb_too_wide(orb, avg_daily_range, max_ratio=1.5):
    ratio = orb.range_size / avg_daily_range
    return ratio > max_ratio    # 너무 넓으면 그 leg 스킵

2단계 — FVG, “가격이 건너뛴 빈 공간” 찾기

FVG(Fair Value Gap)는 3개 연속 캔들에서, 1번 봉과 3번 봉 사이에 겹치지 않는 가격 공간이 생긴 자리다. 상승 FVG는 1번 봉 고가가 3번 봉 저가보다 낮을 때(가격이 위로 점프) 성립한다. 이 조건 하나가 함수의 전부다.

Python

def detect_bullish_fvg(candles):     # 정확히 3개 봉
    c1, c3 = candles.iloc[0], candles.iloc[2]
    if c1["High"] < c3["Low"]:        # 겹치지 않는 빈 공간
        return FairValueGap(top=c3["Low"], bottom=c1["High"],
                            size=c3["Low"] - c1["High"])
    return None

하락 FVG는 정확히 거울상이다(1번 봉 저가 > 3번 봉 고가). 이렇게 감지한 FVG box로 가격이 나중에 되돌아오면 그때가 진입 후보가 된다.

3단계 — Strict FVG, 가짜 신호를 걷어내는 핵심 조건

여기가 이 전략의 진짜 엔지니어링 포인트다. 단순히 “돌파 후 어딘가에 FVG가 생겼다”로 진입하면 신호가 너무 많고 대부분 가짜다. 원본 규칙의 핵심은 FVG가 ORB 라인을 관통해야 한다는 것. 이를 두 조건으로 코드화했다.

(S1) 몸통이 ORB 라인을 가로지름 — 돌파 봉의 시가는 ORB 라인 아래, 종가는 위. 즉 Open ≤ ORB high ≤ Close

(S2) FVG 영역이 ORB 라인을 포함fvg.bottom ≤ ORB high ≤ fvg.top

Python

def check_breakout_with_fvg(bars, orb_high, i, strict=False):
    candle = bars.iloc[i]
    # 돌파: ORB high 위 종가 + 양봉
    if not (candle["Close"] > orb_high and candle["Close"] > candle["Open"]):
        return None
    # (S1) 몸통이 ORB 라인을 가로지름
    if strict and not (candle["Open"] <= orb_high <= candle["Close"]):
        return None
    fvg = detect_bullish_fvg(bars.iloc[i-1 : i+2])
    if fvg is None:
        return None
    # (S2) FVG 영역이 ORB 라인을 포함
    if strict and not (fvg.bottom <= orb_high <= fvg.top):
        return None
    return fvg

strict 한 줄의 효과가 컸다. 백테스트에서 가짜 시그널을 약 70% 제거했다. 신호 수가 줄어드는 대신, 남은 신호의 질이 올라간다 — 이 맞바꿈이 시리즈 뒤편에서 다시 중요한 주제가 된다.

4단계 — 시그널 엔진, 조각을 하나의 신호로

ORB와 FVG 조각을 실제 매매 신호로 합치는 곳이 시그널 엔진이다. 스캔 윈도우(9:45~10:55 ET)의 5분봉을 돌면서, 돌파+FVG가 확인되면 진입가·손절·목표를 하나의 신호 객체로 만든다.

Python

def scan_for_signal(bars_5m, orb, symbol, rr_ratio=2.0,
                    strict=True, allowed_killzones=["AM_MACRO"],
                    require_displacement=False, ...):
    # 1. 각 봉이 ORB 돌파 + FVG(strict=ORB 관통) 형성?
    # 2. 진입 = FVG 중간값,  손절 = 직전 봉 저가
    # 3. 목표 = 진입 + (진입-손절) × R:R
    # 4. killzone·displacement·sweep 필터 통과?
    return TradeSignal(entry_price=..., stop_loss=...,
                       take_profit=..., rr_ratio=rr_ratio)  # 없으면 None

신호 객체(TradeSignal)에는 진입가·손절·목표뿐 아니라, 부분 익절가(1.5R)와 R:R 비율까지 담긴다. 진입·청산 규칙은 이렇게 정해진다.

진입 — 되돌림이 FVG 중간값에 닿는 순간 시장가 매수
손절(SL) — 직전 봉 저가
목표(TP) — 킬존별 R:R (AM_MACRO 1:3, 늦은 진입 1:2)
부분 익절 — 1.5R 도달 시 일부 청산
손익분기 이동 — 11:00 ET에 미청산 포지션 SL을 진입가로
강제 청산 — 15:50 ET 시장가 종료(오버나잇 리스크 0)

여기에 킬존 필터(개장 후 첫 40분에 나온 setup만 허용), 변위 봉(displacement) 검증, sweep+전환(CHoCH) 시퀀스 같은 상위 필터가 옵션으로 얹힌다. 함수 인자만 20개가 넘는데, 각 필터는 켜고 끌 수 있어서 백테스트로 효과를 검증하며 단계적으로 추가했다.

실제로 한 번 걸린 신호 — 2026-04-07 SQQQ

이론만으로는 감이 안 오니, 실거래 로그에서 가장 깔끔했던 사례 하나를 그대로 옮긴다.

• ORB 형성: SQQQ 고가 $77.57 / 저가 $76.52 (폭 $1.05)

• 강한 양봉이 ORB 고가를 몸통으로 관통하며 마감 → FVG [$77.98 ~ $78.13] 형성

• 되돌림이 FVG 중간값 $78.05에 닿음 → 매수 진입

• 약 8분 뒤 목표가 $79.24 도달 → 익절 ✅ +1.78R

돌파 → 변위 → FVG → 되돌림 → 진입 → 목표. 앞에서 코드로 쪼갠 조각들이 실거래에서 정확히 이 순서로 맞물린다.

정직한 트레이드오프 — 필터를 다 켜면 매매가 거의 없다

여기까지만 보면 “필터를 촘촘히 쌓을수록 좋은 봇”처럼 들린다. 하지만 실제로 상위 ICT 필터를 전부 켜자, 신호가 거의 잡히지 않았다. 60일 구간에서 조건이 다 맞아떨어지는 날이 손에 꼽을 정도였다. 흔한 패턴은 이랬다.

• ORB 돌파 후 되돌림 없이 추세 일변도 → 진입 자리 없음
• ORB 안에서 종일 횡보 → 돌파 자체가 안 나옴
• 좋은 setup이 킬존(첫 40분) 이후에 발생 → 필터로 차단
• Daily Bias가 중립이라 그날 자체를 스킵

“quality > quantity”라는 철학은 근사하지만, 매매 빈도가 낮다는 건 그 자체로 검증할 표본이 적다는 뜻이기도 하다. 그리고 표본이 적을수록, 몇 번 안 되는 거래에 붙는 수수료의 무게가 결과를 좌우하기 시작한다. 이 지점이 다음 두 글의 주제다 — 내가 만든 전략을 어떻게 의심했고(리스크 점검), 결국 왜 이 인트라데이 전략을 접었는지(수수료 이야기).

구현은 끝났지만, 엔지니어링에서 진짜 어려운 부분은 “돌아가게 만드는 것”이 아니라 “이게 정말 돈을 버는지 정직하게 확인하는 것”이었다. 코드는 시킨 대로 완벽히 돌아갔다. 문제는 언제나 그 코드가 무엇을 하도록 시켰는가에 있었다.

G
GraceMoon
데이터로 보는 시장·일상 기록

공개 데이터(시장·뉴스·실거래)를 모아 직접 정리하고, 올리기 전에 사실관계를 한 번 더 확인합니다.

본 글은 정보 제공을 목적으로 하며, 특정 종목의 매수·매도 권유가 아닙니다. 모든 투자 판단과 책임은 투자자 본인에게 있습니다.

본 글은 공개된 데이터와 출처를 바탕으로 작성했습니다. 최종 업데이트: 2026-07-20

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤