토스증권 API 잔고 조회 — holdings 함정 6가지
GET /api/v1/holdings이고,
헤더 X-Tossinvest-Account에는 계좌번호가 아니라 accountSeq를 넣습니다.
가장 많이 걸리는 곳은 세 군데입니다 —
계좌 목록 조회는 초당 1회라 루프마다 부르면 429가 나고,
quantity는 매도 가능 수량이 아니며,
모든 금액이 문자열이고 손익률은 소수비율(0.1077 = 10.77%)입니다.
토스증권 오픈API로 봇을 붙이면 주문보다 잔고를 먼저 만나게 됩니다.
그런데 이 API는 응답 모양이 꽤 달라서 KIS나 키움에서 쓰던 잔고 파싱 코드를 그대로 옮기면 어긋납니다.
이 글은 토스증권 개발자 문서의 OpenAPI 명세 원문(openapi.tossinvest.com의
openapi.json과 API 레퍼런스, 2026-09-01 확인)을 기준으로
잔고를 코드로 다룰 때 실제로 걸려 넘어지는 지점 6개만 골랐습니다.
client_credentials 토큰 발급과 첫 호출까지는
토스증권 오픈API 가이드에서 끝냈다고 가정합니다.
이 글에서 다루는 것
- 요청은 두 줄 — 토큰 헤더와 계좌 헤더
- 함정 1 — 계좌번호가 아니라
accountSeq다 - 함정 2 — 계좌 목록은 초당 1회다
- 함정 3 —
quantity는 매도 가능 수량이 아니다 - 함정 4 — 숫자가 전부 문자열이고
rate는 소수비율이다 - 함정 5 —
krw는 0인데usd는null이다 - 함정 6 — 현금이 없다. 총자산이 아니다
- 잔고를 표로 정규화하는 실행 코드
요청은 두 줄 — 토큰 헤더와 계좌 헤더
토스증권 오픈API는 카테고리에 따라 필요한 헤더가 다릅니다. 시세는 토큰만으로 되지만 계좌·자산·주문·조건주문은 계좌 식별 헤더가 하나 더 붙습니다. 공식 문서의 예시가 이 구조를 그대로 보여줍니다.
# 시세 — 토큰만 필요
curl -s 'https://openapi.tossinvest.com/api/v1/stocks?symbols=005930' \
-H 'Authorization: Bearer eyJhbGciOi...'
# 계좌·자산 — 토큰 + 계좌 헤더
curl -s 'https://openapi.tossinvest.com/api/v1/holdings' \
-H 'Authorization: Bearer eyJhbGciOi...' \
-H 'X-Tossinvest-Account: 1'
파라미터는 symbol 하나가 선택적으로 붙습니다.
국내는 6자리 숫자(005930), 미국은 티커(AAPL)이고
영문 대소문자·숫자·마침표·하이픈만 허용됩니다.
symbol을 주면 해당 종목만 필터링될 뿐 아니라 요약 금액도 그 종목 기준으로 다시 계산됩니다.
전체 평가금액을 보려던 코드에 실수로 symbol이 섞이면 요약이 통째로 달라지므로 주의하십시오.
함정 1 — 계좌번호가 아니라 accountSeq다
X-Tossinvest-Account에 계좌번호를 넣는 실수가 가장 흔합니다.
이 헤더가 받는 값은 Long 타입의 accountSeq,
즉 GET /api/v1/accounts 응답에 들어 있는 계좌 식별 키입니다.
계좌 모델은 이렇게 생겼습니다.
| 필드 | 타입 | 설명 |
|---|---|---|
accountNo | String | 계좌번호 — 헤더에 넣는 값이 아니다 |
accountSeq | Long | 계좌 식별 키 — 이 값을 헤더에 넣는다 |
accountType | String | BROKERAGE / OVERSEAS_DERIVATIVES / PENSION_SAVINGS / RESHORING_INVESTMENT |
두 실수는 에러가 다르게 납니다. 이 구분이 디버깅 시간을 크게 줄입니다.
// 헤더를 아예 안 보낸 경우 — 400
{
"error": {
"requestId": "01HXYZABCDEFG123456789",
"code": "account-header-required",
"message": "..."
}
}
// 값이 잘못된 경우 (예: 계좌번호를 넣음) — 404
{
"error": {
"requestId": "01HXYZABCDEFG123456789",
"code": "account-not-found",
"message": "..."
}
}
requestId는 응답 헤더 X-Request-Id와 같은 값입니다.
로그에 이 값을 남겨 두면 나중에 문의할 때 그대로 첨부할 수 있습니다.
에러 코드 체계 전반은 토스증권 오픈API 에러코드에 정리했습니다.
참고로 accountType enum에는 네 종류가 정의돼 있지만
계좌 목록 API는 현재 종합매매 BROKERAGE만 반환하며 자녀계좌는 쓸 수 없습니다.
공식 문서는 클라이언트가 모르는 enum 값도 허용하도록 구현하라고 명시합니다 —
나중에 연금저축 계좌가 열렸을 때 봇이 죽지 않게 하려면 지금 그렇게 짜 두는 것이 맞습니다.
함정 2 — 계좌 목록은 초당 1회다
accountSeq가 필요하니 잔고를 부를 때마다 계좌 목록을 먼저 조회하는 코드를 쓰기 쉽습니다.
여기서 바로 429가 납니다.
두 API의 호출 한도 그룹이 다르고, 차이가 5배이기 때문입니다.
| API | Rate Limits Group | 한도 |
|---|---|---|
GET /api/v1/accounts | ACCOUNT | 초당 최대 1회 |
GET /api/v1/holdings | ASSET | 초당 최대 5회 |
GET /api/v1/sellable-quantity | ORDER_INFO | 초당 최대 6회 · 09:00~09:10 KST는 초당 3회 |
GET /api/v1/buying-power | ORDER_INFO | 초당 최대 6회 · 09:00~09:10 KST는 초당 3회 |
POST /api/v1/orders | ORDER | 초당 최대 10회 |
해법은 단순합니다. accountSeq는 프로그램 시작 때 한 번만 조회해서 들고 있으면 됩니다.
계좌 목록은 초 단위로 변하는 값이 아닙니다.
import os, time, requests
from decimal import Decimal
BASE = "https://openapi.tossinvest.com"
_account_seq = None # 프로세스 수명 동안 캐싱
def account_seq(token):
global _account_seq
if _account_seq is None: # ACCOUNT 그룹은 초당 1회 — 딱 한 번만
r = requests.get(f"{BASE}/api/v1/accounts",
headers={"Authorization": f"Bearer {token}"})
r.raise_for_status()
accounts = r.json()["result"]
_account_seq = accounts[0]["accountSeq"] # accountNo가 아니다
return _account_seq
429가 났을 때 곧바로 재시도하면 상황이 나빠집니다.
토스증권은 정상 응답과 429 응답 모두에
X-RateLimit-Limit·X-RateLimit-Remaining·X-RateLimit-Reset을 내려 주고,
429에는 Retry-After가 추가로 붙습니다.
공식 권장은 Retry-After만큼 기다린 뒤 지수 백오프(1s → 2s → 4s)와 지터를 함께 적용하는 것입니다.
에러 코드는 edge-rate-limit-exceeded입니다.
한도 수치 자체는 운영 상황에 따라 사전 공지 없이 조정될 수 있으므로
상수로 박지 말고 X-RateLimit-Limit 헤더를 읽어 쓰는 편이 안전합니다.
함정 3 — quantity는 매도 가능 수량이 아니다
items[].quantity는 보유 수량입니다.
이미 걸어 둔 매도 주문이 있거나 결제가 도래하지 않은 물량이 있으면
실제로 팔 수 있는 수량은 그보다 적습니다.
이걸 그대로 매도 수량으로 넣으면 주문이 거부됩니다.
// 매도 가능 수량을 확인하지 않고 주문했을 때 — 422
{
"error": {
"code": "insufficient-sellable-quantity",
"message": "매도 가능 수량이 부족합니다."
}
}
GET /api/v1/sellable-quantity의 응답은 sellableQuantity 한 필드뿐입니다.
국내는 정수(주 단위), 미국은 소수점이 포함될 수 있습니다.
전량 매도를 구현할 때는 반드시 이 값을 기준으로 삼으십시오.
함정 4 — 숫자가 전부 문자열이고 rate는 소수비율이다
공식 예시 응답을 그대로 보면 수량도 가격도 손익률도 전부 따옴표 안에 있습니다.
BigDecimal 타입이라 정밀도를 잃지 않으려고 문자열로 직렬화한 것입니다.
{
"result": {
"totalPurchaseAmount": { "krw": "6500000", "usd": "1553" },
"marketValue": {
"amount": { "krw": "7200000", "usd": "1785" },
"amountAfterCost": { "krw": "7050000", "usd": "1771.43" }
},
"profitLoss": {
"amount": { "krw": "700000", "usd": "232" },
"amountAfterCost": { "krw": "550000", "usd": "218.43" },
"rate": "0.1179",
"rateAfterCost": "0.0983"
},
"dailyProfitLoss": { "amount": { "krw": "100000", "usd": "25" }, "rate": "0.0141" },
"items": [
{
"symbol": "005930", "name": "삼성전자",
"marketCountry": "KR", "currency": "KRW",
"quantity": "100", "lastPrice": "72000", "averagePurchasePrice": "65000",
"marketValue": { "purchaseAmount": "6500000", "amount": "7200000",
"amountAfterCost": "7050000" },
"profitLoss": { "amount": "700000", "amountAfterCost": "550000",
"rate": "0.1077", "rateAfterCost": "0.0846" },
"dailyProfitLoss": { "amount": "100000", "rate": "0.0141" },
"cost": { "commission": "14400", "tax": "135600" }
},
{
"symbol": "AAPL", "name": "Apple Inc.",
"marketCountry": "US", "currency": "USD",
"quantity": "10", "lastPrice": "178.5", "averagePurchasePrice": "155.3",
"profitLoss": { "rate": "0.1494", "rateAfterCost": "0.1406" }
}
]
}
}
여기서 챙길 것이 세 가지입니다.
rate는 소수비율입니다. 공식 스펙이0.1077= 10.77%라고 못 박고 있습니다. 퍼센트로 보여주려면 100을 곱해야 합니다.float가 아니라Decimal로 변환하십시오. 미국 주식은 소수점 수량이 가능하므로float반올림 오차가 그대로 주문 수량으로 넘어갈 수 있습니다.amount와amountAfterCost를 구분하십시오. 뒤쪽이 수수료·세금 공제 후 값이고,cost.commission·cost.tax로 그 근거가 따로 옵니다.tax는 세금이 없으면null입니다. 봇의 손익 판정에 어느 쪽을 쓸지는 처음에 한 번 정해서 끝까지 같은 기준으로 가야 합니다 (관련 논의는 백테스트의 비용·세금 반영에 있습니다).
위 JSON은 공식 문서의 예시 값이라 금액 자체가 실제 수수료·세율을 반영한 수치는 아닙니다.
필드 구조를 보는 용도로만 읽고, 실제 비용은 본인 계좌의 응답과
GET /api/v1/commissions로 확인하십시오.
함정 5 — krw는 0인데 usd는 null이다
요약의 금액은 통화별로 krw·usd로 나뉩니다. 그런데 비어 있을 때의 값이 서로 다릅니다.
스펙 원문 그대로 옮기면 krw는 국내 종목이 없으면 0,
usd는 해외 종목이 없으면 null입니다.
그래서 국내 종목만 들고 있는 계좌에서 총평가금액을 더하려고 하면 이렇게 됩니다.
# 이렇게 쓰면 국내 종목만 있는 계좌에서 터진다
total = Decimal(mv["krw"]) + Decimal(mv["usd"]) # TypeError: usd is None
# 안전한 형태 — usd는 항상 없을 수 있다고 본다
def dec(v):
return Decimal(v) if v is not None else Decimal("0")
total_krw = dec(mv["krw"])
total_usd = dec(mv["usd"])
한 가지 더. 요약의 profitLoss.rate는
전체 자산을 현재 환율로 원화 환산한 기준입니다.
즉 환율이 움직이면 종목을 하나도 안 건드려도 이 값이 바뀝니다.
국내와 해외 성과를 나눠서 보고 싶다면 요약을 쓰지 말고 items를 통화별로 직접 집계해야 합니다.
함정 6 — 현금이 없다. 총자산이 아니다
holdings는 이름 그대로 보유 주식만 돌려줍니다.
공식 설명은 국내(KR)·미국(US) 주식만 포함하며 해외 옵션·채권은 제외한다고 적고 있고,
보유 종목이 없으면 요약 금액은 0이고 items는 빈 배열입니다.
따라서 봇이 "지금 현금 비중이 얼마인가"를 알려면 holdings 하나로는 부족합니다.
GET /api/v1/buying-power를 currency(KRW 또는 USD)와 함께 따로 호출해야 하고,
이 값은 미수거래를 제외한 현금 기반 매수 가능 금액입니다.
미수를 쓰는 계좌라면 이 숫자가 곧 현금 전액이라고 해석하면 안 됩니다.
잔고를 표로 정규화하는 실행 코드
위 여섯 가지를 한 번에 처리하는 형태는 이렇습니다. 전략 코드가 증권사를 모르게 만드는 것이 목표입니다.
def fetch_positions(token):
seq = account_seq(token) # 함정 2 — 캐싱된 accountSeq
r = requests.get(f"{BASE}/api/v1/holdings", headers={
"Authorization": f"Bearer {token}",
"X-Tossinvest-Account": str(seq), # 함정 1 — accountNo가 아니다
})
if r.status_code == 429: # 함정 2 — Retry-After를 지킨다
time.sleep(float(r.headers.get("Retry-After", "1")))
return fetch_positions(token)
r.raise_for_status()
res = r.json()["result"]
rows = []
for it in res["items"]:
rows.append({
"symbol": it["symbol"],
"market": it["marketCountry"], # KR / US
"currency": it["currency"],
"qty": dec(it["quantity"]), # 함정 4 — Decimal
"avg": dec(it["averagePurchasePrice"]),
"last": dec(it["lastPrice"]),
"pl": dec(it["profitLoss"]["amount"]),
"pl_pct": dec(it["profitLoss"]["rate"]) * 100, # 함정 4 — 소수비율
"tax": dec(it.get("cost", {}).get("tax")), # null 가능
"sellable": None, # 함정 3 — 매도 직전에 따로 조회
})
mv = res["marketValue"]["amount"]
summary = {"krw": dec(mv["krw"]), "usd": dec(mv["usd"])} # 함정 5 — usd는 null 가능
return rows, summary
def sellable(token, symbol):
"""함정 3 — 매도 주문 직전에만 부른다. ORDER_INFO 그룹은 09:00~09:10에 한도가 절반이다."""
r = requests.get(f"{BASE}/api/v1/sellable-quantity",
params={"symbol": symbol},
headers={"Authorization": f"Bearer {token}",
"X-Tossinvest-Account": str(account_seq(token))})
r.raise_for_status()
return dec(r.json()["result"]["sellableQuantity"])
붙이기 전 체크리스트
accountSeq를 한 번만 조회해서 캐싱했는가.ACCOUNT는 초당 1회다.- 헤더에
accountNo를 넣고 있지 않은가.404 account-not-found의 대부분이 이것이다. - 매도 수량을
sellableQuantity로 잡고 있는가.quantity가 아니다. - 금액을
Decimal로 다루고rate에 100을 곱하고 있는가. usd가null일 때 죽지 않는가.krw는 0인데usd는null이다.429에서Retry-After를 읽고 백오프하는가. 즉시 재시도는 상황을 악화시킨다.
자주 묻는 것
KIS·키움의 잔고 조회와 뭐가 다른가요?
KIS는 tr_cont와 연속조회 키로 다음 페이지를 이어 받고(KIS 잔고 연속조회),
키움은 kt00018로 계좌평가잔고내역을 받습니다(키움 잔고 kt00018).
토스는 items 배열을 한 번에 주고 통화별 요약을 함께 얹는 구조라
파싱은 더 단순한 대신 통화 분리와 문자열 숫자 처리가 새로 생깁니다.
여러 증권사를 함께 붙일 계획이면 위 코드처럼 공통 포지션 표로 정규화하십시오.
잔고를 몇 초마다 조회해야 하나요?
ASSET 그룹이 초당 5회이므로 기술적으로는 자주 부를 수 있지만,
잔고는 주문이 체결될 때 바뀌는 값이라 초 단위 폴링은 대부분 낭비입니다.
체결을 실시간으로 알아야 한다면 폴링 대신
웹소켓의 개인 주문 이벤트를 구독하고, 잔고는 체결 알림을 받은 뒤에 한 번 새로 읽는 편이 훨씬 안정적입니다.
주문 정정·취소 쪽은 토스증권 주문 정정·취소에 정리했습니다.
403이 나는데 계좌 헤더 문제인가요?
아닐 가능성이 큽니다. 토스증권 오픈API는 허용 IP 관리에 등록되지 않은 IP에서의 호출을 403으로 차단합니다.
집에서 되던 코드가 클라우드 서버에 올리자마자 403이 난다면 거의 이 경우입니다.
서버를 옮기거나 IP가 바뀔 때마다 허용 IP를 다시 등록해야 한다는 점을 운영 절차에 넣어 두십시오.
계좌가 여러 개면 어떻게 하나요?
GET /api/v1/accounts가 배열을 돌려주므로 accountSeq도 여러 개가 됩니다.
다만 현재는 종합매매 BROKERAGE만 노출되고 자녀계좌는 사용할 수 없습니다.
봇이 어느 계좌를 쓸지는 코드에서 [0]으로 집지 말고 설정 파일에 명시하는 편이 안전합니다.
2026-09-01 기준으로 토스증권 개발자 문서(developers.tossinvest.com)가 공개한
openapi.tossinvest.com의 OpenAPI 명세 원문과 개요 문서에 정의된
엔드포인트·파라미터·응답 스키마·Rate Limits Group·에러 코드를 기준으로 작성했습니다.
본문의 JSON은 공식 문서의 예시 값이며 실제 수수료·세율을 나타내지 않습니다.
필드 구성·호출 한도·지원 범위는 증권사 공지로 예고 없이 바뀝니다.
운영 전 토스증권 개발자 포털의 현재 명세와 실제 응답으로 대조하십시오.
알고랩은 투자자문업·투자일임업을 영위하지 않으며, 이 글은 수익이나 시장 방향을 예측하지 않고 투자 권유가 아닙니다.
잔고·주문이 어긋나지 않는 봇, 만들어 드립니다
보유 수량과 매도 가능 수량, 현금 잔고를 한 상태로 묶고 호출 한도까지 지키는 주문 로직을 증권사별로 정규화해 실제로 돌아가는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기