토스증권 보유 주식 대시보드 | 자동매매 개발기 #7-7

토스증권 보유 주식 대시보드를 실시간으로 연동해서, 익절·손절 조건까지 원클릭으로 설정할 수 있게 만든 Phase 7-7 개발기다.

🌐 Read in English

💡 이번 Phase 7-7 요약

  • 목적: 토스증권 보유 주식 대시보드를 실시간 연동하고, 그 옆에서 바로 익절/손절 조건을 설정할 수 있게 만들었다.
  • 핵심 포인트: 종목코드만 나오던 표를 get_display_name() 유틸리티로 “삼성전자 (005930)” 형태로 통일해서 가독성을 확 끌어올렸다.
  • 검증: python-multipart 연동 포함 pytest 130개 100% 통과, DRY_RUN 모드에서 가상 매수→익절/손절 가상 매도 전체 사이클 검증 완료.

토스증권 보유 주식 대시보드가 없으니 정작 내가 뭘 들고 있는지 확인하려면 매번 토스 앱을 켜야 했다. 봇은 잘 돌아가는데 “지금 계좌 상태가 어떤지”는 여전히 딴 데서 봐야 하는 게 이상했다. 종목코드 몇 자리만 보고 삼성전자인지 SK하이닉스인지 헷갈리는 것도 은근 스트레스였고. 그래서 이번 Phase에서는 보유 주식 시각화랑, 그 자리에서 바로 조건을 걸 수 있는 원클릭 폼을 붙이는 걸 목표로 잡았다.

1. 토스증권 API 보유 주식 시각화 & 원클릭 조건 설정 (dashboard.py)

toss_portfolio.fetch_holdings()로 계좌 보유 종목의 수량, 평단가, 현재가, 평가손익, 수익률을 가져와서 대시보드 상단 표에 뿌렸다. 여기까지는 단순 조회였는데, 문제는 “이 종목 익절 3%로 걸어야지” 싶을 때마다 다시 전략 설정 화면으로 넘어가야 했다는 거다. 클릭 수가 늘어날수록 귀찮아서 결국 안 쓰게 되더라. 그래서 보유 주식 표 각 행에 inline Form을 바로 붙였다.

<form action="/api/strategies" method="post">
  <input type="hidden" name="symbol" value="{symbol}">
  <input type="number" name="target_price" placeholder="매수 목표가">
  <input type="number" name="take_profit_pct" placeholder="익절 %" step="0.1">
  <input type="number" name="stop_loss_pct" placeholder="손절 %" step="0.1">
  <button type="submit">즉시 저장</button>
</form>

FastAPI에서 이 폼 데이터를 받으려면 python-multipart 패키지가 필요하다. 처음엔 이걸 빼먹고 테스트했더니 서버가 RuntimeError: Form data requires "python-multipart" to be installed. 를 그대로 뱉어서 원인 찾는 데 좀 걸렸다. 에러 메시지만 보면 딱 패키지 이름까지 알려주는데도 처음엔 “왜 폼이 안 먹지” 하면서 프론트 코드부터 의심했던 게 함정이었다. pip install python-multipart 한 줄로 해결됐지만, requirements.txt에 안 박혀 있으면 배포 환경에서 또 똑같이 터질 걸 알기에 바로 추가해뒀다. 폼 처리 방식이 헷갈리면 FastAPI 공식 Form 데이터 처리 가이드를 참고하면 된다.

라우트는 두 개로 나눴다. 저장은 POST /api/strategies, 삭제는 POST /api/strategies/delete. 저장 로직에서 기존 조건이 있으면 UPDATE, 없으면 INSERT 하도록 strategy_configs 테이블에 UPSERT 패턴을 넣었다. SQLite에서는 ON CONFLICT ... DO UPDATE 구문으로 이걸 한 줄에 처리할 수 있는데, 자세한 문법은 SQLite 공식 UPSERT 문서에 잘 정리돼 있다.

INSERT INTO strategy_configs (symbol, target_price, take_profit_pct, stop_loss_pct)
VALUES (?, ?, ?, ?)
ON CONFLICT(symbol) DO UPDATE SET
  target_price = excluded.target_price,
  take_profit_pct = excluded.take_profit_pct,
  stop_loss_pct = excluded.stop_loss_pct;

2. 종목명 캐싱과 주식 포트폴리오 표시 개선 (db.py)

지금까지는 표마다 종목코드 6자리만 덩그러니 있었다. 매번 “005930이 뭐였지” 하면서 검색하는 게 은근 시간 잡아먹었다. 그래서 SQLite에 stock_names 캐시 테이블을 새로 만들어서, API에서 종목명을 받아올 때마다 save_stock_name(code, name)으로 저장해뒀다.

