Initial commit: Korean stock value-investing AI pipeline

- 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>
This commit is contained in:
kyu
2026-05-20 21:33:56 +09:00
commit 6d3b0bacc0
60 changed files with 17818 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
curl \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/logs
EXPOSE 5757
CMD ["python", "-m", "uvicorn", "main:app", \
"--host", "0.0.0.0", \
"--port", "5757", \
"--workers", "4", \
"--loop", "uvloop", \
"--http", "httptools", \
"--log-level", "info"]
+136
View File
@@ -0,0 +1,136 @@
"""
한국 주식/금융 전문 용어 사전
형태소 분석 보완용 — 바른 API가 분리하지 못하는 복합어 및 금융 전문어를 정의
"""
# ── 재무제표 / 회계 ─────────────────────────────────────────────────────────────
FINANCIAL_STATEMENT = {
"매출액","매출원가","매출총이익","영업이익","영업손실","영업외수익","영업외비용",
"세전이익","당기순이익","당기순손실","포괄손익","이익잉여금","자본잉여금",
"납입자본","기타자본","비지배지분","총자산","총부채","총자본","유동자산",
"비유동자산","유동부채","비유동부채","현금및현금성자산","단기금융상품",
"매출채권","재고자산","유형자산","무형자산","투자자산","영업활동현금흐름",
"투자활동현금흐름","재무활동현금흐름","잉여현금흐름","FCF","EBITDA","EBIT",
"ROE","ROA","ROI","EPS","BPS","DPS","PER","PBR","PSR","EV/EBITDA","PCR",
"부채비율","유동비율","당좌비율","자기자본비율","이자보상배율","영업이익률",
"순이익률","자산회전율","재고자산회전율","매출채권회전율",
}
# ── 주식 시장 구조 ───────────────────────────────────────────────────────────────
MARKET_STRUCTURE = {
"코스피","코스닥","코넥스","K-OTC","유가증권시장","장외시장","선물시장",
"옵션시장","ETF","ETN","ELS","DLS","리츠","SPAC","공모주","유상증자","무상증자",
"감자","합병","분할","인적분할","물적분할","자사주매입","자사주소각","배당",
"중간배당","특별배당","주식배당","현금배당","우선주","보통주","전환사채","CB",
"신주인수권부사채","BW","교환사채","EB","DR","GDR","ADR","상장","상장폐지",
"거래정지","매매정지","관리종목","투자주의","투자경고","투자위험","단기과열",
"공매도","대차거래","프로그램매매","외국인","기관","개인","세력","수급",
"시가총액","거래량","거래대금","52주최고","52주최저","신고가","신저가",
}
# ── 기술적 분석 지표 ─────────────────────────────────────────────────────────────
TECHNICAL_INDICATORS = {
"이동평균","이평선","골든크로스","데드크로스","MACD","RSI","스토캐스틱",
"볼린저밴드","볼밴","일목균형표","지지","저항","추세선","채널","삼각수렴",
"쐐기형","헤드앤숄더","역헤드앤숄더","더블탑","더블바텀","","갭업","갭다운",
"윗꼬리","아래꼬리","망치형","도지","장악형","상승반전","하락반전",
"거래량폭발","거래량급증","음봉","양봉","캔들","봉차트","분봉","일봉","주봉","월봉",
"오버솔드","오버바우트","과매수","과매도","다이버전스","모멘텀",
}
# ── 기업 이벤트 / 공시 ───────────────────────────────────────────────────────────
CORPORATE_EVENTS = {
"실적발표","어닝시즌","어닝서프라이즈","어닝쇼크","가이던스","실적전망",
"수주","수주잔고","계약체결","MOU","LOI","업무협약","전략적제휴","조인트벤처",
"JV","M&A","인수합병","적대적인수","공개매수","TOB","경영권분쟁","주주행동주의",
"주주총회","정기주총","임시주총","이사회","대표이사교체","CEO교체","감사의견",
"한정의견","의견거절","부적정의견","횡령","배임","회계부정","분식회계","리콜",
"소송","피소","과징금","제재","영업정지","특허침해","임상성공","임상실패",
"FDA승인","식약처승인","NDA","IND","임상1상","임상2상","임상3상",
}
# ── 거시경제 / 정책 ──────────────────────────────────────────────────────────────
MACRO_ECONOMICS = {
"기준금리","금리인상","금리인하","금리동결","통화정책","양적완화","QE","QT",
"테이퍼링","연준","FED","FOMC","한국은행","한은","ECB","BOJ","금통위",
"환율","달러원","원달러","엔화","위안화","유로","달러인덱스","DXY",
"인플레이션","디플레이션","스태그플레이션","CPI","PPI","PCE","GDP","GNP",
"경상수지","무역수지","외환보유고","국채","회사채","하이일드","신용등급",
"무디스","S&P","피치","국제유가","WTI","브렌트","천연가스","LNG","구리","","",
"반도체수급","공급망","서플라이체인","리쇼어링","니어쇼어링","관세","무역전쟁",
"경제성장률","실업률","소비자심리지수","PMI","ISM","주택착공","소매판매",
}
# ── 산업 / 섹터 키워드 ───────────────────────────────────────────────────────────
SECTOR_KEYWORDS = {
# 반도체
"반도체","메모리","낸드","D램","HBM","HBM3","HBM4","파운드리","팹리스",
"TSMC","삼성파운드리","파운드리","EUV","GAA","3나노","2나노","첨단패키징",
"CoWoS","인터포저","칩렛","AI반도체","NPU","GPU","AI가속기",
# 2차전지/전기차
"배터리","2차전지","리튬이온","전고체배터리","양극재","음극재","분리막","전해질",
"전기차","BEV","PHEV","HEV","충전인프라","급속충전","에너지밀도","배터리팩",
"배터리셀","모듈","BMS","리튬","코발트","니켈","망간","황산리튬",
# 바이오/헬스
"바이오","제약","의약품","신약","오리지널","제네릭","바이오시밀러","항체","mRNA",
"세포치료","유전자치료","CAR-T","ADC","이중항체","희귀질환","항암제","치매",
"알츠하이머","비만치료제","GLP-1","당뇨","심혈관","호흡기",
# AI/IT/플랫폼
"인공지능","AI","LLM","GPT","클로드","제미나이","거대언어모델","생성AI","AGI",
"클라우드","AWS","애저","GCP","SaaS","PaaS","IaaS","데이터센터","GPU서버",
"반도체설계","엣지AI","온디바이스","자율주행","ADAS","라이다","카메라모듈",
"로봇","협동로봇","물류로봇","휴머노이드",
# 방산/우주
"방산","방위산업","무기수출","미사일","드론","무인기","위성","우주항공","발사체",
# 건설/부동산
"부동산","아파트","분양","착공","준공","건설수주","PF","프로젝트파이낸싱",
"리츠","REITs","임대","역세권","재개발","재건축",
}
# ── 가격 움직임 표현 (감성 분석용) ───────────────────────────────────────────────
PRICE_MOVEMENT = {
"급등","급락","폭등","폭락","상한가","하한가","강세","약세","반등","반락",
"상승","하락","보합","소폭상승","소폭하락","대폭상승","대폭하락",
"신고가경신","52주신고가","역사적신고가","저점매수","고점매도",
"눌림","눌림목","지지","저항돌파","박스권","횡보","추세전환",
"모멘텀강화","모멘텀약화","수급개선","수급악화","외국인순매수","외국인순매도",
"기관순매수","기관순매도","공매도잔고","공매도비율",
}
# ── 투자 전략 / 버핏 스타일 ──────────────────────────────────────────────────────
INVESTMENT_STRATEGY = {
"가치투자","성장투자","배당투자","퀀트투자","모멘텀투자","역발상투자",
"내재가치","안전마진","경제적해자","모트","경쟁우위","진입장벽","가격결정력",
"브랜드가치","플랫폼효과","네트워크효과","전환비용","규모의경제","비용우위",
"장기투자","복리효과","분산투자","집중투자","포트폴리오","자산배분","리밸런싱",
"저PER","저PBR","고ROE","배당수익률","배당성향","주주환원","자본배분",
"현금창출력","잉여현금흐름","재투자수익률","ROIC","WACC",
}
# 동사/형용사 형태 (형태소 분석에서 VV/VA 태그로 뽑히는 것들)
FINANCE_VERBS: set[str] = {
"급등","급락","폭등","폭락","상승","하락","반등","돌파","이탈","회복",
"초과","달성","하회","상회","급성장","개선","악화","감소","증가",
}
# 전체 통합 사전 (lookup용)
ALL_FINANCE_TERMS: set[str] = (
FINANCIAL_STATEMENT
| MARKET_STRUCTURE
| TECHNICAL_INDICATORS
| CORPORATE_EVENTS
| MACRO_ECONOMICS
| SECTOR_KEYWORDS
| PRICE_MOVEMENT
| INVESTMENT_STRATEGY
)
# 감성 판단에 중요한 우선순위 높은 단어들
HIGH_PRIORITY_TERMS: set[str] = {
"어닝서프라이즈","어닝쇼크","급등","급락","폭등","폭락","상한가","하한가",
"수주","임상성공","임상실패","FDA승인","횡령","배임","분식회계","상장폐지",
"관리종목","거래정지","배당증가","자사주소각","M&A","합병","분할",
"골든크로스","데드크로스","신고가","신저가","52주신고가","52주신저가",
"외국인순매수","기관순매수","공매도잔고감소","실적발표","가이던스상향",
"가이던스하향","CEO교체","대표이사교체","감사의견거절",
}
+250
View File
@@ -0,0 +1,250 @@
"""
바른 API FastAPI 서버 v2
- 서버 시작 시 KRX 전체 종목 동적 로딩
- 24시간마다 자동 갱신
- Bareun gRPC 연결
"""
import asyncio, hashlib, os, re, time
from contextlib import asynccontextmanager
from typing import Optional
import orjson, redis.asyncio as aioredis, structlog
from bareunpy import Tagger
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from prometheus_fastapi_instrumentator import Instrumentator
from pydantic import BaseModel, Field
from stock_loader import load_all_stocks, auto_refresh_stocks
from finance_dict import ALL_FINANCE_TERMS, HIGH_PRIORITY_TERMS, FINANCE_VERBS as FD_VERBS
structlog.configure(processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(),
])
logger = structlog.get_logger()
BAREUN_API_KEY = os.getenv("BAREUN_API_KEY", "")
BAREUN_SERVER_HOST = os.getenv("BAREUN_SERVER_HOST", "bareun")
BAREUN_SERVER_PORT = int(os.getenv("BAREUN_SERVER_PORT", "5656"))
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
REDIS_DB = int(os.getenv("REDIS_DB", "1"))
NEWS_DEDUP_TTL = int(os.getenv("NEWS_DEDUP_TTL", "86400"))
STOPWORDS = {
"","","","","","또한","","","","","아래","관련","통해",
"위해","대해","따라","때문","이후","이전","현재","최근","지난","올해","내년",
"이번","오늘","어제","내일","이날","같은","다른","많은","","가장","매우",
"이미","아직","모든","","전체","일부","국내","해외","글로벌","세계","한국",
"미국","중국","일본","유럽","가운데","한편","다만","특히","실제","여전히",
"앞으로","지속","계속","자체","관계자","","","","","경우","상황",
"","","",
}
FINANCE_KEYWORDS = ALL_FINANCE_TERMS
FINANCE_VERBS = {
"급등","급락","폭등","폭락","상승","하락","반등","하락세","상승세",
"돌파","이탈","회복","저항","지지","돌파구","매집","매도","매수",
"초과달성","하회","상회","달성","부진","급성장","감소","증가","개선","악화",
} | FD_VERBS
START_TIME = time.time()
class AppState:
tagger: Optional[Tagger] = None
redis: Optional[aioredis.Redis] = None
stock_map: dict[str, str] = {}
stock_count: int = 0
refresh_task: Optional[asyncio.Task] = None
state = AppState()
@asynccontextmanager
async def lifespan(app: FastAPI):
state.stock_map = await load_all_stocks()
state.stock_count = len(state.stock_map)
logger.info("stocks.ready", count=state.stock_count)
state.refresh_task = asyncio.create_task(auto_refresh_stocks(state, 24))
try:
state.tagger = Tagger(BAREUN_API_KEY, BAREUN_SERVER_HOST, BAREUN_SERVER_PORT)
logger.info("tagger.ok")
except Exception as e:
logger.warning("tagger.failed", error=str(e))
try:
state.redis = aioredis.Redis(host=REDIS_HOST, port=REDIS_PORT,
password=REDIS_PASSWORD, db=REDIS_DB, decode_responses=True,
socket_connect_timeout=5, retry_on_timeout=True)
await state.redis.ping()
logger.info("redis.ok")
except Exception as e:
logger.error("redis.failed", error=str(e))
yield
if state.refresh_task: state.refresh_task.cancel()
if state.redis: await state.redis.aclose()
app = FastAPI(title="바른 API v2", version="2.0.0", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
class AnalyzeRequest(BaseModel):
title: str; content: str = ""; url: str = ""; source: str = ""; published_at: str = ""
class StockMention(BaseModel):
name: str; code: str; count: int
class AnalyzeResponse(BaseModel):
hash: str; is_duplicate: bool; stocks: list[StockMention]; keywords: list[str]
filtered_text: str; token_count: int; processing_time_ms: float
class BatchRequest(BaseModel):
items: list[AnalyzeRequest]
def news_hash(title, url):
return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16]
async def is_duplicate(h):
if not state.redis: return False
try:
r = await state.redis.set(f"news:dedup:{h}", "1", ex=NEWS_DEDUP_TTL, nx=True)
return r is None
except: return False
def extract_morphemes(text):
if not state.tagger or not text.strip():
return [w for w in text.split() if len(w) >= 2 and w not in STOPWORDS]
try:
result = []
for t, p in state.tagger.pos(text):
if len(t) < 2 or t in STOPWORDS:
continue
if p in ("NNG", "NNP", "SL"):
result.append(t)
elif p in ("VV", "VA", "XR") and t in FINANCE_VERBS:
result.append(t)
return result
except:
return [w for w in text.split() if len(w) >= 2 and w not in STOPWORDS]
def extract_stocks(text):
found = {}
for name, code in state.stock_map.items():
if len(name) < 2:
continue # 1글자 종목명은 오탐 과다 → 제외
if name.isascii():
# 영문/숫자 약칭(KT·SK·DB 등)은 단어경계 강제 (SKT·KTX 오탐 차단)
pat = rf"(?<![A-Za-z0-9]){re.escape(name)}(?![A-Za-z0-9])"
c = len(re.findall(pat, text))
else:
c = text.count(name) # 한글명: 교착어 특성상 부분일치 유지
if c > 0:
found[name] = StockMention(name=name, code=code, count=c)
# 더 긴 종목명에 포함된 짧은 종목명 제거 ('한국' ⊂ '한국전력', 'KT' ⊂ 'KT&G')
names = list(found)
for n in names:
if any(n != m and n in m for m in names):
found.pop(n, None)
return sorted(found.values(), key=lambda x: x.count, reverse=True)
def build_filtered(nouns, stocks):
sn = {s.name for s in stocks}
seen = set()
result = []
# 1순위: 고중요도 금융 이벤트 (어닝서프라이즈, 상한가 등)
for n in nouns:
if n in HIGH_PRIORITY_TERMS and n not in seen:
result.append(n); seen.add(n)
# 2순위: 종목명
for n in nouns:
if n in sn and n not in seen:
result.append(n); seen.add(n)
# 3순위: 전문 금융 용어 사전
for n in nouns:
if n in FINANCE_KEYWORDS and n not in seen:
result.append(n); seen.add(n)
# 4순위: 나머지 의미있는 명사
for n in nouns:
if n not in STOPWORDS and len(n) >= 2 and n not in seen:
result.append(n); seen.add(n)
return " ".join(result[:120])
def scan_finance_terms(text: str) -> list[str]:
"""형태소 분석 없이 원문에서 금융 전문 용어 직접 탐색 (복합어 대응)"""
found = []
for term in ALL_FINANCE_TERMS:
if term in text:
found.append(term)
return found
def _analyze(req):
text = f"{req.title} {req.content}".strip()
h = news_hash(req.title, req.url)
nouns = extract_morphemes(text)
# 원문 직접 스캔으로 형태소 분석이 놓친 복합어 추가
direct_terms = scan_finance_terms(text)
nouns = list(dict.fromkeys(nouns + direct_terms))
stocks = extract_stocks(text)
kw = list(dict.fromkeys(n for n in nouns if len(n) >= 2))[:50]
ft = build_filtered(nouns, stocks)
return h, stocks, kw, ft
@app.get("/health")
async def health():
tok = state.tagger is not None
rok = False
if state.redis:
try: await state.redis.ping(); rok = True
except: pass
return JSONResponse(content={"status": "ok" if tok else "degraded",
"tagger": "ok" if tok else "unavailable", "redis": "ok" if rok else "error",
"stocks_loaded": state.stock_count, "uptime": round(time.time()-START_TIME,1)})
@app.post("/analyze")
async def analyze(req: AnalyzeRequest):
t = time.perf_counter()
h, stocks, kw, ft = _analyze(req)
dup = await is_duplicate(h)
ms = round((time.perf_counter()-t)*1000, 2)
return Response(content=orjson.dumps(AnalyzeResponse(
hash=h, is_duplicate=dup, stocks=stocks, keywords=kw,
filtered_text=ft, token_count=len(ft.split()), processing_time_ms=ms
).model_dump()), media_type="application/json")
@app.post("/analyze/batch")
async def analyze_batch(req: BatchRequest):
if len(req.items) > 50: raise HTTPException(400, "최대 50개")
t = time.perf_counter()
results = []
for item in req.items:
try:
h, stocks, kw, ft = _analyze(item)
dup = await is_duplicate(h)
results.append({"title":item.title,"hash":h,"is_duplicate":dup,
"stocks":[s.model_dump() for s in stocks],"keywords":kw,
"filtered_text":ft,"token_count":len(ft.split())})
except Exception as e:
results.append({"title":item.title,"error":str(e),"is_duplicate":False})
ms = round((time.perf_counter()-t)*1000, 2)
dups = sum(1 for r in results if r.get("is_duplicate"))
return Response(content=orjson.dumps({"total":len(results),"duplicates":dups,
"processed":len(results)-dups,"elapsed_ms":ms,"results":results}),
media_type="application/json")
@app.get("/stocks")
async def stocks_list():
return JSONResponse(content={"count":len(state.stock_map),
"stocks":[{"name":k,"code":v} for k,v in list(state.stock_map.items())[:500]]})
@app.post("/stocks/refresh")
async def refresh():
m = await load_all_stocks()
if len(m) > 50:
state.stock_map = m; state.stock_count = len(m)
return JSONResponse(content={"status":"ok","count":len(m)})
raise HTTPException(500, "종목 로딩 실패")
@app.delete("/dedup/flush")
async def flush():
if not state.redis: raise HTTPException(503)
keys = await state.redis.keys("news:dedup:*")
if keys: await state.redis.delete(*keys)
return {"deleted": len(keys)}
+14
View File
@@ -0,0 +1,14 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
uvloop==0.19.0
httptools==0.6.1
httpx==0.27.0
redis==5.0.4
pydantic==2.7.1
pydantic-settings==2.3.1
bareunpy==1.7.3
python-multipart==0.0.9
orjson==3.10.3
structlog==24.2.0
prometheus-fastapi-instrumentator==7.0.0
tenacity==8.3.0
+162
View File
@@ -0,0 +1,162 @@
"""
KRX 전체 종목 동적 로딩
- 서버 시작 시 1회 로딩
- 매일 자정 자동 갱신
- 실패 시 하드코딩 폴백
"""
import asyncio
import json
import io
import time
from datetime import datetime
import httpx
import structlog
logger = structlog.get_logger()
# 폴백 종목 (네트워크 실패 시 사용)
FALLBACK_STOCKS = {
"코스피": "KOSPI", "코스닥": "KOSDAQ", "코스피200": "KOSPI200",
"삼성전자": "005930", "SK하이닉스": "000660", "LG에너지솔루션": "373220",
"현대차": "005380", "기아": "000270", "셀트리온": "068270",
"카카오": "035720", "네이버": "035420", "NAVER": "035420",
"삼성바이오로직스": "207940", "KB금융": "105560", "POSCO홀딩스": "005490",
"신한지주": "055550", "LG화학": "051910", "삼성SDI": "006400",
"현대모비스": "012330", "하나금융지주": "086790", "SK텔레콤": "017670",
"KT": "030200", "LG전자": "066570", "한화에어로스페이스": "012450",
"삼성물산": "028260", "HD현대중공업": "329180", "한국전력": "015760",
"HMM": "011200", "대한항공": "003490", "카카오뱅크": "323410",
"크래프톤": "259960", "하이브": "352820", "에코프로": "086520",
"알테오젠": "196170", "한미반도체": "042700",
}
# 약칭/별칭
ALIASES = {
"삼전": "005930", "하닉": "000660", "현차": "005380",
"카뱅": "323410", "삼바": "207940", "삼성바이오": "207940",
"한에솔": "012450", "한화에어": "012450", "LG엔솔": "373220",
"SK하닉": "000660", "포홀": "005490", "에프엔에프": "383220",
"현대자동차": "005380", "네이버": "035420",
}
async def fetch_krx_stocks() -> dict[str, str]:
"""KRX에서 전체 상장 종목 가져오기"""
stock_map = {
"코스피": "KOSPI", "코스닥": "KOSDAQ",
"코스피200": "KOSPI200", "KRX": "KRX",
}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "http://data.krx.co.kr/contents/MDC/MDI/mdiLoader/index.cmd",
}
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
for mkt_id, mkt_name in [("STK", "KOSPI"), ("KSQ", "KOSDAQ")]:
try:
payload = {
"bld": "dbms/MDC/STAT/standard/MDCSTAT01901",
"locale": "ko_KR",
"mktId": mkt_id,
"share": "1",
"csvxls_is498": "false",
}
resp = await client.post(
"http://data.krx.co.kr/comm/bldAttend/getJsonData.cmd",
data=payload,
)
if resp.status_code == 200:
data = resp.json()
items = data.get("OutBlock_1", [])
for item in items:
name = item.get("ISU_ABBRV", "").strip()
code = item.get("ISU_SRT_CD", "").strip()
if name and code and len(code) == 6:
stock_map[name] = code
logger.info("krx.loaded", market=mkt_name, count=len(items))
else:
logger.warning("krx.http_error", market=mkt_name, status=resp.status_code)
except Exception as e:
logger.warning("krx.fetch_failed", market=mkt_name, error=str(e))
# KRX 실패 시 네이버 금융 폴백
if len(stock_map) < 100:
logger.info("krx.fallback_naver", reason="KRX returned too few stocks")
stock_map.update(await fetch_naver_stocks())
return stock_map
async def fetch_naver_stocks() -> dict[str, str]:
"""네이버 금융에서 종목 가져오기 (KRX 폴백)"""
stock_map = {}
headers = {"User-Agent": "Mozilla/5.0"}
import re
async with httpx.AsyncClient(timeout=15, headers=headers) as client:
for sosok in [0, 1]: # 0=KOSPI, 1=KOSDAQ
for page in range(1, 45):
try:
url = f"https://finance.naver.com/sise/sise_market_sum.naver?sosok={sosok}&page={page}"
resp = await client.get(url)
text = resp.content.decode("euc-kr", errors="ignore")
rows = re.findall(
r"main\.naver\?code=(\d{6})[^>]*>([^<]+)</a>", text
)
if not rows:
break
for code, name in rows:
name = name.strip()
if name and code:
stock_map[name] = code
except Exception:
break
logger.info("naver.loaded", count=len(stock_map))
return stock_map
async def load_all_stocks() -> dict[str, str]:
"""전체 종목 로딩 (KRX → 네이버 → 폴백)"""
try:
stock_map = await fetch_krx_stocks()
if len(stock_map) > 100:
stock_map.update(ALIASES)
logger.info("stocks.loaded", total=len(stock_map), source="KRX")
return stock_map
except Exception as e:
logger.warning("stocks.krx_failed", error=str(e))
try:
stock_map = await fetch_naver_stocks()
if len(stock_map) > 100:
stock_map.update({
"코스피": "KOSPI", "코스닥": "KOSDAQ",
"코스피200": "KOSPI200", "KRX": "KRX",
})
stock_map.update(ALIASES)
logger.info("stocks.loaded", total=len(stock_map), source="Naver")
return stock_map
except Exception as e:
logger.warning("stocks.naver_failed", error=str(e))
# 최종 폴백
fallback = {**FALLBACK_STOCKS, **ALIASES}
logger.warning("stocks.using_fallback", total=len(fallback))
return fallback
async def auto_refresh_stocks(state_ref, interval_hours: int = 24):
"""백그라운드에서 종목 목록 자동 갱신"""
while True:
await asyncio.sleep(interval_hours * 3600)
try:
new_map = await load_all_stocks()
if len(new_map) > 50:
state_ref.stock_map = new_map
logger.info("stocks.refreshed", total=len(new_map))
except Exception as e:
logger.warning("stocks.refresh_failed", error=str(e))