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:
@@ -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"]
|
||||
@@ -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교체","대표이사교체","감사의견거절",
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user