직접 굴리는 자동매매 시스템을 개발하고 있다. Rust + Python + React 로 시세 조회, 백테스트, 자동 주문, 시그널 매매까지 기능은 갖췄다.
그동안 PR 흐름을 돌아보면 BackgroundTask supervisor, pre-condition guard, DeploymentStatus::Abandoned 전이, stuck monitor, LocalSim 시세 — 켜지도 않은 시스템에 안전 잠금장치만 줄줄이 만들고 있었다. 처음에는 이상하게 느껴졌다. 다른 기능을 더 만들지 않고 왜 끄는 법부터 짜고 있나.
자동매매라는 게 그렇다. 잘못 돌아가면 돈이 움직이고, 새벽에 깨워줄 사람도 없고, 아직 실 데이터로 검증하지도 못한 상태다. 이 셋이 겹치니까 “잘 동작” 보다 “안전하게 중지” 부터 만들게 됐다.
ETF 리밸런싱과 포트폴리오 자동 관리
만들고 싶었던 건 증권 기반의 자산 관리 시스템이었다. 자산 포트폴리오를 수학적 알고리즘으로 정의하고, 그 정의대로 자동으로 비중을 조정하는 일을 사람 손 없이 굴리는 게 목표였다.
시작은 ETF 기반 리밸런싱이었다. ETF 는 한 종목을 사면 분산이 따라오니까 종목 선정의 의사결정 비용이 낮고, 매수/매도 빈도도 낮아 거래 비용 부담이 적다. 자산배분 단위의 자동 매매로 첫 단계에 적합했다. 자산배분 전략을 백테스트로 검증하고, 검증된 전략을 계좌에 배포해서 정해진 주기로 비중을 자동으로 맞춘다.
다음 단계는 개별 종목 단위였다. 팩터 스크리닝으로 종목을 추리고, 백테스트로 검증하고, 시그널 매매로 진입과 청산을 자동화한다. 자산배분보다 의사결정 단위가 더 잘게 쪼개진다. 다만 흐름은 같다 — 수학적 정의를 만들고, 정의대로 자동으로 굴린다.
Rust + Python 분리와 broker abstraction
초기 코드 골격은 두 가지였다. Rust + Python 분리와 broker abstraction. 둘 다 안전을 의도하고 한 결정은 아니었다.
Rust + Python 분리는 워크로드 분담이었다.
실주문 흐름 — 자동 주문, 잔고 동기화, 시그널 평가 — 은 Rust 서버가 담당한다. 운영 환경(Oracle Cloud ARM Always Free)의 자원이 한정이라 메모리 사용량이 적은 언어가 유리했고, 시그널 매매의 latency 도 GC pause 없는 쪽이 안전하다고 봤다. Rust 를 배우고 싶었던 동기도 있었다.
백테스트, 팩터 스크리닝 같은 stateless 계산은 Python 퀀트 엔진이 맡고, DB 의존이 없다. vectorbt 가 백테스트를 잘 지원해서 Rust 로 직접 구현할 이유가 없었다.
broker abstraction 으로 KIS 와 LocalSim 을 같은 trait 로 추상화했다. 주문 실행기는 trait 만 의존하고, 실제 KIS 구현체와 LocalSim 구현체는 같은 인터페이스를 만족한다. LocalSim 은 처음에는 단순히 KIS 가 없는 환경에서 개발하려고 만든 것이었다.
운영 진입을 앞두고 다시 보니, 이 둘이 그대로 첫 번째 안전선이 되어 있었다. Rust + Python 분리가 두 프로세스의 장애 전파 범위를 나누고, broker abstraction 으로 모든 자동 흐름을 LocalSim 위에서 dry-run 으로 굴려볼 수 있다.
자동매매 흐름에 이후에 의식적으로 이어붙인 안전선이 중지, 차단, 감지, 시뮬레이션이다.
BackgroundTask supervisor 와 abandoned 전이
처음 추가한 안전선은 중지였다.
가장 먼저 한 작업은 BackgroundTask supervisor 였다. 시그널 엔진, 리스크 모니터, US 시장 폴링 같은 장기 실행 백그라운드 작업이 panic 으로 죽었을 때 그 사실을 아무도 모르는 상황이 위험하다고 봤다. supervisor 가 panic 을 감지하고, 정책에 따라 재기동하거나 영구 정지시킨다. trait 와 enum 으로 restart policy 를 정의해두니 작업마다 다른 정책을 끼울 수 있어서 깔끔했다.
다음으로 DeploymentStatus::Abandoned 전이를 붙였다. 처음에는 청산이 안 끝나면 운영자가 직접 정지시키는 흐름이었는데, 실제로 며칠씩 stuck 인 채로 두면 위험할 것 같았다. 청산 진척이 일정 시간 이상 없으면 배포 상태가 Abandoned 로 전이되도록 했다. Abandoned 상태에서는 자동 매매 흐름이 멈추고, 더 이상의 주문이 나가지 않는다.
force-abandon API 도 같은 흐름에서 추가했다. 자동 전이가 동작하지 않을 경우를 대비해서 외부에서 한 번에 멈추는 수단을 따로 뒀다. “절대 멈출 수 없는 상태” 가 생기지 않게 하는 게 목적이었다.
마지막으로 스케줄러에 자동 abandoned 전이를 붙였다. 매일 정해진 시점에 청산 상태를 점검하고, 조건이 맞는 배포는 자동으로 abandoned 처리한다. 사람이 매일 점검하지 않아도 비정상 상태가 누적되지 않게 됐다.
pre-condition guard 와 chat_id gate
다음 작업은 차단이었다. 중지가 동작 중에 거는 잠금이라면, 차단은 시작 자체를 막는 잠금이다.
첫 번째는 deployment 의 pre-condition guard 였다. 브로커 자격증명이 등록되지 않은 상태에서는 배포 활성화와 청산이 차단된다. 원래는 활성화 도중에 자격증명을 가져와서 누락되면 실패시키는 흐름이었는데, 실패 시점이 너무 늦었다. 활성화 직전에 자격증명을 먼저 확인해서, 누락되면 그 자리에서 차단되도록 바꿨다.
Telegram chat_id onboarding gate 도 비슷한 결정이었다. 자동 매매가 시작되면 체결, 시그널, 정합성 불일치 알림이 즉시 도착해야 하는데, 알림 채널이 설정되지 않은 상태로 배포가 활성화되면 이슈가 생겨도 알 수 있는 방법이 없었다. 그래서 chat_id 가 등록되지 않은 사용자가 배포를 만들 때 강한 경고를 띄우는 검증 단계를 두었다.
pre-condition guard 도, chat_id gate 도 처음에는 활성화 시점에 잡다가 차츰 앞으로 당겨졌다. 차단을 늦게 잡으면 잘못된 상태가 깊이 들어와서 되돌리는 비용이 컸다.
stuck monitor 와 잔고 정합성
중지와 차단이 동작하려면 먼저 이상이 감지되어야 했다. 감지가 다음 작업이었다.
LiquidationStuckMonitorTask 가 첫 번째다. 청산이 진행 중인 배포의 진척을 주기적으로 보고, 일정 시간 이상 변화가 없으면 Sentry 로 알람을 올린다. 자동 abandoned 전이와 짝이 된다 — 자동으로 멈출 시점이 됐다는 신호를 사람이 알 수 있게 됐다.
잔고 정합성 검증은 결이 좀 달랐다. KIS 의 실 잔고와 장부에 기록된 strategy_position 을 비교해서 불일치가 나면 Telegram 으로 알린다. 자동 매매가 의도한 만큼 정확히 반영됐는지를 브로커 쪽 잔고로 대조하는 일이다. drift 가 생기면 reconcile API 로 보정한다.
마지막으로 알림 채널을 정리했다. Sentry 와 Telegram 의 Trading/System 채널을 분리해서, 실주문 흐름 관련 이벤트는 Trading 채널로, 그 외 운영/시스템 이상은 System 채널로 라우팅했다. 1인 운영이라 알림이 잡음에 묻히면 감지 자체가 무의미해진다는 게 동기였다.
LocalSim 과 dev seed
마지막은 시뮬레이션이었다. 실제 자금을 굴리기 전에 전체 회로를 흘려볼 수 있게 만들고 싶었다.
LocalSim 시세 스트림이 그 출발점이다. 처음에는 단조 함수로 시세를 흉내 냈다. 회로가 동작하는지만 보면 됐을 때는 충분했는데, 시그널 평가와 리밸런싱을 다양한 패턴에서 검증하려니 부족했다. 그래서 GBM 으로 모델을 바꿨다. 종목별로 drift 와 volatility 를 다르게 주면 상승/하락/정체가 섞인 시세가 만들어진다.
dev trading_credentials seed 도 시뮬레이션을 위해 만든 도구였다. 로컬에서 실주문 흐름을 흘려보려면 자격증명이 필요한데, 매번 수동으로 넣기 귀찮았다. 멱등 UPSERT 로 시드 데이터를 까는 명령을 Makefile target 으로 묶었다. dev 환경 재현이 한 줄로 끝나니까 검증을 자주 돌리게 됐다.
시뮬레이터가 없으면 실주문이 곧 첫 통합 테스트가 된다. 자동매매에서는 그게 가장 비싼 테스트라 봤다. LocalSim 위에서 며칠을 무사히 도는 모습을 봐야 실주문으로 옮길 자신감이 생긴다.
회고
켜지 않은 시스템에 끄는 법부터 짜는 게 처음에는 이상했는데, 개발하다 보니 자동매매 + 1인 + 운영 전이 겹친 상황에서 가장 합리적인 순서였다고 느낀다. 다른 기능을 더 만드는 것보다 안전선을 먼저 갖추는 게 운영 진입 시점에 손이 떨릴 일을 줄여준다.
그래도 켜기까지는 아직 멀었다. LocalSim 위에서 전체 자동 흐름이 일정 기간 무사히 도는 모습을 보고 싶다. 시그널 발생부터 주문, 체결, 잔고 동기화, 청산까지 한 사이클이 깨끗하게 도는지 확인하지 못한 상태에서 실주문으로 옮기는 건 위험하다. 각 안전선이 실제로 작동하는지도 회로 단위로 한 번씩 깨봐야 한다. supervisor 가 정말 panic 을 잡는지, guard 가 잘못된 활성화를 막는지, stuck monitor 가 실제로 알람을 올리는지.
첫 실주문도 그 자체가 안전선이다. 작은 금액으로 일정 기간을 검증한 뒤에 단계적으로 비중을 늘릴 생각이다. 한 번에 큰 비중을 올리는 건 그동안 만든 안전선과 반대 방향의 결정이다.
다음 회고는 운영을 시작한 뒤에 쓸 것이다.
참고
- 백테스트 성과 지표 — 자산배분과 개별 종목 전략을 검증한 백테스트 지표
- 포트폴리오 운용과 팩터 스코어링 — 개별 종목 단위 팩터 스크리닝의 배경
- 기술적 지표와 매매 시그널 — 시그널 매매에 사용한 기술적 지표
- 한국 계좌 유형별 투자 제약 — KIS 계좌 연동의 배경이 되는 한국 시장 제약