AI를 활용한 재밌는 것들을 개발합니다

2026년 5월 4일 월요일

TWAP 분할 주문 & 자동 정정 주문 시스템 구현기 [Claude Code · 자동매매 리밸런싱 · 3편]

TWAP 분할주문 OrderQueue asyncio 미체결 정정

TWAP 분할 주문 & 자동 정정
주문 시스템 구현기

균등 리밸런싱 엔진부터 10초 간격 큐, 마지막 회차 소급 보정까지 — 실전 코드 공개

📌 시리즈 3편입니다
2편에서 키움 REST API 클라이언트와 계좌 조회를 완성했습니다. 이번 편에서는 리밸런싱 계산 엔진, 10초 간격 주문 큐, 미체결 자동 정정, 시간 스케줄 관리까지 주문 시스템 전체를 다룹니다.

전체 주문 흐름

자동매매 시스템의 핵심은 언제, 무엇을, 얼마나 주문할지를 정확히 계산하고, 순서대로 실행하는 것입니다.

[ScheduleManager] 시간 도래 감지
    ↓
[RebalancingEngine] 매도/매수 계획 계산
    ↓
[OrderManager] 회차별 주문 생성
    ↓
[OrderQueue] 10초 간격 순차 실행
    ↓
[키움 REST API] 실제 주문 전송
    ↓
[미체결 폴링] 3분마다 체결 확인 → 10분 경과 시 정정

RebalancingEngine — 균등 배분 계산

리밸런싱 엔진은 계좌 현황을 받아 무엇을 얼마나 매도/매수해야 하는지 계획을 세웁니다. 비중 설정이 있으면 그에 따라, 없으면 균등으로 계산합니다.

def build_plan(self, account_info, excluded_codes, added_stocks,
               sell_split, buy_split, sell_enabled, buy_enabled, weights):

    active = [p for p in account_info.positions if p.code not in excluded_codes]
    # 편입 종목 중 이미 보유한 종목은 제외하고, 제외 목록도 건너뜀
    added_codes = {s["code"] for s in added_stocks if s["code"] not in excluded_codes}
    new_codes = added_codes - {p.code for p in active}

    n = len(active) + len(new_codes)
    default_w = 100.0 / n if n > 0 else 0.0
    total_eval = sum(p.eval_amount for p in active) + account_info.deposit

    def get_weight(code):
        return weights.get(code, default_w)

    def get_target(code):
        return int(total_eval * get_weight(code) / 100)

    sell_plans, buy_plans = [], []
    for pos in active:
        diff = pos.eval_amount - get_target(pos.code)
        if diff > 0 and sell_enabled:
            total_qty = diff // pos.current_price
            if total_qty > 0:
                sell_plans.append(SellPlan(..., round_qtys=_compute_sell_rounds(total_qty, sell_split)))
        elif diff < 0 and buy_enabled:
            buy_plans.append(BuyPlan(..., round_amounts=_compute_buy_rounds(-diff, buy_split, pos.current_price)))

누산기 방식 수량 분배

10분할 시 수량이 딱 나누어떨어지지 않을 때가 많습니다. 예를 들어 총 33주 10분할이면 회차마다 3.3주를 해야 하는데, 이걸 어떻게 처리할까요?

Claude Code가 제안한 누산기(accumulator) 방식을 사용합니다. 소수점을 누적했다가 1 이상이 되면 그만큼 주문하고 남은 소수를 다음 회차로 이월합니다.

def _compute_sell_rounds(total_qty, total_rounds):
    step = total_qty / total_rounds
    acc, qtys, total_done = 0.0, [], 0
    for i in range(total_rounds):
        acc += step
        q = int(acc)    # 소수점 버림
        acc -= q        # 나머지 이월
        qtys.append(q)
        total_done += q
    # 마지막 회차에 나머지 수량 보정
    qtys[-1] += total_qty - total_done
    return qtys
예시: 33주 10분할
step = 3.3 → [3, 3, 3, 3, 3, 3, 3, 3, 3, 6] (마지막 회차 3→6으로 보정)
총합 = 33주 정확히 일치

OrderQueue — 10초 간격 순차 실행

키움 API는 짧은 시간에 많은 주문을 보내면 에러를 반환합니다. asyncioQueue를 활용해 10초 간격으로 1종목씩 순차 처리합니다.

import asyncio

class OrderQueue:
    ORDER_INTERVAL = 10  # 주문 간격 (초)

    def __init__(self):
        self._queue: asyncio.Queue = asyncio.Queue()
        self._worker_task = None

    async def enqueue(self, task):
        await self._queue.put(task)

    async def _worker(self):
        while True:
            task = await self._queue.get()
            try:
                await task.execute()
            except Exception as e:
                await self.log_fn(f"주문 실패: {e}")
            finally:
                self._queue.task_done()
                await asyncio.sleep(self.ORDER_INTERVAL)

