Turning a Monthly-Dividend ETF Rotation Strategy Into Code

🌐 Korean

Up front: this post is about how a strategy gets translated into code. It is not investment advice and it guarantees nothing. The structure below can lose money, and it does. No specific product is recommended, so tickers are referred to only as "mid-month ETF" and "month-end ETF." Which instruments go in those slots is entirely the operator's decision, and so is the outcome.

The hard part of automated trading is usually not coming up with a strategy — it is translating the rule already in your head into code. In your head, "rotate around the fifteenth, wait if the gap is wide, otherwise leave it alone" is one sentence. Unfold it into conditionals and it becomes twenty branches.

This post covers implementing a rotation between two monthly-dividend ETFs in Apps Script. The short version: collapsing those twenty branches into a single array was the best decision in the whole implementation.

Two slots, and why only two

To collect a distribution from a monthly-dividend ETF you have to hold it through a specific date, and that date differs by product. Pick two instruments whose ex-dividend cycles are offset and a window opens: after one product's deadline passes, you can move into the other before its deadline arrives.

The bot calls those two positions the "mid-month" and "month-end" slots. The UI shows exactly two ticker cards, each carrying a symbol, current price, held quantity, NAV gap and dividend purchase deadline. Fixing the slot count at two matters: allow an arbitrary number of holdings and you introduce a brand-new question — which one do I swap with which — and that is a completely different strategy.

Two ticker cards with mid-month and month-end badges, price, quantity, NAV gap and dividend deadline

The slot count is fixed at two. Each card shows the dividend purchase deadline next to the NAV gap.

The schedule is one array

The first version looked like if (day < 15) { ... } else if (day < 29) { ... } else { ... }. Then the requirements arrived. "On the 13th and 14th, do not rotate — just run the grid." "After the 29th, stop buying and only hold." The conditionals tangled fast.

The replacement was a single array indexed by day of month.

// Index = day of the month (1-31). Slot 0 is unused.

//   0 : buy mid-month ETF, sell month-end ETF

//   1 : buy month-end ETF, sell mid-month ETF

//   3 : mid-month ETF buying closed - hold, grid trading only

//   4 : month-end ETF buying closed - hold, grid trading only

//   9 : unused

//                   [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];

Deciding what today calls for is now one array read.

var iDay = Number(currentDate.getDate());

var [BuyDay15, BuyDay30] = getSwapBuyDay();   // dividend deadlines for both

var FromSymbol, ToSymbol, SwapLastDay;

if (SwapOrderDayOpen[iDay] == 0 || SwapOrderDayOpen[iDay] == 3) {

  FromSymbol  = OrderSymbol[1];     // month-end -> mid-month

  ToSymbol    = OrderSymbol[0];

  SwapLastDay = Number(BuyDay15);

} else if (SwapOrderDayOpen[iDay] == 1 || SwapOrderDayOpen[iDay] == 4) {

  FromSymbol  = OrderSymbol[0];     // mid-month -> month-end

  ToSymbol    = OrderSymbol[1];

  SwapLastDay = Number(BuyDay30);

} else {

  continue;                          // 9 = do nothing today

}

Three reasons this array is better. The rule is visible — it reads like a calendar, so "why does nothing happen on the 13th?" is answered by looking at the array, not the code. It is easy to change — if the product's dividend schedule shifts, you edit a few digits. And separating 0/1 from 3/4 keeps "a rotation day" and "a hold-only day" as genuinely different values.

That last point matters in practice. Buying right before a dividend deadline collects the distribution but eats the full ex-dividend drop. So for the days leading into a deadline, new buying closes and the bot runs the grid on existing holdings only. That is what 3 and 4 encode.

Why the deadline is a parameter

BuyDay15 and BuyDay30 are not hardcoded — they are values the operator types into the UI, which is why each ticker card has a "dividend purchase deadline" field.

The reason is simple: products change their schedules. An issuer can shift the record date, or you may swap in an entirely different instrument. Bake that into code and every change needs a redeploy — and a forgotten redeploy means orders on the wrong day. As a parameter, it is a field edit.

The code uses it only as the cutoff for "how late may I still rotate."

// Past SwapLastDay, do not rotate - entering after the deadline

// means taking the ex-dividend drop without the distribution.

if (pofitCloseMax < SymbolprofitPer

    && SwapLastDay > iDay

    && iHourMin > startSwap && iHourMin < endSwap

    && (mainsymbol == ToSymbol || SymbolNavgapSwap > NavgapMin)) {

  SwapTrading(FromSymbol, ToSymbol, SellQty, BuyQty);

}

Four conditions: profit is above target, we are before the deadline, we are inside the rotation window, and the gap condition holds. Miss any one and nothing happens that day — and having "do nothing" as the default is far safer in a trading bot.

NAV gap and order gap are different things

These are easy to confuse. The bot has two kinds of "difference" and they do unrelated jobs.

