[2026-05-19] 모닝 루틴 강화 — RSS 4개 언론사 + 네이버 종목별 뉴스 추가, 07:30 시작

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 08:35:19 +09:00
parent b54d61531f
commit e5144d7d48
3 changed files with 134 additions and 72 deletions
+6 -2
View File
@@ -1,6 +1,7 @@
# claude_morning — 장 전 분석 # claude_morning — 장 전 분석
오늘 날짜 기준으로 장 전 분석을 수행하고 `data/daily_context.json`을 생성한다. 오늘 날짜 기준으로 장 전 분석을 수행하고 `data/daily_context.json`을 생성한다.
07:30에 스케줄러가 자동 실행. 완료 후 /start-bot으로 봇 시작.
## 실행 순서 ## 실행 순서
@@ -8,7 +9,10 @@
```bash ```bash
python app/ai/morning.py --print python app/ai/morning.py --print
``` ```
위 명령을 실행해 뉴스 헤드라인과 KIS 시장 데이터를 수집한다. 위 명령을 실행해 다음 데이터를 수집한다:
- **RSS 뉴스**: 한경증권·한경경제·파이낸셜뉴스·매경증권 4개 언론사 (~80건)
- **KIS 수급**: 거래량 상위 30종목, 외국인/기관 순매수 상위 10종목, 업종 동향
- **종목별 뉴스**: 네이버 검색 API로 거래량 상위 20종목 각 5건
### 2. 분석 ### 2. 분석
수집된 데이터를 바탕으로 다음 항목을 판단한다: 수집된 데이터를 바탕으로 다음 항목을 판단한다:
@@ -18,7 +22,7 @@ python app/ai/morning.py --print
- **주목 섹터**: 수급·뉴스 모두 긍정적인 섹터 - **주목 섹터**: 수급·뉴스 모두 긍정적인 섹터
- **회피 섹터**: 악재·수급 부진 섹터 - **회피 섹터**: 악재·수급 부진 섹터
- **boosted_tickers**: 거래량 상위 + 외국인 순매수 겹치는 종목코드 - **boosted_tickers**: 거래량 상위 + 외국인 순매수 겹치는 종목코드
- **blacklist_tickers**: 악재(횡령·소송·거래정지 등) 종목코드 - **blacklist_tickers**: 종목별 뉴스에서 악재(횡령·소송·거래정지 등) 감지된 종목코드
- **position_size_multiplier**: 0.5(약세) ~ 1.0(중립) ~ 1.5(강세) - **position_size_multiplier**: 0.5(약세) ~ 1.0(중립) ~ 1.5(강세)
- **trade_allowed**: sentiment_score < 40이면 false - **trade_allowed**: sentiment_score < 40이면 false
+4 -3
View File
@@ -41,9 +41,10 @@
## 하루 자동화 흐름 ## 하루 자동화 흐름
``` ```
07:55 StockBot_Bot → run_bot.ps1 → claude /start-bot → 봇 백그라운드 시작 07:30 StockBot_Morning → run_morning.ps1 → claude /morning → RSS+네이버 뉴스+수급 분석 → daily_context.json
08:15 StockBot_Morning → run_morning.ps1 → claude /morning → 뉴스+수급 분석 → daily_context.json 완료 후 자동으로 /start-bot 호출 → 봇 백그라운드 시작
08:30 봇이 daily_context.json 로드 → 유니버스 30종목 확정 08:30 봇이 daily_context.json 로드 → Discord에 분석 결과 전송 → 유니버스 30종목 확정
08:50 목표가 계산
09:00 매매 루프 시작 (변동성 돌파 신호 + AI 필터) 09:00 매매 루프 시작 (변동성 돌파 신호 + AI 필터)
14:50 강제 전량 청산 (절대 불변) 14:50 강제 전량 청산 (절대 불변)
15:10 일일 결산 → Discord 전송 15:10 일일 결산 → Discord 전송
+124 -67
View File
@@ -11,11 +11,13 @@ Claude Code headless가 이 스크립트를 실행해 데이터를 수집한 뒤
""" """
import asyncio import asyncio
import html as html_lib
import json import json
import logging import logging
import os import os
import re import re
import sys import sys
import xml.etree.ElementTree as ET
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -57,7 +59,6 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
import aiohttp import aiohttp
from bs4 import BeautifulSoup
from app.execution.kis_client import KISClient from app.execution.kis_client import KISClient
TODAY = datetime.now().strftime("%Y-%m-%d") TODAY = datetime.now().strftime("%Y-%m-%d")
@@ -71,61 +72,113 @@ _HEADERS = {
"Chrome/120.0.0.0 Safari/537.36" "Chrome/120.0.0.0 Safari/537.36"
), ),
"Accept-Language": "ko-KR,ko;q=0.9", "Accept-Language": "ko-KR,ko;q=0.9",
"Referer": "https://finance.naver.com/",
} }
# 경제/증권 RSS 피드 (소스명, URL)
# ── 뉴스 크롤링 ───────────────────────────────────────────────────────────── RSS_FEEDS = [
("한경_증권", "https://www.hankyung.com/feed/finance"),
async def fetch_news() -> list[str]: ("한경_경제", "https://www.hankyung.com/feed/economy"),
url = "https://finance.naver.com/news/mainnews.naver" ("파이낸셜_증권", "http://www.fnnews.com/rss/fn_realnews_stock.xml"),
try: ("매경_증권", "http://file.mk.co.kr/news/rss/rss_50200011.xml"),
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 []
# ── 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"<!\[CDATA\[|\]\]>", "", 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: async def fetch_market_data(kis: KISClient) -> dict:
data: dict = { data: dict = {
@@ -136,7 +189,7 @@ async def fetch_market_data(kis: KISClient) -> dict:
} }
try: 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'])}종목") logger.info(f"거래량 순위 {len(data['volume_rank'])}종목")
await asyncio.sleep(1.1) await asyncio.sleep(1.1)
except Exception as e: except Exception as e:
@@ -144,7 +197,7 @@ async def fetch_market_data(kis: KISClient) -> dict:
try: try:
fi = await kis.get_foreign_institution_rank(top_n=20) 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] data["institution_buy"] = fi["institution"][:10]
logger.info("외국인/기관 수급 수집") logger.info("외국인/기관 수급 수집")
await asyncio.sleep(1.1) await asyncio.sleep(1.1)
@@ -169,14 +222,10 @@ async def main(print_mode: bool = False):
for d in ["data/news", "data/market"]: for d in ["data/news", "data/market"]:
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
# 뉴스 수집 # 1. RSS 뉴스 수집 (4개 언론사)
news = await fetch_news() news = await fetch_rss_news()
Path(NEWS_PATH).write_text(
json.dumps({"date": TODAY, "headlines": news}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# KIS 시장 데이터 # 2. KIS 시장 데이터
kis = KISClient() kis = KISClient()
market: dict = {"volume_rank": [], "foreign_buy": [], "institution_buy": [], "sectors": []} market: dict = {"volume_rank": [], "foreign_buy": [], "institution_buy": [], "sectors": []}
try: try:
@@ -185,23 +234,31 @@ async def main(print_mode: bool = False):
except Exception as e: except Exception as e:
logger.warning(f"KIS 수집 실패: {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( 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", encoding="utf-8",
) )
logger.info(f"수집 완료 → {NEWS_PATH}, {MARKET_PATH}") logger.info(f"수집 완료 → {NEWS_PATH}, {MARKET_PATH}")
if print_mode: if print_mode:
# Claude Code가 읽을 수 있도록 stdout으로 출력
print(json.dumps( print(json.dumps(
{ {
"date": TODAY, "date": TODAY,
"news_headlines": news, "news_headlines": news, # RSS 전체 (~80건)
"volume_rank": market["volume_rank"][:10], "volume_rank": market["volume_rank"][:20],
"foreign_buy_top5": market["foreign_buy"][:5], "foreign_buy_top10": market["foreign_buy"],
"institution_buy_top5": market["institution_buy"][:5], "institution_buy_top10": market["institution_buy"],
"sectors": market["sectors"][:15], "sectors": market["sectors"][:15],
"stock_news": stock_news, # 종목별 뉴스
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=2,