AlgoLab Blog · 토스증권 오픈API 실무 · 2026

토스증권 API 잔고 조회 — holdings 함정 6가지

토스증권 · 계좌·자산 2026-09-01 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약 토스증권 오픈API의 잔고 조회는 GET /api/v1/holdings이고, 헤더 X-Tossinvest-Account에는 계좌번호가 아니라 accountSeq를 넣습니다. 가장 많이 걸리는 곳은 세 군데입니다 — 계좌 목록 조회는 초당 1회라 루프마다 부르면 429가 나고, quantity는 매도 가능 수량이 아니며, 모든 금액이 문자열이고 손익률은 소수비율(0.1077 = 10.77%)입니다.

토스증권 오픈API로 봇을 붙이면 주문보다 잔고를 먼저 만나게 됩니다. 그런데 이 API는 응답 모양이 꽤 달라서 KIS나 키움에서 쓰던 잔고 파싱 코드를 그대로 옮기면 어긋납니다. 이 글은 토스증권 개발자 문서의 OpenAPI 명세 원문(openapi.tossinvest.comopenapi.json과 API 레퍼런스, 2026-09-01 확인)을 기준으로 잔고를 코드로 다룰 때 실제로 걸려 넘어지는 지점 6개만 골랐습니다. client_credentials 토큰 발급과 첫 호출까지는 토스증권 오픈API 가이드에서 끝냈다고 가정합니다.

이 글에서 다루는 것

  1. 요청은 두 줄 — 토큰 헤더와 계좌 헤더
  2. 함정 1 — 계좌번호가 아니라 accountSeq
  3. 함정 2 — 계좌 목록은 초당 1회
  4. 함정 3 — quantity는 매도 가능 수량이 아니다
  5. 함정 4 — 숫자가 전부 문자열이고 rate는 소수비율이다
  6. 함정 5 — krw는 0인데 usdnull이다
  7. 함정 6 — 현금이 없다. 총자산이 아니다
  8. 잔고를 표로 정규화하는 실행 코드

요청은 두 줄 — 토큰 헤더와 계좌 헤더

토스증권 오픈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 응답에 들어 있는 계좌 식별 키입니다. 계좌 모델은 이렇게 생겼습니다.

필드타입설명
accountNoString계좌번호 — 헤더에 넣는 값이 아니다
accountSeqLong계좌 식별 키 — 이 값을 헤더에 넣는다
accountTypeStringBROKERAGE / 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배이기 때문입니다.

APIRate Limits Group한도
GET /api/v1/accountsACCOUNT초당 최대 1회
GET /api/v1/holdingsASSET초당 최대 5회
GET /api/v1/sellable-quantityORDER_INFO초당 최대 6회 · 09:00~09:10 KST는 초당 3회
GET /api/v1/buying-powerORDER_INFO초당 최대 6회 · 09:00~09:10 KST는 초당 3회
POST /api/v1/ordersORDER초당 최대 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 한 필드뿐입니다. 국내는 정수(주 단위), 미국은 소수점이 포함될 수 있습니다. 전량 매도를 구현할 때는 반드시 이 값을 기준으로 삼으십시오.

GET /api/v1/holdings (ASSET · 초당 5회) items[].quantity — 보유 수량 평가금액·손익·수수료·세금이 함께 온다 GET /api/v1/sellable-quantity (ORDER_INFO) sellableQuantity — 매도 가능 수량 보유 수량보다 작을 수 있다 GET /api/v1/buying-power 현금 기준 매수 가능 금액 (currency=KRW·USD) holdings에 없는 것 현금 · 해외 옵션 · 채권 봇의 "지금 상태" = holdings + sellable-quantity + buying-power
잔고 한 번으로는 봇의 현재 상태가 완성되지 않는다

함정 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" }
      }
    ]
  }
}

여기서 챙길 것이 세 가지입니다.

위 JSON은 공식 문서의 예시 값이라 금액 자체가 실제 수수료·세율을 반영한 수치는 아닙니다. 필드 구조를 보는 용도로만 읽고, 실제 비용은 본인 계좌의 응답과 GET /api/v1/commissions로 확인하십시오.

함정 5 — krw는 0인데 usdnull이다

요약의 금액은 통화별로 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-powercurrency(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"])

붙이기 전 체크리스트

자주 묻는 것

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시간 빠른 답변 가능합니다.

무료 상담 시작하기