
들어가며
알고리즘 트레이딩을 제대로 시작해보기로 하면서, 첫 단계로 잡은 목표는 거창하지 않았다. “내가 지금 투자하고 있는 상품을 API로 불러와서 분석하는 프로그램” 하나만 만들어보자는 것. 토스증권 Open API 사전 신청을 해서 client_id/client_secret을 발급받은 뒤, Claude Code를 터미널에 띄워놓고 하나씩 붙여나갔다. 이 글은 그 과정 — 특히 실제로 걸려 넘어졌던 버그들 — 을 있는 그대로 기록한 것이다.
API부터 훑어보기
공식 문서가 완전하지 않은 부분이 있어서, 처음부터 코드를 짜기보다 API 스펙을 먼저 확인하는 데 시간을 썼다. 정리하면:
- Base URL은
https://openapi.tossinvest.com, 인증은 OAuth2 Client Credentials Grant (POST /oauth2/token) GET /api/v1/accounts로 계좌 목록을,GET /api/v1/holdings로 보유 자산을 가져온다 (후자는X-Tossinvest-Account헤더 필요)GET /api/v1/prices로 실시간 시세,GET /api/v1/candles로 일봉/분봉을 가져온다
여기서 배운 첫 번째 원칙: 응답 필드명을 추측해서 파서부터 짜지 말 것. 대신 inspect_api.py라는 스크립트를 먼저 만들어서 raw JSON을 그대로 찍어보고, 실제 필드 구조를 눈으로 확인한 뒤에 파싱 로직을 짰다. 덕분에 뜻밖의 발견도 하나 했는데 — holdings 응답에 이미 현재가·평가금액·손익이 전부 계산되어 들어있어서, 별도로 시세를 조회할 필요조차 없었다.
아키텍처가 세 번 바뀐 이야기
처음엔 그냥 CLI 스크립트 하나였다. toss_client.py(API 래퍼) → portfolio.py(holdings 응답을 pandas DataFrame으로 바꾸는 공용 로직) → analyze_portfolio.py(터미널에 표로 출력하는 리포트), 이렇게 세 파일로 시작했다.
그 다음 “표보다 뷰어처럼 보고 싶다”는 요구에 맞춰 Streamlit으로 웹 대시보드(app.py)를 붙였다. 파이차트, 종목별 표, 일별 자산 추이 라인차트까지 — 여기서 새로운 문제가 하나 생겼다. 토스 API엔 과거 평가금액을 조회하는 히스토리 엔드포인트가 없다. 그래서 앱을 열 때마다(또는 스케줄러로 매일) 오늘자 스냅샷을 로컬 SQLite에 직접 쌓는 방식(history.py, snapshot.py)을 채택했다. 오늘부터 시작해서 앞으로 쌓이는 구조라, 과거로 소급은 안 된다는 게 명확한 한계다.
여기에 재미있는 기능 하나를 더 얹었다. **“오늘부로 원금을 전부 S&P500(SPY)에 몰빵했다면?” vs “그냥 현금으로 뒀다면?”**을 실제 포트폴리오와 나란히 비교하는 차트(benchmark.py)다. 지수 자체는 API가 안 줘서 SPY ETF를 프록시로 썼다.
그런데 정작 다 만들고 나니 “이걸 확인하려고 매번 streamlit run을 켜고 브라우저 탭을 계속 열어둬야 하는 게 아직 좀 어색하다”는 피드백이 나왔다. 맞는 말이었다. 그래서 마지막으로 한 번 더 방향을 틀었다 — Plotly.js를 통째로 파일 안에 인라인한 정적 HTML 리포트(report.py)로. 이제는 스케줄러가 매일 파일 하나를 갱신해두면, 그냥 더블클릭하거나 고정해둔 브라우저 탭을 새로고침하는 것만으로 충분하다. 서버도, 켜둔 터미널도 필요 없다. Streamlit 앱은 가끔 더 깊이 파고들고 싶을 때를 위해 남겨뒀다.
실제로 겪은 버그 5가지
1. .env.example에 진짜 키를 붙여넣음
inspect_api.py를 처음 실행해보라고 안내했더니, 진짜 client_id/client_secret이 .env가 아니라 .env.example에 들어간 채로 돌아왔다. .env.example은 빈 템플릿용이라 .gitignore 대상이 아니어서, 이 상태로 git에 커밋했다면 실키가 그대로 공개될 뻔했다. 다행히 아직 git 저장소를 만들기 전이라 실제 유출은 없었지만, .env(진짜 값)와 .env.example(빈 템플릿)의 역할을 다시 한번 명확히 하고 넘어갔다.
2. accountNo로 헤더를 채웠다가 account-not-found
X-Tossinvest-Account 헤더에 accounts 응답의 accountNo(11자리 계좌번호)를 그대로 넣었더니 400 에러가 났다. 에러 본문을 그대로 출력하게 코드를 고쳐서 원인을 봤더니 “해당 계좌번호를 찾을 수 없습니다”였다. 공식 문서만으론 정확한 헤더 값 형식을 확신할 수 없었는데, 제3자 오픈소스 프로젝트(tossinvest-mcp)의 실제 사용 예시를 보고서야 accountNo가 아니라 accountSeq(보통 "1")를 넣어야 한다는 걸 확인했다.
3. 대문자 "USD" vs 소문자 "usd"
holdings 응답에서 종목별 currency 필드는 "USD"(대문자)인데, 최상위 totalPurchaseAmount/marketValue 같은 딕셔너리의 키는 "usd"(소문자)였다. 그대로 조회하면 예외 없이 조용히 None → 0으로 떨어져서, 화면엔 “총평가금액 0.00”이라고만 표시됐다. 에러가 나지 않는 버그가 가장 늦게 발견된다는 걸 다시 확인한 순간이었다.
4. SQL 바인딩 개수가 안 맞음
SQLite INSERT문에 컬럼 9개를 선언해놓고 값은 8개만 튜플에 담아 넘겼다. captured_at 값 하나를 빠뜨린 단순 실수였는데, 이건 즉시 예외가 나서 오히려 빨리 잡혔다.
5. 날짜 필터가 폴백 후보까지 걸러낸 버그 (가장 까다로웠던 것)
“S&P500 몰빵/원금 그대로 시나리오가 실제 포트폴리오 추이와 다른 날짜에서 시작하는 것 같다”는 피드백을 받고 원인을 찾는 데 제일 오래 걸렸다. SPY 과거 종가를 since ~ until 범위로 필터링하고 있었는데, 하필 시작일이 주말이라 그날 캔들이 없었고, “그 이전 거래일 종가로 대체”하려던 폴백 후보 데이터까지 같은 필터에 걸려 통째로 제외되면서 동기화 함수가 아무 것도 안 하고 조용히 끝나버리고 있었다. 그 사이 예전 스키마의 오래된 데이터가 그대로 남아있어서 마치 “두 시나리오가 다른 날 시작하는 것”처럼 보였던 것이다.
고친 방법은 두 가지였다. 하한 필터를 없애고, 시작일뿐 아니라 모든 날짜에 대해 “당일 종가가 없으면 가장 최근 거래일 종가로 채우는” forward-fill 로직으로 일반화했다. 그리고 애초에 실제 포트폴리오 값을 벤치마크 테이블에 따로 저장하던 중복도 없애고, history(실제 추이)와 날짜 기준으로 join하도록 다시 짰다 — 두 시스템이 애초에 어긋날 수 없는 구조로 만든 것이다.
지금까지 세운 원칙
- 응답 필드명을 추측해서 파서부터 짜지 않는다. raw JSON을 먼저 눈으로 본다.
- CLI·웹뷰어·정적 리포트가 계산 로직(
portfolio.py,benchmark.py)을 공유하게 만든다. 버그 하나를 고치면 세 곳이 동시에 고쳐진다. - 매매 판단(“사라”/“팔아라”)은 코드가 하지 않는다. 비중, 손익률, 상위 종목 같은 사실 기반 지표만 계산한다.
- 주문/매매 API는 이번 단계에서 건드리지 않는다. 조회 전용으로 충분히 검증한 다음 단계다.
마치며
거창한 자동매매 시스템을 만든 건 아니다. 지금은 그저 내 계좌를 API로 읽어서 표와 차트로 보여주는 도구일 뿐이다. 하지만 인증 흐름, 응답 스키마의 함정, “조용히 실패하는” 버그를 찾아내는 감각 — 다음 단계인 백테스팅과 매매 시그널 개발로 넘어가기 전에 반드시 다져둬야 할 기초 체력을 이번에 쌓은 셈이다. 다음 글에서는 과거 시세로 전략을 검증하는 백테스팅 이야기를 다뤄볼 예정이다.
이 글은 개인 위키에 정리해둔 toss-portfolio-analyzer 빌드 로그를 블로그용으로 재구성한 것입니다.
댓글