키움증권 Open API(REST + WebSocket)로 미국주식 계좌를 감시하다가, 등록해둔 조건(가격)에 도달하면 사람 개입 없이 매도·매수 주문을 직접 전송하는 데스크톱 프로그램입니다. 매매 판단은 여전히 제가 하고, 이 프로그램은 그 판단을 실행하는 규율만 담당합니다 — 화면 앞에 없어도, 감정이 흔들려도 등록해둔 규칙은 그대로 지켜집니다.

01왜 만들었나

프리마켓·애프터마켓 구간에서는 시장가 주문 자체가 불가능합니다. 그래서 이 프로그램의 모든 주문은 발동 시점 현재가에서 일정 비율만큼 벗어난 지정가로 나갑니다 — 익절은 현재가의 −0.5%, 손절은 −1%. 시장가가 아닌데도 사실상 즉시 체결되도록 스스로에게 걸어둔 규칙입니다.

# 프리·애프터마켓에서는 시장가가 불가능해 현재가보다 낮은 지정가로 매도
TAKE_PROFIT_OFFSET = 0.995    # 익절: 발동 시점 현재가의 -0.5%
STOP_LOSS_OFFSET   = 0.99     # 손절: 발동 시점 현재가의 -1%

02구조 — 두 개의 통로, 하나의 메인 스레드

REST API(토큰 발급·시세조회·잔고조회·주문)와 WebSocket(실시간 체결가 FE, 실시간 주문체결 F5) 두 채널을 함께 씁니다. 특히 F5는 계좌 전체의 체결·주문 이벤트를 종목 등록 여부와 무관하게 흘려보내는데, 그 안에 보유수량이 이미 계산되어 들어 있습니다. 매도 신호가 뜬 순간 보유수량을 다시 조회할 필요가 없다는 뜻이고, 이 설계 하나로 판단과 주문 사이의 지연이 왕복 요청 한 번만큼 줄어듭니다.

GUI가 도는 메인 스레드와, 웹소켓·텔레그램·안전망 폴링을 도는 백그라운드 스레드들은 상태를 직접 공유하지 않습니다. 전부 큐(Queue)로만 대화합니다 — 백그라운드는 결과를 큐에 담아 던지기만 하고, 실제 리스트 수정과 화면 갱신은 주기적으로 도는 메인 스레드 폴러가 전담합니다. 스레드 세이프티를 습관이 아니라 구조로 강제한 형태입니다.

03감시 네 가지

  • 익절(Take Profit) — 현재가가 목표가 이상이면 보유수량의 설정 비중만큼 매도.
  • 손절(Stop Loss) — 현재가가 목표가 이하면 매도. 다만 익절보다 다뤄야 할 문제가 하나 더 있습니다 (다음 항목).
  • 매수감시(스탑바이) — 현재가가 목표가 이상으로 오르면 매수. 방향만 반대인 동일한 스탑.
  • 트레일링("스탑의 스탑") — 가격이 도달가 위로 오르면 그 자체로 매매하지 않고, 그 자리에서 새로운 손절감시를 만들어냅니다. 주가가 오르는 만큼 방어선이 자동으로 따라 올라가는 셈입니다.

04손절의 진짜 난제 — 이미 깔려있는 매도주문

한 종목에 익절 주문이 이미 미체결로 걸려있는 상태에서 손절 조건까지 뜨면, 실제로 팔 수 있는 "자유 수량"은 보유수량에서 그 미체결분을 뺀 값입니다. recalc_stoploss_plan()은 이걸 조건이 뜨기 전에 미리 계산해두고, 모자라면 취소 후 재주문(왕복 2번, 그 사이 오버셀 리스크)이 아니라 기존 주문의 가격만 손절가로 정정하는 쪽을 택합니다. 정정 대상은 가격이 제일 높은(=제일 안 팔릴 것 같은) 주문부터 우선순위를 매겨, 필요한 만큼만 편입합니다.

pending_orders = sorted(pending_sell_orders.get(stk_cd, {}).values(),
                         key=lambda o: o["price"], reverse=True)
free = max(0, holdings_qty - sum(o["qty"] for o in pending_orders))

# 자유 수량만으로 충분하면 정정 없이 신규매도만 준비
# 부족하면 비싼(=안 팔릴) 주문부터 정정 대상으로 편입해 부족분을 채움

05실패를 전제로 한 설계

정정 API는 "가장 최근 주문번호"를 알아야 하는데, 정정을 거듭할수록 그 번호는 계속 바뀝니다. 반면 내부 추적은 "최초 주문번호"라는 고정된 식별자로 이어가야 합니다. 이 둘을 stable_key(추적용 고정 식별자)와 ord_no(실제 API 호출에 쓰는 최신 번호)로 분리해서 관리합니다. 실거래로 확인된 레이스 컨디션 — 막 정정된 주문을 아주 짧은 간격으로 또 정정하려 하면 서버가 거부하는 경우 — 이 발생하면, 그 자리에서 서버의 실제 미체결 현황을 다시 받아와 계획을 재수립하고 즉시 재주문합니다. "이번 정정이 실패했다"로 끝나는 게 아니라 "손절 자체가 누락되지 않는다"를 보장하는 게 핵심입니다.

06이중 안전망

실시간 이벤트가 유실되거나 순서가 꼬일 가능성은 항상 열어둡니다. 30초마다 미체결 매도주문 전체를 서버에서 다시 받아 로컬 장부를 통째로 재동기화하고, 보유수량도 120초 주기로 별도 재확인합니다. 계정이 다른 곳에서 로그인되어 토큰이 무효화되면(키움 API는 동시접속을 허용하지 않습니다) 즉시 감지해서 모달로 알리고, 확인을 누르면 토큰 재발급과 웹소켓 재연결까지 자동으로 시도합니다.

07원격 제어 — 텔레그램

화면 앞에 계속 붙어있지 않아도 되도록, 감시 등록·삭제·목록조회·로그파일 수신을 텔레그램 명령으로 처리하고, 모든 발동·성공·실패를 텔레그램으로도 알립니다.

08지금 이 자리

버전 16.3. 코드 곳곳의 "[설계원칙 변경]", "실측 확인" 같은 주석들은 실거래에서 관찰한 문제를 다음 버전의 재료로 삼아온 흔적입니다. 정정 실패 패턴을 관찰(Observe)하고, 재시도 전략을 조율(Tune)하고, 다시 실거래에 반영(Iterate)해온 루프 그 자체가 지금 이 문서가 서 있는 FOTIB 순환과 정확히 겹칩니다. 지금은 그 관찰과 조율을 계속 반복하는 단계입니다.