NAV gap

How far the ETF price sits from net asset value. It matters for rotation because moving while the gap is wide pays that difference as a cost. Selling something undervalued to buy something overvalued is the worst case.

So the bot compares both NAV gaps, and when the difference is negligible it closes out instead of rotating.

var SymbolNavgapSwap = Number(SymbolNavgap[OrderSymbol[0]])

                     - Number(SymbolNavgap[OrderSymbol[1]]);

var SymbolprofitPer  = Number(QtyMap[OrderSymbol[0]].profitPer)

                     + Number(QtyMap[OrderSymbol[1]].profitPer);

// Gap difference inside +/-0.03: no reason to rotate -> close

if ((SymbolNavgapSwap < 0.03 && SymbolNavgapSwap > -0.03)

    || SymbolprofitPer > Number(pofitCloseMax)) {

  setOpen("Order");

  SwapClose(OrderSymbol[0], QtyMap);

  SwapClose(OrderSymbol[1], QtyMap);

  setOpen("Close");

  continue;

}

Order gap

This one is for grid trading: how far from the current price to place the next order. Two settings bound it.

setMinGap("0.002");   // 0.2% floor

setMaxGap("0.02");    // 2% ceiling

The interesting part is that the value is derived from held quantity rather than fixed.

var gapPer = 0;

var minGapPer = Number(getMinGap());

// More holdings -> tighter spacing; fewer holdings -> wider

if (nowprice0 > nowprice1) {

  gapPer = 1 / Number(QtyMap[OrderSymbol[0]].totalBal);

} else {

  gapPer = 1 / Number(QtyMap[OrderSymbol[1]].totalBal);

}

if (gapPer < minGapPer) {

  gapPer = minGapPer;          // put a floor under it

}

setNextPricePer(0, gapPer);

setNextPrice(0, nowprice0 * gapPer);

What 1 / quantity buys you: wide spacing when holdings are small so the bot does not fire orders casually, tightening as the position builds. A hundred shares gives 1%, five hundred gives 0.2%. And without the minGap floor the spacing converges toward zero as quantity grows, eventually falling below the tick size. Skip the floor and orders pour out endlessly.

Where rotation meets the grid

The two strategies divide along the time axis.

  • Rotation happens roughly twice a month, only inside defined date ranges
  • The grid runs continuously in between, working the swings within whatever is held

Values 3 and 4 in the date array are exactly that boundary: "new buying closed, grid still running" expressed as a single value. Without that state the bot keeps accumulating right up to the dividend deadline, which — as above — is where you take the drop without the benefit.

One more detail. The rotation window shifts slightly every day.

// Randomize the rotation window each session

setSwapTime(randomFloatRange(9.31, 13.01),

            randomFloatRange(14.01, 15.01));

The reason is execution quality. Ordering the same instrument in the same direction at the same minute every day leaves your own footprint in that minute's book. Jittering the window blurs the pattern. At individual size the market impact is hard to argue for, but this costs essentially nothing, so it stayed.

What running it actually taught me

A few things only showed up after the code was live.

Most days, nothing happens. Either the array says 3 or 4, or one of the four conditions fails, and the day passes untouched. At first this looked like the bot was broken. It is correct behavior — which is why a status log explaining why it is not buying right now turned out to matter as much as the trading logic.

NAV gaps move more than expected, especially right after the open and near the close. That is why the rotation window starts after 9:30 rather than at the bell.

And it loses money sometimes. Collect a distribution and the account still shrinks if the ex-dividend drop plus price decline exceeds it. This structure does not generate returns; it executes a fixed rule without a human hand. If the rule is wrong, the bot will execute the wrong rule very diligently.

Balance history chart from a live account, amounts withheld

A live account (amounts withheld). It rises in some stretches and falls in others — this structure does not prevent losses.

Summary

  • Express date-dependent rules as an array indexed by day, not an if-chain — it reads like a calendar and edits like one
  • Keep "rotation day" and "hold-only day" as distinct values so you can close buying before a dividend deadline
  • The deadline belongs in parameters, not in code — products change their schedules
  • NAV gap and order gap are unrelated — one decides whether to rotate, the other decides where to place orders
  • Deriving grid spacing from 1/quantity self-adjusts, but without a floor the orders never stop
  • When conditions do not line up, doing nothing has to be the default

The next post covers why this bot splits notifications into a user channel and a developer channel.

Note: this is an engineering write-up, not investment advice. The strategy described here guarantees no return and can lose principal. Distributions come with ex-dividend adjustments, and ETFs carry additional NAV-gap, liquidity and expense-ratio risk. Automated trading can amplify losses through software bugs, network failures and market conditions nobody planned for. No specific instrument is recommended; trading decisions and their outcomes are entirely your own responsibility. Validate thoroughly with paper trading or minimal size before pointing anything at a live account.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기