def get_display_name(code: str) -> str:
    """005930 -> '삼성전자 (005930)' 형태로 변환. 캐시에 없으면 코드만 반환."""
    name = db.get_stock_name(code)
    return f"{name} ({code})" if name else code

이 함수 하나를 보유 주식 목록, 거래 조건 목록, 최근 거래 이력, 최근 알림 이력 — 네 군데 표에 전부 적용했더니 주식 포트폴리오 시각화가 눈에 띄게 좋아졌다. 종목코드만 보고 “이게 뭐였지” 하며 검색하던 일이 없어진 게 생각보다 컸다. 캐시가 비어있는 신규 종목은 일단 코드만 표시되다가, 다음 API 호출 때 자동으로 이름이 채워지는 방식이라 별도 초기화 작업 없이도 자연스럽게 채워진다.

3. 조건 동기화 & 익절·손절 자동화 사이클 (auto_trader.py)

웹에서 조건을 바꿨는데 봇을 재시작해야 반영되면 의미가 없다. 그래서 감시 루프가 매 주기마다 DB에서 최신 조건을 다시 읽어오도록 했다. 웹 폼에서 목표가를 수정하면 다음 감시 주기(기본 60초)에 바로 반영된다.

DRY_RUN = True 상태에서 시나리오를 돌려봤다. 가상 종목 삼성전자(005930)를 목표가 70,000원에 걸어두고, 시뮬레이션 시세가 그 아래로 내려가면 가상 매수 → 평단가 대비 +5% 도달 시 익절 가상 매도, -3% 도달 시 손절 가상 매도가 발동되도록 짰다. 매도가 발동되면 trades 테이블에 SELL 레코드가 남고, 알림도 같이 나간다.

가상 계좌 기준: 삼성전자(005930) 10주, 평단가 70,000원 → 현재가 73,500원 도달 시 익절 매도 발동 (실제 계좌 데이터 아님, 전부 마스킹 처리)

이 사이클을 며칠 동안 DRY_RUN으로 돌려보면서, 목표가를 웹에서 바꿨을 때 실제로 다음 주기에 반영되는지도 로그로 확인했다. 재시작 없이 조건이 바뀌는 걸 눈으로 확인하니 그제서야 “이제 진짜 쓸만하네”라는 생각이 들었다.

4. 알림 이중화 & 보안 진단 통합 (notifier.py / security_check.py)

WARNING 이상 등급 이벤트는 텔레그램과 이메일(SMTP)로 동시에 나간다. 한쪽 채널이 죽어도 다른 쪽으로 알림이 살아있게 하려는 목적이다. security_check.py.env 파일이 Git에 안 들어갔는지, 5중 안전 브레이크가 정상 작동 중인지, OAuth2 토큰과 계좌 연결 상태가 살아있는지를 계좌 잔고나 실제 값을 건드리지 않고(비파괴) 점검해서 리포트로 뽑아준다.

5. 검증: pytest 130개

이번 스프린트에서 새로 추가한 폼 라우트, 캐싱 로직, 가상 매수-매도 사이클까지 포함해서 pytest 스위트를 돌렸다. python-multipart 의존성 이슈를 해결한 뒤로는 폼 관련 테스트도 전부 초록불이었다.

$ pytest -v
...
130 passed in 8.42s

FAQ

Q1. python-multipart는 왜 필요한가요?

FastAPI가 HTML <form> 데이터를 파싱하려면 이 패키지가 필요합니다. 없으면 RuntimeError: Form data requires "python-multipart" to be installed. 오류가 발생합니다.

Q2. DRY_RUN 모드에서 실제 매매가 발생하나요?

아니요. DRY_RUN=True 상태에서는 실제 주문이 나가지 않고 로그와 DB 기록만 남는 가상 매매 시뮬레이션입니다.

Q3. 종목명 캐시는 얼마나 자주 갱신되나요?

API 조회 시점마다 save_stock_name()이 호출돼 최신 종목명으로 덮어씁니다. 별도 배치 갱신은 없습니다.

Q4. UPSERT 패턴을 쓰는 이유는 뭔가요?

같은 종목에 조건을 두 번 저장할 때 매번 존재 여부를 먼저 조회(SELECT)하고 분기하는 대신, ON CONFLICT 구문으로 INSERT/UPDATE를 한 번에 처리해서 쿼리 왕복 횟수를 줄일 수 있기 때문입니다.

Q5. 이 시리즈 코드는 공개되어 있나요?

현재는 학습/기록 목적의 비공개 개인 프로젝트로, 핵심 로직 위주로 블로그에 정리하고 있습니다.

👉 다음 편은 아직 발행 전입니다. 시리즈 전체 목차는 상단을 참고해줘.

댓글 남기기