토스증권 조건주문 API — OCO는 종목당 1개
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·에러 표 원문입니다.
목차
- 조건주문이 대신해 주는 것 — 감시 주체가 바뀐다
- SINGLE·OCO·OTO — 세 타입의 제약표
- 함정 ① OCO·OTO는 종목당 1개다
- 함정 ② 수정은 부분 수정이 아니라 전체 재설정
- 함정 ③ 상태가 2층이다
- 함정 ④ 목록에 앱에서 건 조건주문도 섞여 온다
- 실전 코드 — 진입과 동시에 손절·익절 걸기
- 자주 묻는 질문
1. 조건주문이 대신해 주는 것 — 감시 주체가 바뀐다
직접 만든 봇의 손절은 시세를 계속 받고 조건을 계산해 주문을 내는 루프이고, 이 루프가 내 컴퓨터에서 돌아야 합니다.
노트북이 절전으로 들어가거나 인터넷이 끊기면 손절도 같이 멈춥니다.
조건주문은 그 루프를 증권사 서버로 옮깁니다 — 감시가(triggerPrice)와 매매 방향만 등록하면
가격이 닿았을 때 토스증권이 주문을 만듭니다.
| 구분 | 내용 (공식 스펙 원문 기준) |
|---|---|
| 엔드포인트 | POST /api/v1/conditional-orders (서버 https://openapi.tossinvest.com) |
| 필수 헤더 | X-Tossinvest-Account — GET /api/v1/accounts의 accountSeq |
| 발동 세션 (국내) | KRX 정규장에서만 발동 |
| 발동 세션 (해외) | 장 구분과 상관없이 거래 가능한 모든 시간대에 발동 |
| 호출 한도 | CONDITIONAL_ORDER 초당 5회 / CONDITIONAL_ORDER_HISTORY 초당 10회 |
| 멱등키 | clientOrderId (최대 36자, [a-zA-Z0-9-_]) |
국내 종목의 발동 세션이 KRX 정규장으로 한정된다는 점을 먼저 보십시오. 시간외단일가나 장 개시 전 동시호가 구간의 가격 움직임으로는 조건이 발동하지 않습니다. "밤사이 악재가 나면 조건주문이 알아서 처리해 준다"는 기대는 국내 종목에는 성립하지 않습니다. 해외 종목은 반대로 거래 가능한 모든 시간대에 발동합니다.
2. SINGLE·OCO·OTO — 세 타입의 제약표
| type | 감시 구조 | 방향 제약 | 호가유형 | 종목당 개수 |
|---|---|---|---|---|
SINGLE | first 한 조건만 감시 | 제약 없음 (BUY/SELL) | LIMIT·MARKET | 제한 없음 |
OCO | 둘을 동시 감시, 하나 충족 시 나머지 자동 취소 | first·second 둘 다 SELLfirst 감시가 > 현재가 > second 감시가 | LIMIT만 | 1개 |
OTO | first 체결 후 second 감시 시작 | first=BUY, second=SELL | LIMIT만 | 1개 |
공식 스펙에 실린 요청 예시 네 가지를 그대로 옮깁니다.
// 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"}}
quantity와 orderType은 그룹 공통값입니다.
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 하나로 관리하고 체결될 때마다
modify로 quantity와 감시가를 갱신하거나,
⑵ SINGLE 여러 개로 나눠 걸고 한쪽이 체결되면 나머지를 봇이 직접 취소하는 것입니다.
⑵는 자동 취소를 봇이 떠안는다는 뜻이라 OCO의 장점이 사라집니다.
4. 함정 ② 수정은 부분 수정이 아니라 전체 재설정
수정 경로는 POST /api/v1/conditional-orders/{conditionalOrderId}/modify입니다.
PATCH가 아니고, 의미도 부분 수정이 아닙니다.
공식 설명이 "조건주문 전체를 재설정하므로 유지할 조건도 함께 전달해야 합니다"라고 적고 있습니다.
| 항목 | 등록(POST) | 수정(modify) |
|---|---|---|
symbol | 필수 | 넣지 않는다 (conditionalOrderId로 식별) |
type·quantity·orderType·expireDate·first | 필수 | 전부 필수 |
second | OCO/OTO만 | 빠뜨리면 그 조건이 사라진다 |
| 타입 전환 | — | 허용 (예: SINGLE → OCO) |
손절가만 올리려다 익절 조건을 지우는 사고가 여기서 납니다.
{"first": {...}}만 보내면 second가 없는 요청이 되고, 결과는 익절 조건이 사라진 상태입니다.
안전한 구현은 수정 전에 상세 조회로 현재 값을 받아 그대로 실어 보내고, 바꿀 항목만 덮어쓰는 것입니다.
5. 함정 ③ 상태가 2층이다
조회 응답의 status는 그룹 단위이고, first.status·second.status는
조건(leg) 단위입니다. 두 집합이 같지 않습니다.
| 상태 | 그룹 status | leg status | 뜻 |
|---|---|---|---|
WATCHING | O | O | 조건 감시 중 |
PAUSED | O | O | 일시중지 |
ORDERING / ORDERED | O | O | 조건 충족 — 주문 생성 중 / 생성됨 |
COMPLETED / EXPIRED | O | O | 완료 / 만료 |
HOLDING | X | O | OTO의 first 체결 전 대기 |
CANCELED | X | O | OCO 한쪽 충족으로 자동 취소된 반대편 |
스펙이 "leg 전용 상태인 HOLDING·CANCELED 는 최상위 status 로는 내려오지 않습니다"라고
명시합니다. 최상위만 읽는 봇은 OTO의 second가 아직 대기 중인지를 구분할 수 없습니다.
그룹 status는 살아 있는 조건의 상태를 대표로 따르기 때문입니다.
조건이 발동해 주문이 만들어지면 leg에 triggeredOrderId가 채워집니다.
이 값은 일반 주문 API에 그대로 쓸 수 있는 주문 ID라
GET /api/v1/orders/{orderId}로 체결 결과를 이어서 추적하면 됩니다
(토스증권 주문 정정·취소 참고).
6. 함정 ④ 목록에 앱에서 건 조건주문도 섞여 온다
GET /api/v1/conditional-orders의 공식 설명입니다 —
"이 API 로 등록한 조건주문뿐 아니라 다른 채널(토스증권 앱 등)에서 등록한 조건주문도 함께 반환됩니다."
목록을 받아 전부 취소하는 코드는 사람이 앱에서 걸어 둔 조건주문까지 지웁니다.
등록할 때 clientOrderId에 bot- 같은 접두사를 붙여 두고,
봇이 만든 것만 골라 취소·수정하십시오. 계좌를 사람과 봇이 같이 쓰는 경우 이건 선택이 아니라 필수입니다.
조회 파라미터에도 잔가시가 있습니다.
status가 필수이고OPEN/CLOSED중 하나만 넣을 수 있습니다. 둘 다 보려면 두 번 호출해야 합니다.OPEN=WATCHING·PAUSED·ORDERING·ORDERED,CLOSED=COMPLETED·EXPIRED.- 페이징은 커서 기반 —
limit기본 20·최대 100, 응답의nextCursor를 다음cursor로 넘깁니다. - 타입별 필터는 없습니다. 응답의
type으로 직접 걸러야 합니다.
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-request에 tickSize와 nearestPrices가 함께 내려오니
그 값으로 보정하십시오. 수수료·세금·호출 한도·조건주문 정책은
토스증권 공식 문서와 약관의 현재 내용으로 확인하십시오.
과거 데이터가 미래를 보장하지 않으며 이 글은 특정 종목이나 매매 방식을 권하지 않습니다.
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 비교를 보십시오.