AlgoLab Blog · LS증권 API 실무 · 2026

LS증권 OpenAPI 파이썬 주문 — REST 함정 7가지

LS증권 · REST 주문 2026-09-05 · 약 9분 읽기 · 알고랩 AlgoLab
한 줄 요약

LS증권 OpenAPI 는 REST 가 https://openapi.ls-sec.co.kr:8080, 실시간이 wss://openapi.ls-sec.co.kr:9443 로 포트가 갈립니다. KIS·키움 코드를 그대로 옮기면 일곱 군데에서 막힙니다. ⑴ 포트가 둘이고 실시간 경로는 자산군마다 다릅니다(/websocket/stock·/websocket/futureoption …). ⑵ 토큰 파라미터가 appsecret 이 아니라 appsecretkey 이고 scope=oob 가 필요합니다. ⑶ expires_in86400 인데 공식 안내는 ‘익일 07시까지’ 라 아침에 끊길 수 있습니다. ⑷ 요청에 계좌번호를 넣지 않습니다 — 앱키가 계좌에 묶입니다. ⑸ 주문 TR 의 종목코드는 IsuNo: "A005930" 처럼 A 가 붙고, 시세 TR 의 shcode 는 6자리입니다. ⑹ 주문은 성공해도 rsp_cd00000 이 아닙니다(00040 매수 주문이 완료되었습니다). ⑺ 초당 한도가 TR 마다 다릅니다(주문 10, 정정·취소 3, 계좌조회 1).

이 글의 순서

  1. 먼저 구조 — 신청부터 첫 호출까지
  2. 함정 1 · 포트가 두 개다
  3. 함정 2 · appsecretkeyscope=oob
  4. 함정 3 · 토큰이 24시간을 못 채운다
  5. 함정 4 · 계좌번호를 안 넣는다
  6. 함정 5 · 주문만 종목코드에 A 가 붙는다
  7. 함정 6 · 성공인데 rsp_cd00000 이 아니다
  8. 함정 7 · 초당 한도가 TR 마다 다르다
  9. 연속조회와 실시간 — 두 가지 더
  10. 납품 전 체크리스트

먼저 구조 — 신청부터 첫 호출까지

LS증권(구 이베스트투자증권)에는 윈도우 상주형 xingAPI 와, 이 글이 다루는 OPEN API(REST) 두 갈래가 있습니다. 어느 쪽을 고를지는 LS증권·대신증권 자동매매 API 완전 가이드에 정리해 두었고, 여기서는 REST 를 고른 다음을 다룹니다. 신청 순서부터 걸림돌입니다.

  1. 홈페이지 공동인증서 로그인 → 고객센터 > 매매시스템 > API > 사용등록/해지.
  2. xingAPI 사용등록을 먼저 끝냅니다. REST 만 쓸 생각이어도 이 단계가 앞에 있습니다.
  3. 그다음 OPEN API 를 신청합니다. 안내상 최대 3계좌까지 가능합니다.
  4. 모의투자는 따로 신청해 별도 앱키를 받습니다. 그 뒤 앱키·시크릿키를 확인합니다.

여기서 이미 KIS·키움과 갈립니다. KIS 는 개발자센터에서 앱키를 만들고 CANO(종합계좌번호)를 파라미터로 넘겨 계좌를 지정합니다. LS 는 신청 단계에서 계좌를 고르고, 그 계좌에 묶인 앱키가 나옵니다. 이 차이가 나중에 함정 4로 되돌아옵니다.

호출 구조 자체는 단순합니다. 모든 TR 이 POST 이고, 무엇을 부를지는 URL 이 아니라 tr_cd 헤더로 지정합니다. 바디는 <TR코드>InBlock, 응답은 <TR코드>OutBlock 입니다.

구분주소비고
토큰 발급POST https://openapi.ls-sec.co.kr:8080/oauth2/tokenform-urlencoded
주식 시세·차트POST .../stock/market-data · .../stock/chartt1101·t1102·t8412
주식 주문POST .../stock/orderCSPAT00601·00701·00801
주식 계좌POST .../stock/accnot0424·t0425·CSPAQ12200
선물옵션POST .../futureoption/{market-data,chart,order,accno}국내파생
실시간wss://openapi.ls-sec.co.kr:9443/websocket/<분류>포트가 다름
내 봇 python appkey + appsecretkey :8080 /oauth2/token scope=oob → Bearer token(raw JWT) :9443 /websocket/stock tr_type 1·3 · S3_ / SC1 :8080 TR /stock/order /stock/accno /stock/market-data tr_cd 헤더로 분기 InBlock / OutBlock 한 앱키 = 한 계좌 · REST 8080 · 실시간 9443 — 방화벽에서 둘 다
LS증권 OpenAPI 요청 경로 — 포트가 둘, 분기는 tr_cd 헤더

