월배당 ETF 갈아타기 전략을 코드로 옮기기 — 날짜 배열 하나로 끝내기
먼저 밝힙니다. 이 글은 전략을 코드로 옮기는 방법에 대한 기록이며, 투자 권유가 아니고 수익을 보장하지 않습니다. 아래 구조는 손실이 날 수 있고 실제로 납니다. 특정 종목을 추천하지 않으므로 상품명 대신 "월중 ETF / 월말 ETF"로만 씁니다. 어떤 종목을 넣을지는 전적으로 사용자 판단이며 그 결과도 본인 책임입니다.
자동매매에서 어려운 건 대체로 전략을 생각해 내는 것이 아니라 이미 머릿속에 있는 규칙을 코드로 옮기는 것입니다. 사람 머릿속에서는 "15일쯤에 갈아타고, 괴리 벌어지면 기다리고, 애매하면 그냥 둔다"가 한 문장인데, 이걸 조건문으로 펴면 갑자기 분기가 스무 개로 늘어납니다.
이 글은 월배당 ETF 두 종목을 번갈아 타는 규칙을 Apps Script로 옮기면서 정리한 것들입니다. 결론부터 말하면, 스무 개의 분기를 배열 하나로 줄인 것이 이 구현에서 가장 잘한 결정이었습니다.
월중과 월말, 두 칸으로 나눈 이유
월배당 ETF는 배당을 받으려면 특정 날짜까지 보유하고 있어야 합니다. 이 날짜가 상품마다 다릅니다. 그래서 배당락 주기가 어긋나는 두 종목을 잡으면, 한쪽의 마감일이 지난 뒤 다른 쪽의 마감일 전까지 옮겨 타는 구간이 생깁니다.
봇은 이 두 자리를 "월중" 칸과 "월말" 칸으로 부릅니다. 화면에도 종목 카드가 두 장뿐이고, 각 카드에 종목번호·현재가·보유수량·괴리율·배당매수마감일이 붙습니다. 슬롯이 두 개로 고정이라는 점이 중요합니다 — 종목을 몇 개든 담을 수 있게 만들면 "어느 것과 어느 것을 바꿀까"라는 문제가 새로 생기고, 그건 전혀 다른 전략입니다.
슬롯은 두 개로 고정입니다. 각 카드에 배당매수마감일과 괴리율이 함께 보입니다.
스케줄을 배열 하나로
처음에는 이렇게 썼습니다. if (day < 15) { ... } else if (day < 29) { ... } else { ... }. 그런데 요구가 붙습니다. "13~14일은 갈아타지 말고 그냥 그리드만 돌려라." "29일 이후는 새로 사지 말고 유지만 해라." 조건문이 금세 엉킵니다.
바꾼 방법은 날짜를 인덱스로 쓰는 배열 하나입니다.
// 인덱스 = 그 달의 날짜(1~31). 0번 칸은 쓰지 않는다.
// 0 : 월중 ETF 매수, 월말 ETF 매도
// 1 : 월말 ETF 매수, 월중 ETF 매도
// 3 : 월중 ETF 매수 불가 — 현 상태 유지, 그리드 매매만
// 4 : 월말 ETF 매수 불가 — 현 상태 유지, 그리드 매매만
// 9 : 미사용
// [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ...]
var SwapOrderDayOpen = [9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 4, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3];
이제 오늘 뭘 해야 하는지는 배열 한 번 읽으면 끝입니다.
var iDay = Number(currentDate.getDate());
var [BuyDay15, BuyDay30] = getSwapBuyDay(); // 두 종목의 배당 매수마감일
var FromSymbol, ToSymbol, SwapLastDay;
if (SwapOrderDayOpen[iDay] == 0 || SwapOrderDayOpen[iDay] == 3) {
FromSymbol = OrderSymbol[1]; // 월말 → 월중
ToSymbol = OrderSymbol[0];
SwapLastDay = Number(BuyDay15);
} else if (SwapOrderDayOpen[iDay] == 1 || SwapOrderDayOpen[iDay] == 4) {
FromSymbol = OrderSymbol[0]; // 월중 → 월말
ToSymbol = OrderSymbol[1];
SwapLastDay = Number(BuyDay30);
} else {
continue; // 9 = 아무것도 하지 않는 날
}
이 배열이 좋은 이유가 셋 있습니다. 규칙이 눈으로 보입니다 — 달력을 그대로 옮긴 모양이라 "13·14일에 왜 안 사지?"를 코드를 읽지 않고 배열만 봐도 압니다. 바꾸기 쉽습니다 — 상품의 배당 스케줄이 바뀌면 숫자 몇 개만 고칩니다. 그리고 0/1과 3/4를 분리한 덕분에 "갈아타는 날"과 "사지는 않고 유지만 하는 날"이 코드에서도 다른 값으로 남습니다.
마지막 항목이 실전에서 중요합니다. 배당 마감 직전에 새로 사면 배당은 받지만 배당락을 그대로 맞습니다. 그래서 마감 며칠 전부터는 신규 매수를 닫고 보유분만 그리드로 굴리는 구간을 따로 만들었습니다. 그게 3과 4입니다.
배당 마감일은 왜 파라미터인가
BuyDay15·BuyDay30은 하드코딩이 아니라 사용자가 화면에서 입력하는 값입니다. 종목 카드에 "배당매수마감일" 칸이 따로 있는 이유입니다.
고정할 수 없는 이유는 단순합니다. 상품이 스케줄을 바꿉니다. 운용사가 배당 기준일을 조정하기도 하고, 종목 자체를 다른 것으로 갈아 끼울 수도 있습니다. 이걸 코드에 박아 두면 그때마다 배포를 해야 하고, 배포를 잊으면 잘못된 날에 주문이 나갑니다. 값으로 빼 두면 화면에서 고치고 끝입니다.
코드는 이 값을 "언제까지 갈아타도 되는가"의 마지노선으로만 씁니다.
// SwapLastDay 를 넘겼으면 갈아타지 않는다 —
// 마감일이 지난 뒤의 진입은 배당 없이 배당락만 맞는 자리다.
if (pofitCloseMax < SymbolprofitPer
&& SwapLastDay > iDay
&& iHourMin > startSwap && iHourMin < endSwap
&& (mainsymbol == ToSymbol || SymbolNavgapSwap > NavgapMin)) {
SwapTrading(FromSymbol, ToSymbol, SellQty, BuyQty);
}
조건이 넷입니다. 수익이 목표를 넘었고, 마감일 전이고, 갈아타기 시간대이고, 괴리 조건이 맞을 때만 움직입니다. 넷 중 하나만 어긋나도 그날은 아무 일도 일어나지 않습니다 — "안 하는 것"이 기본값인 구조가 매매 봇에서는 훨씬 안전합니다.
괴리율과 갭 — 두 개의 다른 파라미터
혼동하기 쉬운데, 이 봇에는 성격이 다른 두 종류의 "차이"가 있습니다.
괴리율 (NAV gap)
ETF 가격이 순자산가치에서 얼마나 떨어져 있는지입니다. 갈아타기에서 이게 중요한 이유는, 괴리가 벌어진 상태에서 옮겨 타면 그 차이를 그대로 비용으로 낸다는 점입니다. 파는 쪽이 저평가고 사는 쪽이 고평가면 최악입니다.
그래서 두 종목의 괴리율 차이를 보고, 차이가 거의 없으면 갈아타는 대신 그냥 정리합니다.
var SymbolNavgapSwap = Number(SymbolNavgap[OrderSymbol[0]])
- Number(SymbolNavgap[OrderSymbol[1]]);
var SymbolprofitPer = Number(QtyMap[OrderSymbol[0]].profitPer)
+ Number(QtyMap[OrderSymbol[1]].profitPer);
// 괴리 차가 ±0.03 안쪽이면 옮겨 탈 이유가 없다 → 청산
if ((SymbolNavgapSwap < 0.03 && SymbolNavgapSwap > -0.03)
|| SymbolprofitPer > Number(pofitCloseMax)) {
setOpen("Order");
SwapClose(OrderSymbol[0], QtyMap);
SwapClose(OrderSymbol[1], QtyMap);
setOpen("Close");
continue;
}
갭 (주문 간격)
이쪽은 그리드 매매용입니다. 다음 주문을 현재가에서 몇 퍼센트 떨어진 곳에 걸 것인가. 설정값은 두 개입니다.
setMinGap("0.002"); // 최소 0.2%
setMaxGap("0.02"); // 최대 2%
재미있는 건 이 값이 고정이 아니라 보유 수량에서 계산된다는 점입니다.
var gapPer = 0;
var minGapPer = Number(getMinGap());
// 보유 수량이 많을수록 촘촘하게, 적을수록 넓게
if (nowprice0 > nowprice1) {
gapPer = 1 / Number(QtyMap[OrderSymbol[0]].totalBal);
} else {
gapPer = 1 / Number(QtyMap[OrderSymbol[1]].totalBal);
}
if (gapPer < minGapPer) {
gapPer = minGapPer; // 하한을 깔아 준다
}
setNextPricePer(0, gapPer);
setNextPrice(0, nowprice0 * gapPer);
1 / 보유수량이라는 식이 하는 일은 이렇습니다. 보유가 적을 때는 갭을 넓게 잡아 함부로 주문을 내지 않고, 쌓일수록 촘촘하게 만듭니다. 100주를 들고 있으면 1%, 500주면 0.2%. 그리고 하한(minGap)이 없으면 수량이 커질수록 갭이 0에 수렴해 호가 단위보다 작아지므로 반드시 바닥을 깔아야 합니다. 이 하한이 없으면 주문이 무한히 쏟아집니다.
갈아타기와 그리드가 만나는 지점
두 전략은 시간 축에서 나뉩니다.
- 갈아타기는 한 달에 두 번 정도, 정해진 날짜 구간에서만 일어납니다
- 그리드는 그 사이 내내 돌면서 같은 종목 안에서 오르내림을 먹습니다
날짜 배열의 3·4가 정확히 이 경계입니다. "신규 매수는 닫되 그리드는 계속" 이라는 상태를 값 하나로 표현한 것입니다. 이 구간이 없으면 배당 마감 직전에 계속 새로 사들이게 되고, 그건 앞서 말한 대로 배당락만 맞는 자리입니다.
한 가지 더. 갈아타기가 도는 시간대는 매일 조금씩 다르게 잡습니다.
// 매 세션 갈아타기 시간대를 무작위로 잡는다
setSwapTime(randomFloatRange(9.31, 13.01),
randomFloatRange(14.01, 15.01));
이유는 체결 품질입니다. 매일 정확히 같은 시각에 같은 종목을 같은 방향으로 주문하면 그 시각의 호가에 자기 발자국이 남습니다. 시간대를 흔들면 그 패턴이 흐려집니다. 개인 규모에서 시장에 영향을 준다고 보기는 어렵지만, 비용이 거의 없는 조치라 넣어 두었습니다.
실제로 돌려 보면
구현하고 나서 알게 된 것들이 있습니다.
"아무것도 하지 않는 날"이 대부분입니다. 배열의 값이 3·4이거나, 조건 넷 중 하나가 안 맞아서 그냥 지나가는 날이 훨씬 많습니다. 처음에는 "봇이 안 도는 거 아닌가" 싶었는데, 이게 정상 동작입니다. 그래서 화면에 "지금 왜 안 사는지"를 보여 주는 상태 로그가 실제로는 매매 기능만큼 중요했습니다.
괴리율은 생각보다 자주 흔들립니다. 특히 장 시작 직후와 마감 직전에 크게 벌어집니다. 갈아타기 시간대를 9시 30분 이후로 잡은 것이 이 때문입니다.
그리고 손실은 납니다. 배당을 받아도 배당락과 가격 하락이 그보다 크면 계좌는 줄어듭니다. 이 구조는 수익을 만들어 내는 장치가 아니라 정해진 규칙을 사람 손 없이 실행하는 장치입니다. 규칙이 틀렸으면 봇은 그 틀린 규칙을 아주 성실하게 실행합니다.
실제 운영 화면(금액 비공개). 오르는 구간과 내려가는 구간이 함께 있습니다 — 이 구조는 손실을 막아 주지 않습니다.
정리
- 날짜별 규칙은 if 체인 대신 날짜를 인덱스로 쓰는 배열로 — 달력처럼 읽히고 고치기 쉽다
- "갈아타는 날"과 "유지만 하는 날"을 다른 값으로 분리해야 배당 마감 직전 신규 매수를 막을 수 있다
- 배당 마감일은 코드가 아니라 파라미터 — 상품이 스케줄을 바꾸기 때문이다
- 괴리율(NAV gap)과 주문 갭은 다른 것 — 하나는 갈아탈지 판단하고, 하나는 어디에 걸지 정한다
- 그리드 갭을
1/보유수량으로 두면 자연스럽게 조절되지만 하한이 없으면 주문이 쏟아진다 - 조건이 안 맞으면 아무것도 하지 않는 것이 기본값이어야 한다
다음 글에서는 이 봇이 알림을 사용자용과 개발자용으로 나눈 이유를 다룹니다.
※ 이 글은 개발 기록이며 투자 권유가 아닙니다. 여기 설명한 전략은 수익을 보장하지 않으며 원금 손실이 발생할 수 있습니다. 배당은 배당락을 동반하고, ETF는 괴리·유동성·운용보수 등 추가 위험이 있습니다. 자동매매는 시스템 오류·네트워크 장애·예상치 못한 시장 상황으로 손실이 확대될 수 있습니다. 특정 종목을 추천하지 않으며, 투자 판단과 그 결과는 전적으로 본인 책임입니다. 실계좌 적용 전 반드시 소액 또는 모의투자로 충분히 검증하시기 바랍니다.
댓글
댓글 쓰기