KIS 기간별매매손익 조회 — TTTC8715R 실현손익
TTTC8715R
(/uapi/domestic-stock/v1/trading/inquire-period-trade-profit),
일별 합산은 TTTC8708R
(/uapi/domestic-stock/v1/trading/inquire-period-profit).
한국투자 HTS [0856] 기간별 매매손익 화면의 종목별 탭과 일별 탭이 그대로 API가 된 것입니다.
응답은 output1(명세 행 목록) + output2(합계 한 줄)로 오고,
실현손익은 rlzt_pfls, 총합은 tot_rlzt_pfls입니다.
여기서 fee(수수료)·tl_tax(제세금)·loan_int(대출이자)를 빼지 않으면
봇 수익이 실제보다 크게 잡힙니다.
"그래서 지금까지 얼마 벌었어요?"에 답하는 화면을 만들 때 대부분 첫 단추부터 어긋납니다. 잔고 조회를 붙여 놓고 평가금액이 늘었으면 벌었다고 표시하는 것이죠. 잔고가 알려 주는 건 "지금 얼마짜리를 들고 있나"라서, 시세가 오르면 봇이 아무 것도 안 해도 숫자가 오릅니다. 반대로 봇이 이번 달에 스무 번 매매해 확정한 손익은 잔고 어디에도 안 나옵니다. 확정 손익은 별도 API로 조회해야 합니다.
목차
- 평가손익과 실현손익은 다른 숫자다
- API가 두 개인 이유 — TTTC8715R vs TTTC8708R
- 요청 항목 — 여섯 개가 전부 필수다
- 응답 항목 — output1과 output2
- 함정 5가지
- 파이썬 실전 코드 — 연속조회까지
- 일일 성과 리포트로 묶기
- 자주 묻는 질문
1. 평가손익과 실현손익은 다른 숫자다
두 값은 이름이 비슷해서 자주 섞이는데, 봇 성과를 볼 때는 완전히 다른 용도입니다.
| 평가손익 (미실현) | 실현손익 (확정) | |
|---|---|---|
| 어디서 오나 | 잔고 조회TTTC8434R 계열 | 기간별매매손익TTTC8715R / TTTC8708R |
| 무엇을 재나 | 아직 안 판 종목의 현재 가치 | 이미 팔아서 손익이 확정된 건 |
| 시세에 흔들리나 | 초 단위로 흔들린다 | 흔들리지 않는다 |
| 봇 성과 판단 | 부적합 — 시장이 오르면 같이 오른다 | 적합 — 봇의 매매 결과만 남는다 |
| 세금·수수료 | 반영 안 됨 | fee·tl_tax가 항목으로 따로 온다 |
리포트 설계 원칙 — 두 값을 하나로 합치지 마십시오. 확정 손익과 미실현 평가손익을 두 줄로 분리해서 보여 줘야 "오늘 봇이 잘한 것"과 "오늘 시장이 오른 것"이 구분됩니다. 섞어 놓으면 상승장에서 전략이 망가지고 있어도 숫자는 좋아 보입니다. 이 착시가 전략이 망가진 것을 늦게 발견하게 만드는 흔한 경로입니다.
2. API가 두 개인 이유 — TTTC8715R vs TTTC8708R
한국투자증권 공식 샘플 코드의 주석을 보면 이 둘의 관계가 명확합니다.
HTS(eFriend Plus) [0856] 기간별 매매손익 화면에서
"종목별"을 클릭했을 때가 TTTC8715R,
"일별"을 클릭했을 때가 TTTC8708R입니다.
즉 같은 화면의 탭 두 개가 API 두 개로 갈라진 것이고, 데이터 원본은 같습니다.
| 구분 | TTTC8715R | TTTC8708R |
|---|---|---|
| 정식 명칭 | 기간별매매손익현황조회 | 기간별손익일별합산조회 |
| URL | /uapi/domestic-stock/v1/ | /uapi/domestic-stock/v1/ |
| HTS 대응 | [0856] → 종목별 | [0856] → 일별 |
| 행의 단위 | 매매일자 + 종목 | 매매일자 |
| 고유 요청 항목 | — | INQR_DVSN(조회구분) 추가 |
| 고유 응답 항목 | pdno·prdt_name·trad_dvsn_name·hldg_qty·pchs_unpr·sll_pric | sll_qty1·buy_qty1 |
| 쓰는 곳 | "어느 종목에서 벌고 잃었나" | "어느 날 벌고 잃었나" (일별 곡선) |
봇 리포트를 만든다면 대체로 둘 다 필요합니다.
TTTC8708R로 일별 손익 곡선을 그리고,
TTTC8715R로 어떤 종목이 그 곡선을 만들었는지 내려다보는 구조가 자연스럽습니다.
전략이 여러 개 돌고 있다면 봇 자체 매매일지의
주문번호와 종목코드를 키로 붙여 전략별로 쪼갤 수 있습니다.
3. 요청 항목 — 여섯 개가 전부 필수다
TTTC8715R의 요청 파라미터입니다. 공식 샘플 코드가 여섯 개를 필수로 검증하고 있고,
비어 있으면 호출 전에 ValueError를 던집니다.
| 파라미터 | 의미 | 값 |
|---|---|---|
CANO | 종합계좌번호 | 앞 8자리 (필수) |
ACNT_PRDT_CD | 계좌상품코드 | 뒤 2자리 (필수) |
SORT_DVSN | 정렬구분 | 00 최근 / 01 과거 / 02 최근 (필수) |
INQR_STRT_DT | 조회시작일자 | YYYYMMDD (필수) |
INQR_END_DT | 조회종료일자 | YYYYMMDD (필수) |
CBLC_DVSN | 잔고구분 | 00 전체 (필수) |
PDNO | 상품번호(종목코드) | 선택 — 비우면 전 종목 |
CTX_AREA_FK100 | 연속조회검색조건100 | 첫 호출은 "" |
CTX_AREA_NK100 | 연속조회키100 | 첫 호출은 "" |
TTTC8708R은 여기에 INQR_DVSN(조회구분, 00)이 하나 더 붙어
일곱 개가 필수입니다. 이 하나 때문에 두 API를 같은 함수로 감싸다가 자주 틀립니다.
CANO와 ACNT_PRDT_CD가 무엇인지, 계좌번호를 어디서 잘라 넣어야 하는지 헷갈린다면
CANO·ACNT_PRDT_CD 계좌번호 분리 글을 먼저 보시는 편이 빠릅니다.
이 두 값을 잘못 넣으면 손익이 0건으로 돌아오는데, 에러가 아니라 정상 응답이라 원인을 찾기 어렵습니다.
4. 응답 항목 — output1과 output2
공식 샘플 코드는 응답을 이렇게 받습니다. output1은 리스트, output2는 한 줄짜리 합계입니다.
그래서 output2는 pandas.DataFrame으로 만들 때 index=[0]을 붙여야 합니다.
current_data1 = pd.DataFrame(res.getBody().output1) # 명세 행 목록
current_data2 = pd.DataFrame(res.getBody().output2, index=[0]) # 합계 한 줄
output1 — 매매 명세 (행마다 하나)
| 필드 | 의미 | 봇에서 쓰는 법 |
|---|---|---|
trad_dt | 매매일자 | 일별 집계 키 |
pdno | 상품번호(종목코드) | 전략 매핑 키 |
prdt_name | 상품명 | 리포트 표시용 |
trad_dvsn_name | 매매구분명 | 현금/신용 등 구분 |
hldg_qty | 보유수량 | 잔여 포지션 |
pchs_unpr | 매입단가 | 평균 진입가 |
buy_qty / buy_amt | 매수수량 / 매수금액 | 회전율 계산 |
sll_pric / sll_qty / sll_amt | 매도가격 / 매도수량 / 매도금액 | 청산 확인 |
rlzt_pfls | 실현손익 | 핵심 값 |
pfls_rt | 손익률 | %로 표시 |
fee | 수수료 | 순손익에서 차감 |
tl_tax | 제세금 | 순손익에서 차감 |
loan_int | 대출이자 | 신용 사용 시 차감 |
loan_dt | 대출일자 | 신용 만기 추적 |
output2 — 기간 합계 (한 줄)
| 매도 쪽 | 매수 쪽 | 총계 |
|---|---|---|
sll_qty_smtlsll_tr_amt_smtlsll_fee_smtlsll_tltx_smtlsll_excc_amt_smtl |
buyqty_smtlbuy_tr_amt_smtlbuy_fee_smtlbuy_tax_smtlbuy_excc_amt_smtl |
tot_qtytot_tr_amttot_fee / tot_tltxtot_excc_amttot_rlzt_pfls / tot_pftrt |
이름이 미묘하게 다릅니다.
TTTC8715R의 매수수량합계는 buyqty_smtl(언더바 없음)인데
TTTC8708R에서는 buy_qty_smtl(언더바 있음)입니다.
두 API를 같은 파서로 돌리면 한쪽에서 조용히 KeyError가 나거나,
.get()으로 감싸 놨다면 합계가 0으로 찍힙니다.
필드 매핑은 API별로 따로 두십시오.
5. 함정 5가지
함정 ① 실현손익을 그대로 순이익으로 쓴다
rlzt_pfls 옆에 fee·tl_tax·loan_int가 별도 항목으로 옵니다.
응답이 비용을 어느 단계까지 차감해 주는지는 상품·계좌 유형에 따라 다를 수 있으므로,
봇 리포트에서는 실현손익과 비용을 분리해 찍어 두고 순손익은 코드에서 직접 계산하는 편이 안전합니다.
나중에 숫자가 안 맞을 때 어디서 어긋났는지 추적할 수 있는 유일한 방법입니다.
회전율이 높은 전략일수록 이 차이가 커집니다.
함정 ② 응답 값이 전부 문자열이다
KIS REST API 응답의 숫자 항목은 문자열로 옵니다.
rlzt_pfls가 "-12500"처럼 오기 때문에 그대로 더하면
TypeError: unsupported operand type(s)가 나거나, 문자열끼리 이어 붙습니다.
조회 직후 한 번에 숫자로 바꾸는 계층을 두고 그 아래에서는 숫자만 다루십시오.
빈 문자열이 섞여 오는 경우가 있어 기본값 처리가 필요합니다.
def to_int(v, default=0):
try:
return int(str(v).strip().replace(",", "") or default)
except (TypeError, ValueError):
return default
함정 ③ 연속조회를 안 걸어 뒤쪽 데이터가 사라진다
조회 기간이 길거나 종목이 많으면 한 번에 다 오지 않습니다.
CTX_AREA_FK100·CTX_AREA_NK100과 tr_cont로 이어 받아야 합니다.
공식 샘플 코드는 이 과정을 재귀로 돌면서 max_depth(기본 10)로 상한을 둡니다.
상한이 없으면 응답이 계속 이어질 때 무한 루프에 빠지고, 간격이 없으면 호출 제한에 걸립니다.
KIS 에러코드에서 가장 자주 보이는 EGW00201이 바로 그 상황입니다.
함정 ④ 계좌번호가 틀려도 에러가 안 난다
CANO·ACNT_PRDT_CD가 잘못되면 손익이 0건인 정상 응답으로 옵니다.
"이번 달에 매매를 안 했나 보다"로 넘어가기 딱 좋습니다.
0건일 때는 같은 기간을 주문체결조회로 한 번 더 확인하는
교차검증을 넣어 두십시오. 체결은 있는데 손익이 0건이면 계좌 파라미터 문제입니다.
함정 ⑤ 모의투자에서 되는지 확인 없이 개발한다
KIS는 API마다 모의투자 지원 여부가 다르고, TR ID도 TTTC(실전) / VTTC(모의)로 갈립니다.
예를 들어 매수가능조회는 공식 샘플이 TTTC8908R과 VTTC8908R을 환경에 따라 갈라 쓰는데,
TTTC8715R·TTTC8708R 쪽 샘플 코드에는 그런 모의투자 분기가 없이 TR ID가 하나만 지정되어 있습니다.
지원 범위는 공지 없이 바뀌므로 개발자 포털에서 현재 상태를 확인하시고,
모의 단계에서 성과 집계까지 검증해야 한다면 체결 내역 기반 자체 집계로 우회하는 경로를 미리 설계해 두십시오.
모의투자에서 실전으로 넘어갈 때 이 차이가 한꺼번에 터집니다.
6. 파이썬 실전 코드 — 연속조회까지
아래는 TTTC8715R을 연속조회까지 포함해 호출하고 순손익을 직접 계산하는 형태입니다.
인증 토큰 발급은 KIS API 발급 가이드의 절차를 그대로 씁니다.
import time, requests
BASE = "https://openapi.koreainvestment.com:9443"
PATH = "/uapi/domestic-stock/v1/trading/inquire-period-trade-profit"
def period_trade_profit(token, appkey, appsecret, cano, prdt,
start, end, max_page=10):
rows, summary = [], {}
fk = nk = ""
tr_cont = ""
for _ in range(max_page):
headers = {
"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {token}",
"appkey": appkey,
"appsecret": appsecret,
"tr_id": "TTTC8715R", # 기간별매매손익현황조회
"tr_cont": tr_cont,
"custtype": "P",
}
params = {
"CANO": cano,
"ACNT_PRDT_CD": prdt,
"SORT_DVSN": "01", # 01: 과거순 - 곡선 그리기 편함
"INQR_STRT_DT": start, # YYYYMMDD
"INQR_END_DT": end,
"CBLC_DVSN": "00", # 00: 전체
"PDNO": "", # 비우면 전 종목
"CTX_AREA_FK100": fk,
"CTX_AREA_NK100": nk,
}
r = requests.get(BASE + PATH, headers=headers, params=params, timeout=10)
body = r.json()
if body.get("rt_cd") != "0":
raise RuntimeError(f"{body.get('msg_cd')} {body.get('msg1')}")
rows.extend(body.get("output1") or [])
summary = body.get("output2") or summary
tr_cont = r.headers.get("tr_cont", "")
if tr_cont not in ("F", "M"): # 이어질 데이터 없음
break
fk = body.get("ctx_area_fk100", "").strip()
nk = body.get("ctx_area_nk100", "").strip()
tr_cont = "N" # 다음 호출은 이어받기
time.sleep(0.35) # 호출 제한 여유
return rows, summary
받은 뒤 순손익을 직접 빼는 부분입니다. 여기가 이 글의 핵심입니다.
from collections import defaultdict
def daily_net_pnl(rows):
"""일자별 {실현손익, 비용, 순손익} 집계"""
agg = defaultdict(lambda: {"gross": 0, "cost": 0})
for r in rows:
d = r.get("trad_dt", "")
gross = to_int(r.get("rlzt_pfls"))
cost = to_int(r.get("fee")) + to_int(r.get("tl_tax")) + to_int(r.get("loan_int"))
agg[d]["gross"] += gross
agg[d]["cost"] += cost
out = []
for d in sorted(agg):
g, c = agg[d]["gross"], agg[d]["cost"]
out.append({"date": d, "gross": g, "cost": c, "net": g - c})
return out
output2의 합계와 대조해 보면 파싱이 맞는지 바로 확인됩니다.
합계 쪽 이름은 tot_rlzt_pfls(총실현손익)·tot_fee(총수수료)·tot_tltx(총제세금)입니다.
rows, summary = period_trade_profit(...)
mine = sum(x["gross"] for x in daily_net_pnl(rows))
theirs = to_int(summary.get("tot_rlzt_pfls"))
assert mine == theirs, f"파싱 불일치: 내 합계 {mine} vs API 합계 {theirs}"
print("총실현손익", theirs,
"총수수료", to_int(summary.get("tot_fee")),
"총제세금", to_int(summary.get("tot_tltx")),
"총수익률", summary.get("tot_pftrt"))
이 assert 한 줄을 꼭 넣으십시오.
연속조회가 중간에 끊겼거나 필드 이름을 잘못 읽었을 때
리포트가 조용히 틀린 숫자를 찍는 것을 막아 줍니다.
성과 집계는 틀려도 예외가 안 나기 때문에, 스스로 검산하지 않으면 몇 달 뒤에 발견합니다.
7. 일일 성과 리포트로 묶기
실무에서는 장 마감 후 한 번 돌려 텔레그램으로 쏘는 형태가 가장 많습니다. 확정 손익은 장중에 계속 바뀌지 않습니다.
- 스케줄 — 정규장 종료 후 여유를 두고 1회. 휴장일에는 건너뜁니다.
- 본문 구성 — ① 오늘 확정 손익(
net) ② 누적 확정 손익 ③ 미실현 평가손익(잔고 조회, 별도 줄) ④ 종목별 상위·하위 3건. - 비용 노출 —
tot_fee·tot_tltx를 반드시 같이 찍습니다. 회전율이 높은 전략일수록 이 줄이 커집니다. - 보관 — 응답 원본(JSON)을 날짜별로 남겨 두십시오. 재조회로는 복원되지 않는 시점 값이 있습니다.
종목별 실현손익이 특정 구간에서만 크게 음수라면 진입 조건이 아니라 주문 수량 산정이 원인인 경우가 흔합니다. 같은 신호라도 얼마씩 실었느냐에 따라 결과 분포가 달라집니다.
8. 자주 묻는 질문
잔고 조회로 봇 수익률을 계산하면 안 되나요?
잔고가 보여 주는 건 아직 팔지 않은 종목의 평가손익입니다. 시세가 움직이면 같이 움직이므로
어제와 오늘을 비교해도 봇이 확정한 성과가 아닙니다.
확정 손익은 TTTC8715R·TTTC8708R에서, 미실현 평가손익은 잔고 조회에서
각각 가져와 두 줄로 분리해 표시하십시오.
조회 기간을 몇 년치로 한 번에 넣어도 되나요?
공식 샘플 코드의 예시는 20230216~20240301처럼 1년 넘는 구간을 그대로 넣고 있고,
대신 연속조회로 나눠 받는 구조입니다.
다만 기간 상한과 1회 응답 건수는 명세가 바뀔 수 있으므로,
운영 코드에서는 월 단위로 끊어 호출하고 결과를 이어 붙이는 쪽을 권합니다.
실패 시 재시도 범위가 좁아지고, 중간에 끊겨도 어디까지 받았는지 명확합니다.
해외주식 손익도 같은 API로 되나요?
아닙니다. 국내주식과 해외주식은 경로 자체가 다릅니다.
한국투자증권 공식 저장소에도 domestic_stock과 overseas_stock 아래에
inquire_period_profit이 각각 따로 존재합니다.
해외주식은 환율과 결제일까지 얽히므로 원화 환산 시점을 어디로 잡을지 먼저 정하고 붙이십시오.
전략이 여러 개인데 전략별 손익을 나눌 수 있나요?
API 응답만으로는 안 됩니다. 증권사는 계좌 단위로만 손익을 계산합니다.
봇이 주문을 낼 때 주문번호와 전략 ID를 자체 DB에 남겨 두고
나중에 pdno·trad_dt로 조인하는 방식이 현실적이며,
과거 데이터가 없으면 나중에 얹을 수 없으므로 처음부터 넣어야 합니다.
이 값들을 그대로 믿어도 되나요?
이 글의 TR ID·URL·요청/응답 항목명은
2026-08-21 시점 한국투자증권 공식 오픈API 저장소(koreainvestment/open-trading-api)의
국내주식 inquire_period_trade_profit·inquire_period_profit 샘플 코드에서 확인한 것이고,
코드 안의 숫자는 이해를 돕기 위한 예시입니다.
증권사 명세와 모의투자 지원 범위는 예고 없이 바뀌므로
운영에 넣기 전 KIS Developers 개발자 포털의 현재 명세로 대조하시고,
도메인·TR ID는 상수로 박지 말고 설정으로 빼 두시기 바랍니다.
세금·수수료 항목의 해석이 필요한 경우에는 반드시 세무 전문가나 증권사 고객센터에 확인하십시오.
성과가 숫자로 보이는 봇이 필요하다면
주문·체결·손익 집계까지 묶어서 매일 리포트가 도착하는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기