랭그래프 활용한 주식 정보 도출 프로젝트
- 투자 의견 조회
ㄴ 전반적인 주식 판단 기준(ex. 채무, 리스크 등등)을 기준으로 판단하여 도출
- 투자 추천
ex)
{
"tickers": [
"NVDA", "GOOGL", "AAPL"
],
"risk_type": "aggressive"
}
This commit is contained in:
Generated
+1
-4
@@ -2,13 +2,10 @@
|
|||||||
<module type="PYTHON_MODULE" version="4">
|
<module type="PYTHON_MODULE" version="4">
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="~/Source/project/STOCK_APP/.venv" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="~/Source/project/STOCK_APP/.venv" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PackageRequirementsSettings" />
|
|
||||||
<component name="PyDocumentationSettings" />
|
|
||||||
<component name="ReSTService" />
|
|
||||||
<component name="TestRunnerService" />
|
|
||||||
</module>
|
</module>
|
||||||
@@ -8,5 +8,6 @@ class Settings(BaseSettings):
|
|||||||
watsonx_project_id: str = Field(alias="WATSONX_PROJECT_ID")
|
watsonx_project_id: str = Field(alias="WATSONX_PROJECT_ID")
|
||||||
watsonx_url: str = Field(alias="WATSONX_URL")
|
watsonx_url: str = Field(alias="WATSONX_URL")
|
||||||
hf_token: str = Field(alias="HF_TOKEN")
|
hf_token: str = Field(alias="HF_TOKEN")
|
||||||
|
serper_api_key: str = Field(alias="SERPER_API_KEY")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
from backend.services.news_service import get_news
|
from backend.services.news_service import get_news
|
||||||
from backend.services.financial_service import get_financial_info
|
from backend.services.financial_service import get_financial_info
|
||||||
from backend.services.technical_service import get_technical_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):
|
async def news_node(state):
|
||||||
news_list = await get_news(state["company_name"])
|
news_list = await get_news(state["company_name"])
|
||||||
@@ -15,7 +22,58 @@ async def technical_node(state):
|
|||||||
return {"technicals": technicals}
|
return {"technicals": technicals}
|
||||||
|
|
||||||
async def competitor_node(state):
|
async def competitor_node(state):
|
||||||
pass
|
competitors = await get_competitor_info(state["ticker"])
|
||||||
|
return {"competitors": competitors}
|
||||||
|
|
||||||
async def report_node(state):
|
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}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
class StockAnalysis(TypedDict):
|
class StockAnalysisState(TypedDict):
|
||||||
query:str
|
query:str
|
||||||
ticker:str
|
ticker:str
|
||||||
company_name:str
|
company_name:str
|
||||||
new:list
|
news:list
|
||||||
financials:dict
|
financials:dict
|
||||||
technicals:dict
|
technicals:dict
|
||||||
competitors:list
|
competitors:list
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,40 +4,34 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from backend.routers.stock_router import router as stock_router
|
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.evalu_router import router as evalu_router
|
||||||
# from backend.routers.assistant_router import router as assistant_router
|
# from backend.routers.assistant_router import router as assistant_router
|
||||||
# from backend.routers.call_router import router as call_router
|
# from backend.routers.call_router import router as call_router
|
||||||
# from backend.repository.seed import seed_customers
|
# from backend.repository.seed import seed_customers
|
||||||
|
|
||||||
# @asynccontextmanager
|
@asynccontextmanager
|
||||||
# async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# print("서버 시작")
|
print("서버 시작")
|
||||||
#
|
|
||||||
# # Base 에 등록된 모든 모델에 테이블 자동 생성
|
# Base 에 등록된 모든 모델에 테이블 자동 생성
|
||||||
# Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
# print("[DB] 테이블 생성 완료 (또는 이미 존재)")
|
print("[DB] 테이블 생성 완료 (또는 이미 존재)")
|
||||||
#
|
|
||||||
# db = SessionLocal()
|
yield
|
||||||
# try:
|
print("서버 종료")
|
||||||
# # 기본 user 데이터 삽입
|
|
||||||
# seed_customers(db)
|
|
||||||
# finally:
|
|
||||||
# db.close()
|
|
||||||
#
|
|
||||||
# yield
|
|
||||||
# print("서버 종료")
|
|
||||||
|
|
||||||
# app = FastAPI()
|
# app = FastAPI()
|
||||||
# app = FastAPI(title="상담 LLM", version="1.0", lifespan=lifespan)
|
# 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
|
#### $주식 AI 분석 FastAPI + LangGraph + yfinance + ChromaDB
|
||||||
|
|
||||||
### 주요 기능
|
### 주요 기능
|
||||||
| 번호 | 기능 | 엔드포인트 |
|
| 번호 | 기능 | 엔드포인트 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|1 | 기업 종합 분석 | 'POST /api/stock/analysis'
|
|1 | 기업 종합 분석 | 'POST /api/stock/analysis'
|
||||||
|
|2 | 투자 의견 조회 | 'GET /api/stock/opinion/{analysis_id}'
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}}
|
||||||
|
"""
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from backend.schemas.stock_schemas import StockAnalyzeReq
|
from backend.schemas.stock_schemas import StockAnalyzeReq
|
||||||
from backend.services.stock_service import StockService
|
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 = APIRouter(prefix="/api/stock", tags=["기헙종합분석"])
|
||||||
|
|
||||||
@router.post("/analyze", summary="기업 종합 분석", description="자연어로 해당기업을 요청하면 뉴스, 재무, 기술적, 경쟁사 분석을 수행합니다.", )
|
@router.post("/analyze", summary="기업 종합 분석", description="자연어로 해당기업을 요청하면 뉴스, 재무, 기술적, 경쟁사 분석을 수행합니다.", )
|
||||||
async def stock_analyze(req:StockAnalyzeReq):
|
async def stock_analyze(req:StockAnalyzeReq, db:Session=Depends(get_db)):
|
||||||
service = StockService()
|
service = get_stock_service()
|
||||||
return await service._extract(req.query)
|
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,
|
||||||
|
}
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
class StockAnalyzeReq(BaseModel):
|
class StockAnalyzeReq(BaseModel):
|
||||||
query: str
|
query: str
|
||||||
|
|
||||||
class TickerInfo(BaseModel):
|
class TickerInfo(BaseModel):
|
||||||
ticker: str
|
ticker: str
|
||||||
company_name: str
|
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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import yahooquery as yf
|
import yfinance as yf
|
||||||
|
# from yahooquery import Ticker
|
||||||
|
|
||||||
async def get_financial_info(ticker: str):
|
async def get_financial_info(ticker: str):
|
||||||
"""
|
"""
|
||||||
@@ -7,30 +8,29 @@ async def get_financial_info(ticker: str):
|
|||||||
company = yf.Ticker(ticker)
|
company = yf.Ticker(ticker)
|
||||||
|
|
||||||
info = company.info
|
info = company.info
|
||||||
income = company.income_stmtf
|
income = company.income_stmt
|
||||||
balance = company.balance_sheet
|
balance = company.balance_sheet
|
||||||
cashflow = company.cash_flow
|
cashflow = company.cash_flow
|
||||||
|
|
||||||
return {
|
return {
|
||||||
# 시가 총액
|
# 시가총액
|
||||||
"market_cap":info.get("marketCap"),
|
"market_cap": info.get("marketCap"),
|
||||||
# 현재 주가
|
# 현재주가
|
||||||
"current_price":info.get("currentPrice"),
|
"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
|
# per
|
||||||
"pe_ratio":info.get("trailingPE"),
|
"pe_ratio": info.get("trailingPE"),
|
||||||
# pbr
|
# pbr
|
||||||
"pb_ratio":info.get("priceToBook")
|
"pb_ratio": info.get("priceToBook"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from langchain_community.utilities import GoogleSerperAPIWrapper
|
from langchain_community.utilities import GoogleSerperAPIWrapper
|
||||||
|
|
||||||
from backend.schemas.news_schemas import NewsItem
|
from backend.schemas.news_schemas import NewsItem
|
||||||
|
from backend.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
async def get_news(company_name: str):
|
async def get_news(company_name: str):
|
||||||
@@ -8,8 +8,9 @@ async def get_news(company_name: str):
|
|||||||
구글 뉴스 검색 후
|
구글 뉴스 검색 후
|
||||||
title, snippet, url, source, date 추출
|
title, snippet, url, source, date 추출
|
||||||
"""
|
"""
|
||||||
search = GoogleSerperAPIWrapper(type="news")
|
search = GoogleSerperAPIWrapper(type="news", serper_api_key=f"{settings.serper_api_key}")
|
||||||
results = await search.results(f"{company_name} stock news")
|
# results = search.aresults(f"{company_name} stock news")
|
||||||
|
results = await search.aresults(f"{company_name} stock news")
|
||||||
news_list = []
|
news_list = []
|
||||||
|
|
||||||
for item in results['news'][:10]:
|
for item in results['news'][:10]:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
from backend.schemas.stock_schemas import TickerInfo
|
from backend.schemas.stock_schemas import TickerInfo
|
||||||
import yahooquery as yq
|
import yahooquery as yq
|
||||||
from fastapi import HTTPException
|
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 = {
|
COMPANY_MAP = {
|
||||||
"NVDA": {
|
"NVDA": {
|
||||||
@@ -115,6 +121,8 @@ class StockService:
|
|||||||
if alias.lower() in query:
|
if alias.lower() in query:
|
||||||
return TickerInfo(ticker = ticker, company_name = info["company_name"])
|
return TickerInfo(ticker = ticker, company_name = info["company_name"])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _extract_company_keyword(self, query:str):
|
def _extract_company_keyword(self, query:str):
|
||||||
"""
|
"""
|
||||||
query : 엔비디아 분석해줘
|
query : 엔비디아 분석해줘
|
||||||
@@ -145,4 +153,89 @@ class StockService:
|
|||||||
return TickerInfo(ticker=result["symbol"], company_name=result["longname"])
|
return TickerInfo(ticker=result["symbol"], company_name=result["longname"])
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import yahooquery as yf
|
import yfinance as yf
|
||||||
from ta.trend import MACD, SMAIndicator
|
from ta.trend import MACD, SMAIndicator
|
||||||
from ta.momentum import RSIIndicator
|
from ta.momentum import RSIIndicator
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ async def get_technical_info(ticker: str):
|
|||||||
# 주가 데이터 다운로드
|
# 주가 데이터 다운로드
|
||||||
df = yf.download(ticker, period="1y", interval="1d")
|
df = yf.download(ticker, period="1y", interval="1d")
|
||||||
# 종가 가져오기
|
# 종가 가져오기
|
||||||
close = df['Close'].squeeze()
|
close = df["Close"].squeeze()
|
||||||
|
|
||||||
macd = MACD(close)
|
macd = MACD(close)
|
||||||
macd_value = float(macd.macd().iloc[-1])
|
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"
|
trend = "bullish" if macd_value > signal_value else "bearish"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
# 현재 주가
|
# 현재주가
|
||||||
"current_price":close.iloc[-1],
|
"current_price": close.iloc[-1],
|
||||||
# 20 일선
|
# 20일선
|
||||||
"sma20":float(SMAIndicator(close, window=20).sma_indicator().iloc[-1]),
|
"sma20": float(SMAIndicator(close, window=20).sma_indicator().iloc[-1]),
|
||||||
# 60 일선
|
# 60일선
|
||||||
"sma60":float(SMAIndicator(close, window=60).sma_indicator().iloc[-1]),
|
"sma60": float(SMAIndicator(close, window=60).sma_indicator().iloc[-1]),
|
||||||
# rsi
|
# 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": macd_value,
|
||||||
# macd_signal
|
# macd_signal
|
||||||
"macd_signal": signal_value,
|
"macd_signal": signal_value,
|
||||||
# trend
|
# trend
|
||||||
"trend": trend,
|
"trend": trend,
|
||||||
}
|
}
|
||||||
|
|
||||||
Binary file not shown.
Reference in New Issue
Block a user