함정 1 · 포트가 두 개다

가장 먼저 부딪히는 벽입니다. 국내 증권사 REST API 중 비표준 포트를 쓰는 곳이 흔치 않아, 사내망이나 클라우드 보안그룹에서 443 만 열어 둔 채 시작하면 원인 모를 타임아웃을 며칠 붙잡게 됩니다.

게다가 실시간은 주소 하나로 끝나지 않습니다. 자산군별로 경로가 갈립니다.

분류WebSocket 경로대표 TR
주식/websocket/stockS3_ KOSPI체결, K3_ KOSDAQ체결
선물옵션/websocket/futureoption국내파생 실시간
해외주식/websocket/overseas-stock해외 실시간
해외선물옵션/websocket/overseas-futureoption해외파생 실시간
업종·투자정보·기타/websocket/indtp · /websocket/investinfo · /websocket/etc 

주식과 선물옵션을 같이 보는 봇이라면 WebSocket 연결이 두 개가 됩니다. 재접속·핑·구독 복구를 연결 단위로 짜야 한다는 뜻이라, KIS 처럼 엔드포인트 하나에 tr_id 로 몰아넣는 구조를 상상하고 설계하면 나중에 뜯어고치게 됩니다.

함정 2 · appsecretkeyscope=oob

KIS 코드를 복사해 온 사람이 반드시 걸리는 자리입니다. KIS 는 appkey·appsecret 을 JSON 으로 보내지만, LS 는 appkey·appsecretkeyscope=oob 까지 붙여 form 으로 보냅니다. 시크릿 쪽 이름에 key 가 한 번 더 붙습니다.

LS 의 토큰 요청은 JSON 이 아니라 form-urlencoded 입니다. 공식 요청 예시는 이렇게 한 줄입니다.

appkey=BSrTOOZNoXtxt8CnaiSo1qPfzCoc0WgfP2vu&appsecretkey=d3HloL6TO7RKMVdEqf5Nhw2dnzUFAQwq&grant_type=client_credentials&scope=oob

응답은 이렇게 옵니다.

{
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIs...",
    "scope": "oob",
    "token_type": "Bearer",
    "expires_in": 86400
}

파이썬으로는 이렇게 됩니다. json= 이 아니라 data= 라는 점, 그리고 TR 호출 때의 content-type 은 반대로 JSON 이라는 점이 핵심입니다.

import os, requests

BASE = "https://openapi.ls-sec.co.kr:8080"

