Files
trading/bareunaapi-main.py
kyu 6d3b0bacc0 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>
2026-05-20 21:33:56 +09:00

205 lines
8.2 KiB
Python

"""
바른 API FastAPI 서버 v2
- 서버 시작 시 KRX 전체 종목 동적 로딩
- 24시간마다 자동 갱신
- Bareun gRPC 연결
"""
import asyncio, hashlib, os, 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
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 = {
"실적","매출","영업이익","순이익","적자","흑자","손실","이익","투자","배당",
"자사주","유상증자","합병","인수","M&A","IPO","상장","상폐","거래정지","금리",
"기준금리","환율","달러","반도체","배터리","전기차","수소","AI","인공지능",
"클라우드","바이오","의약품","임상","승인","특허","수주","계약","부도","파산",
"부채","시총","코스피","코스닥","나스닥",
}
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:
return [t for t, p in state.tagger.pos(text)
if p in ("NNG","NNP","SL") and len(t) >= 2 and t not in STOPWORDS]
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():
c = text.count(name)
if c > 0: found[name] = StockMention(name=name, code=code, count=c)
return sorted(found.values(), key=lambda x: x.count, reverse=True)
def build_filtered(nouns, stocks):
sn = {s.name for s in stocks}
p = [n for n in nouns if n in sn]
f = [n for n in nouns if n in FINANCE_KEYWORDS]
g = [n for n in nouns if n not in STOPWORDS and len(n) >= 2]
return " ".join(list(dict.fromkeys(p + f + g))[:100])
def _analyze(req):
text = f"{req.title} {req.content}".strip()
h = news_hash(req.title, req.url)
nouns = extract_morphemes(text)
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)}