From e5144d7d485ccf6af4eb115488071ca3fe132a78 Mon Sep 17 00:00:00 2001 From: jongjae Date: Tue, 19 May 2026 08:35:19 +0900 Subject: [PATCH] =?UTF-8?q?[2026-05-19]=20=EB=AA=A8=EB=8B=9D=20=EB=A3=A8?= =?UTF-8?q?=ED=8B=B4=20=EA=B0=95=ED=99=94=20=E2=80=94=20RSS=204=EA=B0=9C?= =?UTF-8?q?=20=EC=96=B8=EB=A1=A0=EC=82=AC=20+=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=A2=85=EB=AA=A9=EB=B3=84=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=2007:30=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetch_news() → fetch_rss_news(): 한경·파이낸셜·매경 RSS ~80건 - fetch_stock_news_naver(): 거래량 상위 20종목 × 5건 (NAVER_CLIENT_ID/SECRET) - 스케줄러 08:15 → 07:30 변경 (수집 여유 확보) - blacklist_tickers 악재 감지 강화 (종목별 뉴스 기반) Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/morning.md | 8 +- CLAUDE.md | 7 +- app/ai/morning.py | 191 +++++++++++++++++++++++------------- 3 files changed, 134 insertions(+), 72 deletions(-) diff --git a/.claude/commands/morning.md b/.claude/commands/morning.md index 2534f74..906a765 100644 --- a/.claude/commands/morning.md +++ b/.claude/commands/morning.md @@ -1,6 +1,7 @@ # claude_morning — 장 전 분석 오늘 날짜 기준으로 장 전 분석을 수행하고 `data/daily_context.json`을 생성한다. +07:30에 스케줄러가 자동 실행. 완료 후 /start-bot으로 봇 시작. ## 실행 순서 @@ -8,7 +9,10 @@ ```bash python app/ai/morning.py --print ``` -위 명령을 실행해 뉴스 헤드라인과 KIS 시장 데이터를 수집한다. +위 명령을 실행해 다음 데이터를 수집한다: +- **RSS 뉴스**: 한경증권·한경경제·파이낸셜뉴스·매경증권 4개 언론사 (~80건) +- **KIS 수급**: 거래량 상위 30종목, 외국인/기관 순매수 상위 10종목, 업종 동향 +- **종목별 뉴스**: 네이버 검색 API로 거래량 상위 20종목 각 5건 ### 2. 분석 수집된 데이터를 바탕으로 다음 항목을 판단한다: @@ -18,7 +22,7 @@ python app/ai/morning.py --print - **주목 섹터**: 수급·뉴스 모두 긍정적인 섹터 - **회피 섹터**: 악재·수급 부진 섹터 - **boosted_tickers**: 거래량 상위 + 외국인 순매수 겹치는 종목코드 -- **blacklist_tickers**: 악재(횡령·소송·거래정지 등) 종목코드 +- **blacklist_tickers**: 종목별 뉴스에서 악재(횡령·소송·거래정지 등) 감지된 종목코드 - **position_size_multiplier**: 0.5(약세) ~ 1.0(중립) ~ 1.5(강세) - **trade_allowed**: sentiment_score < 40이면 false diff --git a/CLAUDE.md b/CLAUDE.md index fb4daee..3a24ef0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,9 +41,10 @@ ## 하루 자동화 흐름 ``` -07:55 StockBot_Bot → run_bot.ps1 → claude /start-bot → 봇 백그라운드 시작 -08:15 StockBot_Morning → run_morning.ps1 → claude /morning → 뉴스+수급 분석 → daily_context.json -08:30 봇이 daily_context.json 로드 → 유니버스 30종목 확정 +07:30 StockBot_Morning → run_morning.ps1 → claude /morning → RSS+네이버 뉴스+수급 분석 → daily_context.json + 완료 후 자동으로 /start-bot 호출 → 봇 백그라운드 시작 +08:30 봇이 daily_context.json 로드 → Discord에 분석 결과 전송 → 유니버스 30종목 확정 +08:50 목표가 계산 09:00 매매 루프 시작 (변동성 돌파 신호 + AI 필터) 14:50 강제 전량 청산 (절대 불변) 15:10 일일 결산 → Discord 전송 diff --git a/app/ai/morning.py b/app/ai/morning.py index 20a2dd0..0f4d83d 100644 --- a/app/ai/morning.py +++ b/app/ai/morning.py @@ -11,11 +11,13 @@ Claude Code headless가 이 스크립트를 실행해 데이터를 수집한 뒤 """ import asyncio +import html as html_lib import json import logging import os import re import sys +import xml.etree.ElementTree as ET from datetime import datetime from pathlib import Path @@ -57,7 +59,6 @@ logging.basicConfig( logger = logging.getLogger(__name__) import aiohttp -from bs4 import BeautifulSoup from app.execution.kis_client import KISClient TODAY = datetime.now().strftime("%Y-%m-%d") @@ -71,61 +72,113 @@ _HEADERS = { "Chrome/120.0.0.0 Safari/537.36" ), "Accept-Language": "ko-KR,ko;q=0.9", - "Referer": "https://finance.naver.com/", } - -# ── 뉴스 크롤링 ───────────────────────────────────────────────────────────── - -async def fetch_news() -> list[str]: - url = "https://finance.naver.com/news/mainnews.naver" - try: - async with aiohttp.ClientSession() as session: - async with session.get( - url, headers=_HEADERS, timeout=aiohttp.ClientTimeout(total=15) - ) as resp: - raw = await resp.read() - - try: - html = raw.decode("euc-kr") - except Exception: - html = raw.decode("utf-8", errors="replace") - - soup = BeautifulSoup(html, "html.parser") - headlines: list[str] = [] - - for tag in soup.select( - "ul.simpleNewsList li a, " - ".newsListBody .articleSubject a, " - ".mainNewsList .articleSubject a" - ): - text = tag.get_text(strip=True) - if len(text) >= 10: - headlines.append(text) - - if not headlines: - for tag in soup.find_all("a", href=re.compile(r"news")): - text = tag.get_text(strip=True) - if 10 <= len(text) <= 120: - headlines.append(text) - - seen: set[str] = set() - result: list[str] = [] - for h in headlines: - if h not in seen: - seen.add(h) - result.append(h) - if len(result) >= 15: - break - - logger.info(f"뉴스 {len(result)}건 수집") - return result - except Exception as e: - logger.warning(f"뉴스 크롤링 실패: {e}") - return [] +# 경제/증권 RSS 피드 (소스명, URL) +RSS_FEEDS = [ + ("한경_증권", "https://www.hankyung.com/feed/finance"), + ("한경_경제", "https://www.hankyung.com/feed/economy"), + ("파이낸셜_증권", "http://www.fnnews.com/rss/fn_realnews_stock.xml"), + ("매경_증권", "http://file.mk.co.kr/news/rss/rss_50200011.xml"), +] -# ── KIS 시장 데이터 ────────────────────────────────────────────────────────── +# ── RSS 뉴스 수집 ───────────────────────────────────────────────────────────── + +async def fetch_rss_news() -> list[str]: + """4개 경제지 RSS에서 증권/경제 헤드라인 수집 (소스당 최대 20건)""" + headlines: list[str] = [] + seen: set[str] = set() + + async with aiohttp.ClientSession(headers=_HEADERS) as session: + for source_name, url in RSS_FEEDS: + try: + async with session.get( + url, timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + raw = await resp.read() + + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + text = raw.decode("euc-kr", errors="replace") + + root = ET.fromstring(text) + count = 0 + for item in root.findall(".//item"): + el = item.find("title") + if el is None or not el.text: + continue + title = html_lib.unescape(el.text.strip()) + title = re.sub(r"", "", title).strip() + title = re.sub(r"<[^>]+>", "", title).strip() + if len(title) >= 10 and title not in seen: + seen.add(title) + headlines.append(title) + count += 1 + if count >= 20: + break + + logger.info(f"RSS [{source_name}] {count}건") + + except Exception as e: + logger.warning(f"RSS 실패 [{source_name}]: {e}") + + logger.info(f"RSS 뉴스 총 {len(headlines)}건 수집") + return headlines + + +# ── 네이버 종목별 뉴스 ──────────────────────────────────────────────────────── + +async def fetch_stock_news_naver(stocks: list[dict]) -> dict: + """네이버 검색 API로 거래량 상위 종목별 최신 뉴스 5건 수집""" + client_id = os.getenv("NAVER_CLIENT_ID", "") + client_secret = os.getenv("NAVER_CLIENT_SECRET", "") + + if not client_id or not client_secret: + logger.warning("NAVER API 키 없음 — 종목별 뉴스 스킵") + return {} + + naver_headers = { + "X-Naver-Client-Id": client_id, + "X-Naver-Client-Secret": client_secret, + } + + result: dict = {} + async with aiohttp.ClientSession(headers=naver_headers) as session: + for stock in stocks[:20]: + ticker = stock.get("ticker", "") + name = stock.get("name", "") + if not name or name == ticker: + continue + try: + params = {"query": name, "display": 5, "sort": "date"} + async with session.get( + "https://openapi.naver.com/v1/search/news.json", + params=params, + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + data = await resp.json() + + items = data.get("items", []) + news_list = [ + html_lib.unescape(re.sub(r"<[^>]+>", "", item.get("title", ""))) + for item in items + if item.get("title") + ] + if news_list: + result[ticker] = {"name": name, "headlines": news_list} + + await asyncio.sleep(0.11) # 초당 9건 이하 유지 + + except Exception as e: + logger.warning(f"종목 뉴스 실패 [{name}]: {e}") + + logger.info(f"종목별 뉴스 {len(result)}개 종목 수집") + return result + + +# ── KIS 시장 데이터 ─────────────────────────────────────────────────────────── async def fetch_market_data(kis: KISClient) -> dict: data: dict = { @@ -136,7 +189,7 @@ async def fetch_market_data(kis: KISClient) -> dict: } try: - data["volume_rank"] = await kis.get_volume_rank(top_n=20) + data["volume_rank"] = await kis.get_volume_rank(top_n=30) logger.info(f"거래량 순위 {len(data['volume_rank'])}종목") await asyncio.sleep(1.1) except Exception as e: @@ -144,7 +197,7 @@ async def fetch_market_data(kis: KISClient) -> dict: try: fi = await kis.get_foreign_institution_rank(top_n=20) - data["foreign_buy"] = fi["foreign"][:10] + data["foreign_buy"] = fi["foreign"][:10] data["institution_buy"] = fi["institution"][:10] logger.info("외국인/기관 수급 수집") await asyncio.sleep(1.1) @@ -169,14 +222,10 @@ async def main(print_mode: bool = False): for d in ["data/news", "data/market"]: os.makedirs(d, exist_ok=True) - # 뉴스 수집 - news = await fetch_news() - Path(NEWS_PATH).write_text( - json.dumps({"date": TODAY, "headlines": news}, ensure_ascii=False, indent=2), - encoding="utf-8", - ) + # 1. RSS 뉴스 수집 (4개 언론사) + news = await fetch_rss_news() - # KIS 시장 데이터 + # 2. KIS 시장 데이터 kis = KISClient() market: dict = {"volume_rank": [], "foreign_buy": [], "institution_buy": [], "sectors": []} try: @@ -185,23 +234,31 @@ async def main(print_mode: bool = False): except Exception as e: logger.warning(f"KIS 수집 실패: {e}") + # 3. 네이버 종목별 뉴스 (거래량 상위 20종목) + stock_news = await fetch_stock_news_naver(market["volume_rank"]) + + # 파일 저장 + Path(NEWS_PATH).write_text( + json.dumps({"date": TODAY, "headlines": news}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) Path(MARKET_PATH).write_text( - json.dumps({"date": TODAY, **market}, ensure_ascii=False, indent=2), + json.dumps({"date": TODAY, **market, "stock_news": stock_news}, ensure_ascii=False, indent=2), encoding="utf-8", ) logger.info(f"수집 완료 → {NEWS_PATH}, {MARKET_PATH}") if print_mode: - # Claude Code가 읽을 수 있도록 stdout으로 출력 print(json.dumps( { "date": TODAY, - "news_headlines": news, - "volume_rank": market["volume_rank"][:10], - "foreign_buy_top5": market["foreign_buy"][:5], - "institution_buy_top5": market["institution_buy"][:5], + "news_headlines": news, # RSS 전체 (~80건) + "volume_rank": market["volume_rank"][:20], + "foreign_buy_top10": market["foreign_buy"], + "institution_buy_top10": market["institution_buy"], "sectors": market["sectors"][:15], + "stock_news": stock_news, # 종목별 뉴스 }, ensure_ascii=False, indent=2,