def get_token():
    r = requests.post(
        BASE + "/oauth2/token",
        headers={"content-type": "application/x-www-form-urlencoded"},
        data={                                  # json= 아님
            "grant_type": "client_credentials",
            "appkey":       os.environ["LS_APPKEY"],
            "appsecretkey": os.environ["LS_APPSECRET"],   # appsecret 아님
            "scope": "oob",
        },
        timeout=10,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def call_tr(token, path, tr_cd, body, tr_cont="N", tr_cont_key=""):
    return requests.post(BASE + path, timeout=10, headers={
            "content-type": "application/json; charset=UTF-8",   # 여기는 JSON
            "authorization": f"Bearer {token}",
            "tr_cd": tr_cd, "tr_cont": tr_cont, "tr_cont_key": tr_cont_key,
        }, data=json.dumps(body, ensure_ascii=False).encode("utf-8"))

앱키를 코드에 박지 마십시오. 위 예시의 키 문자열은 LS증권 공식 문서의 샘플 값이며 실제 키가 아닙니다. 실계좌 앱키는 주문 권한이 그대로 붙은 자격증명입니다. 환경변수나 별도 설정 파일로 분리하고 저장소에 올리지 마십시오.

함정 3 · 토큰이 24시간을 못 채운다

발급 응답의 expires_in86400, 곧 24시간입니다. 그런데 LS증권 공식 이용 안내에는 토큰 유효기간이 “신청일로부터 익일 07시까지” 로 적혀 있습니다. 두 값이 항상 같은 시각을 가리키지는 않습니다.

월요일 14:30 에 받은 토큰을 expires_in 만 믿고 화요일 14:30 까지 캐시했다고 해 봅시다. 화요일 08:20 장 시작 준비 루틴에서 서버는 이미 07시에 끊어 놨는데 코드에는 재발급 경로가 없습니다. 봇은 조회 실패를 잔고 0 으로 해석하고 포지션이 없는 것처럼 행동합니다.

대응은 두 가지를 같이 겁니다. ㈎ 인증 실패 시 재발급 — 만료 시각을 믿지 말고, 인증 계열 실패를 만나면 토큰을 버리고 재발급 후 재시도합니다. ㈏ 장 시작 전 강제 재발급 — 08시경 스케줄러에서 무조건 새 토큰을 받아 둡니다. 구조는 KIS API 토큰 만료와 갱신과 같습니다.

확인 안내. 토큰 정책은 증권사가 예고 없이 바꿉니다. 위 두 값은 LS증권 OPEN API 포털의 이용 안내와 접근토큰 발급 스펙에서 각각 확인한 것으로, 운영 코드에 반영하기 전에 공식 문서에서 현재 값을 다시 확인하십시오.

함정 4 · 계좌번호를 안 넣는다

KIS 를 먼저 써 본 사람은 “필수 파라미터가 빠진 것 아닌가” 하고 한참 찾습니다. 안 빠졌습니다. LS 는 요청에 계좌번호를 넣지 않습니다.

잔고 조회 t0424 의 공식 요청 예시입니다.

{
  "t0424InBlock": {
    "prcgb": "",
    "chegb": "",
    "dangb": "",
    "charge": "",
    "cts_expcode": ""
  }
}

예수금·주문가능금액 조회 CSPAQ12200 은 더 짧습니다. 받는 값이 BalCreTp 하나입니다.

{
  "CSPAQ12200InBlock1": { "BalCreTp": "1" }
}

계좌번호는 응답에 채워져 돌아옵니다.

{
    "CSPAQ12200OutBlock1": { "AcntNo": "12345678901", "Pwd": "********", "BalCreTp": "1" },
    "CSPAQ12200OutBlock2": {
        "MnyOrdAbleAmt": 307,   // 현금주문가능금액
        "SeOrdAbleAmt": 306,    // 코스피 주문가능   KdqOrdAbleAmt: 코스닥
        "BalEvalAmt": 227989450, "DpsastTotamt": 227989757
    }
}

앞의 신청 구조 때문입니다. 앱키가 계좌에 묶여 발급되어 서버가 이미 계좌를 압니다. 최대 3계좌까지 신청할 수 있고, 계좌마다 앱키가 따로 생깁니다.

설계가 통째로 달라집니다. KIS 라면 CANO·ACNT_PRDT_CD 만 바꿔 여러 계좌를 한 프로세스에서 돌릴 수 있습니다(KIS API 계좌번호 CANO·ACNT_PRDT_CD 참고). LS 에서 계좌를 바꾼다는 것은 자격증명을 바꾼다는 뜻이라, 계좌별로 토큰도 레이트리밋 버킷도 따로 가져가야 합니다. 다계좌 운용을 전제로 견적을 내면서 이걸 KIS 기준으로 잡으면 구조를 다시 짜야 합니다.

함정 5 · 주문만 종목코드에 A 가 붙는다

같은 증권사 안에서 표기가 갈리는 경우라 더 잘 걸립니다.

쓰임TR필드
시세 조회t1101·t1102shcode"005930" · "078020" — 6자리
체결/미체결t0425expcode"005930" — 6자리
주문CSPAT00601IsuNo"A005930" · "A272210" — A 접두

현물주문 CSPAT00601 의 공식 요청 예시 전문입니다.

{
  "CSPAT00601InBlock1" : {
    "IsuNo"         : "A272210",   // 종목코드 — A 접두
    "OrdQty"        : 1,
    "OrdPrc"        : 35000,
    "BnsTpCode"     : "2",         // 1 매도 / 2 매수
    "OrdprcPtnCode" : "00",        // 호가유형 (00 지정가)
    "MgntrnCode"    : "000",       // 신용거래코드 (000 보통)
    "LoanDt"        : "",
    "OrdCndiTpCode" : "0",         // 주문조건
    "MbrNo"         : "NXT"        // 거래소 지정
  }
}

MbrNo"NXT" 가 들어간 것도 눈여겨보십시오. 국내 주식은 KRX 와 넥스트레이드(NXT) 두 시장으로 갈리고 어느 시장으로 보낼지를 주문에 실어야 합니다(NXT·KRX 주문 라우팅). 허용 값은 공식 문서에서 확인하십시오. 실무에서는 종목코드를 한 군데서만 정규화합니다.

def isu_no(code):    # 주문용 — 6자리면 A 를 붙인다
    code = code.strip().upper()
    return code if code.startswith("A") else "A" + code

def shcode(code):    # 시세·계좌용 — A 를 뗀다
    code = code.strip().upper()
    return code[1:] if code.startswith("A") else code

함정 6 · 성공인데 rsp_cd00000 이 아니다

가장 위험한 함정입니다 — 중복 주문으로 이어집니다.

조회 TR 은 성공하면 rsp_cd"00000" 입니다(t1101·t0424·t0425). 그래서 대부분의 래퍼가 rsp_cd == "00000" 을 성공 판정으로 씁니다. 주문 계열은 다릅니다. 현물주문 CSPAT00601 의 공식 응답 예시입니다.

{
    "CSPAT00601OutBlock1": { "AcntNo": "20*********", "IsuNo": "A272210", "OrdQty": 1, "OrdPrc": "35000.00", ... },
    "CSPAT00601OutBlock2": {
        "OrdNo": 32004,          // 주문번호 — 이게 접수 증거
        "OrdTime": "153257702", "OrdMktCode": "10",
        "OrdAmt": 35000, "IsuNm": "한화시스템"
    },
    "rsp_cd": "00040",
    "rsp_msg": "매수 주문이 완료되었습니다."
}

rsp_cd"00040" 인데 메시지는 “매수 주문이 완료되었습니다” 입니다. 현물취소주문 CSPAT00801 예시도 "00156" 입니다.

이 조합이 만드는 사고. rsp_cd != "00000" 이면 예외를 던지는 래퍼를 그대로 쓰면, 정상 접수된 주문이 실패로 잡힙니다. 재시도 로직이 붙어 있으면 같은 주문을 한 번 더 넣습니다. 서버에는 OrdNo 두 개가 남고, 체결도 두 번 납니다. 잔고를 다시 조회해서야 뭔가 어긋난 걸 알게 됩니다.

거부된 응답과 비교하면 판정 기준이 분명해집니다. 정정주문 CSPAT00701 입니다.

{
    "rsp_cd": "03181",
    "rsp_msg": "주문가격이 하한가 미달입니다.",
    "CSPAT00701OutBlock2": { "OrdNo": 0, "OrdTime": "", "IsuNm": "", ... }
}

차이가 보입니다. 접수되면 OrdNo 에 값이 있고, 거부되면 OrdNo0 입니다. 그래서 판정은 이렇게 겁니다.

ORDER_OK = {"00040", "00156"}   # 관측된 성공 코드 — 공식 문서에서 확장할 것

def submit(token, block):
    r = call_tr(token, "/stock/order", "CSPAT00601", {"CSPAT00601InBlock1": block})
    if r.status_code == 429:
        raise RateLimited("CSPAT00601 초당 10건 초과")
    d = r.json()
    ord_no = (d.get("CSPAT00601OutBlock2") or {}).get("OrdNo", 0)

    if ord_no:                       # 주문번호가 있으면 접수된 것
        return ord_no
    raise OrderRejected(d.get("rsp_cd"), d.get("rsp_msg"))   # 예: 03181 하한가 미달

그리고 재시도를 자동으로 걸지 마십시오. 주문 응답이 애매하면(타임아웃 포함) 재주문이 아니라 t0425 체결/미체결 조회로 실제 상태를 먼저 확인합니다. 이 원칙을 어겨 나는 사고는 장애 복구 플레이북에 정리해 두었습니다.

함정 7 · 초당 한도가 TR 마다 다르다

LS 는 TR 코드마다 한도가 따로 적혀 있습니다. 공식 스펙의 ThroughputQuotaRule 에 TR 별 requestLimit 이 들어 있고, 각 TR 문서에도 transactionPerSec 로 표시됩니다.

TR이름경로초당
CSPAT00601현물주문/stock/order10
CSPAT00701현물정정주문/stock/order3
CSPAT00801현물취소주문/stock/order3
t1101주식현재가호가조회/stock/market-data10
t1102주식현재가(시세)조회/stock/market-data10
t0424주식잔고2/stock/accno2
t0425주식체결/미체결/stock/accno2
CSPAQ12200예수금·주문가능금액/stock/accno1
t8410·t8412주식차트(일주월년·N분)/stock/chart1

정정·취소가 주문보다 빡빡합니다(3 대 10) — 지정가를 계속 따라 올리는 전략이 여기서 먼저 막힙니다. ⑵ 계좌 조회가 가장 빡빡합니다(1) — 주문 직후 잔고를 매번 확인하는 루프는 조회에서 죽습니다. ⑶ 개인용 ThroughputQuotaRule 과 법인용 CorpThroughputQuotaRule같은 값입니다. 그래서 레이트리미터는 전역 하나가 아니라 TR 코드별 버킷이어야 합니다.

import time
from collections import defaultdict, deque

LIMITS = {"CSPAT00601": 10, "CSPAT00701": 3, "CSPAT00801": 3,
          "t1101": 10, "t1102": 10, "t0424": 2, "t0425": 2,
          "CSPAQ12200": 1, "t8410": 1, "t8412": 1}
_win = defaultdict(deque)

def throttle(tr_cd):
    """TR 코드별 1초 슬라이딩 윈도우. 한도 미상이면 보수적으로 1건/초."""
    limit, q = LIMITS.get(tr_cd, 1), _win[tr_cd]
    while True:
        now = time.monotonic()
        while q and now - q[0] >= 1.0:
            q.popleft()
        if len(q) < limit:
            q.append(now); return
        time.sleep(max(1.0 - (now - q[0]), 0.01))

429 를 만났을 때의 회복 전략은 키움 REST API 429 대응에 써 두었습니다. 증권사는 달라도 원리는 같습니다.

연속조회와 실시간 — 두 가지 더

연속조회는 헤더로 주고받는다

목록이 길면 한 번에 다 오지 않습니다. LS 는 응답 헤더tr_cont(다음 장이 있으면 Y)와 tr_cont_key 를 내려주고, 그 두 값을 다음 요청 헤더에 그대로 실어 보내면 다음 장을 줍니다.

다만 TR 에 따라 바디에도 연속 키가 있습니다. t0424cts_expcode, t0425cts_ordno 가 그렇습니다. 헤더만 따라가면 같은 페이지를 반복해서 받는 TR 이 생기므로, 그 TR 문서에 cts_ 로 시작하는 입력 필드가 있는지 먼저 보십시오.

실시간은 시장별로 TR 이 갈린다

등록 메시지는 이 모양입니다. tr_type"3" 이면 실시간 시세 등록, "4" 면 해제, "1"·"2" 는 주문체결 같은 이벤트 등록·해제입니다.

{
  "header": { "token": "eyJ0eXAiOiJKV1Qi...", "tr_type": "3" },
  "body":   { "tr_cd": "S3_", "tr_key": "005930" }
}

여기서 두 가지가 걸립니다.

주문 이벤트는 종목이 아니라 계좌 단위라 tr_key 가 빈 문자열이고 tr_type"1" 입니다 — SC0 가 주식주문접수, SC1 이 주식주문체결입니다.

체결 통보 본문의 ordno·execprc·execno 로 주문 시 받은 OrdNo 와 짝을 맞춰 추적하고, 체결 데이터의 exchname 으로 KRX 인지 확인합니다. 전체 실시간 TR 목록은 LS증권 공식 문서에서 확인하십시오.

납품 전 체크리스트

LS증권 OpenAPI 봇을 실계좌에 올리기 전에

고지. 이 글은 기술 자료이며 특정 종목·전략을 권유하지 않습니다. 본문의 엔드포인트·TR 코드·요청/응답 예시·초당 한도는 LS증권 OPEN API 포털의 공식 이용 안내와 API 스펙을 대조해 정리했지만, 증권사 API 스펙은 예고 없이 바뀌므로 실제 필드명과 코드 값은 반드시 LS증권 공식 문서에서 다시 확인하십시오. 알고랩(퀀트웍스)은 투자자문업·투자일임업을 영위하지 않으며, 고객이 지정한 규칙을 프로그램으로 구현해 드리는 도구 제공자입니다.

LS증권 REST 로 도는 봇, 맡기시겠어요?

토큰 재발급부터 TR 별 한도 관리, 중복 주문 방지까지 실계좌에서 버티는 상태로 만들어 드립니다. 24시간 빠른 답변 가능합니다.

상담 문의하기