AlgoLab Blog · 토스증권 오픈API · 조건주문 · 2026

토스증권 조건주문 API — OCO는 종목당 1개

토스증권 · 주문 2026-09-14 · 약 7분 읽기 · 알고랩 AlgoLab
한 줄 요약 POST /api/v1/conditional-orders가격 감시를 토스증권 서버로 넘깁니다 — 봇이 꺼져 있어도 손절이 걸립니다. 타입은 SINGLE·OCO·OTO 셋인데 제약이 붙습니다. OCO·OTO는 한 종목에 1개만 걸 수 있고(중복 시 422 duplicate-conditional-order), 지정가(LIMIT)만 되며, expireDate가 필수라 무기한 감시는 없습니다.

토스증권 오픈API 자동매매 가이드는 조건주문을 한 줄로만 소개했고, 토스증권 주문 정정·취소는 조건주문의 경로·메서드가 다르다는 데까지만 다뤘습니다. 이 글은 그 앞 단계 조건주문을 어떻게 만드는가 하나만 봅니다. 기준은 공식 스펙 openapi.tossinvest.com/openapi-docs/latest/openapi.json(v1.2.15)과 overview.md의 Rate Limits·에러 표 원문입니다.

목차

  1. 조건주문이 대신해 주는 것 — 감시 주체가 바뀐다
  2. SINGLE·OCO·OTO — 세 타입의 제약표
  3. 함정 ① OCO·OTO는 종목당 1개다
  4. 함정 ② 수정은 부분 수정이 아니라 전체 재설정
  5. 함정 ③ 상태가 2층이다
  6. 함정 ④ 목록에 앱에서 건 조건주문도 섞여 온다
  7. 실전 코드 — 진입과 동시에 손절·익절 걸기
  8. 자주 묻는 질문

1. 조건주문이 대신해 주는 것 — 감시 주체가 바뀐다

직접 만든 봇의 손절은 시세를 계속 받고 조건을 계산해 주문을 내는 루프이고, 이 루프가 내 컴퓨터에서 돌아야 합니다. 노트북이 절전으로 들어가거나 인터넷이 끊기면 손절도 같이 멈춥니다. 조건주문은 그 루프를 증권사 서버로 옮깁니다 — 감시가(triggerPrice)와 매매 방향만 등록하면 가격이 닿았을 때 토스증권이 주문을 만듭니다.

