숫자와 문장을 연결하는 리포트 생성기
앞 장에서는 가격 차트, 수익률, 팩터 점수, 백테스트 결과를 한 화면에서 확인하는 대시보드를 만들었다. 이제 그 숫자들을 사람이 읽기 쉬운 투자 메모로 바꿔보자.
여기서 만들 도구는 AI 투자 리포트 생성기다. 사용자가 종목 티커를 입력하면 프로그램이 주가, 기본 재무지표, 기업 정보, 뉴스 요약을 모은다. 그다음 LLM이 이 자료를 읽고 투자 메모 초안을 작성한다.
중요한 점은 역할 구분이다.
AI는 결론을 대신 내리는 투자 조언자가 아니다. AI는 흩어진 자료를 정리하고, 긍정 요인과 부정 요인을 나누고, 더 확인해야 할 질문을 제안하는 연구 보조원이다. 최종 판단은 사람이 해야 한다.
실제로 PydanticAI를 활용한 공개 예제 프로젝트도 사용자가 티커를 입력하면 유효성을 확인하고, Yahoo Finance에서 금융 데이터를 가져온 뒤, 여러 AI 에이전트가 분석 보고서를 생성하는 구조를 사용한다.1 우리는 이 흐름을 더 안전하게 바꿔서, '매수·매도 추천'이 아니라 '검토용 투자 메모'로 제한한다.
AI 생성 이미지
전체 구조
리포트 생성기의 흐름은 다음과 같다.
사용자가 종목 티커를 입력한다.
티커가 실제로 조회 가능한지 확인한다.
주가 데이터와 기업 기본 정보를 가져온다.
수익률, 변동성, MDD 같은 지표를 계산한다.
밸류에이션, 실적 변화, 뉴스 항목을 별도 블록으로 정리한다.
LLM에 정리된 입력 자료만 전달한다.
LLM은 정해진 JSON 구조로 리포트 초안을 만든다.
프로그램은 수치와 출처를 다시 점검한다.
최종 마크다운 리포트를 저장한다.
핵심은 6번이다. LLM에게 "애플 분석해줘"라고만 묻지 않는다. 그렇게 하면 모델은 그럴듯하지만 확인되지 않은 내용을 섞을 수 있다. 대신 우리가 계산한 숫자와 수집한 뉴스만 입력으로 제공하고, 그 범위 안에서만 요약하게 만든다.
티커를 먼저 검증하기
가장 먼저 할 일은 입력값 검증이다. 사용자가 AAPL, MSFT, 005930.KS 같은 티커를 입력하면 실제로 데이터가 존재하는지 확인해야 한다.
import yfinance as yf
import pandas as pd
def validate_ticker(ticker: str) -> str:
ticker = ticker.strip().upper()
if not ticker:
raise ValueError("티커를 입력하세요.")
data = yf.download(
ticker,
period="1mo",
auto_adjust=True,
progress=False
)
if data.empty:
raise ValueError(f"조회 가능한 가격 데이터가 없습니다: {ticker}")
return ticker이 함수는 완벽한 검증기는 아니다. 데이터 제공자가 일시적으로 응답하지 않거나, 특정 거래소 티커 형식이 다를 수도 있다. 그래도 최소한 "존재하지 않는 티커로 리포트를 만드는 문제"는 줄일 수 있다.
한국 종목은 데이터 소스마다 표기 방식이 다르다. Yahoo Finance에서는 삼성전자를 005930.KS처럼 입력해야 한다. 다른 API를 쓴다면 같은 종목이라도 005930, KRX:005930처럼 달라질 수 있다. 따라서 리포트 생성기에는 반드시 "사용한 데이터 소스와 티커 형식"을 남겨야 한다.
표준 입력 데이터 만들기
LLM에게 바로 원천 데이터를 던지면 안 된다. 원천 데이터는 길고, 불규칙하고, 모델이 중요한 부분을 놓치기 쉽다. 먼저 우리가 사용할 표준 입력 구조를 만들어야 한다.
예를 들면 다음과 같다.
from datetime import datetime
def get_price_data(ticker: str, period="1y") -> pd.DataFrame:
price = yf.download(
ticker,
period=period,
auto_adjust=True,
progress=False
)
if price.empty:
raise ValueError("가격 데이터가 비어 있습니다.")
return price
def calculate_price_metrics(price: pd.DataFrame) -> dict:
close = price["Close"].dropna()
returns = close.pct_change().dropna()
cumulative = (1 + returns).cumprod()
drawdown = cumulative / cumulative.cummax() - 1
total_return = close.iloc[-1] / close.iloc[0] - 1
volatility = returns.std() * (252 ** 0.5)
mdd = drawdown.min()
return {
"start_date": str(close.index[0].date()),
"end_date": str(close.index[-1].date()),
"start_price": round(float(close.iloc[0]), 2),
"end_price": round(float(close.iloc[-1]), 2),
"total_return_pct": round(float(total_return * 100), 2),
"annualized_volatility_pct": round(float(volatility * 100), 2),
"mdd_pct": round(float(mdd * 100), 2),
}여기서 LLM이 계산할 것은 없다. 수익률, 변동성, MDD는 우리가 직접 계산한다. LLM은 이 숫자를 읽고 해석만 한다.
이 원칙이 중요하다.
계산은 코드가 하고, 설명은 AI가 한다.
AI에게 계산까지 맡기면 숫자가 틀릴 수 있다. 특히 소수점, 기간, 연율화 방식처럼 작은 차이가 결과를 크게 바꾼다. 따라서 정량 지표는 파이썬으로 계산하고, LLM은 그 의미를 자연어로 정리하게 해야 한다.
재무와 기업 정보 블록 만들기
다음은 기업 기본 정보와 밸류에이션 지표다. Yahoo Finance의 info에는 시가총액, PER, PBR, ROE, 배당수익률 같은 항목이 들어 있을 수 있다. 다만 모든 종목에 모든 값이 있는 것은 아니다.
def get_company_info(ticker: str) -> dict:
t = yf.Ticker(ticker)
info = t.get_info()
return {
"ticker": ticker,
"company_name": info.get("longName"),
"sector": info.get("sector"),
"industry": info.get("industry"),
"country": info.get("country"),
"market_cap": info.get("marketCap"),
"trailing_pe": info.get("trailingPE"),
"forward_pe": info.get("forwardPE"),
"price_to_book": info.get("priceToBook"),
"return_on_equity": info.get("returnOnEquity"),
"dividend_yield": info.get("dividendYield"),
"revenue_growth": info.get("revenueGrowth"),
"earnings_growth": info.get("earningsGrowth"),
}값이 없으면 억지로 채우지 않는다. None이면 그대로 둔다. AI에게도 "없는 값은 추정하지 말라"고 지시한다.
예를 들어 PER이 비어 있는데 AI가 "PER은 업종 평균보다 낮다"고 말하면 위험하다. 입력에 없는 정보를 만들어낸 것이다. 리포트 생성기는 이런 문장을 막아야 한다.
뉴스 블록은 출처와 함께 저장한다
뉴스는 투자 메모에 유용하지만 가장 조심해야 할 데이터다. 제목만 보고 과도한 해석을 하면 안 된다. 또한 뉴스 API는 지연될 수 있고, 중복 기사나 광고성 콘텐츠가 섞일 수 있다.
뉴스 블록은 최소한 제목, 발행 시각, 출처, 링크를 함께 저장한다.
def get_news_block(ticker: str, limit=5) -> list[dict]:
t = yf.Ticker(ticker)
news = t.news or []
items = []
for item in news[:limit]:
items.append({
"title": item.get("title"),
"publisher": item.get("publisher"),
"link": item.get("link"),
"provider_publish_time": item.get("providerPublishTime"),
})
return items뉴스 본문 전체를 가져오지 못하고 제목만 있는 경우가 많다. 이때 AI에게 "뉴스의 세부 내용을 단정하지 말라"고 알려야 한다. 제목만 있으면 제목 수준의 요약만 가능하다.
LLM 입력은 데이터 블록으로 나눈다
이제 리포트 생성기에 들어갈 표준 입력을 만든다.
def build_report_input(ticker: str) -> dict:
ticker = validate_ticker(ticker)
price = get_price_data(ticker, period="1y")
return {
"data_as_of": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"data_source": "Yahoo Finance via yfinance",
"ticker": ticker,
"company_info": get_company_info(ticker),
"price_metrics": calculate_price_metrics(price),
"news": get_news_block(ticker),
}이 구조가 중요한 이유는 명확하다. LLM이 참고할 수 있는 근거를 제한하기 때문이다.
입력 블록은 다음처럼 나뉜다.
기업 개요 블록: 회사명, 섹터, 산업, 국가
가격 추세 블록: 시작가, 현재가, 기간 수익률, 변동성, MDD
밸류에이션 블록: PER, PBR, ROE, 배당수익률
실적 변화 블록: 매출 성장률, 이익 성장률
뉴스 블록: 최근 뉴스 제목, 출처, 링크
데이터 품질 블록: 결측치, 기준일, 데이터 소스
좋은 AI 리포트는 좋은 입력에서 나온다. 입력이 흐릿하면 출력도 흐릿하다.
구조화된 출력 만들기
이제 AI가 자유로운 에세이를 쓰게 하지 말고, 정해진 형태로 답하게 하자. 이를 위해 Pydantic 모델을 사용할 수 있다. Pydantic은 데이터의 형태를 검사하는 파이썬 라이브러리다. PydanticAI 같은 도구를 쓰면 LLM의 출력을 이런 데이터 모델에 맞춰 받을 수 있다.
from pydantic import BaseModel, Field
from typing import List
class InvestmentReport(BaseModel):
company_overview: str = Field(description="입력 데이터에 근거한 기업 개요")
key_metrics_summary: List[str] = Field(description="핵심 지표 요약")
positive_factors: List[str] = Field(description="긍정적으로 볼 수 있는 요인")
negative_factors: List[str] = Field(description="부정적으로 볼 수 있는 요인")
risks: List[str] = Field(description="투자자가 확인해야 할 리스크")
follow_up_questions: List[str] = Field(description="추가로 확인할 질문")
data_quality_notes: List[str] = Field(description="데이터 한계와 결측 정보")
final_note: str = Field(description="매수·매도 추천이 아닌 검토용 요약")출력 항목은 의도적으로 단순하게 잡았다. 초보자에게 필요한 것은 긴 보고서보다 명확한 체크리스트다.
이 회사가 무엇을 하는가?
최근 가격 흐름은 어떤가?
지표상 좋아 보이는 점은 무엇인가?
불편한 점은 무엇인가?
더 확인해야 할 질문은 무엇인가?
이 다섯 가지를 정리할 수 있으면 리포트 초안으로 충분하다.
프롬프트는 제한을 분명히 써야 한다
AI 리포트 생성기의 시스템 지시는 짧고 단호해야 한다.
SYSTEM_PROMPT = """
당신은 투자 리서치 보조원입니다.
사용자가 제공한 데이터 블록에 근거해서만 투자 메모 초안을 작성하세요.
입력에 없는 수치, 뉴스, 전망, 목표주가를 만들지 마세요.
매수, 매도, 보유 추천을 하지 마세요.
모든 판단은 '검토 필요', '가능성', '리스크'의 형태로 표현하세요.
결측치가 있으면 데이터 품질 메모에 적으세요.
"""좋은 프롬프트는 AI를 똑똑하게 보이게 만드는 문장이 아니다. 위험한 행동을 못 하게 막는 규칙이다.
예를 들어 다음 문장은 금지해야 한다.
"지금 매수하기 좋다."
"목표주가는 20% 상승 여력이 있다."
"확실한 성장주다."
"뉴스를 보면 실적 개선이 확정적이다."
대신 다음처럼 쓰게 해야 한다.
"최근 1년 수익률은 높지만, 변동성도 함께 확인해야 한다."
"PER 값이 제공되지 않아 밸류에이션 판단에는 한계가 있다."
"뉴스 제목만으로는 실적 영향을 단정하기 어렵다."
"다음 실적 발표에서 매출 성장률과 마진 변화를 확인할 필요가 있다."
리포트를 마크다운으로 저장하기
구조화된 결과를 받았다면 사람이 읽기 쉬운 마크다운으로 저장한다.
def render_markdown(report: InvestmentReport, raw_input: dict) -> str:
lines = []
lines.append(f"# {raw_input['ticker']} 투자 메모 초안")
lines.append("")
lines.append(f"- 데이터 기준일: {raw_input['data_as_of']}")
lines.append(f"- 데이터 출처: {raw_input['data_source']}")
lines.append("- 용도: 투자 판단 전 검토용 리서치 메모")
lines.append("")
lines.append("## 기업 개요")
lines.append(report.company_overview)
lines.append("")
lines.append("## 핵심 지표 요약")
for item in report.key_metrics_summary:
lines.append(f"- {item}")
lines.append("")
lines.append("## 긍정 요인")
for item in report.positive_factors:
lines.append(f"- {item}")
lines.append("")
lines.append("## 부정 요인")
for item in report.negative_factors:
lines.append(f"- {item}")
lines.append("")
lines.append("## 주요 리스크")
for item in report.risks:
lines.append(f"- {item}")
lines.append("")
lines.append("## 추가 확인 질문")
for item in report.follow_up_questions:
lines.append(f"- {item}")
lines.append("")
lines.append("## 데이터 품질 메모")
for item in report.data_quality_notes:
lines.append(f"- {item}")
lines.append("")
lines.append("## 고지")
lines.append(report.final_note)
lines.append("본 문서는 매수·매도 추천이 아니며, 투자 판단과 책임은 투자자 본인에게 있습니다.")
return "n".join(lines)실제 저장은 간단하다.
from pathlib import Path
def save_report(markdown: str, ticker: str):
Path("reports").mkdir(exist_ok=True)
path = Path("reports") / f"{ticker}_investment_memo.md"
path.write_text(markdown, encoding="utf-8")
return path이제 리포트는 대시보드의 부가 기능이 될 수도 있고, 매일 아침 자동 생성되는 투자 메모가 될 수도 있다.
검증 절차를 넣어야 한다
AI 리포트에서 가장 중요한 부분은 생성이 아니라 검증이다.
첫째, 숫자는 원천 데이터와 다시 대조한다. LLM이 "1년 수익률은 15.2%"라고 썼다면, 그 숫자가 price_metrics에 실제로 있는지 확인해야 한다. 가능하면 AI가 숫자를 새로 계산하지 못하게 하고, 제공된 숫자만 문장에 넣게 한다.
둘째, 결측치를 확인한다. PER, PBR, ROE가 없는데 밸류에이션 해석이 들어가면 안 된다. 데이터가 없으면 "판단하기 어렵다"고 쓰는 것이 정직한 리포트다.
셋째, 뉴스 출처를 확인한다. 뉴스 제목만 제공했는데 AI가 본문 내용을 아는 것처럼 쓰면 안 된다. 링크가 없는 뉴스는 신뢰도를 낮게 표시한다.
넷째, 데이터 기준일을 표시한다. 주가와 뉴스는 시간이 지나면 바로 낡는다. 오늘 만든 리포트가 다음 주에도 유효하다고 가정하면 안 된다.
다섯째, 금지 문구를 검사한다.
FORBIDDEN_PHRASES = [
"반드시 매수",
"무조건 매수",
"확실한 수익",
"목표주가",
"추천합니다",
]
def check_forbidden_phrases(markdown: str):
found = [p for p in FORBIDDEN_PHRASES if p in markdown]
if found:
raise ValueError(f"금지 문구가 포함되었습니다: {found}")이 검사는 단순하지만 효과가 있다. 자동 리포트는 투자 결정을 대신하면 안 된다. 검토를 돕는 문서로 남아야 한다.
좋은 리포트는 결론보다 질문을 남긴다
초보 투자자는 리포트에서 답을 찾고 싶어 한다. "사야 하나, 말아야 하나?"라는 답이다. 하지만 데이터 기반 투자자는 다른 질문을 해야 한다.
이 종목의 최근 상승은 실적 개선과 함께 나타났는가?
변동성에 비해 수익률은 충분했는가?
MDD가 내 투자 성향이 감당할 수 있는 수준인가?
밸류에이션 지표가 업종 평균과 비교해 어떤 위치인가?
뉴스는 단기 이벤트인가, 장기 구조 변화인가?
이 아이디어를 백테스트 가능한 전략으로 바꿀 수 있는가?
AI 투자 리포트 생성기의 가장 큰 가치는 여기에 있다. 종목을 찍어주는 것이 아니라, 생각할 질문을 정리해준다.
자동 리포트의 한계
자동 리포트는 편리하지만 한계가 분명하다.
데이터 API는 지연될 수 있다. 일부 재무지표는 오래된 값일 수 있다. 수정주가 반영 방식이 다를 수 있다. 뉴스 데이터는 누락되거나 중복될 수 있다. LLM은 입력을 오해하거나, 조심하라고 지시해도 과한 표현을 쓸 수 있다.
그래서 리포트 하단에는 항상 다음 정보를 포함해야 한다.
데이터 기준일
사용한 데이터 출처
가격 데이터 기간
결측된 주요 지표
뉴스 출처와 링크
모델이 생성한 초안이라는 사실
투자 책임은 사용자에게 있다는 고지
이 문구는 형식적인 면책만을 위한 것이 아니다. 리포트를 읽는 사람이 "이 문서는 완성된 판단이 아니라 검토용 초안"임을 계속 기억하게 만드는 장치다.
사람이 최종 판단하는 구조
이 장에서 만든 리포트 생성기는 AI Quant Lab의 중요한 부품이다. 대시보드는 숫자와 차트를 보여준다. 리포트 생성기는 그 숫자와 차트를 문장으로 바꾼다. 그러나 마지막 단계에는 반드시 사람이 있어야 한다.
좋은 구조는 다음과 같다.
코드가 데이터를 수집한다.
코드가 지표를 계산한다.
AI가 자료를 요약한다.
AI가 리스크와 추가 질문을 제안한다.
사람이 원천 데이터와 리포트를 대조한다.
사람이 투자 여부와 비중을 결정한다.
결과를 투자 일지에 남긴다.
이 구조에서는 AI가 투자자를 대신하지 않는다. 오히려 투자자의 판단 과정을 더 투명하게 만든다.
주식 투자는 예언이 아니다. 가설을 세우고, 데이터를 모으고, 실험하고, 틀릴 가능성을 확인하는 과정이다. AI 투자 리포트 생성기도 같은 목적을 가져야 한다. 더 빠른 확신이 아니라, 더 좋은 검토를 돕는 도구여야 한다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
