Qualtative Analysis가 필요한 이유
주식이나 금융 시장에서 투자를 결정하는데 있어 흔히 사용하는 방식 중 하나가 재무제표, 차트, 지표 등 정량적 분석(Quantative Analysis)이다. 하지만 이런 수치적인 데이터만으로는 시장 심리, 특정 이슈 및 트렌드를 놓칠 수 있다.
예를 들어 최근 중국의 deepseek로 인해 미국 반도체, 테크 주식들이 대폭락을 했는데 이는 재무제표에는 반영되지는 않지만 투자심리와 주가 변동에 큰 영향을 미친것이다.
이러한 이유로 정성적 분석(Qualitative Analysis)가 중요해졌다. 뉴스 기사, 업계 평가, 산업의 미래투자가치, 애널리스트 코멘트 등은 미래의 트렌드를 파악하면서 투자 판단에 활용할 수 있는 유용한 텍스트 정보들이 필요하다.
이를 RAG 시스템에 적용하면 뉴스를 토대로 검색(Retrieval) -> 요약 혹은 답변(Generation)을 수행하여 우리가 모든 뉴스를 챙겨보지 않아도 질문을 했을 때 필요한 정보를 끌어낼 수 있고, 또한 LLM의 추론, 분석 능력을 통해 투자에 도움이 될 수 있다.
예를 들어 "최근 삼성전자의 주가 변동 원인" 이라는 질문을 날리면 LLM이 최신 뉴스를 검색하여 관련 정보를 제공할 수 있다.
BeautifulSoup으로 웹 크롤링 하기
위에서 언급한 정성적 분석을 위해서는 뉴스 데이터가 필요하다. 하지만 증권 뉴스는 수시로 업데이트되고, 기존의 뉴스 데이터셋은 실시간으로 업데이트 되지 않는다. 따라서 실시간 이슈나 최근 기사를 빠르게 확보할 수 없다.
따라서 우리는 웹 크롤링을 통해 뉴스 포털이나 웹사이트에서 최신 기사를 수집하여 데이터셋을 실시간으로 추가해야한다.
웹 크롤링은 beautifulsoup 라이브러리를 사용한다. 먼저 pip로 패키지를 설치하고 import 하자.
pip install requests beautifulsoup4
import requests
from bs4 import BeautifulSoup
request로 HTML 가져오기
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"}
def fetch_html(url: str) -> BeautifulSoup:
"""Fetch the HTML content of a URL and return a BeautifulSoup object."""
response = requests.get(url, headers=HEADERS)
response.raise_for_status()
return BeautifulSoup(response.text, 'html.parser')
request.get(url)로 서버에 GET요청을 보내고 response.text로 HTML 문자열을 얻는다.
해당 문자열을 BeautifulSoup 객체에 넘기면 parsing을 수행하게 된다.
특정 요소 찾기
태그 이름, 클래스 이름 등 html에서 특정 요소를 찾아 우리가 원하는 데이터만 추출해야 한다.
먼저 find를 이용할 때는 tag를 기준으로하고 attrs에 class나 id 값을 넣어주면 된다.
# 예: 기사 리스트가 <div class="news_area"> ... </div> 형태
articles = soup.find_all("div", attrs={"class":"news_area"})
for article in articles:
title_element = article.find("a", attrs={"class":"news_title"})
if title_element:
title = title_element.get_text(strip=True)
link = title_element["href"]
print("제목:", title)
print("URL :", link)
select를 이용하면 태그, 클래스, ID에 따라 앞에 붙는 기호가 달라진다.
soup.select("div") # <div>Example</div> 태그 select
soup.select(".example") # <div class="example">Example</div> 클래스 select
soup.select("#example") # <div id="example">Example</div> ID select
# 조합해서 선택하는 것도 가능하다
soup.select("div.example")
soup.select("div#example)
특정 태그와 클래스, ID를 조합해서 선택할 수 있다.
또한 중첩된 구조의 하위 태그를 선택할 수 있다.
예를들어 아래와 같이 div 내부에 p 태그의 기사 내용이 존재한다면
<div>
<p class="article">Paragraph</p>
</div>
soup.select("div > p.article")
soup.select("div p.article")
이렇게 ">"로 바로 하위태그를 선택하거나 공백을 넣어 모든 하위태그를 선택한다.
텍스트 추출
<dd class="articleSubject">
<a href="https://n.news.naver.com/mnews/article/243/0000071961" target="_blank">
서학개미 아빠가 세뱃돈 대신 사주는 종목은
</a>
</dd>
이런 html에 대하여 우리는 뉴스 링크와 뉴스 제목 데이터를 얻고 싶다.
그렇다면 "dd > a" 태그를 선택한 뒤 <a> 태그 내부의 href link와 텍스트를 얻으면 된다.
soup.select_one("dd > a")["href"]
soup.select_one("dd > a").get("href")
# 'https://n.news.naver.com/mnews/article/243/0000071961'
soup.select_one("dd > a").get_text()
# '서학개미 아빠가 세뱃돈 대신 사주는 종목은'
soup.select로 우리의 타겟을 지정한 뒤 get()으로 "class", "target", "href" 등 우리가 원하는 속성명을 입력하면 해당 속성값을 얻을 수 있다.
<a>뉴스제목</a> 형태처럼 태그로 가둬진 텍스트의 경우에는 get_text()을 사용하면 내부 텍스트를 얻을 수 있다.
네이버 뉴스 크롤링
네이버증권의 뉴스->주요뉴스를 누르면 해당 페이지가 나온다. https://finance.naver.com/news/mainnews.naver
F12를 눌러 크롬 개발자도구로 해당 뉴스목록이 어떤 html 형식을 갖는지 알아보자.
<div class="mainNewsList _replaceNewsLink">
<ul class="newsList">
<li class="block1">
<dl>
<dt class="thumb">
<a href="https://n.news.naver.com/mnews/article/009/0005435793" target="_blank"><img src="https://imgnews.pstatic.net/image/thumb70/009/2025/01/29/5435793.jpg" onerror="this.src='https://ssl.pstatic.net/static/nfinance/2017/02/27/thumb_72x54.gif'"></a>
</dt>
<dd class="articleSubject">
<a href="https://n.news.naver.com/mnews/article/009/0005435793" target="_blank">“6만전자 간다” vs “모든 악재는 충분히 반영”…삼성전자 보는 증권가 엇갈린 시선</a>
</dd>
<dd class="articleSummary">
코스피 대장주 삼성전자의 주가 향방을 두고 증권가도 좀처럼 갈피를 잡지 못하는 모습이다. 삼성전자의 주가가 최근 크게 하락한 상황이지만..
<span class="press">매일경제 </span>
<span class="bar">|</span>
<span class="wdate">2025-01-29 22:07:08</span>
</dd>
</dl>
</li>
...중략
</ul>
</div>
대략 위와같은 형태를 가지고 있다.
우리가 원하는건 각 뉴스의 링크이니까 mainNewsList -> newsList -> articleSubject 내부의 href 링크를 가져오면 된다.
<tbody>
<tr>
<td class="on">
<a href="/news/mainnews.naver?&page=1">1</a>
</td>
<td>
<a href="/news/mainnews.naver?&page=2">2</a>
</td>
<td>
<a href="/news/mainnews.naver?&page=3">3</a>
</td>
<td class="pgRR">
<a href="/news/mainnews.naver?&page=3">맨뒤
<img src="https://ssl.pstatic.net/static/n/cmn/bu_pgarRR.gif" width="8" height="5" alt="" border="0">
</a>
</td>
</tr>
</tbody>
또한 주요뉴스 페이지에는 날짜별로 여러 page가 존재한다. 따라서 페이지네이션 부분 html도 참고하여 가장 마지막 페이지 번호를 가져와야 한다.
우리는 특정 일자의 모든 증권 뉴스를 가져오는 것이 목표이고, 크롤링 과정은 다음과 같다.
- 특정 일자의 주요뉴스 페이지 진입
- 해당 일자의 페이지네이션에서 html에서 마지막 페이지 번호 추출
- 1페이지부터 마지막 페이지까지 각 페이지에 들어있는 뉴스 link 추출.
- 각 뉴스 link에 들어가서 뉴스 내용 추출.
먼저 url에 대하여 html을 가져오는 함수를 정의한다.
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"
}
def fetch_html(url: str) -> BeautifulSoup:
"""Fetch the HTML content of a URL and return a BeautifulSoup object."""
response = requests.get(url, headers=HEADERS)
response.raise_for_status()
return BeautifulSoup(response.text, 'html.parser')
이후 마지막 페이지 번호를 얻어오는 함수를 정의한다.
pgRR이 마지막 번호를 나타내는 태그이다.
def extract_num_pagination(url: str) -> int:
"""Extract pagination links to iterate through pages."""
# pagination_links = [a["href"] for an in soup.select("table.Nnavi td a") if "href" in a.attrs]
soup = fetch_html(url)
next_page = soup.select_one("table.Nnavi td.pgRR a")
next_page_link = next_page["href"] if next_page else None
last_page = next_page_link.split("=")[-1]
return last_page
각 페이지 번호를 눌러보면
https://finance.naver.com/news/mainnews.naver?date=2025-01-28&page=2
이런 형태로 request parameter에 "page" 붙어있으니 for loop으로 page마다 접근하면 된다.
각 페이지에 접근하여 해당 페이지에 존재하는 뉴스 링크들을 가져오는 함수를 생성한다.
def convert_real_news_link(news_link: str) -> str:
# Parse the URL and extract query parameters
parsed_url = urllib.parse.urlparse(news_link)
query_params = urllib.parse.parse_qs(parsed_url.query)
# Extract relevant parameters
article_id = query_params.get('article_id', [None])[0]
office_id = query_params.get('office_id', [None])[0]
converted_link = f"https://n.news.naver.com/mnews/article/{office_id}/{article_id}"
return converted_link
def extract_news_links(url: str) -> list:
"""Extract news links from the main news list."""
link_prefix = "https://finance.naver.com"
news_links = []
soup = fetch_html(url)
news_list = soup.select("div.mainNewsList._replaceNewsLink ul.newsList li.block1")
for news_item in news_list:
link_tag = news_item.select_one("dd.articleSubject a")
if link_tag:
news_links.append(convert_real_news_link(link_prefix + link_tag["href"]))
return news_links
실제로 news block의 href에 접근하여 뉴스 링크를 가져오게되면 아래와 같은 url을 가져오게 된다.
하지만 여기에 접근하면 아무런 뉴스 데이터를 가져올 수 없다.
따라서 아래의 실제 뉴스링크에 접근할 수 있도록 article_id와 office_id를 추출하여 실제 뉴스기사 링크로 변환해주는 함수를 추가했다.
# 추출한 뉴스 링크
https://finance.naver.com/news/news_read.naver?article_id=0005435793&office_id=009&mode=mainnews&type=&date=2025-01-29&page=1
# 실제 뉴스 링크
https://n.news.naver.com/mnews/article/009/0005435793
이후 각 링크에 접근하여 뉴스 기사 내용을 가져오면 된다.
def extract_news_text(url: str) -> str:
"""Extract clean text from HTML with custom tag handling."""
soup = fetch_html(url)
content = soup.find("article", id="dic_area") # 기사 내용의 부모 태그
def process_element(element):
"""Recursive function to process each element and extract text."""
summary = []
clean_text = []
for idx, child in enumerate(element.children):
if child.name == "span":
if child.attrs.get("data-type") == "ore":
# <span> 태그 처리: data-type이 'ore'인 경우 내부 텍스트 유지
clean_text.append(child.get_text(strip=True))
else:
strong_tag = child.find("strong")
if strong_tag:
strong_text = strong_tag.get_text(separator="\n", strip=True)
summary.append(strong_text)
elif child.name == "br":
# <br> 태그 처리: 줄바꿈 기호로 치환
clean_text.append("\n")
elif child.string:
if not child.string.strip().endswith("//"):
# 일반 텍스트 노드 처리
clean_text.append(child.string.strip())
elif child.name: # 다른 태그는 재귀적으로 처리
clean_text.append(process_element(child))
return "".join(clean_text), "".join(summary)
return process_element(content) if content else ""
if __name__ == '__main__':
page_num = extract_num_pagination("https://finance.naver.com/news/mainnews.naver")
news_link_list = []
for i in range(int(page_num)):
page_news_link = "https://finance.naver.com/news/mainnews.naver?" + f"&page={i + 1}"
news_link_list += extract_news_links(page_news_link)
print(news_link_list)
news = extract_news_text(news_link_list[0])
print(news)
위에서 설명한 과정대로 페이지 번호 획득 -> 각 페이지의 뉴스 링크 추출 -> 각 뉴스에 접근하여 텍스트 추출을 실행하는 코드이다.
실행 결과는 아래와 같다.
['https://n.news.naver.com/mnews/article/009/0005435793', 'https://n.news.naver.com/mnews/article/057/0001869087', 'https://n.news.naver.com/mnews/article/057/0001869076', 'https://n.news.naver.com/mnews/article/215/0001196769', 'https://n.news.naver.com/mnews/article/009/0005435737', 'https://n.news.naver.com/mnews/article/003/0013038677', 'https://n.news.naver.com/mnews/article/015/0005087433', 'https://n.news.naver.com/mnews/article/018/0005933195', 'https://n.news.naver.com/mnews/article/243/0000071961', 'https://n.news.naver.com/mnews/article/243/0000071959', 'https://n.news.naver.com/mnews/article/011/0004444815', 'https://n.news.naver.com/mnews/article/421/0008046232', 'https://n.news.naver.com/mnews/article/018/0005933147', 'https://n.news.naver.com/mnews/article/018/0005933141', 'https://n.news.naver.com/mnews/article/421/0008046209', 'https://n.news.naver.com/mnews/article/119/0002918243', 'https://n.news.naver.com/mnews/article/421/0008046183', 'https://n.news.naver.com/mnews/article/018/0005933130', 'https://n.news.naver.com/mnews/article/215/0001196746', 'https://n.news.naver.com/mnews/article/215/0001196745', 'https://n.news.naver.com/mnews/article/119/0002918234', 'https://n.news.naver.com/mnews/article/243/0000071951', 'https://n.news.naver.com/mnews/article/008/0005146655', 'https://n.news.naver.com/mnews/article/215/0001196742', 'https://n.news.naver.com/mnews/article/016/0002421586', 'https://n.news.naver.com/mnews/article/015/0005087392', 'https://n.news.naver.com/mnews/article/421/0008046110', 'https://n.news.naver.com/mnews/article/018/0005933089', 'https://n.news.naver.com/mnews/article/016/0002421575', 'https://n.news.naver.com/mnews/article/018/0005933085', 'https://n.news.naver.com/mnews/article/119/0002918218', 'https://n.news.naver.com/mnews/article/008/0005146634', 'https://n.news.naver.com/mnews/article/421/0008046057', 'https://n.news.naver.com/mnews/article/001/0015182988', 'https://n.news.naver.com/mnews/article/215/0001196734', 'https://n.news.naver.com/mnews/article/018/0005933082', 'https://n.news.naver.com/mnews/article/021/0002686709', 'https://n.news.naver.com/mnews/article/015/0005087375', 'https://n.news.naver.com/mnews/article/119/0002918212', 'https://n.news.naver.com/mnews/article/011/0004444766', 'https://n.news.naver.com/mnews/article/001/0015182938', 'https://n.news.naver.com/mnews/article/215/0001196731', 'https://n.news.naver.com/mnews/article/417/0001054676', 'https://n.news.naver.com/mnews/article/018/0005933074', 'https://n.news.naver.com/mnews/article/016/0002421555', 'https://n.news.naver.com/mnews/article/008/0005146617', 'https://n.news.naver.com/mnews/article/018/0005933073', 'https://n.news.naver.com/mnews/article/366/0001050128', 'https://n.news.naver.com/mnews/article/366/0001050127', 'https://n.news.naver.com/mnews/article/008/0005146612', 'https://n.news.naver.com/mnews/article/417/0001054672', 'https://n.news.naver.com/mnews/article/001/0015182852', 'https://n.news.naver.com/mnews/article/629/0000360212']
('코스피 대장주 삼성전자의 주가 향방을 두고 증권가도 좀처럼 갈피를 잡지 못하는 모습이다. 삼성전자의 주가가 최근 크게 하락한 상황이지만 6만전자까지 눈높이를 낮춰 잡은 증권사의 보고서가 발간됐기 때문이다. 반면 이미 삼성전자의 주가에 모든 악재가 충분히 반영됐다는 평가도 나오면서 증권가의 의견이 팽팽히 맞서고 있다.\n\n1월 한 달 사이 11개 증권가 목표가↓29일 증권가에 따르면 최근 iM증권은 삼성전자의 목표가를 기존 7만1000원에서 6만8000원으로 하향 조정했다. 지난 2일부터 이날까지 이달 들어 삼성전자의 목표가를 하향한 보고서는 총 11건이 발간됐다. 이 가운데 삼성전자의 목표가를 6만원 선까지 낮춘 건 iM증권이 유일하다.\n\n지난해 4분기 삼성전자가 부진한 실적을 받아 든 데 이어 1분기 실적도 전 분기와 유사할 것이라는 부정적인 관측 때문이다. 앞서 삼성전자는 4분기 잠정 매출과 영업이익을 각각 75조원, 6조5000억원으로 발표했다. 매출은 예상보다 제품 판매가 부진했고, 영업이익의 둔화는 이익률의 부진과 함께 대규모 일회성 비용이 발생했다는 분석이다.\n\n특히 iM증권은 1분기 DDR4 가격의 하락 본격화, 더블데이트레이트(DDR)5 가격의 하락 개시, 고대역폭메모리(HBM) 출하량의 정체에 따라 D램 평균판매가격(ASP)이 6% 하락하고, 낸드 ASP는 10% 또는 그 이상의 낙폭을 보일 것으로 추정했다. 계절적 수요 감소·고객들의 재고 축소에 따라 메모리 반도체 출하량도 전 분기 대비 증가하기 어려울 것으로 보인다는 평가다.\n\n송명섭 iM증권 연구원은 “반도체 하락 사이클이 이제 막 시작됐고 삼성전자 실적에 대한 컨센서스가 하향 조정될 가능성이 높아 본격적인 주가 상승에는 좀 더 시간이 필요할 것으로 판단한다”며 “충분한 여유를 가지고 저점 매수 기회를 노리는 전략을 권고한다”고 말했다.\n\n“주가순자산비율 역사적 하단”반면 삼성전자의 주가 낙폭이 이미 충분하다고 보는 시각도 있다. KB증권은 최근 6개월간 삼성전자의 주가가 32% 하락해 주가순자산비율(P/B) 0.9배로 역사적 하단을 기록하고 있다고 진단했다.\n\n올해 상반기 중 HBM3E 12단과 HBM4에 대해 생산 내부 승인(PRA)을 목표로 하고 있어 향후 엔비디아 HBM 제품 승인 가능성이 커질 것이라는 전망이다. 블랙웰 출시 지연에 따른 시간적 여유 확보와 웨이퍼 투입량 감소 및 전략적 감산 시작 등을 고려하면 오는 반기부터 D램, 낸드의 전반적인 수급 개선이 기대되기 때문이다.\n\n이에 따라 KB증권은 최근 삼성전자의 목표가를 7만원으로 유지하기도 했다. 김동원 KB증권 연구원은 “현재 삼성전자 주가는 목표주가 감안 시 상승 여력은 30% 이상인 반면 하락 위험은 10% 미만인 것으로 추정돼 하반기 실적 개선을 고려할 때 모든 악재는 이미 충분히 반영된 상태로 판단된다”고 설명했다.\n\n', '')
Process finished with exit code 0
'AI, 머신러닝' 카테고리의 다른 글
[Spark] 빅데이터 분산처리를 위한 PySpark 설치와 PostgreSQL 연결하고 세션 생성하기. (1) | 2025.02.02 |
---|---|
RAG 시스템 강화하기 - LangChain으로 Multi-Query, Reranker 적용한 증권 뉴스 RAG 구현 (1) | 2025.01.30 |
RAG 시스템 강화하기 - Multi-Query, Self-Query, Reranker 알아보기 (0) | 2025.01.29 |
OpenAI API와 LangChain으로 만드는 고성능 RAG 시스템 구현하기 (0) | 2025.01.28 |
RAG 코드 간단하게 구현하기 (2) | 2025.01.27 |