구분내용 (공식 스펙 원문 기준)
엔드포인트POST /api/v1/conditional-orders (서버 https://openapi.tossinvest.com)
필수 헤더X-Tossinvest-AccountGET /api/v1/accountsaccountSeq
발동 세션 (국내)KRX 정규장에서만 발동
발동 세션 (해외)장 구분과 상관없이 거래 가능한 모든 시간대에 발동
호출 한도CONDITIONAL_ORDER 초당 5회 / CONDITIONAL_ORDER_HISTORY 초당 10회
멱등키clientOrderId (최대 36자, [a-zA-Z0-9-_])

국내 종목의 발동 세션이 KRX 정규장으로 한정된다는 점을 먼저 보십시오. 시간외단일가나 장 개시 전 동시호가 구간의 가격 움직임으로는 조건이 발동하지 않습니다. "밤사이 악재가 나면 조건주문이 알아서 처리해 준다"는 기대는 국내 종목에는 성립하지 않습니다. 해외 종목은 반대로 거래 가능한 모든 시간대에 발동합니다.

2. SINGLE·OCO·OTO — 세 타입의 제약표

type감시 구조방향 제약호가유형종목당 개수
SINGLEfirst 한 조건만 감시제약 없음 (BUY/SELL)LIMIT·MARKET제한 없음
OCO둘을 동시 감시, 하나 충족 시 나머지 자동 취소first·second 둘 다 SELL
first 감시가 > 현재가 > second 감시가
LIMIT1개
OTOfirst 체결 후 second 감시 시작first=BUY, second=SELLLIMIT1개

공식 스펙에 실린 요청 예시 네 가지를 그대로 옮깁니다.

// SINGLE 지정가 — orderPrice 필수
{"symbol":"005930","type":"SINGLE","quantity":"100","orderType":"LIMIT",
 "clientOrderId":"my-order-001","expireDate":"2026-09-10",
 "first":{"orderSide":"SELL","triggerPrice":"295","orderPrice":"295"}}

// SINGLE 시장가 — orderPrice를 넣으면 안 된다
{"symbol":"005930","type":"SINGLE","quantity":"100","orderType":"MARKET",
 "clientOrderId":"my-order-002","expireDate":"2026-09-10",
 "first":{"orderSide":"SELL","triggerPrice":"295"}}

// OCO — 익절(305) + 손절(295), 둘 다 SELL
{"symbol":"005930","type":"OCO","quantity":"100","orderType":"LIMIT",
 "clientOrderId":"my-order-003","expireDate":"2026-09-10",
 "first" :{"orderSide":"SELL","triggerPrice":"305","orderPrice":"305"},
 "second":{"orderSide":"SELL","triggerPrice":"295","orderPrice":"294.5"}}

// OTO — 290에 사고, 체결되면 320 감시 시작
{"symbol":"005930","type":"OTO","quantity":"100","orderType":"LIMIT",
 "clientOrderId":"my-order-004","expireDate":"2026-09-10",
 "first" :{"orderSide":"BUY", "triggerPrice":"290","orderPrice":"290"},
 "second":{"orderSide":"SELL","triggerPrice":"320","orderPrice":"320"}}

quantityorderType은 그룹 공통값입니다. first·second 안에 따로 넣는 값이 아닙니다. 즉 "절반은 익절하고 절반은 손절"처럼 수량을 나누는 OCO는 만들 수 없습니다 — 스펙이 "OCO/OTO 는 동일 포지션이라 first/second 가 같은 수량을 씁니다"라고 못 박고 있습니다. 분할 청산이 필요하면 조건주문 하나로는 안 되고 설계를 바꿔야 합니다.

expireDate가 필수다

expireDate(YYYY-MM-DD)는 선택이 아니라 필수입니다. 만료일까지 조건이 충족되지 않으면 조건주문은 EXPIRED로 자동 종료됩니다. 무기한으로 걸어 두는 옵션이 없다는 뜻이라, 장기 보유 포지션에 손절을 상시로 유지하려면 봇이 만료 전에 다시 등록하거나 modify로 날짜를 미는 작업을 스스로 해야 합니다. 이 갱신을 빠뜨리면 어느 날 조용히 손절이 사라져 있습니다.

3. 함정 ① OCO·OTO는 종목당 1개다

공식 에러표에 이 제약이 한 줄로 들어 있습니다.

HTTP 422 Unprocessable Entity
{
  "error": {
    "requestId": "01HXYZABCDEFG123456789",
    "code": "duplicate-conditional-order",
    "message": "동일 종목에 이미 그룹 조건주문(OCO/OTO)이 있습니다."
  }
}
// 스펙 주석: OCO·OTO 는 종목당 1개 / SINGLE 은 제한 없음

분할 매수 봇이 여기서 막힙니다. 삼성전자를 세 번에 나눠 사면서 각 진입마다 OCO를 걸어 두는 설계는 두 번째 등록부터 422입니다. 우회 방법은 두 가지입니다 — ⑴ 포지션을 합쳐 OCO 하나로 관리하고 체결될 때마다 modifyquantity와 감시가를 갱신하거나, ⑵ SINGLE 여러 개로 나눠 걸고 한쪽이 체결되면 나머지를 봇이 직접 취소하는 것입니다. ⑵는 자동 취소를 봇이 떠안는다는 뜻이라 OCO의 장점이 사라집니다.

4. 함정 ② 수정은 부분 수정이 아니라 전체 재설정

수정 경로는 POST /api/v1/conditional-orders/{conditionalOrderId}/modify입니다. PATCH가 아니고, 의미도 부분 수정이 아닙니다. 공식 설명이 "조건주문 전체를 재설정하므로 유지할 조건도 함께 전달해야 합니다"라고 적고 있습니다.

항목등록(POST)수정(modify)
symbol필수넣지 않는다 (conditionalOrderId로 식별)
type·quantity·orderType·expireDate·first필수전부 필수
secondOCO/OTO만빠뜨리면 그 조건이 사라진다
타입 전환허용 (예: SINGLEOCO)

손절가만 올리려다 익절 조건을 지우는 사고가 여기서 납니다. {"first": {...}}만 보내면 second가 없는 요청이 되고, 결과는 익절 조건이 사라진 상태입니다. 안전한 구현은 수정 전에 상세 조회로 현재 값을 받아 그대로 실어 보내고, 바꿀 항목만 덮어쓰는 것입니다.

5. 함정 ③ 상태가 2층이다

조회 응답의 status그룹 단위이고, first.status·second.status조건(leg) 단위입니다. 두 집합이 같지 않습니다.

상태그룹 statusleg status
WATCHINGOO조건 감시 중
PAUSEDOO일시중지
ORDERING / ORDEREDOO조건 충족 — 주문 생성 중 / 생성됨
COMPLETED / EXPIREDOO완료 / 만료
HOLDINGXOOTO의 first 체결 전 대기
CANCELEDXOOCO 한쪽 충족으로 자동 취소된 반대편

스펙이 "leg 전용 상태인 HOLDING·CANCELED 는 최상위 status 로는 내려오지 않습니다"라고 명시합니다. 최상위만 읽는 봇은 OTO의 second가 아직 대기 중인지를 구분할 수 없습니다. 그룹 status는 살아 있는 조건의 상태를 대표로 따르기 때문입니다.

조건이 발동해 주문이 만들어지면 leg에 triggeredOrderId가 채워집니다. 이 값은 일반 주문 API에 그대로 쓸 수 있는 주문 IDGET /api/v1/orders/{orderId}로 체결 결과를 이어서 추적하면 됩니다 (토스증권 주문 정정·취소 참고).

6. 함정 ④ 목록에 앱에서 건 조건주문도 섞여 온다

GET /api/v1/conditional-orders의 공식 설명입니다 — "이 API 로 등록한 조건주문뿐 아니라 다른 채널(토스증권 앱 등)에서 등록한 조건주문도 함께 반환됩니다."

목록을 받아 전부 취소하는 코드는 사람이 앱에서 걸어 둔 조건주문까지 지웁니다. 등록할 때 clientOrderIdbot- 같은 접두사를 붙여 두고, 봇이 만든 것만 골라 취소·수정하십시오. 계좌를 사람과 봇이 같이 쓰는 경우 이건 선택이 아니라 필수입니다.

조회 파라미터에도 잔가시가 있습니다.

OCO first SELL 305 (익절) second SELL 295 (손절) 하나 충족 = 나머지 CANCELED 동시 감시 · 둘 다 SELL 지정가만 · 종목당 1개 OTO first BUY 290 second SELL 320 체결 전에는 HOLDING 진입 → 청산 순차 감시
OCO는 동시 감시 후 한쪽 자동 취소, OTO는 first 체결 뒤에야 second 감시가 시작된다

7. 실전 코드 — 진입과 동시에 손절·익절 걸기

import requests, datetime as dt

BASE = "https://openapi.tossinvest.com"

def headers(token, account_seq):
    return {"Authorization": f"Bearer {token}",
            "X-Tossinvest-Account": str(account_seq),
            "Content-Type": "application/json"}

def place_oco(token, seq, symbol, qty, take_profit, stop_loss, days=30, tag="bot"):
    # OCO — first(익절) 감시가 > 현재가 > second(손절) 감시가, 둘 다 SELL, 지정가만
    body = {
        "symbol": symbol, "type": "OCO", "quantity": str(qty),
        "orderType": "LIMIT",
        "clientOrderId": f"{tag}-{symbol}-{dt.datetime.now():%Y%m%d%H%M%S}",
        "expireDate": (dt.date.today() + dt.timedelta(days=days)).isoformat(),
        "first":  {"orderSide": "SELL", "triggerPrice": str(take_profit),
                   "orderPrice": str(take_profit)},
        "second": {"orderSide": "SELL", "triggerPrice": str(stop_loss),
                   "orderPrice": str(stop_loss)},
    }
    r = requests.post(f"{BASE}/api/v1/conditional-orders",
                      headers=headers(token, seq), json=body, timeout=10)
    if r.status_code == 422:
        err = r.json().get("error", {})
        if err.get("code") == "duplicate-conditional-order":
            return {"skipped": "이미 이 종목에 OCO/OTO가 있다 — modify로 갱신할 것"}
    r.raise_for_status()
    return r.json()["result"]          # {"conditionalOrderId": ..., "clientOrderId": ...}

def my_open_orders(token, seq, tag="bot"):
    # status는 필수 — OPEN/CLOSED 중 하나만. 앱에서 건 것이 섞여 오므로 tag로 거른다
    out, cursor = [], None
    while True:
        q = {"status": "OPEN", "limit": 100}
        if cursor:
            q["cursor"] = cursor
        d = requests.get(f"{BASE}/api/v1/conditional-orders",
                         headers=headers(token, seq), params=q, timeout=10).json()["result"]
        out += [c for c in d["conditionalOrders"]
                if (c.get("clientOrderId") or "").startswith(tag)]
        if not d.get("hasNext"):
            return out
        cursor = d["nextCursor"]

위 익절·손절 가격은 호출 형태를 보여 주는 자리표시자입니다. 어떤 손절폭이나 목표 수익률이 좋은지는 이 글이 답할 수 없고, 특정 값이 수익을 올린다고 주장하지 않습니다. 조건주문은 감시를 대신해 줄 뿐 체결을 보장하지 않습니다 — 감시가에 닿아 생성되는 것은 지정가 주문이므로 급락 구간에서는 체결되지 않고 남을 수 있고, 국내 종목은 KRX 정규장 밖에서는 발동 자체가 일어나지 않습니다. 호가 단위가 맞지 않으면 400 invalid-requesttickSizenearestPrices가 함께 내려오니 그 값으로 보정하십시오. 수수료·세금·호출 한도·조건주문 정책은 토스증권 공식 문서와 약관의 현재 내용으로 확인하십시오. 과거 데이터가 미래를 보장하지 않으며 이 글은 특정 종목이나 매매 방식을 권하지 않습니다.

8. 자주 묻는 질문

토스증권 API로 손절 주문을 미리 걸어 둘 수 있나요?

걸 수 있습니다. POST /api/v1/conditional-orders 로 등록하면 감시를 토스증권 서버가 하므로 봇을 띄운 컴퓨터가 꺼져 있어도 감시는 계속됩니다. 요청에는 symbol, type, quantity, orderType, expireDate, first 가 필수이고 first 안에 orderSide 와 triggerPrice 를 넣습니다. 다만 국내 주식은 KRX 정규장에서만 발동되고 해외 주식은 거래 가능한 모든 시간대에 발동됩니다.

OCO와 OTO는 뭐가 다른가요?

OCO는 One-Cancels-the-Other 로 두 조건을 동시에 감시하다 하나가 충족되면 나머지가 자동 취소됩니다. 손절선과 익절선을 같이 거는 형태이고 first 와 second 가 모두 SELL 이어야 하며 first 감시가가 현재가보다 높고 second 감시가가 현재가보다 낮아야 합니다. OTO는 first 가 체결된 다음에야 second 감시가 시작되며 first 가 BUY, second 가 SELL 입니다. 둘 다 지정가 LIMIT 만 지원합니다.

조건주문을 종목마다 여러 개 걸 수 있나요?

SINGLE 은 제한이 없지만 OCO 와 OTO 는 종목당 1개입니다. 이미 그룹 조건주문이 있는 종목에 또 등록하면 422 응답에 duplicate-conditional-order 코드가 내려옵니다. 분할 매수처럼 같은 종목에 여러 포지션을 따로 관리하는 봇은 OCO 를 포지션마다 걸 수 없으므로 SINGLE 여러 개를 쓰거나 수량을 합쳐 그룹 하나로 관리해야 합니다.

조건주문 수정은 바꾸고 싶은 값만 보내면 되나요?

아닙니다. modify 는 조건주문 전체를 재설정합니다. 공식 설명이 유지할 조건도 함께 전달해야 한다고 명시합니다. type, quantity, orderType, expireDate, first 가 모두 필수이고 second 를 빼면 그 조건은 사라집니다. 종목은 conditionalOrderId 로 식별하므로 symbol 은 넣지 않으며 SINGLE 을 OCO 로 바꾸는 타입 전환도 됩니다. 조회한 값을 그대로 실어 보내고 바꿀 항목만 덮어쓰는 방식으로 구현하십시오.

조건주문 목록을 조회하면 내가 API로 건 것만 나오나요?

아닙니다. 공식 설명이 다른 채널 즉 토스증권 앱 등에서 등록한 조건주문도 함께 반환한다고 적어 두었습니다. 타입별 필터는 없고 응답의 type 필드로 구분해야 합니다. 목록을 받아 일괄 취소하는 코드는 사람이 앱에서 걸어 둔 것까지 지우므로, 등록할 때 clientOrderId 에 봇 전용 접두사를 넣고 그것만 골라 다루십시오.

조건주문 상태 값은 어떻게 읽나요?

상태가 두 층입니다. 최상위 status 는 WATCHING, PAUSED, ORDERING, ORDERED, COMPLETED, EXPIRED 여섯 가지이고 first.status 와 second.status 에는 HOLDING 과 CANCELED 가 더 있습니다. HOLDING 은 OTO 의 first 가 아직 체결되지 않아 second 가 대기 중인 상태, CANCELED 는 OCO 한쪽 충족으로 반대편이 자동 취소된 상태입니다. 이 둘은 최상위로 내려오지 않습니다. 목록 조회의 status 파라미터는 필수이고 OPEN 과 CLOSED 중 하나만 넣을 수 있어 전체를 받으려면 두 번 호출해야 합니다.

마무리 — 서버에 맡길 수 있는 부분만 맡긴다

조건주문의 값어치는 "봇이 죽어도 감시는 산다" 한 줄입니다. 24시간 켜 둔 서버가 없는 개인에게는 이게 큽니다. 대신 종목당 1개·지정가만·expireDate 필수·수정은 전체 재설정이라는 네 가지 제약이 따라옵니다. 이 제약을 모르고 설계하면 분할 매수에서 422로 막히거나, 수정 한 번에 익절 조건이 사라집니다.

토스증권 오픈API 전반(발급·OAuth·주문·호출 한도)은 토스증권 오픈API 자동매매 가이드에, 에러 코드 체계는 토스증권 오픈API 에러코드에, 첫 주문 흐름은 파이썬으로 첫 주문 내보기에 있습니다. 다른 증권사와의 비교는 증권사 API 비교를 보십시오.

손절이 빠지지 않는 봇이 필요하다면

조건주문 등록·갱신·만료 관리까지 포함해 맞춰 만들어 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기