3편에서 검증된 룰을 손에 쥐었으면, 다음은 그걸 실제로 돌리는 일이다. 한국투자증권(KIS) Open API 위에 cron이 평일 15:20 한 번 호출하는 1회 실행 워커를 만들었다.
흐름은 단순하다.
- 활성 전략 로드
- KIS 잔고 조회
- 보유 종목에 exit policy 적용 → 매도
- screen으로 진입 후보 추출 → 시가 갭 필터 → 빈 슬롯만큼 매수
last_rebalance_at갱신

문제는 단순한 흐름이 KIS API 의 4가지 함정과 만나면서 만드는 디테일이다.
함정 1 — EGW00123 (토큰 만료)
KIS 토큰은 24h TTL. 컨테이너 재시작하면 메모리 토큰이 날아가 매번 재발급되는데, KIS 토큰 발급은 일일 호출 횟수 제한이 있어서 잘못 운용하면 라이브 매매 자체가 멈춘다.
해결 둘.
(a) 파일 영속화 — /app/data/kis_token_{mode}.json 에 토큰을 저장하고 docker-compose volume으로 영속화.
# service/kis/client.py
def _save_token_to_file(self):
with open(self._token_file, "w") as f:
json.dump({
"token": self._token,
"expires": self._token_expires.isoformat(),
"mode": self.mode,
}, f)
os.chmod(self._token_file, 0o600) # 토큰은 비밀
docker-compose.yml에 ./apps/engine/data:/app/data 매핑이 있어서 컨테이너 재기동에도 토큰이 살아남는다.
(b) EGW00123 자동 재시도 — 만료 직전이라 캐시는 살아 있는데 KIS가 거부하는 케이스 (시간 동기화 / 서버 측 무효화 등) 를 위해 자동 재시도 wrapper.
def _retry_on_token_expired(self, fn):
try:
return fn()
except KisError as e:
if "EGW00123" not in str(e):
raise
print("⚠️ EGW00123 — 토큰 캐시 무효화 후 재시도")
self._clear_token() # 메모리 + 파일 모두
return fn() # 재발급 + 재호출
모든 KIS 호출(get_balance, place_order, get_current_price, …)이 이 wrapper 를 통한다.
함정 2 — EGW00201 (Rate limit)
KIS 모의계좌는 초당 거래건수 2건 제한. 30종목 시가 조회를 그대로 루프 돌리면 곧장 EGW00201로 차단된다.
해결: 호출 사이 sleep + 1회 재시도.
# service/live/executor.py
KIS_QUOTE_SLEEP_SEC = 0.5 # 호출 사이 sleep
KIS_RATE_LIMIT_RETRY_SLEEP_SEC = 1.2 # rate limit 재시도 전 대기
def _place_order_with_retry(kis, symbol, qty, side):
for attempt in range(2):
try:
kis.place_order(symbol=symbol, qty=qty, side=side, order_type="market")
return True
except KisError as e:
if "EGW00201" in str(e) and attempt == 0:
time.sleep(KIS_RATE_LIMIT_RETRY_SLEEP_SEC)
continue
return False
매도 → 갭 조회 → 매수 라인 모두 호출 사이 sleep 0.5s. 30종목이면 추가 15초가 걸리지만, rate limit 회피의 비용으론 싸다.
함정 3 — 동시호가 (15:20–15:30) 시장가
매매 시간을 15:20에 잡은 건 의도된 것이다. 한국 시장 동시호가 시간대(15:20–15:30)에 시장가로 주문을 넣으면 모두 15:30 일괄 체결가(종가) 로 fill된다. 즉 30종목을 한 라운드에 시장가로 던지면 전부 동일 종가로 fill되니, 종목별 체결가 분산 걱정이 없다.
이게 cron 1회 실행 패턴과 잘 맞는다. run_once가 sell → screen → buy 순서로 동기적으로 도는데, 매도 주문이 fill되기 전이라도 시장가는 자동으로 15:30 batch fill에 합류한다. 매도 후 매수 사이의 race condition이 사라진다.
# service/live/executor.py (요약)
def run_once(dry_run=False):
# ... balance, sync ...
# 1. Sell
for symbol, qty, name, reason, trade_id in to_exit:
kis.place_order(symbol=symbol, qty=qty, side="sell", order_type="market")
# 2. Screen + gap filter
# 3. Buy
for symbol, qty in selected:
kis.place_order(symbol=symbol, qty=qty, side="buy", order_type="market")
체결가는 모두 15:30 종가. 백테스트 가정 (체결가 = 당일 종가) 과 라이브 동작이 자연스럽게 일치한다.
함정 4 — Symbol 형식 불일치
KIS 잔고는 6자리 코드('000150')로 돌아오고, DB와 백테스트 엔진은 .KS / .KQ suffix('000150.KS')를 쓴다. 이걸 그대로 비교하면 같은 종목을 다른 종목으로 인식해서, 이미 보유한 종목을 또 매수하는 버그가 났다.
# service/live/executor.py
def _to_code(symbol):
"""KIS 6자리 ↔ DB suffix 비교용 정규화."""
return symbol.split(".")[0] if symbol else symbol
# 보유 + 방금 매도한 종목 코드 (.KS 떼고) 로 정규화 후 비교
held_codes = {_to_code(h["symbol"]) for h in holdings}
sold_codes = {_to_code(s) for s in sold_syms}
candidates = [
c for c in raw_candidates
if _to_code(c["symbol"]) not in held_codes | sold_codes
]
같은 함정이 latest factors 조회에도 있다. 보유 종목 6자리 코드로 factors 테이블을 조회할 때 .KS / .KQ 후보 둘 다 시도해서 매치되는 걸로 신호 평가한다.
보너스 — --dry-run 의 가치
run_once(dry_run=True)는 실제 KIS 주문을 발송하지 않고 흐름만 시뮬레이션한다. 실전 전환(KIS_MODE=real) 직전엔 항상 --dry-run으로 한 번 돌려본다.
- 모든 매도/매수 후보가 합리적인지 (종목·수량·이유)
- exit reason이 의도대로 분배되는지 (
stop_loss만 너무 많지 않은지) - 시가 갭 필터가 너무 많은 종목을 잘라내고 있진 않은지
dry_run 출력을 사람이 한 번 검토 → 실 매매 진입. 이 한 단계가 큰 사고를 막는다.
회고
라이브 매매를 만들면서 가장 강하게 느낀 것.
백테스트는 “코드”를 다루지만, 라이브는 “외부 시스템의 디테일”을 다룬다.
EGW00123, EGW00201, 동시호가 시간대, symbol 형식 — 이런 건 모두 백테스트 코드만 보던 시점에선 보이지 않다가 실제 KIS API를 두드리는 순간 한꺼번에 튀어나온다. 4가지 함정은 결국 “외부 시스템과 접하는 모든 경계에 retry · 정규화 · 검증을 박아둔다” 라는 한 줄로 요약된다.
다음 편은 시리즈 마지막 — Hysteresis 청산 룰 자동 도출과 자본 균등 분배. 백테스트와 라이브를 한 결로 묶어주는 두 가지 작은 디테일.