OrderManager — 주문 접수 & 미체결 정정

OrderManager는 실제 주문 API를 호출하고, 미체결 주문을 추적합니다. 키움 REST API의 주문 응답에서 주문번호를 추출하는 것이 핵심입니다.

async def place_order(self, code, order_type, qty, price=0):
    body = {
        "acnt_no": self.account_no,
        "stk_cd": code,
        "ord_qty": str(qty),
        "ord_prc": "0",  # 시장가
        "buy_sell_tp": "01" if order_type == "sell" else "02",
        "ord_tp": "01",  # 시장가
    }
    data = await self.client.post_order("/api/dostk/ordr", body)

    # 주문번호 키가 tr_id마다 다름 — 모두 시도
    output = data.get("output", data)
    order_no = ""
    for key in ("ord_no", "odno", "ORD_NO", "ordNo"):
        val = str(output.get(key, "")).strip()
        if val and val != "0":
            order_no = val; break
    return order_no

마지막 회차 소급 보정

10회차 분할 주문에서 중간에 주문이 실패하거나 부분 체결되면, 계획보다 적게 매도/매수됩니다. 마지막 회차에서 이를 자동으로 보정합니다.

async def execute_sell_round(self, plan, round_idx, is_last):
    if is_last:
        # 앞 회차까지 실제로 주문된 총 수량 확인
        already = self._submitted_qty(plan.code, "sell")
        qty = plan.total_qty - already
        if qty != plan.round_qtys[round_idx]:
            await self.log_fn(
                f"[마지막 회차 소급] {plan.name} "
                f"계획 {plan.round_qtys[round_idx]}주 → 실제 잔여 {qty}주"
            )
    else:
        qty = plan.round_qtys[round_idx]

    if qty > 0:
        await self.place_order(plan.code, "sell", qty)
소급 보정이 필요한 이유
10회차 중 3회차에서 주문이 실패하면, 10회차가 되어도 총 매도 목표량에 미달합니다. 마지막 회차에서 "앞 회차까지 실제로 주문된 양"을 확인해 남은 수량을 한 번에 처리합니다.

미체결 폴링 & 주문 정정

주문 접수 후 일정 시간이 지나도 체결이 안 되면 현재가로 주문 정정을 보냅니다.

1
매 3분(설정 가능)마다 미체결 주문 목록 조회
2
주문 접수 후 10분 경과한 주문 식별
3
해당 종목의 현재가 조회
4
정정 주문 API 호출 (미체결 수량을 현재가로 정정)

ScheduleManager — 시간 기반 자동 실행

매도 시작 시간, 매수 시작 시간이 되면 자동으로 주문 프로세스를 시작합니다. asyncio로 1분마다 현재 시각을 체크합니다.

async def _run(self):
    while self.active:
        now = datetime.datetime.now()
        hhmm = now.strftime("%H:%M")

        if not self._sell_done and self.config.sell_enabled:
            if hhmm == self.config.sell_start:
                await self._start_sell()

        if not self._buy_done and self.config.buy_enabled:
            if hhmm == self.config.buy_start:
                # 매도 미체결이 남아있으면 대기
                if await self._sell_cleared():
                    await self._start_buy()

        await asyncio.sleep(30)  # 30초마다 체크
⚠ 매수 전 매도 체결 확인
매수 시작 시각이 됐더라도 미체결 매도 잔량이 있으면 매수를 시작하지 않습니다. 폴링 방식으로 3분마다 확인해서 매도가 완전히 체결된 후에 매수를 시작합니다.

주문 계획 미리보기 — 실행 전 최종 확인

자동매매 시작 전, 어떤 종목을 몇 주 사고팔지 회차별로 미리 확인할 수 있습니다. Claude Code가 제안해서 추가된 안전장치입니다.

항목내용
매도 대상종목명, 현재가, 평가금액, 목표금액, 총 매도수량, 회차별 수량
매수 대상종목명, 현재가, 평가금액, 목표금액, 총 매수금액, 회차별 금액
제외 종목리밸런싱에서 제외된 종목 목록

매도/매수 대상이 화면에 펼쳐지면 내용이 많아 스크롤이 길어지는 문제가 있었습니다. Claude Code가 탭 UI로 매도/매수를 전환하며 보는 방식을 제안했고, 훨씬 보기 편해졌습니다.

✅ 3편 완성 체크리스트
✔ RebalancingEngine — 균등/비중 배분 계산
✔ 누산기 방식 수량 분배 (소수점 이월)
✔ OrderQueue — 10초 간격 asyncio 순차 처리
✔ OrderManager — 주문번호 추출, 마지막 회차 소급 보정
✔ 미체결 폴링 & 10분 경과 시 주문 정정
✔ ScheduleManager — 매도/매수 시간 자동 트리거
✔ 주문 계획 탭 미리보기

댓글 없음:

댓글 쓰기

가장 많이 본 글