[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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 전송
|
||||||
|
|||||||
+111
-54
@@ -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"),
|
||||||
|
("한경_경제", "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"),
|
||||||
|
]
|
||||||
|
|
||||||
# ── 뉴스 크롤링 ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def fetch_news() -> list[str]:
|
# ── RSS 뉴스 수집 ─────────────────────────────────────────────────────────────
|
||||||
url = "https://finance.naver.com/news/mainnews.naver"
|
|
||||||
|
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:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(
|
async with session.get(
|
||||||
url, headers=_HEADERS, timeout=aiohttp.ClientTimeout(total=15)
|
url, timeout=aiohttp.ClientTimeout(total=10)
|
||||||
) as resp:
|
) as resp:
|
||||||
raw = await resp.read()
|
raw = await resp.read()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
html = raw.decode("euc-kr")
|
text = raw.decode("utf-8")
|
||||||
except Exception:
|
except UnicodeDecodeError:
|
||||||
html = raw.decode("utf-8", errors="replace")
|
text = raw.decode("euc-kr", errors="replace")
|
||||||
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
root = ET.fromstring(text)
|
||||||
headlines: list[str] = []
|
count = 0
|
||||||
|
for item in root.findall(".//item"):
|
||||||
for tag in soup.select(
|
el = item.find("title")
|
||||||
"ul.simpleNewsList li a, "
|
if el is None or not el.text:
|
||||||
".newsListBody .articleSubject a, "
|
continue
|
||||||
".mainNewsList .articleSubject a"
|
title = html_lib.unescape(el.text.strip())
|
||||||
):
|
title = re.sub(r"<!\[CDATA\[|\]\]>", "", title).strip()
|
||||||
text = tag.get_text(strip=True)
|
title = re.sub(r"<[^>]+>", "", title).strip()
|
||||||
if len(text) >= 10:
|
if len(title) >= 10 and title not in seen:
|
||||||
headlines.append(text)
|
seen.add(title)
|
||||||
|
headlines.append(title)
|
||||||
if not headlines:
|
count += 1
|
||||||
for tag in soup.find_all("a", href=re.compile(r"news")):
|
if count >= 20:
|
||||||
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
|
break
|
||||||
|
|
||||||
logger.info(f"뉴스 {len(result)}건 수집")
|
logger.info(f"RSS [{source_name}] {count}건")
|
||||||
return result
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"뉴스 크롤링 실패: {e}")
|
logger.warning(f"RSS 실패 [{source_name}]: {e}")
|
||||||
return []
|
|
||||||
|
logger.info(f"RSS 뉴스 총 {len(headlines)}건 수집")
|
||||||
|
return headlines
|
||||||
|
|
||||||
|
|
||||||
# ── KIS 시장 데이터 ──────────────────────────────────────────────────────────
|
# ── 네이버 종목별 뉴스 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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:
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user