diff --git a/project/STOCK_APP/.idea/STOCK_APP.iml b/project/STOCK_APP/.idea/STOCK_APP.iml index bf1af76..5e2524c 100644 --- a/project/STOCK_APP/.idea/STOCK_APP.iml +++ b/project/STOCK_APP/.idea/STOCK_APP.iml @@ -2,13 +2,10 @@ + - - - - \ No newline at end of file diff --git a/project/STOCK_APP/backend/config/settings.py b/project/STOCK_APP/backend/config/settings.py index 1d35237..e96a0ef 100644 --- a/project/STOCK_APP/backend/config/settings.py +++ b/project/STOCK_APP/backend/config/settings.py @@ -8,5 +8,6 @@ class Settings(BaseSettings): watsonx_project_id: str = Field(alias="WATSONX_PROJECT_ID") watsonx_url: str = Field(alias="WATSONX_URL") hf_token: str = Field(alias="HF_TOKEN") + serper_api_key: str = Field(alias="SERPER_API_KEY") settings = Settings() \ No newline at end of file diff --git a/project/STOCK_APP/backend/graph/nodes.py b/project/STOCK_APP/backend/graph/nodes.py index 948df77..d32209f 100644 --- a/project/STOCK_APP/backend/graph/nodes.py +++ b/project/STOCK_APP/backend/graph/nodes.py @@ -1,6 +1,13 @@ from backend.services.news_service import get_news from backend.services.financial_service import get_financial_info from backend.services.technical_service import get_technical_info +from backend.services.competitor_service import get_competitor_info +from langchain_core.prompts import ChatPromptTemplate +from backend.ai.llm import hugging_llm +from backend.prompts.all_prompt import REPORT_PROMPT + +prompt = ChatPromptTemplate.from_template(REPORT_PROMPT) +report_chain = prompt | hugging_llm async def news_node(state): news_list = await get_news(state["company_name"]) @@ -15,7 +22,58 @@ async def technical_node(state): return {"technicals": technicals} async def competitor_node(state): - pass + competitors = await get_competitor_info(state["ticker"]) + return {"competitors": competitors} async def report_node(state): - pass + """ + 수집된 정보를 LLM 에게 넘기고 분석 요청 + 1. 뉴스기사 + 2. 재무정보(시가총액, 매출, 순이익...) + 3. 기술적 지표(현재주가, 20일선, 60일선, rsi...) + 4. 경쟁회사 재무 정보 + """ + + news_text = "\n".join([f"제목 : {n.title}\n내용 : {n.snippet}" for n in state["news"]]) + + financials = state['financials'] + financial_text = f""" + 시가총액 : {financials['market_cap']} + 매출 : {financials['revenue']} + 총자산 : {financials['total_assets']} + 순이익 : {financials['net_income']} + PER : {financials['pe_ratio']} + PBR : {financials['pb_ratio']} +""" + + technicals = state['technicals'] + technical_text = f""" + 현재주가 : {technicals['current_price']} + 20일선 : {technicals['sma20']} + 60일선 : {technicals['sma60']} + RSI : {technicals['rsi']} + MACD : {technicals['macd']} + MACD_SIGNAL : {technicals['macd_signal']} + 추세 : {technicals['trend']} +""" + + competitor_text = "\n".join([f""" + 티커 : {c['ticker']} + 회사 : {c['company_name']} + 시가총액 : {c['market_cap']} + PER : {c['pe_ratio']} + 매출성장률 : {c['revenue_growth']} + 순이익률 : {c['profit_margin']} +""" for c in state['competitors']]) + + report = await report_chain.ainvoke( + { + "company_name" : state["company_name"], + "news": news_text, + "financials": financial_text, + "technicals": technical_text, + "competitors": competitor_text, + } + ) + + return {"report": report.content} \ No newline at end of file diff --git a/project/STOCK_APP/backend/graph/state.py b/project/STOCK_APP/backend/graph/state.py index b936935..e6f6a47 100644 --- a/project/STOCK_APP/backend/graph/state.py +++ b/project/STOCK_APP/backend/graph/state.py @@ -1,10 +1,10 @@ from typing import TypedDict -class StockAnalysis(TypedDict): +class StockAnalysisState(TypedDict): query:str ticker:str company_name:str - new:list + news:list financials:dict technicals:dict competitors:list diff --git a/project/STOCK_APP/backend/graph/stock_graph.py b/project/STOCK_APP/backend/graph/stock_graph.py new file mode 100644 index 0000000..a4b87f9 --- /dev/null +++ b/project/STOCK_APP/backend/graph/stock_graph.py @@ -0,0 +1,30 @@ +from langgraph.graph import StateGraph, START, END +from backend.graph.state import StockAnalysisState +from backend.graph.nodes import news_node, financial_node, competitor_node, report_node, technical_node +import logging + +logger = logging.getLogger(__name__) + +def build_stock_graph(): + # 그래프 생성 + graph = StateGraph(StockAnalysisState) + + # 노드 등록 + graph.add_node("news", news_node) + graph.add_node("financial", financial_node) + graph.add_node("technical", technical_node) + graph.add_node("competitors", competitor_node) + graph.add_node("report", report_node) + + # 연결 + graph.add_edge(START, "news") + graph.add_edge("news", "financial") + graph.add_edge("financial", "technical") + graph.add_edge("technical", "competitors") + graph.add_edge("competitors", "report") + graph.add_edge("report", END) + + # 실행 + compiled = graph.compile() + logger.info("LangGraph 주식 분석 그래프 빌드 완료") + return compiled \ No newline at end of file diff --git a/project/STOCK_APP/backend/main.py b/project/STOCK_APP/backend/main.py index 0be982c..35a9c93 100644 --- a/project/STOCK_APP/backend/main.py +++ b/project/STOCK_APP/backend/main.py @@ -4,40 +4,34 @@ from contextlib import asynccontextmanager from backend.routers.stock_router import router as stock_router -# from backend.repository.db_init import Base, SessionLocal, engine -# +from backend.repository.db_init import Base, SessionLocal, engine + # from backend.routers.evalu_router import router as evalu_router # from backend.routers.assistant_router import router as assistant_router # from backend.routers.call_router import router as call_router # from backend.repository.seed import seed_customers -# @asynccontextmanager -# async def lifespan(app: FastAPI): -# print("서버 시작") -# -# # Base 에 등록된 모든 모델에 테이블 자동 생성 -# Base.metadata.create_all(bind=engine) -# print("[DB] 테이블 생성 완료 (또는 이미 존재)") -# -# db = SessionLocal() -# try: -# # 기본 user 데이터 삽입 -# seed_customers(db) -# finally: -# db.close() -# -# yield -# print("서버 종료") +@asynccontextmanager +async def lifespan(app: FastAPI): + print("서버 시작") + + # Base 에 등록된 모든 모델에 테이블 자동 생성 + Base.metadata.create_all(bind=engine) + print("[DB] 테이블 생성 완료 (또는 이미 존재)") + + yield + print("서버 종료") # app = FastAPI() # app = FastAPI(title="상담 LLM", version="1.0", lifespan=lifespan) -app = FastAPI(title="$ Stock AI", version="1.0", description=""" +app = FastAPI(title="$ Stock AI", version="1.0", lifespan=lifespan, description=""" #### $주식 AI 분석 FastAPI + LangGraph + yfinance + ChromaDB ### 주요 기능 | 번호 | 기능 | 엔드포인트 | | --- | --- | --- | |1 | 기업 종합 분석 | 'POST /api/stock/analysis' + |2 | 투자 의견 조회 | 'GET /api/stock/opinion/{analysis_id}' """, ) diff --git a/project/STOCK_APP/backend/prompts/all_prompt.py b/project/STOCK_APP/backend/prompts/all_prompt.py new file mode 100644 index 0000000..4d376a2 --- /dev/null +++ b/project/STOCK_APP/backend/prompts/all_prompt.py @@ -0,0 +1,43 @@ +REPORT_PROMPT = """ +당신은 월가 애널리스트입니다. + +회사명 : +{company_name} + +최근 뉴스 : +{news} + +재무정보 : +{financials} + +기술적 지표 : +{technicals} + +경쟁사 : +{competitors} + +다음 형식으로 분석하세요. + +1. 기업 개요 +2. 최근 뉴스 핵심 요약 +3. 재무 상태 분석 +4. 기술적 분석 +5. 경쟁사 비교 +6. 주요 리스크 +7. 종합의견 + +반드시 구체적인 수치를 인용하세요. +""" + +INVESTOR_SENTIMENT_PROMPT = """ +다음 뉴스들의 전체적인 투자 심리를 분석하시오. + +{news_text} + +반드시 JSON 형식으로 응답하세요. +{{ + "positive":0, + "negative":0, + "neutral":0, +}} +""" \ No newline at end of file diff --git a/project/STOCK_APP/backend/repository/db_init.py b/project/STOCK_APP/backend/repository/db_init.py new file mode 100644 index 0000000..090bd3e --- /dev/null +++ b/project/STOCK_APP/backend/repository/db_init.py @@ -0,0 +1,23 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker +from pathlib import Path + +Path("db").mkdir(parents=True, exist_ok=True) + +# echo sql 구문 출력시키는 +engine = create_engine('sqlite:///db/stock.db', echo=True) + +# 세션 팩토리 생성 +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + +# Base = declarative_Base() +class Base(DeclarativeBase): + pass + +# session 을 다른 모듈에서 사용할 수 있도록 제공 +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/project/STOCK_APP/backend/repository/models.py b/project/STOCK_APP/backend/repository/models.py new file mode 100644 index 0000000..8f68601 --- /dev/null +++ b/project/STOCK_APP/backend/repository/models.py @@ -0,0 +1,28 @@ +from backend.repository.db_init import Base +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from sqlalchemy import JSON, ForeignKey, String + +class StockAnalysis(Base): + __tablename__ = "stock_analysis" + + analysis_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + ticker:Mapped[str] + company_name:Mapped[str] + analysis_json: Mapped[dict] = mapped_column(JSON) + report:Mapped[str] + created_at:Mapped[datetime] = mapped_column(default=datetime.now, nullable=False) + opinion = relationship("InvestmentOpinion", back_populates="analysis", uselist=False) + +class InvestmentOpinion(Base): + __tablename__ = "investment_opinion" + + opinion_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + analysis_id: Mapped[int] = mapped_column(ForeignKey("stock_analysis.analysis_id"), unique=True) + opinion: Mapped[str] + # 투자의견 : Buy, sell..... + rating:Mapped[str] = mapped_column(String(20), nullable=True) + score:Mapped[int] = mapped_column(default=0) + created_at:Mapped[datetime] = mapped_column(default=datetime.now, nullable=False) + analysis = relationship("StockAnalysis", back_populates="opinion") + diff --git a/project/STOCK_APP/backend/routers/stock_router.py b/project/STOCK_APP/backend/routers/stock_router.py index a0eaefc..7b4431e 100644 --- a/project/STOCK_APP/backend/routers/stock_router.py +++ b/project/STOCK_APP/backend/routers/stock_router.py @@ -1,12 +1,30 @@ from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session - from backend.schemas.stock_schemas import StockAnalyzeReq from backend.services.stock_service import StockService +from sqlalchemy.orm import Session +from backend.repository.db_init import get_db +from backend.services.stock_service import get_stock_service +from backend.schemas.stock_schemas import RecommendStock +from backend.services.recommend_service import generate_portfolio_report, portfolio_service router = APIRouter(prefix="/api/stock", tags=["기헙종합분석"]) @router.post("/analyze", summary="기업 종합 분석", description="자연어로 해당기업을 요청하면 뉴스, 재무, 기술적, 경쟁사 분석을 수행합니다.", ) -async def stock_analyze(req:StockAnalyzeReq): - service = StockService() - return await service._extract(req.query) \ No newline at end of file +async def stock_analyze(req:StockAnalyzeReq, db:Session=Depends(get_db)): + service = get_stock_service() + return await service.analyze(req.query, db) + +@router.get("/opinion/{analysis_id}", summary="투자 의견 조회", description="", ) +async def stock_analyze_opinion(analysis_id:int, db:Session=Depends(get_db)): + service = get_stock_service() + return await service.opinion_service(analysis_id, db) + +@router.post("/recommend", summary="투자 추천", description="", ) +async def stock_recommend(req: RecommendStock, db: Session=Depends(get_db)): + result = portfolio_service(req, db) + report = await generate_portfolio_report(result['portfolio'], req.risk_type) + return { + "risk_type":req.risk_type, + "portfolio":result['portfolio'], + "report":report, + } \ No newline at end of file diff --git a/project/STOCK_APP/backend/schemas/stock_schemas.py b/project/STOCK_APP/backend/schemas/stock_schemas.py index eeae064..2905f69 100644 --- a/project/STOCK_APP/backend/schemas/stock_schemas.py +++ b/project/STOCK_APP/backend/schemas/stock_schemas.py @@ -1,9 +1,21 @@ from pydantic import BaseModel - +from enum import Enum class StockAnalyzeReq(BaseModel): query: str class TickerInfo(BaseModel): ticker: str - company_name: str \ No newline at end of file + company_name: str + + +# enum +# risk_type : aggressive, balanced, conservative +class RiskType(str, Enum): + AGGRESSIVE = "aggressive" + BALANCED = "balanced" + CONSERVATIVE = "conservative" + +class RecommendStock(BaseModel): + tickers: list[str] + risk_type : RiskType \ No newline at end of file diff --git a/project/STOCK_APP/backend/services/competitor_service.py b/project/STOCK_APP/backend/services/competitor_service.py new file mode 100644 index 0000000..93d2dea --- /dev/null +++ b/project/STOCK_APP/backend/services/competitor_service.py @@ -0,0 +1,41 @@ +import yfinance as yf + +COMPETITOR_MAP = { + "NVDA": ["AMD", "INTC", "TSM", "AVGO"], + "AMD": ["NVDA", "INTC", "TSM"], + "TSLA": ["RIVN", "GM", "F"], + "AAPL": ["MSFT", "GOOGL", "AMZN"], + "MSFT": ["AAPL", "GOOGL", "AMZN"], + "GOOGL": ["MSFT", "META", "AMZN"], +} + +async def get_competitor_info(ticker: str): + """ + 경쟁 관계 회사에 대한 핵심 정보 추출 + """ + competitors_tickers = COMPETITOR_MAP.get(ticker, []) + + results =[] + for comp in competitors_tickers: + company = yf.Ticker(comp) + info = company.info + + results.append( + { + # + "ticker": comp, + # 회사 이름 + "company_name": info.get("longName"), + # 시가총액 + "market_cap": info.get("marketCap"), + # per + "pe_ratio": info.get("pe_ratio"), + # 매출 성장률 + "revenue_growth": info.get("revenue_growth"), + # 순 이익률 + "profit_margin": info.get("profitMargins"), + } + ) + + return results + diff --git a/project/STOCK_APP/backend/services/financial_service.py b/project/STOCK_APP/backend/services/financial_service.py index de7ddee..ffce1be 100644 --- a/project/STOCK_APP/backend/services/financial_service.py +++ b/project/STOCK_APP/backend/services/financial_service.py @@ -1,4 +1,5 @@ -import yahooquery as yf +import yfinance as yf +# from yahooquery import Ticker async def get_financial_info(ticker: str): """ @@ -7,30 +8,29 @@ async def get_financial_info(ticker: str): company = yf.Ticker(ticker) info = company.info - income = company.income_stmtf + income = company.income_stmt balance = company.balance_sheet cashflow = company.cash_flow return { - # 시가 총액 - "market_cap":info.get("marketCap"), - # 현재 주가 - "current_price":info.get("currentPrice"), + # 시가총액 + "market_cap": info.get("marketCap"), + # 현재주가 + "current_price": info.get("currentPrice"), # 매출 - "revenue":income.loc["Total Revenue"].iloc[0], + "revenue": income.loc["Total Revenue"].iloc[0], # 손익계산 - "operating_income":income.loc["Operating Income"].iloc[0], + "operating_income": income.loc["Operating Income"].iloc[0], # 순이익 - "net_income":income.loc["Net Income"].iloc[0], + "net_income": income.loc["Net Income"].iloc[0], # 총자산 - "total_assets":balance.loc["Total Assets"].iloc[0], + "total_assets": balance.loc["Total Assets"].iloc[0], # 총부채 - "total_debt":balance.loc["Total Debt"].iloc[0], + "total_debt": balance.loc["Total Debt"].iloc[0], # 현금흐름 - "free_cash_flow":cashflow.loc["Free Cash Flow"].iloc[0], + "free_cash_flow": cashflow.loc["Free Cash Flow"].iloc[0], # per - "pe_ratio":info.get("trailingPE"), + "pe_ratio": info.get("trailingPE"), # pbr - "pb_ratio":info.get("priceToBook") - } - + "pb_ratio": info.get("priceToBook"), + } \ No newline at end of file diff --git a/project/STOCK_APP/backend/services/news_service.py b/project/STOCK_APP/backend/services/news_service.py index a90b99a..01721a7 100644 --- a/project/STOCK_APP/backend/services/news_service.py +++ b/project/STOCK_APP/backend/services/news_service.py @@ -1,6 +1,6 @@ from langchain_community.utilities import GoogleSerperAPIWrapper - from backend.schemas.news_schemas import NewsItem +from backend.config.settings import settings async def get_news(company_name: str): @@ -8,8 +8,9 @@ async def get_news(company_name: str): 구글 뉴스 검색 후 title, snippet, url, source, date 추출 """ - search = GoogleSerperAPIWrapper(type="news") - results = await search.results(f"{company_name} stock news") + search = GoogleSerperAPIWrapper(type="news", serper_api_key=f"{settings.serper_api_key}") + # results = search.aresults(f"{company_name} stock news") + results = await search.aresults(f"{company_name} stock news") news_list = [] for item in results['news'][:10]: diff --git a/project/STOCK_APP/backend/services/recommend_service.py b/project/STOCK_APP/backend/services/recommend_service.py new file mode 100644 index 0000000..4d0c3ea --- /dev/null +++ b/project/STOCK_APP/backend/services/recommend_service.py @@ -0,0 +1,71 @@ +from sqlalchemy.orm import Session +from backend.schemas.stock_schemas import RecommendStock, RiskType +from backend.repository.models import StockAnalysis +from fastapi import HTTPException +from backend.ai.llm import hugging_llm + +def portfolio_service(req, db): + """ + 사용자가 입력한 종목에 대해서 분석정보 찾기 + risk_type: 비중 조절 + """ + + analyses = db.query(StockAnalysis).filter(StockAnalysis.ticker.in_(req.tickers)).all() + + portfolio_items = [] + for analysis in analyses: + if not analysis.opinion: + continue + + portfolio_items.append( + { + "ticker": analysis.ticker, + "score": analysis.opinion.score, + "rating": analysis.opinion.rating, + } + ) + + if not portfolio_items: + raise HTTPException(status_code=5000, detail="분석된 종목을 찾을 수 없습니다.") + + # 투자성향 + risk_type = req.risk_type + import math + for item in portfolio_items: + score = item["score"] + + if risk_type == RiskType.AGGRESSIVE: + item['adjusted_score'] = score ** 1.5 + elif risk_type == RiskType.CONSERVATIVE: + item['adjusted_score'] = math.sqrt(score) + else: + item['adjusted_score'] = score + total_adjusted = sum(item['adjusted_score'] for item in portfolio_items) + + for item in portfolio_items: + item['weight'] = round(item['adjusted_score'] / total_adjusted * 100,2) + + return {"risk_type": req.risk_type, "portfolio": portfolio_items} + + +async def generate_portfolio_report(portfolio_items, risk_type): + portfolio_text = "\n".join([f"{item['ticker']}: {item['weight']}%" for item in portfolio_items]) + prompt = f""" +당신은 월가의 포트폴리오 전략가입니다. + +투자성향 +{risk_type.value} + +포트폴리오: +{portfolio_text} + +다음을 작성하시오. +1. 포트폴리오 요약 +2. 강점 +3. 약점 +4. 주요 리스크 +5. 추천 투자기간 +6. 리밸런싱 전략 +""" + result = await hugging_llm.ainvoke(prompt) + return result.content \ No newline at end of file diff --git a/project/STOCK_APP/backend/services/score_service.py b/project/STOCK_APP/backend/services/score_service.py new file mode 100644 index 0000000..e2e919b --- /dev/null +++ b/project/STOCK_APP/backend/services/score_service.py @@ -0,0 +1,137 @@ +from langchain_core.output_parsers import JsonOutputParser +from langchain_core.prompts import ChatPromptTemplate +from backend.ai.llm import hugging_llm +from backend.prompts.all_prompt import INVESTOR_SENTIMENT_PROMPT + +def get_rating(score): + # 최종판단 + if score >= 85: + return "Strong Buy" + elif score >= 70: + return "Buy" + elif score >= 50: + return "Hold" + elif score >= 30: + return "Sell" + return "Strong Sell" + +# 재무 정보 1. PER(20점) +def get_per_score(per): + if per is None: + return 0 + if per < 20: + return 20 + elif per < 35: + return 15 + return 10 + +# 2. 순이익률 +def get_profit_margin(financials): + net_income = financials.get("net_income") + revenue = financials.get("revenue") + + if not revenue: + return 0, 0 + profit_margin = net_income / revenue + + profit_score = 0 + if profit_margin > 0.3: + profit_score = 20 + elif profit_margin > 0.15: + profit_score = 15 + else: + profit_score = 10 + + return profit_score, profit_margin + +# 3. 기술분석 (20점) +def get_tech_score(technicals): + technicals_score = 0 + trend = technicals['trend'] + if trend == 'bullish': + technicals_score += 10 + + macd = technicals['macd'] + macd_signal = technicals['macd_signal'] + + if macd > macd_signal: + technicals_score += 10 + return technicals_score + +# 4. 경쟁사 분석(20점) +def get_com_score(competitor, per, profit_margin): + # 경쟁사 per 평균 비교 + competitors_score = 0 + pe_list = [c["pe_ratio"] for c in competitor if c["pe_ratio"] is not None] + + if not pe_list: + avg_pe = None + else: + avg_pe = sum(pe_list) / len(pe_list) + + if avg_pe is not None and per < avg_pe: + competitors_score += 10 + + profit_margin_list = [c["profit_margin"] for c in competitor if c["profit_margin"] is not None] + + avg_profit_margin = sum(profit_margin_list) / len(profit_margin_list) + if profit_margin > avg_profit_margin: + competitors_score += 10 + + return competitors_score + +# 5. 뉴스 분석(20점) +async def get_news_score(news_list): + # 뉴스 투자 심리 + news_text = "\n".join( + [f"제목 : {n['title']}\n내용 : {n['snippet']}" for n in news_list] + ) + + prompt = ChatPromptTemplate.from_template(INVESTOR_SENTIMENT_PROMPT) + + chain = prompt | hugging_llm | JsonOutputParser() + + sentiment = await chain.ainvoke({"news_text":news_text}) + + total = sentiment["positive"] + sentiment["negative"] + sentiment["neutral"] + + if total == 0: + return 0 + + positive_ratio = sentiment["positive"] / total + return round(positive_ratio * 20) + +async def evaluation(analysis): + score = 0 + data = analysis.analysis_json + + # 1. per 점수 + pe_ratio = data['financials'].get('pe_ratio') + per_score = get_per_score(pe_ratio) + + score += per_score + + # 2. 순이익률 점수 + profit_score, profit_margin = get_profit_margin(data['financials']) + score += profit_score + + # 3. 기술적 점수 + technicals_score = get_tech_score(data["technicals"]) + score += technicals_score + + # 4. 경쟁사 점수 + competitor_score = get_com_score(data['competitors'], pe_ratio, profit_margin) + score += competitor_score + + # 5. 뉴스 점수 + news_score = await get_news_score(data['news']) + score += news_score + + return { + "total_score": score, + "per_score": per_score, + "profit_score": profit_score, + "technical_score": technicals_score, + "competitor_score": competitor_score, + "news_score": news_score + } diff --git a/project/STOCK_APP/backend/services/stock_service.py b/project/STOCK_APP/backend/services/stock_service.py index 4f0b8ff..8da1474 100644 --- a/project/STOCK_APP/backend/services/stock_service.py +++ b/project/STOCK_APP/backend/services/stock_service.py @@ -1,6 +1,12 @@ from backend.schemas.stock_schemas import TickerInfo import yahooquery as yq from fastapi import HTTPException +from backend.graph.stock_graph import build_stock_graph +from backend.repository.models import StockAnalysis, InvestmentOpinion +from fastapi.encoders import jsonable_encoder +from typing import Optional +from backend.services.score_service import evaluation, get_rating +from backend.ai.llm import hugging_llm COMPANY_MAP = { "NVDA": { @@ -115,6 +121,8 @@ class StockService: if alias.lower() in query: return TickerInfo(ticker = ticker, company_name = info["company_name"]) + return None + def _extract_company_keyword(self, query:str): """ query : 엔비디아 분석해줘 @@ -145,4 +153,89 @@ class StockService: return TickerInfo(ticker=result["symbol"], company_name=result["longname"]) except Exception: - return None \ No newline at end of file + return None + + async def analyze(self, query: str, db): + # 티커, 회사명 추출 + ticker_info = await self._extract(query) + + # 그래프 실행 + stock_graph = build_stock_graph() + result = await stock_graph.ainvoke( + { + "query": query, + "ticker": ticker_info.ticker, + "company_name": ticker_info.company_name + } + ) + + # 데이터베이스 저장 + # 테이블과 관련있는 모델 객체 생성 + entity = StockAnalysis( + ticker = result['ticker'], + company_name = result['company_name'], + analysis_json = jsonable_encoder(result), + report = result['report'] + ) + db.add(entity) + db.commit() + db.refresh(entity) + + return {"analysis_id":entity.analysis_id, **result} + + async def opinion_service(self, analysis_id, db): + # analysis_id 디비 조회 + analysis = db.get(StockAnalysis, analysis_id) + + score_result = await evaluation(analysis) + + # + total_score = score_result['total_score'] + rating = get_rating(total_score) + + # AI 투자 의견서 + opinion = await self.generate_opinion(analysis.report, total_score, rating) + + entity = InvestmentOpinion( + analysis_id = analysis_id, + opinion = opinion, + rating = rating, + score = total_score, + ) + db.add(entity) + db.commit() + db.refresh(entity) + + return {"opinion_id":entity.opinion_id, "analysis_id":analysis_id, "opinion":opinion, "rating":rating, **score_result,} + + async def generate_opinion(self, report, total_score, rating): + prompt = f""" + 당신은 월가의 수석 애널리스트입니다. + + 종합점수 : + {total_score} / 100 + 투자등급 : + {rating} + + 다음 기업 분석 보고서를 기반으로 투자 의견서를 작성하세요. + {report} + + 반드시 아래 형식으로 작성하세요. + 1. 투자등급 + 2. 투자근거 + 3. 핵심리스크 + 4. 단기전망 + 5. 장기전망 + 6. 최종의견 + """ + result = await hugging_llm.ainvoke(prompt) + return result.content + +# 싱글톤(객체 하나만 생성) 서비스 인스턴스 +_service:Optional[StockService] = None + +def get_stock_service(): + global _service + if _service is None: + _service = StockService() + return _service diff --git a/project/STOCK_APP/backend/services/technical_service.py b/project/STOCK_APP/backend/services/technical_service.py index 71ac363..bf26a0b 100644 --- a/project/STOCK_APP/backend/services/technical_service.py +++ b/project/STOCK_APP/backend/services/technical_service.py @@ -1,4 +1,4 @@ -import yahooquery as yf +import yfinance as yf from ta.trend import MACD, SMAIndicator from ta.momentum import RSIIndicator @@ -10,7 +10,7 @@ async def get_technical_info(ticker: str): # 주가 데이터 다운로드 df = yf.download(ticker, period="1y", interval="1d") # 종가 가져오기 - close = df['Close'].squeeze() + close = df["Close"].squeeze() macd = MACD(close) macd_value = float(macd.macd().iloc[-1]) @@ -18,19 +18,18 @@ async def get_technical_info(ticker: str): trend = "bullish" if macd_value > signal_value else "bearish" return { - # 현재 주가 - "current_price":close.iloc[-1], - # 20 일선 - "sma20":float(SMAIndicator(close, window=20).sma_indicator().iloc[-1]), - # 60 일선 - "sma60":float(SMAIndicator(close, window=60).sma_indicator().iloc[-1]), + # 현재주가 + "current_price": close.iloc[-1], + # 20일선 + "sma20": float(SMAIndicator(close, window=20).sma_indicator().iloc[-1]), + # 60일선 + "sma60": float(SMAIndicator(close, window=60).sma_indicator().iloc[-1]), # rsi - "rsi":float(SMAIndicator(close, window=14).rsi().iloc[-1]), + "rsi": float(RSIIndicator(close, window=14).rsi().iloc[-1]), # macd - "macd":macd.macd(), + "macd": macd_value, # macd_signal "macd_signal": signal_value, # trend - "trend": trend, - } - + "trend": trend, + } \ No newline at end of file diff --git a/project/STOCK_APP/db/stock.db b/project/STOCK_APP/db/stock.db new file mode 100644 index 0000000..b91085c Binary files /dev/null and b/project/STOCK_APP/db/stock.db differ