6d3b0bacc0
- 19개 마이크로서비스 (news-collector, score-engine, ta-engine, dart-collector, aux-signal, us-market, graph-engine, telegram-bot, dashboard-api, kis-api 등) - 가치투자 스코어링 + 10공식 앙상블 보팅 (매직포뮬러/F-Score/Altman/PEG/ 모멘텀/Beneish/GP-A/G-Score/Amihud/BAB) - 뉴스 수집→형태소→임베딩→중복제거→AI분석 파이프라인 - 기술적분석 + GAT 그래프신경망 + 미증시 동조 시그널 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
887 lines
43 KiB
Python
887 lines
43 KiB
Python
"""
|
|
멀티소스 금융 뉴스 수집기 + AI 분석 파이프라인 (버핏 스타일 강화)
|
|
- RSS 28개 소스 (네이버 크롤링 제거, 주식 전문 사이트 중심)
|
|
- 매 5분마다 전체 소스 수집
|
|
- 2단계 중복제거: URL해시(Redis) + 제목정규화해시(Redis) + 벡터유사도(Qdrant)
|
|
- 수집 즉시 바른API → Ollama → Qdrant → Ollama(EXAONE) → PostgreSQL
|
|
"""
|
|
import asyncio, hashlib, json, os, re, random, time
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
import asyncpg, httpx, redis.asyncio as aioredis, structlog
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from bs4 import BeautifulSoup
|
|
from fastapi import FastAPI, Query
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
structlog.configure(processors=[
|
|
structlog.processors.TimeStamper(fmt="iso"),
|
|
structlog.processors.add_log_level,
|
|
structlog.processors.JSONRenderer(),
|
|
])
|
|
logger = structlog.get_logger()
|
|
|
|
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
|
|
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
|
|
PG_HOST = os.getenv("POSTGRES_HOST", "postgres")
|
|
PG_PORT = int(os.getenv("POSTGRES_PORT", "5432"))
|
|
PG_DB = os.getenv("POSTGRES_DB", "trading_ai")
|
|
PG_USER = os.getenv("POSTGRES_USER", "kyu")
|
|
PG_PASS = os.getenv("POSTGRES_PASSWORD", "7895123")
|
|
BAREUN_URL = os.getenv("BAREUN_API_URL", "http://bareunaapi:5757")
|
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
|
|
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
|
|
|
|
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
|
|
|
pg_pool: Optional[asyncpg.Pool] = None
|
|
redis_cl: Optional[aioredis.Redis] = None
|
|
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
|
|
|
class S:
|
|
collected = 0; processed = 0; duplicates = 0; errors = 0
|
|
noise = 0; off_topic = 0
|
|
last_run = ""; running = False
|
|
stats = S()
|
|
|
|
def nhash(title, url=""): return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16]
|
|
|
|
def normalize_title(title: str) -> str:
|
|
"""[속보][단독](종합) 등 제거 후 특수문자·공백 제거 → 유사 제목 중복 감지용"""
|
|
t = re.sub(r'[\[\(【〔][^\]\)】〕]{0,10}[\]\)】〕]', '', title)
|
|
t = re.sub(r'[^\w가-힣a-zA-Z0-9]', '', t)
|
|
return t.lower().strip()
|
|
|
|
def is_korean(text: str) -> bool:
|
|
if not text: return True
|
|
hangul = sum(1 for c in text if '가' <= c <= '힣')
|
|
cjk = sum(1 for c in text if '一' <= c <= '鿿' or '㐀' <= c <= '䶿')
|
|
if hangul == 0 and cjk > 2: return False
|
|
return True
|
|
|
|
async def is_dup(title: str, url: str = "") -> bool:
|
|
if not redis_cl: return False
|
|
try:
|
|
# 1차: URL+제목 해시 (완전 동일 기사)
|
|
h_url = nhash(title, url)
|
|
if await redis_cl.set(f"news:u:{h_url}", "1", ex=172800, nx=True) is None:
|
|
return True
|
|
# 2차: 정규화된 제목 해시 (같은 뉴스 다른 소스)
|
|
h_norm = nhash(normalize_title(title))
|
|
if await redis_cl.set(f"news:t:{h_norm}", "1", ex=21600, nx=True) is None:
|
|
return True
|
|
return False
|
|
except: return False
|
|
|
|
# ── RSS 멀티소스 수집 (28개 소스, 네이버 크롤링 제거) ──────
|
|
|
|
RSS_SOURCES = [
|
|
# ── 주요 경제/금융 신문 ──
|
|
("한국경제", "https://www.hankyung.com/feed/all-news"),
|
|
("한국경제", "https://www.hankyung.com/feed/finance"),
|
|
("매일경제", "https://www.mk.co.kr/rss/30100041/"),
|
|
("매일경제", "https://www.mk.co.kr/rss/30200030/"),
|
|
("머니투데이", "https://news.mt.co.kr/mtview.php?type=rss&MTPub=E§ion=E"),
|
|
("머니투데이", "https://news.mt.co.kr/mtview.php?type=rss&MTPub=S"),
|
|
("이데일리", "https://www.edaily.co.kr/rss/news/finance.xml"),
|
|
("이데일리", "https://www.edaily.co.kr/rss/news/markets.xml"),
|
|
("연합뉴스", "https://www.yna.co.kr/rss/economy.xml"),
|
|
("연합뉴스", "https://www.yna.co.kr/rss/market.xml"),
|
|
("조선비즈", "https://biz.chosun.com/rss/rss.htm?site=biz.chosun.com"),
|
|
("헤럴드경제", "https://biz.heraldcorp.com/rss/index.xml"),
|
|
("파이낸셜뉴스", "https://www.fnnews.com/rss/fn_economy.xml"),
|
|
("서울경제", "https://www.sedaily.com/RSS/Economy"),
|
|
("아시아경제", "https://www.asiae.co.kr/rss/economy.htm"),
|
|
("뉴스1", "https://www.news1.kr/rss/industry.xml"),
|
|
("비즈니스포스트", "https://www.businesspost.co.kr/BP?command=rss&rssFeedType=0000"),
|
|
("인포스탁", "https://www.infostock.co.kr/rss/rss_news.xml"),
|
|
# ── 추가 소스 ──
|
|
("뉴스핌", "https://www.newspim.com/rss/view?outtype=1"),
|
|
("뉴시스", "https://www.newsis.com/rss/finance.xml"),
|
|
("이투데이", "https://www.etoday.co.kr/news/newsfeed.php?CateId=0102"),
|
|
("글로벌이코노믹", "https://www.g-enews.com/rss/rss_economy.xml"),
|
|
("전자신문", "https://www.etnews.com/news/latest_news.xml"),
|
|
("디지털타임스", "https://www.dt.co.kr/rss/rss_economy.html"),
|
|
("더벨", "https://www.thebell.co.kr/free/content/xmlService.asp"),
|
|
("스탁데일리", "https://www.stockdaily.kr/rss/rss.xml"),
|
|
("세계일보", "https://www.segye.com/RSS/economyRss.xml"),
|
|
("SBS Biz", "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=EC"),
|
|
]
|
|
|
|
def parse_rss_date(date_str: str) -> str:
|
|
if not date_str:
|
|
return datetime.now().isoformat()
|
|
for fmt in ("%a, %d %b %Y %H:%M:%S %z", "%a, %d %b %Y %H:%M:%S +0900",
|
|
"%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S"):
|
|
try:
|
|
return datetime.strptime(date_str.strip(), fmt).isoformat()
|
|
except: pass
|
|
return datetime.now().isoformat()
|
|
|
|
async def crawl_rss_sources(client) -> list:
|
|
news = []
|
|
for source_name, rss_url in RSS_SOURCES:
|
|
try:
|
|
r = await client.get(rss_url, timeout=15,
|
|
headers={"User-Agent": "Mozilla/5.0", "Accept": "application/rss+xml, application/xml"})
|
|
if r.status_code != 200:
|
|
continue
|
|
try:
|
|
root = ET.fromstring(r.content)
|
|
except ET.ParseError:
|
|
# XML 파싱 실패 시 BeautifulSoup 폴백
|
|
soup = BeautifulSoup(r.content, "lxml-xml")
|
|
items = soup.find_all("item")
|
|
for item in items[:20]:
|
|
title = item.find("title")
|
|
link = item.find("link")
|
|
pub = item.find("pubDate") or item.find("dc:date")
|
|
if title and title.get_text(strip=True):
|
|
t = title.get_text(strip=True)
|
|
if len(t) > 5 and is_korean(t):
|
|
news.append({
|
|
"title": t,
|
|
"url": link.get_text(strip=True) if link else "",
|
|
"source": source_name,
|
|
"content": "",
|
|
"published_at": parse_rss_date(pub.get_text(strip=True) if pub else ""),
|
|
})
|
|
continue
|
|
|
|
ns = {"dc": "http://purl.org/dc/elements/1.1/"}
|
|
items = root.findall(".//item")
|
|
for item in items[:20]:
|
|
title = item.findtext("title", "").strip()
|
|
link = item.findtext("link", "").strip()
|
|
pubdate = item.findtext("pubDate", "") or item.findtext("dc:date", "", ns)
|
|
desc = item.findtext("description", "")
|
|
if title and len(title) > 5 and is_korean(title):
|
|
news.append({
|
|
"title": title,
|
|
"url": link,
|
|
"source": source_name,
|
|
"content": re.sub(r"<[^>]+>", "", desc)[:300] if desc else "",
|
|
"published_at": parse_rss_date(pubdate),
|
|
})
|
|
await asyncio.sleep(0.2)
|
|
except Exception as e:
|
|
logger.debug("rss.fetch.err", source=source_name, error=str(e))
|
|
logger.info("rss.crawled", sources=len(RSS_SOURCES), items=len(news))
|
|
return news
|
|
|
|
# ── 비주식 뉴스 필터 ───────────────────────────────────────
|
|
# 명백한 비주식 카테고리 prefix → 수집 차단
|
|
NOISE_PREFIXES = (
|
|
# 도서/문화
|
|
"[책마을]", "[책꽂이]", "[책&생각]", "[책]", "[리뷰]", "[영화]",
|
|
"[공연]", "[전시]", "[음악]", "[연예]",
|
|
# 의견/사설
|
|
"[사설]", "[칼럼]", "[기고]", "[오피니언]", "[기자수첩]", "[데스크칼럼]",
|
|
"[Why]", "[취재일기]", "[현장에서]", "[광화문]", "[로터리]",
|
|
# 인사/사회
|
|
"[부고]", "[인사]", "[동정]", "[포토]", "[화보]", "[지면]", "[그래픽]",
|
|
# 라이프
|
|
"[건강]", "[푸드]", "[여행]", "[패션]", "[뷰티]", "[자동차]", "[취미]",
|
|
"[운세]", "[날씨]",
|
|
# 스포츠
|
|
"[스포츠]", "[야구]", "[축구]", "[농구]", "[골프]",
|
|
# 광고성/이벤트
|
|
"[보도자료]", "[알림]", "[이벤트]", "[알립니다]",
|
|
)
|
|
|
|
# 종목/시장과 무관한 라이프스타일·클릭베이트 키워드 (제목에 등장하면 차단)
|
|
NOISE_KEYWORDS = (
|
|
# 라이프/소비
|
|
"이직", "퇴사", "신의 직장", "마통", "돈복사", "전기료 폭탄",
|
|
"세탁기", "건조기", "에어컨", "다이어트", "맛집", "할인",
|
|
"결혼", "이혼", "연애", "임신", "출산", "육아",
|
|
# 사회/사건사고 (기업이 부수적으로 언급되는 경우 多)
|
|
"성폭행", "성추행", "음주운전", "마약", "도박", "사기",
|
|
"교통사고", "화재", "살인", "강도",
|
|
# 정치 (종목 영향 적은 일반 정치)
|
|
"선거", "투표", "여론조사", "지지율", "총선", "대선",
|
|
# 라이프스타일 클릭베이트
|
|
"충격", "발칵", "경악", "헉", "화들짝", "깜짝", "대체",
|
|
)
|
|
# 클릭베이트: 제목이 큰따옴표·작은따옴표로 시작하면 의심
|
|
CLICKBAIT_QUOTE_RE = re.compile(r'^[\s]*[\"“‘’\']')
|
|
# 종목 매칭 0건일 때 시장 관련성 판정용 키워드
|
|
MARKET_KEYWORDS = (
|
|
# 지수/시장
|
|
"코스피", "코스닥", "KOSPI", "KOSDAQ", "나스닥", "다우", "S&P", "증시",
|
|
"지수", "시장", "장세", "장중", "거래소",
|
|
# 거시지표
|
|
"환율", "금리", "원달러", "원화", "달러", "엔화", "위안", "유가",
|
|
"금값", "은값", "원유", "WTI", "브렌트", "채권", "국채", "회사채",
|
|
"인플레이션", "물가", "CPI", "PPI", "성장률", "GDP", "경기", "경제",
|
|
# 수급
|
|
"외국인", "외인", "기관", "개인투자자", "프로그램매매", "공매도",
|
|
"신용잔고", "거래대금", "거래량",
|
|
# 종목/거래
|
|
"주식", "주가", "종목", "매수", "매도", "매매", "투자", "테마주", "관련주",
|
|
"급등", "급락", "상승", "하락", "신고가", "신저가", "호재", "악재",
|
|
# 펀드/상품
|
|
"펀드", "ETF", "ETN", "선물", "옵션", "파생", "리츠",
|
|
# 기업이벤트
|
|
"상장", "상폐", "공모", "IPO", "유상증자", "무상증자", "배당", "자사주",
|
|
"M&A", "인수", "합병", "분할", "지분", "스톡옵션",
|
|
# 실적/재무
|
|
"실적", "영업이익", "매출", "순이익", "EBITDA", "ROE", "PER", "PBR",
|
|
"수주", "계약", "수출", "공시",
|
|
# 산업/섹터
|
|
"산업", "섹터", "반도체", "바이오", "2차전지", "배터리", "전기차",
|
|
"AI", "로봇", "방산", "조선", "건설", "유통", "금융", "은행", "보험",
|
|
"증권", "통신", "에너지", "철강", "화학", "정유"
|
|
)
|
|
|
|
def _is_noise_title(title: str) -> bool:
|
|
if not title:
|
|
return True
|
|
if any(title.startswith(p) for p in NOISE_PREFIXES):
|
|
return True
|
|
# 클릭베이트: 따옴표 시작 + 시장 키워드 부재 시 차단
|
|
if CLICKBAIT_QUOTE_RE.match(title) and not any(kw in title for kw in MARKET_KEYWORDS):
|
|
return True
|
|
# 라이프스타일/사건사고 키워드 (제목에서 검사)
|
|
if any(nk in title for nk in NOISE_KEYWORDS):
|
|
return True
|
|
return False
|
|
|
|
def _has_market_relevance(text: str) -> bool:
|
|
"""종목 매칭이 없을 때만 호출. 시장 키워드 ≥2개 매칭 시 통과 (broad 1개로는 부족)"""
|
|
if not text:
|
|
return False
|
|
matches = sum(1 for kw in MARKET_KEYWORDS if kw in text)
|
|
return matches >= 2
|
|
|
|
# ── 파이프라인 ─────────────────────────────────────────────
|
|
_corp_cache: dict = {}
|
|
async def _corp_name(code: str) -> str:
|
|
if code in _corp_cache:
|
|
return _corp_cache[code]
|
|
nm = code
|
|
try:
|
|
row = await pg_pool.fetchrow(
|
|
"SELECT corp_name FROM dart_corps WHERE stock_code=$1", code)
|
|
if row and row["corp_name"]:
|
|
nm = row["corp_name"]
|
|
except Exception:
|
|
pass
|
|
_corp_cache[code] = nm
|
|
return nm
|
|
|
|
|
|
async def pipeline(item, client):
|
|
try:
|
|
# 0. 비주식 카테고리 prefix 차단 (바른API 호출 전)
|
|
if _is_noise_title(item.get("title", "")):
|
|
return "noise"
|
|
|
|
# 1. 바른API
|
|
br = await client.post(f"{BAREUN_URL}/analyze", json={
|
|
"title": item["title"], "content": item.get("content",""),
|
|
"url": item.get("url",""), "source": item.get("source",""),
|
|
"published_at": item.get("published_at","")}, timeout=30)
|
|
bd = br.json()
|
|
if bd.get("is_duplicate"): return "dup"
|
|
|
|
data = {**item, "hash": bd.get("hash",""), "stocks": bd.get("stocks",[]),
|
|
"keywords": bd.get("keywords",[]), "filtered_text": bd.get("filtered_text","")}
|
|
|
|
# raw 백필: 종목코드가 확정된 뉴스는 종목명 인식 실패해도 보존 (관련성 과필터 완화)
|
|
kc = item.get("stock_code")
|
|
if kc and not any(st.get("code") == kc for st in data["stocks"]):
|
|
data["stocks"] = [{"name": await _corp_name(kc), "code": kc}] + data["stocks"]
|
|
|
|
# 1.5. 종목 매칭 + 시장 키워드 둘 다 없으면 스킵 (LLM 비용 절감)
|
|
if not data["stocks"]:
|
|
full_text = (item.get("title","") or "") + " " + (item.get("content","") or "")
|
|
if not _has_market_relevance(full_text):
|
|
return "off_topic"
|
|
|
|
# 2. Ollama 임베딩
|
|
er = await client.post(f"{OLLAMA_URL}/api/embeddings",
|
|
json={"model":"bge-m3","prompt": data["filtered_text"] or data["title"]}, timeout=60)
|
|
emb = er.json().get("embedding")
|
|
if not emb: return "no_embed"
|
|
|
|
# 3. Qdrant 유사도
|
|
try:
|
|
sr = await client.post(f"{QDRANT_URL}/collections/news_vectors/points/search",
|
|
json={"vector":emb,"limit":6,"score_threshold":0.80,"with_payload":True}, timeout=15)
|
|
hits = sr.json().get("result",[])
|
|
if any(h["score"]>=0.92 for h in hits): return "sim_dup" # ≥0.99 근접중복도 차단
|
|
except: hits = []
|
|
|
|
# 3.5. RAG 컨텍스트: 유사 과거뉴스 + 종목 재무·추세·점수 (버핏 판단 근거 주입)
|
|
ctx = []
|
|
rel = [h for h in hits if 0.80 <= h.get("score", 0) < 0.92][:4]
|
|
if rel:
|
|
ctx.append("· 유사 과거뉴스:")
|
|
for h in rel:
|
|
p = h.get("payload", {})
|
|
ctx.append(f" - {(p.get('title') or '')[:55]} → "
|
|
f"{p.get('sentiment','?')}/강도{p.get('intensity','?')}")
|
|
focus = data["stocks"][0] if data["stocks"] else None
|
|
if focus:
|
|
g = lambda v: "-" if v is None else v
|
|
try:
|
|
fin = await pg_pool.fetchrow("""
|
|
SELECT ROUND(roe::numeric,1) roe, ROUND(operating_margin::numeric,1) om,
|
|
ROUND(debt_ratio::numeric,1) dr, ROUND(fcf_ratio::numeric,2) fcf,
|
|
ROUND(revenue_growth::numeric,1) rg
|
|
FROM dart_financials WHERE stock_code=$1 AND reprt_code='11011'
|
|
ORDER BY bsns_year DESC LIMIT 1""", focus["code"])
|
|
if fin:
|
|
ctx.append(f"· {focus['name']} 재무: ROE {g(fin['roe'])}% "
|
|
f"영업이익률 {g(fin['om'])}% 부채비율 {g(fin['dr'])}% "
|
|
f"FCF {g(fin['fcf'])} 매출성장 {g(fin['rg'])}%")
|
|
his = await pg_pool.fetchrow("""
|
|
SELECT COUNT(*) FILTER (WHERE sentiment='호재') pos,
|
|
COUNT(*) FILTER (WHERE sentiment='악재') neg,
|
|
ROUND(AVG(intensity)::numeric,1) ai,
|
|
MODE() WITHIN GROUP (ORDER BY catalyst) cat
|
|
FROM news_analysis
|
|
WHERE primary_stock=$1 AND analyzed_at >= NOW()-INTERVAL '14 days'
|
|
""", focus["code"])
|
|
if his and (his["pos"] or his["neg"]):
|
|
ctx.append(f"· 최근14일 {focus['name']}: 호재 {his['pos']} / "
|
|
f"악재 {his['neg']}, 평균강도 {g(his['ai'])}, "
|
|
f"주catalyst {his['cat'] or '-'}")
|
|
recent = await pg_pool.fetch("""
|
|
SELECT sentiment, intensity, catalyst, left(reason,50) reason
|
|
FROM news_analysis
|
|
WHERE primary_stock=$1 AND reason != '파싱실패'
|
|
AND analyzed_at >= NOW()-INTERVAL '30 days'
|
|
ORDER BY analyzed_at DESC LIMIT 2""", focus["code"])
|
|
if recent:
|
|
ctx.append(f"· {focus['name']} 최근 분석:")
|
|
for r in recent:
|
|
ctx.append(f" - {r['sentiment']}/강도{r['intensity']} "
|
|
f"[{r['catalyst'] or '-'}] {r['reason']}")
|
|
sc = await pg_pool.fetchrow("""
|
|
SELECT ROUND(total_score::numeric,1) total_score, recommendation
|
|
FROM stock_scores
|
|
WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 1""",
|
|
focus["code"])
|
|
if sc:
|
|
ctx.append(f"· 현재 퀀트: 종합점수 {g(sc['total_score'])} "
|
|
f"({sc['recommendation'] or '-'})")
|
|
except Exception as e:
|
|
logger.debug("rag.ctx_err", code=focus["code"], error=str(e))
|
|
context_block = "\n".join(ctx)
|
|
|
|
# 4. Ollama EXAONE 분석 (버핏 관점 강화 프롬프트)
|
|
stocks_str = ", ".join([f'{s["name"]}({s["code"]})' for s in data["stocks"][:5]])
|
|
source = item.get("source", "")
|
|
content_preview = (data.get("filtered_text") or item.get("content") or "")[:400]
|
|
system_prompt = (
|
|
"당신은 워렌 버핏 스타일의 한국 주식 가치투자 애널리스트입니다.\n"
|
|
"뉴스 한 건을 읽고 기업 본질가치 관점에서 영향을 평가합니다.\n"
|
|
"판단 우선순위: 기업 본질가치(실적·FCF·경쟁우위) > 재무 리스크 > 단기 수급·테마\n\n"
|
|
"[sentiment] 호재 / 악재 / 중립 — 본질가치 또는 주가에 미치는 방향\n\n"
|
|
"[intensity] 1~5 정수\n"
|
|
" 5=상장폐지·대규모 횡령 등 기업 존폐\n"
|
|
" 4=연간실적 20% 이상 영향(대형 수주·어닝쇼크 등)\n"
|
|
" 3=분기실적에 유의미한 영향\n"
|
|
" 2=영업환경·업황 변화(직접 실적 수치 영향은 제한적)\n"
|
|
" 1=단순 정보성·정기공시·소폭 코멘트\n\n"
|
|
"[catalyst] 반드시 아래 6개 중 정확히 하나만 출력 (다른 단어·복합어·영문·한자 절대 금지):\n"
|
|
' "실적" = 영업이익·매출·순이익 증감, 흑자/적자 전환, 어닝 서프라이즈/쇼크, 실적 가이던스\n'
|
|
' "수주" = 신규 수주·공급계약·납품, M&A, 신사업·증설·신제품 등 성장 동인\n'
|
|
' "배당" = 배당 확대/축소, 자사주 매입·소각, 주주환원 정책\n'
|
|
' "리스크" = 횡령·배임·소송·감사의견거절·부채급증·유상증자(희석)·경영진 돌발사임·규제/제재·상폐위험\n'
|
|
' "모멘텀" = 본질가치 변화 없는 가격 동인(외국인/기관 수급, 목표주가 조정, 테마·정책 기대)\n'
|
|
' "기타" = 위 어디에도 명확히 속하지 않는 정보성 뉴스\n'
|
|
" 호재/악재면 catalyst를 '기타'로 두지 말고 실적·수주·배당·리스크·모멘텀 중 가장 가까운 것을 고르세요.\n\n"
|
|
"[참고] 블록은 보조 자료일 뿐 — 본 기사 내용으로 판단하고 과거 감성 흐름에 휩쓸리지 마세요.\n"
|
|
"동일 내용이 [참고]의 유사 과거뉴스에 반복되면 신규성이 낮으니 intensity를 보수적으로.\n\n"
|
|
"반드시 스키마에 맞는 유효한 JSON 객체 하나만 출력. 마크다운·설명문 금지."
|
|
)
|
|
user_prompt = (
|
|
f"[출처] {source}\n"
|
|
f"[제목] {data['title'][:200]}\n"
|
|
f"[내용] {content_preview}\n"
|
|
f"[관련종목] {stocks_str or '없음'}\n"
|
|
+ (f"\n[참고]\n{context_block}\n" if context_block else "")
|
|
+ "\ninvestment_action 규칙: 호재+intensity≥3→매수관심, 악재+intensity≥3→매도관심, 그 외→관망\n"
|
|
"primary_stock은 6자리 숫자코드(시장 전체 뉴스면 빈 문자열), "
|
|
"affected_stocks는 영향받는 다른 종목코드 배열, reason은 한 문장 핵심 근거.\n\n"
|
|
"JSON 스키마:\n"
|
|
'{"sentiment":"호재|악재|중립","intensity":1~5,"primary_stock":"005930",'
|
|
'"affected_stocks":["000660"],"reason":"핵심 근거 한 문장",'
|
|
'"investment_action":"매수관심|매도관심|관망",'
|
|
'"catalyst":"실적|수주|배당|리스크|모멘텀|기타"}\n'
|
|
"예시: "
|
|
'{"sentiment":"호재","intensity":4,"primary_stock":"005930",'
|
|
'"affected_stocks":["000660"],"reason":"HBM 대형 수주 확정으로 연간 반도체 실적 큰 폭 개선 기대",'
|
|
'"investment_action":"매수관심","catalyst":"수주"}'
|
|
)
|
|
# EXAONE 호출 + JSON 파싱 (실패 시 1회 재시도 → ~15% 유실 감소)
|
|
a = None
|
|
raw_prev = ""
|
|
for attempt in range(2):
|
|
msgs = [{"role":"system","content": system_prompt},
|
|
{"role":"user","content": user_prompt}]
|
|
if attempt == 1:
|
|
msgs += [{"role":"assistant","content": raw_prev[:600]},
|
|
{"role":"user","content":
|
|
"위 응답이 유효한 JSON이 아닙니다. 설명·마크다운 없이 "
|
|
"스키마에 맞는 JSON 객체 하나만 출력하세요."}]
|
|
try:
|
|
vr = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={
|
|
"model":"exaone3.5:7.8b","messages": msgs,
|
|
"max_tokens":400,"temperature":0.0 if attempt else 0.05},
|
|
timeout=120)
|
|
raw_prev = vr.json()["choices"][0]["message"]["content"]
|
|
c = raw_prev.replace("```json","").replace("```","").strip()
|
|
if not c.startswith("{"): # 앞뒤 설명 제거
|
|
s, e = c.find("{"), c.rfind("}")
|
|
if s != -1 and e > s: c = c[s:e+1]
|
|
a = json.loads(c)
|
|
break
|
|
except Exception:
|
|
continue
|
|
if a is None:
|
|
a = {"sentiment":"중립","intensity":0,"primary_stock":"","affected_stocks":[],"reason":"파싱실패","investment_action":"관망"}
|
|
|
|
# catalyst 6개 enum 강제 (score-engine CATALYST_WEIGHTS 정합 — 일탈값은 '기타')
|
|
if a.get("catalyst") not in ("실적","수주","배당","리스크","모멘텀","기타"):
|
|
a["catalyst"] = "기타"
|
|
|
|
# 5. Qdrant 저장
|
|
try:
|
|
await client.put(f"{QDRANT_URL}/collections/news_vectors/points", json={
|
|
"points":[{"id":random.randint(1,999999999),"vector":emb,
|
|
"payload":{"title":data["title"],"hash":data["hash"],
|
|
"sentiment":a.get("sentiment",""),"intensity":a.get("intensity",0),
|
|
"primary_stock":a.get("primary_stock","")}}]}, timeout=15)
|
|
except: pass
|
|
|
|
# 6. PostgreSQL 저장 (파라미터 바인딩 — 인젝션·이스케이프 유실 방지)
|
|
pub = data.get("published_at") or ""
|
|
try:
|
|
pub_dt = datetime.fromisoformat(pub.replace("Z", "+00:00")) if pub else datetime.now()
|
|
except Exception:
|
|
pub_dt = datetime.now()
|
|
try:
|
|
iv = int(a.get("intensity", 0) or 0)
|
|
except Exception:
|
|
iv = 0
|
|
s = lambda v: str(v if v is not None else "")
|
|
await pg_pool.execute("""
|
|
INSERT INTO news_analysis (title,url,source,published_at,hash,sentiment,intensity,
|
|
primary_stock,affected_stocks,reason,investment_action,keywords,stock_names,stock_codes,
|
|
catalyst,similar_count,analyzed_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,0,NOW())
|
|
ON CONFLICT (hash) DO NOTHING
|
|
""",
|
|
s(data.get("title"))[:500], s(data.get("url"))[:500], s(data.get("source"))[:100],
|
|
pub_dt, data["hash"], s(a.get("sentiment", "중립"))[:10], iv,
|
|
s(a.get("primary_stock"))[:10], json.dumps(a.get("affected_stocks", []), ensure_ascii=False),
|
|
s(a.get("reason"))[:500], s(a.get("investment_action", "관망"))[:10],
|
|
json.dumps(data.get("keywords", [])[:20], ensure_ascii=False),
|
|
json.dumps([s_["name"] for s_ in data.get("stocks", [])], ensure_ascii=False),
|
|
json.dumps([s_["code"] for s_ in data.get("stocks", [])], ensure_ascii=False),
|
|
s(a.get("catalyst", "기타"))[:20])
|
|
return "ok"
|
|
except Exception as e:
|
|
logger.warning("pipeline.err", title=item.get("title","")[:50], error=str(e))
|
|
return "error"
|
|
|
|
# ── 수집 작업 ──────────────────────────────────────────────
|
|
async def job_rss():
|
|
"""RSS 28개 소스 수집 (5분마다, 2단계 중복제거)"""
|
|
if stats.running: return
|
|
stats.running = True
|
|
try:
|
|
async with httpx.AsyncClient() as c:
|
|
news = await crawl_rss_sources(c)
|
|
ok = 0
|
|
for item in news:
|
|
if await is_dup(item["title"], item.get("url", "")):
|
|
stats.duplicates += 1
|
|
continue
|
|
stats.collected += 1
|
|
r = await pipeline(item, c)
|
|
if r == "ok":
|
|
ok += 1
|
|
stats.processed += 1
|
|
elif r == "noise":
|
|
stats.noise += 1
|
|
elif r == "off_topic":
|
|
stats.off_topic += 1
|
|
stats.last_run = datetime.now().isoformat()
|
|
logger.info("job.rss", sources=len(RSS_SOURCES), total=len(news), processed=ok,
|
|
duplicates=stats.duplicates)
|
|
except Exception as e:
|
|
stats.errors += 1
|
|
logger.error("job.rss.err", error=str(e))
|
|
finally:
|
|
stats.running = False
|
|
|
|
NAVER_FINANCE_URLS = [
|
|
("https://finance.naver.com/news/mainnews.naver", "네이버금융"),
|
|
("https://finance.naver.com/news/news_list.nhn?mode=RANK&type=now", "네이버금융"),
|
|
]
|
|
|
|
async def crawl_naver_market(client) -> list:
|
|
news = []
|
|
for url, source in NAVER_FINANCE_URLS:
|
|
try:
|
|
r = await client.get(url, timeout=15, headers=HEADERS)
|
|
if r.status_code != 200:
|
|
continue
|
|
soup = BeautifulSoup(r.text, "html.parser")
|
|
anchors = (
|
|
soup.select("ul.newsList li a") or
|
|
soup.select(".headline_list li a") or
|
|
soup.select("a[href*='news_read']")
|
|
)
|
|
for a in anchors[:30]:
|
|
title = a.get_text(strip=True)
|
|
href = a.get("href", "")
|
|
if not href or not title or len(title) < 5:
|
|
continue
|
|
if href.startswith("/"):
|
|
href = f"https://finance.naver.com{href}"
|
|
if not is_korean(title):
|
|
continue
|
|
news.append({"title": title, "url": href, "source": source,
|
|
"content": "", "published_at": datetime.now().isoformat()})
|
|
except Exception as e:
|
|
logger.debug("naver.market.err", url=url, error=str(e))
|
|
logger.info("naver.market.crawled", items=len(news))
|
|
return news
|
|
|
|
async def crawl_naver_stock_news(client, code: str, max_pages: int = 20, sleep_s: float = 0.3) -> list:
|
|
"""네이버 모바일 종목 뉴스 API (JSON) — 백워드 페이징"""
|
|
items = []
|
|
for page in range(1, max_pages + 1):
|
|
url = f"https://m.stock.naver.com/api/news/stock/{code}?pageSize=20&page={page}"
|
|
try:
|
|
r = await client.get(url, headers=HEADERS, timeout=15)
|
|
if r.status_code != 200:
|
|
break
|
|
data = r.json()
|
|
if not isinstance(data, list) or not data:
|
|
break
|
|
page_items = data[0].get("items", []) if isinstance(data[0], dict) else []
|
|
if not page_items:
|
|
break
|
|
for it in page_items:
|
|
title = (it.get("title") or "").strip()
|
|
article_id = it.get("articleId") or ""
|
|
office_id = it.get("officeId") or ""
|
|
if not title or not article_id or not is_korean(title):
|
|
continue
|
|
url_news = f"https://n.news.naver.com/article/{office_id}/{article_id}"
|
|
dt = it.get("datetime") or ""
|
|
pub = (f"{dt[:4]}-{dt[4:6]}-{dt[6:8]} {dt[8:10]}:{dt[10:12]}"
|
|
if len(dt) >= 12 else "")
|
|
items.append({
|
|
"title": title, "url": url_news,
|
|
"source": it.get("officeName") or "네이버",
|
|
"published_at": pub,
|
|
"content": (it.get("body") or "")[:500],
|
|
})
|
|
await asyncio.sleep(sleep_s)
|
|
except Exception as e:
|
|
logger.debug("naver_mobile.err", code=code, page=page, error=str(e))
|
|
break
|
|
return items
|
|
|
|
|
|
async def save_raw_news(items: list, stock_code: str) -> int:
|
|
"""raw 뉴스 일괄 저장 (분석 스킵, 빠른 누적용)"""
|
|
if not items: return 0
|
|
saved = 0
|
|
async with pg_pool.acquire() as conn:
|
|
for it in items:
|
|
try:
|
|
url = it.get("url", "")
|
|
title = it.get("title", "")
|
|
if not url or not title: continue
|
|
url_hash = hashlib.sha256(url.encode()).hexdigest()
|
|
res = await conn.execute("""
|
|
INSERT INTO news_raw (stock_code, title, url, url_hash, source, published_at_text)
|
|
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (url_hash) DO NOTHING
|
|
""", stock_code, title, url, url_hash,
|
|
it.get("source", ""), it.get("published_at", ""))
|
|
if "INSERT 0 1" in res:
|
|
saved += 1
|
|
except Exception as e:
|
|
logger.debug("raw.save_err", error=str(e))
|
|
return saved
|
|
|
|
|
|
async def job_historical_raw(count: int = 0, max_pages: int = 100):
|
|
"""전체 활성종목 백워드 크롤링 → news_raw에 raw 저장 (LLM 분석 X, 빠름)
|
|
count=0 → 활성 종목 전체"""
|
|
if stats.running:
|
|
logger.warning("historical_raw.skip_running"); return
|
|
stats.running = True
|
|
try:
|
|
async with pg_pool.acquire() as conn:
|
|
if count > 0:
|
|
rows = await conn.fetch("""
|
|
SELECT stock_code FROM dart_corps WHERE is_active=true
|
|
ORDER BY stock_code LIMIT $1
|
|
""", count)
|
|
else:
|
|
rows = await conn.fetch(
|
|
"SELECT stock_code FROM dart_corps WHERE is_active=true ORDER BY stock_code")
|
|
codes = [r["stock_code"] for r in rows]
|
|
logger.info("historical_raw.start", n_stocks=len(codes), max_pages=max_pages)
|
|
total_saved = 0
|
|
async with httpx.AsyncClient() as c:
|
|
for i, code in enumerate(codes):
|
|
try:
|
|
items = await crawl_naver_stock_news(c, code, max_pages=max_pages)
|
|
saved = await save_raw_news(items, code)
|
|
total_saved += saved
|
|
if (i + 1) % 100 == 0:
|
|
logger.info("historical_raw.progress",
|
|
done=i+1, of=len(codes), total_saved=total_saved)
|
|
await asyncio.sleep(0.4)
|
|
except Exception as e:
|
|
stats.errors += 1
|
|
logger.warning("historical_raw.stock_err", code=code, error=str(e))
|
|
stats.last_run = datetime.now().isoformat()
|
|
logger.info("historical_raw.done", n_stocks=len(codes), total_saved=total_saved)
|
|
finally:
|
|
stats.running = False
|
|
|
|
|
|
async def job_process_raw(batch_size: int = 200):
|
|
"""news_raw의 미처리 행을 batch_size만큼 EXAONE 분석 → news_analysis로 이동"""
|
|
async with pg_pool.acquire() as conn:
|
|
rows = await conn.fetch("""
|
|
SELECT id, stock_code, title, url, source, published_at_text
|
|
FROM news_raw WHERE processed=false
|
|
ORDER BY id DESC LIMIT $1
|
|
""", batch_size)
|
|
if not rows:
|
|
logger.info("process_raw.empty"); return 0
|
|
ok = 0
|
|
async with httpx.AsyncClient() as c:
|
|
for r in rows:
|
|
item = {
|
|
"title": r["title"], "url": r["url"],
|
|
"source": r["source"] or "네이버금융",
|
|
"published_at": r["published_at_text"] or "",
|
|
"content": "",
|
|
"stock_code": r["stock_code"],
|
|
}
|
|
try:
|
|
if await is_dup(item["title"], item["url"]):
|
|
stats.duplicates += 1
|
|
else:
|
|
stats.collected += 1
|
|
res = await pipeline(item, c)
|
|
if res == "ok": ok += 1; stats.processed += 1
|
|
elif res == "noise": stats.noise += 1
|
|
elif res == "off_topic": stats.off_topic += 1
|
|
async with pg_pool.acquire() as conn:
|
|
await conn.execute(
|
|
"UPDATE news_raw SET processed=true, processed_at=NOW() WHERE id=$1",
|
|
r["id"])
|
|
except Exception as e:
|
|
stats.errors += 1
|
|
logger.warning("process_raw.err", id=r["id"], error=str(e))
|
|
logger.info("process_raw.batch_done", batch=len(rows), processed_ok=ok)
|
|
return ok
|
|
|
|
|
|
async def job_historical(count: int = 500, max_pages: int = 20):
|
|
"""과거 뉴스 백필: stock_scores 점수 매겨진 활성 종목 우선 + 종목당 max_pages 페이지"""
|
|
if stats.running:
|
|
logger.warning("historical.skip_running")
|
|
return
|
|
stats.running = True
|
|
try:
|
|
async with pg_pool.acquire() as conn:
|
|
# 우선순위: 최근 점수 매겨진 종목 → 그 외 활성 종목 alphabetical
|
|
rows = await conn.fetch("""
|
|
WITH scored AS (
|
|
SELECT DISTINCT stock_code, MAX(score_date) AS last_d
|
|
FROM stock_scores GROUP BY stock_code
|
|
), priority AS (
|
|
SELECT d.stock_code,
|
|
CASE WHEN s.stock_code IS NOT NULL THEN 0 ELSE 1 END AS p,
|
|
COALESCE(s.last_d, '1900-01-01'::date) AS d
|
|
FROM dart_corps d LEFT JOIN scored s ON s.stock_code=d.stock_code
|
|
WHERE d.is_active=true
|
|
)
|
|
SELECT stock_code FROM priority
|
|
ORDER BY p ASC, d DESC, stock_code ASC LIMIT $1
|
|
""", count)
|
|
codes = [r["stock_code"] for r in rows]
|
|
logger.info("historical.start", n_stocks=len(codes), max_pages=max_pages)
|
|
ok = 0; total_items = 0
|
|
async with httpx.AsyncClient() as c:
|
|
for i, code in enumerate(codes):
|
|
try:
|
|
items = await crawl_naver_stock_news(c, code, max_pages=max_pages)
|
|
total_items += len(items)
|
|
for item in items:
|
|
if await is_dup(item["title"], item.get("url", "")):
|
|
stats.duplicates += 1
|
|
continue
|
|
stats.collected += 1
|
|
item["stock_code"] = code
|
|
r = await pipeline(item, c)
|
|
if r == "ok":
|
|
ok += 1; stats.processed += 1
|
|
elif r == "noise": stats.noise += 1
|
|
elif r == "off_topic": stats.off_topic += 1
|
|
if (i + 1) % 50 == 0:
|
|
logger.info("historical.progress", done=i+1, of=len(codes),
|
|
items=total_items, processed=ok)
|
|
await asyncio.sleep(0.5) # 종목 간격 (차단 회피)
|
|
except Exception as e:
|
|
stats.errors += 1
|
|
logger.warning("historical.stock_err", code=code, error=str(e))
|
|
stats.last_run = datetime.now().isoformat()
|
|
logger.info("historical.done", n_stocks=len(codes), items=total_items, processed=ok)
|
|
finally:
|
|
stats.running = False
|
|
|
|
|
|
async def job_market():
|
|
if stats.running:
|
|
return
|
|
stats.running = True
|
|
try:
|
|
async with httpx.AsyncClient() as c:
|
|
news = await crawl_naver_market(c)
|
|
ok = 0
|
|
for item in news:
|
|
if await is_dup(item["title"], item.get("url", "")):
|
|
stats.duplicates += 1
|
|
continue
|
|
stats.collected += 1
|
|
r = await pipeline(item, c)
|
|
if r == "ok":
|
|
ok += 1
|
|
stats.processed += 1
|
|
elif r == "noise":
|
|
stats.noise += 1
|
|
elif r == "off_topic":
|
|
stats.off_topic += 1
|
|
stats.last_run = datetime.now().isoformat()
|
|
logger.info("job.market", total=len(news), processed=ok, duplicates=stats.duplicates)
|
|
except Exception as e:
|
|
stats.errors += 1
|
|
logger.error("job.market.err", error=str(e))
|
|
finally:
|
|
stats.running = False
|
|
|
|
# ── FastAPI ────────────────────────────────────────────────
|
|
app = FastAPI(title="뉴스 수집기 (멀티소스)")
|
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
|
|
|
async def init_news_tables():
|
|
"""news_raw 테이블 (수집/분석 분리용 임시 저장소)"""
|
|
async with pg_pool.acquire() as conn:
|
|
await conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS news_raw (
|
|
id SERIAL PRIMARY KEY,
|
|
stock_code VARCHAR(10),
|
|
title TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
url_hash VARCHAR(64) UNIQUE,
|
|
source VARCHAR(100),
|
|
published_at_text VARCHAR(50),
|
|
collected_at TIMESTAMP DEFAULT NOW(),
|
|
processed BOOLEAN DEFAULT FALSE,
|
|
processed_at TIMESTAMP
|
|
)
|
|
""")
|
|
await conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_news_raw_unprocessed ON news_raw(processed) WHERE processed=false")
|
|
await conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_news_raw_stock ON news_raw(stock_code)")
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
global pg_pool, redis_cl
|
|
pg_pool = await asyncpg.create_pool(host=PG_HOST,port=PG_PORT,database=PG_DB,user=PG_USER,password=PG_PASS,min_size=2,max_size=5)
|
|
redis_cl = aioredis.Redis(host=REDIS_HOST,port=6379,password=REDIS_PASSWORD,db=4,decode_responses=True)
|
|
await init_news_tables()
|
|
# 평일 RSS: 8-18시 5분마다
|
|
scheduler.add_job(job_rss,"cron",day_of_week="mon-fri",hour="8-18",minute="*/5",id="rss_weekday",replace_existing=True)
|
|
# 주말 RSS: 8-22시 15분마다 (누적 학습용)
|
|
scheduler.add_job(job_rss,"cron",day_of_week="sat,sun",hour="8-22",minute="*/15",id="rss_weekend",replace_existing=True)
|
|
# 평일 마켓 (네이버 금융): 9-17시 10분마다
|
|
scheduler.add_job(job_market,"cron",day_of_week="mon-fri",hour="9-17",minute="*/10",id="market",replace_existing=True)
|
|
# raw 뉴스 분석: 24시간 30분마다 batch 200건 처리 (백로그 소화용)
|
|
scheduler.add_job(job_process_raw,"cron",hour="*",minute="*/30",
|
|
id="process_raw",replace_existing=True,kwargs={"batch_size":200})
|
|
# 매주 일요일 새벽 2시 raw 백필 (전체 활성종목)
|
|
scheduler.add_job(job_historical_raw,"cron",day_of_week="sun",hour="2",minute="0",
|
|
id="historical_raw_weekly",replace_existing=True,
|
|
kwargs={"count":0,"max_pages":50})
|
|
scheduler.start()
|
|
logger.info("news-collector.started", sources=len(RSS_SOURCES))
|
|
|
|
@app.on_event("shutdown")
|
|
async def shutdown():
|
|
scheduler.shutdown()
|
|
if pg_pool: await pg_pool.close()
|
|
if redis_cl: await redis_cl.aclose()
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return JSONResponse(content={"status":"ok","collected":stats.collected,"processed":stats.processed,
|
|
"duplicates":stats.duplicates,"noise":stats.noise,"off_topic":stats.off_topic,
|
|
"errors":stats.errors,"last_run":stats.last_run,"running":stats.running})
|
|
|
|
@app.post("/collect/rss")
|
|
async def m_rss():
|
|
asyncio.create_task(job_rss()); return {"status":"started","type":"rss","sources":len(RSS_SOURCES)}
|
|
|
|
@app.post("/collect/market")
|
|
async def m_market():
|
|
asyncio.create_task(job_market()); return {"status":"started","type":"market"}
|
|
|
|
@app.post("/collect/historical")
|
|
async def m_historical(count: int = 500, max_pages: int = 20):
|
|
asyncio.create_task(job_historical(count, max_pages))
|
|
return {"status":"started","type":"historical","count":count,"max_pages":max_pages}
|
|
|
|
@app.post("/collect/historical-raw")
|
|
async def m_historical_raw(count: int = 0, max_pages: int = 100):
|
|
"""전체 활성종목 raw 백필 (LLM 분석 X). count=0 → 전체"""
|
|
asyncio.create_task(job_historical_raw(count, max_pages))
|
|
return {"status":"started","type":"historical_raw","count":count or "전체","max_pages":max_pages}
|
|
|
|
@app.post("/process/raw")
|
|
async def m_process_raw(batch_size: int = 200):
|
|
"""news_raw 미처리분 batch 분석 → news_analysis로 이동"""
|
|
n = await job_process_raw(batch_size)
|
|
return {"status":"done","processed":n}
|
|
|
|
@app.get("/raw/stats")
|
|
async def raw_stats():
|
|
"""raw 백필 진행 상태"""
|
|
async with pg_pool.acquire() as conn:
|
|
s = await conn.fetchrow("""
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE processed=false) AS unprocessed,
|
|
COUNT(*) FILTER (WHERE processed=true) AS processed,
|
|
COUNT(DISTINCT stock_code) AS unique_stocks,
|
|
MIN(collected_at) AS first_collected,
|
|
MAX(collected_at) AS last_collected
|
|
FROM news_raw
|
|
""")
|
|
return dict(s) if s else {}
|
|
|
|
@app.get("/sources")
|
|
async def list_sources():
|
|
return [{"name": s, "url": u} for s, u in RSS_SOURCES]
|