From 315105cebba10db43e0263d1041b29410edf51d9 Mon Sep 17 00:00:00 2001 From: cooney Date: Fri, 19 Jun 2026 18:03:05 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9E=AD=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=ED=95=9C=20=EC=A3=BC=EC=8B=9D=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=8F=84=EC=B6=9C=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20-=20=ED=88=AC=EC=9E=90=20=EC=9D=98=EA=B2=AC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=E3=84=B4=20=EC=A0=84=EB=B0=98=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EC=A3=BC=EC=8B=9D=20=ED=8C=90=EB=8B=A8=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80(ex.=20=EC=B1=84=EB=AC=B4,=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=20=EB=93=B1=EB=93=B1)=EC=9D=84=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=8C=90=EB=8B=A8=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EB=8F=84=EC=B6=9C=20-=20=ED=88=AC=EC=9E=90=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20ex)=20{=20=20=20"tickers":=20[=20=20=20=20=20"NVDA"?= =?UTF-8?q?,=20"GOOGL",=20"AAPL"=20=20=20],=20=20=20"risk=5Ftype":=20"aggr?= =?UTF-8?q?essive"=20}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- project/STOCK_APP/.idea/STOCK_APP.iml | 5 +- project/STOCK_APP/backend/config/settings.py | 1 + project/STOCK_APP/backend/graph/nodes.py | 62 +++++++- project/STOCK_APP/backend/graph/state.py | 4 +- .../STOCK_APP/backend/graph/stock_graph.py | 30 ++++ project/STOCK_APP/backend/main.py | 34 ++--- .../STOCK_APP/backend/prompts/all_prompt.py | 43 ++++++ .../STOCK_APP/backend/repository/db_init.py | 23 +++ .../STOCK_APP/backend/repository/models.py | 28 ++++ .../STOCK_APP/backend/routers/stock_router.py | 28 +++- .../backend/schemas/stock_schemas.py | 16 +- .../backend/services/competitor_service.py | 41 ++++++ .../backend/services/financial_service.py | 32 ++-- .../backend/services/news_service.py | 7 +- .../backend/services/recommend_service.py | 71 +++++++++ .../backend/services/score_service.py | 137 ++++++++++++++++++ .../backend/services/stock_service.py | 95 +++++++++++- .../backend/services/technical_service.py | 25 ++-- project/STOCK_APP/db/stock.db | Bin 0 -> 81920 bytes 19 files changed, 614 insertions(+), 68 deletions(-) create mode 100644 project/STOCK_APP/backend/graph/stock_graph.py create mode 100644 project/STOCK_APP/backend/prompts/all_prompt.py create mode 100644 project/STOCK_APP/backend/repository/db_init.py create mode 100644 project/STOCK_APP/backend/repository/models.py create mode 100644 project/STOCK_APP/backend/services/competitor_service.py create mode 100644 project/STOCK_APP/backend/services/recommend_service.py create mode 100644 project/STOCK_APP/backend/services/score_service.py create mode 100644 project/STOCK_APP/db/stock.db 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 0000000000000000000000000000000000000000..b91085cb4f5bba67644c0b1fb1216e96892bc832 GIT binary patch literal 81920 zcmeIb>vLS!ktaw>)7>_A-JaQ<4##|&8{F15fMx;pelTKp79uH`9`Vs6WsQ3{!MODR zQ5H~zt||y(x4VOsKm{ezVag^%NCs)Bo3x}3dK;ohnT)1mKJ0#-|6o7v&P1>Z5%X!j z%xvubewpW-du}}-(4O7t=qXwTP<8J;kIX!I^5o55=IK9rrQWFpbIsOb*aRbI?svYGO6|S;d!4Yn zP^&lIsI@zbwMOS$bE)2_HyeZBJb&oak=Zjxf-|!(zH%fOydF&K`?`7fT)i3`J$~lM z%STQH$4{IIj=%cKD+hvoUk@AM!i9FdJ?gr7JoxVHsYBnMjX&E8JN3qSdubw_oMemb zO0!iP+E=Aj3p=&yIh_6uW7ubo9y<~n%gIwmkIkO?UhvP4d@q=Y#x1SBdi>}gy?P{= z@Ww0OzjWf%k)tmk=QFWu!KouJ9XWO6_@N`GgLbD`dHtMyGTz3%$wSZo_P1V3?cGyv zRBLax|6~DY)N|o-rzxM$4HETSYVbRrRe6Tp^VUDv`#ay7oZS2NqA)SubNthDG3Ldu zk#Tmsqd3#4S6;8REcIiAtTY#w!p4Pjjc~Cx^aj`V_uI`z@SW2qj>n#F)s~vA&d?{H ziHEM6dUbN&A2_9d{LTNUfBgNK-*_ft3XCZ*rofm2V+xEZFs8tm0%Hn{ zDKMtMm;z%8{8vta|DNIe|J~kw`1$-d|NWkC{-^ddeg1j=eD1mDpa1&TF{R&roq&;7mx9B!a%ca6 zV1K(&Us|el_8$!P&mIk`VJ8eKfV|XN?I3JagZZ#kT?qjvX)iA=E!6S#fncHjdM!Bq zM!i}O4+Ol{38hs48LDVC=rn`5dLwL9aAm$0pxJt3t`)XBt>sE*8TXf$>kHN9a%bAK zqWF+tuC}l+Jw44)Ew>gpqWMl|seN!}W@Tk%S}`BZR(l%YikU`jr9IQBRpuMbh35GS zGhsc!u_w&f6K=SPh72^pkxqV6|${a3ceY&de{j+L%)iwgZKk zw87l-=Ye&?tz?)1uLKxn9W1T4E6eRR;7@IGwhbl)bqo(*cAATc#jsi%&NKnkbXIV$ z<5(hW0W`BvYtJl(t=BQmnJ6z`Fpq?t1eAzUJ|T-JfuXk{b!1GN%`aX^ zv`FlP%nY}$SYjtuEV27EO*%VM2wuaCz&FuvPP?Q=N|@-4;+grdV>QU}CUh(4coN9C z&H?>4I8E(jHCZrQhFZ0XL}aOt?X!`ZX|{kd1Aq|n<4eILbUrjgoidH(|3EOP zAUKiEnCXB7PzcQqIgwc6vDv=eqmNQ9iVUH!tS`074WUpmD$V{5zUQ~qB;8)A?++R7&IC%5tkkV5mq4uBTJU>3j(nS`fF!GB=2H zDp$%Blk#smiAzl^X2EL$(nvz!o>V58DHgnY8nuphHJwc6{i~g3CtNrew%gF0>>-&i zCNtjCdYwBR+fF63dMzp4&w&}3O?&>_+(L5&H1;lm!~aw1QYxRtg~hN^#g#;A8hw@0 zrA)q%$tH6JcE@Yy+V%4Q*zy*TA(ty;QiV(*gANKQ>9zwtvW5L+XtX*uSx$~SU=2Gx ztPNcHm)!LU_2g}{#|~4B%pNHKPx-p~1`VESaI9WwHL=aV0i_LFi90jzk4aOpkWXiF z#cZjNF5;R;_{Q?W0=_X)bnbksxzd?O`{w(lR&%c2Ifus1*BkgcIi1R63Rz>A*dYRC zd}!DhkHV~52o6Q-eqdlE9vUnr(`gd-*B_V=yOiu7*_qSFh7IgYSYHVn!Rgv!z0zz{ zVI?+O!LhKhJQvbnq{Vtj0ky#^^+j%%10zpoO8EjtpURi=Tx`Go&?#fNWUh~%f%coRC77DmM&%S zprC4cLG10Q)GEFqLwqTEgGOIW$?#6`@@5J6S*j#9U)3Uql9QHlRgx~1l9r@HGty#`b3-FCDz-v` z;Au1WN*W(3r3xO`>0)C{%DqKiNS8sva=OAN(zWa~G7eQt;>a4g?z7I@a{`P^RR#=fE!CSj<{MPwaEFYDQ-%9 zrc5b`H)Plr);I)yz|TsNBpJfIiVWAth)+H<0|*Zu1iLu4u*$iW!!V*;xk7$b3dKGG z;X9>&R+*8e*nj~P#k_Ed^AO&aGX>J5K#8bgBgNk-iXZ>x>_ZHm*NZBD$vJ!nV!6Ak zsjOV5gp@;hGF>L4W1>;W@hh@b+6PCmD+k0JLMQJw-1c8WYz=Flwo2M(7@)vA>a4qVhh z0q6hUk@-}x`QqzBT7N2yRVMj#rBs3d?n%Vn zBwS=g1&74NQ|5v$lS+zTLH6yC9Fav-_#pdJbyFyaCWCkeXZQ0l64~4mR`Z}4*@gR6 z{Z#W165OXM#jajySA`pzhjZ#*$yB_FM1sH4s((F>qq#JtvXTzjQx(<-Kh#9MKquov zt^oE>Gbb$b?VN=emApu0waA%8lw)H~cBZBd;<(0BDEm__Tc%FOG=&CegHMZgs^n^- zQmmrG_vC>p$ru}C8xgXAQljCj8PV;PH28_Ra7sgl6zKx#v`Ws()-X(r3R(Co%iJLCq6BH&nCJPqxVGr&Z!``W>LYBI9sB)BjM=w=#c_nH}s8b+&afN*v zBI?eN5*>_1jn0$;c#`*K)5Xza7nM{@<;WZ>V_4$khtVNedH7%!e+?2oHI+)zWfTc8 z7q0mPbpko2V}J)gI26PWFl?)qkG(q?UBP3!xi4D zu)-Zz!~^4Ww_t?22Kg}lj+B0%5JrNGyNqbb;L52|nhlGMkSxI-vR#_a?A=rWWtefi z8QLBnRIXLO!L)Jor^l;r#MM0Qk#aFDJeYm$^n|+M;0P(h!fc6M4wFW7f*rcI7-~js zk?+O|kX`C45{GrnLR^jh^UH>u#yyBq|2qLDK{gu01Jy#&d3O$0`MU{2Se`Q zNSj6#8#a3CnSyA|N`cD=QY0I=Y&Fp~OkS$#B2)9sHrX>`1AsNiS%qvZnHQ78s8b`u z*&IjcuL|80U^R%Gl#8+$=v_ubZrS2PB=ItAS(d-Kz83pT){Up8*n!!}MY?lpekiH( zG$d8!sk&EvV0VL5n*0chxoJ=9XN=Y_kUWN2%Ft?3EK*}dK#fF-o`m!a8Y~Xg6Zn>- z5(~l@J8G__-~qseL30hMo~$K_Buvt7UkhA7qFa(LOijVwkBmXw?x~-_XR$b}Eht;B z5u*N~JCQwk}F+VyA0lN|1LYnP9DItL&7Hxk|< zJ0iv(X<2;fOa!G{R9!6vr-h zt5HwBA>prF9K+b6Iew6+PLA!h$6P8Nm)Phr4q|w~I}xt4aw3sRvM%W ztuVqIX37kU`2h&_vL zL=PF`0qPZbM7><&dt6u zL03~==Bh1-M`K{d2f&OM)x$@!RAtmSuTT_>wu5kTrOO5$+t0`m@ii2wOK8~noECA5 za}+n&U05!N@H&eQ)6@f*kbzU-{mN|F?r}Pk6OX7yoEv0!;?c@Vwzn9{S#hk06v#qx zGm~M85dim-T+%Ta>rhQz(GbS)<;KOKWt0Q%fRi%|W@iJE=KK%PXEjajU8&)aB_Daf zQiGEp_28gtCvtOOdm2!%lk)iB&M5nuG$pHzJ}&9CEa%p04v%ppIGo{M{Fi>GKg|{S z5c^x_0WWWWmS%@~b#Z|nTr=n&1PK}Hj$V>LEZ-!Ys)I)^qo@k9!@zR~-a`kHd_q!s zbMd!2Z+{TbMH}^;n=ZYez3Wz@_VRG5on6NH|7#-b>TVe20xM@}?Ws4gQi6NQj0E0W z{E43&k2qZ2K>jeBpU&w>cIiNplZx?Z(QC;L%hUkStq0t}RveGA{>Ou*&dW|Z$wkKHQ+{gDYRuBb^j5P%Ql}Z;#_j9?NLj5pr)_#v&&&LXvxLxb+;$dHQ$o}=>;&K5c(bQZfks=;C*Lt%w-YR>vG z2Cwx@`Lr~y4Coh*Qr(ylv2{%NX&i}L)grb47}MeRDj$E3a>18kTR|48vSOUo zCEK)b(MB{hgEX6QWPos%${rY91KI@#vI%*aYpqKh%O>PlHrTN?V-8`uMepWxMVfnF z3g6T&*kwgGKO88eCDa&49KwL+{$>@W*dV$s4QDMLb?$xbQno>{d8ryzPN`4m`%#LM z{O;r;f2$$Hw^_K)_@m^*4oX-kWwwYEI)$Bt_T#p)Y$WDLVU8z`spv^w#OS!vvLQj1=G>9u3?v zo^w+IrTKOtniXl*w+8SHUtsW&5*>?q&SaRYY`}v~=5wdS?f$!w$QYt5UD z7`;W0NYB_+@muy}ed}XRU!#<8Boy{RPR&=tI%(qu7mGyhbO$_+K`dLm$l{wWs!;wK z?!FR5X_yHOD7w$i3nxQEWVY!@vE#gWW{^(p(Le^LJjIDlE{UHBCpEcbP9rjWyoVDW zSwEThF@~QS*pQwzU|l%%6+i3X9-L#S;xfrfv{Y0Z-UtK(4u3piq!kk<1>K4gDmYcZrVF9mA?`bI7R#4%0<@~1earOK zH^Vk~-sg=@5O8?s37_Xr-MYn^gmpugzHxWJ=fV%p^@0zPFyMmt0X;IAMT@>knW?FB z^Ua0oIrZY1_onHj^cd~EQ&Wgx(|gp!pw8hvQBPzvrA?MoOzg@P5`*u}IZk~6gX4&Z ztw^?z&-jt}W`~O&cj6npM{H*7bn?+2Zz0$pUN8j;xFR#j2?pzFdS2vz+UFw{p%;A6>++ z?)_`tw9y9%rXyx=x9?)QTfx&`Zgl_pQ;xU$#e?4M%XWyI)OF15%6jkC!`@G?vp0~r z`@^l?tq0qyTfNmM_<10BdjDGQ_9{C1QP8`217B@lz6LsUKYg&2yi1Nda-x&7b*rM5?79yos6(cNCSjn`$zYBTc2&OJ>X!+sBY*tWKz>+n2Z3`l;J{=UVUTEu$%Y?%?HP@9j&y_a4iULV5f7YWE(d@a4ts z)dw;Nh>lZ=5cA%bYwQaOaC>cocCUpAdUMoc?DzX##oz69;YQIlKmU-!t) zRoTT}@WCVY#W#boHAY(PVugvqG$Kq|lae{c%hP5V%Biz= z`yOWuLTtZ{35g`S^=B00>gOC@?+P>yntjM&LE`XA&ezHECJxI0UKw{M_${(2uijLl zTak75Oo}q!S|dGOe^xM@Ek68}T14-+FM+t&ojCcYpKn1;Z$TbxXB0^cn(OlZXZB0& z3`6wTuf!mqKi-l#W4pS2z4z7@rokv}0S)mc<0L_alnmP71 z01HLyd~&;c^*c7lO{rnyY0DXY5alX21i^pYfqrmka1lw1) zdVhNdZeOSyY!LVZJ1RO=b8jMAHhi1mmOj-3KEh7;2;IH+xcA8#KBK+Zs|o+DgkG?( zGlspiz+iUs&VnP-7Z#DxY>a?3q(RrK+6-I)w;y5+u5EvKL&qnsX#DBFi!d#k7ZF_f zR0vjQVyfgZ?L6PA?*8}=4E^3G*G;>FZx*y!_gZ17)w{U?bp@4o8Kysav2P7{Xi$Cy zPaj--`o*g0WW-wn)02Hgu>B4!vP~?!yRov?%AiR!jpc)QvPW^#66!hSSeI$53QtzYq- z^L*BqPg-8kh1=`ouw5V{2EcX9d%636c?C9n@9G_BU8|VQS`k0G5p1W=Wm}bFpxrpE zrso>x=HG8=u&*0i-MctrjIuOfs-QKat#v0;^V(>67?dC4q^T}$os*hxBm&G5*b2Ab zg|g$>0s}P4j|viN^6A!fSWnURLmG?K0ILT!WZ+FrC$K@~p~7O%P&{%$1CdMJ%}?2- zKCA0VM=YBPH=#|K0Uye~;*-W(q8Qhg8_-!BV1Tj}S{-)(WMm5H_N7DD=0$U<4mC=i z1ThjBd4{?1L~dc%AikzW0c|$Scz_hBt!Ot$@%8rp5{4+O()FkJVf%qz{Om2KYTXCB}G z@^bgDKQmXhfAlV<*1i6jJrvUi@w0od-^nYt(d%yEIZrtrY+PztIXjI?uY2`V?K_eW z+23xz-}?xsE$9?I{dm3tJz1H9u_|1$DA=doK`0@#Xb#RW$go@8ypBV;8WgdWqZW|d zVAl>F#kM4h5#OtY!Y{sfNG$+iy2$NO&#+(?sqx-jin?~L(;3w{cU&~XO%{y{P3U=4 z3j^QqumCT^Ut{9B@y51YIjU}6S4p;eJ5Dm!wy#|irc#I8az+Q6c^|G15k-+jjQMYiDt9ht1=pTImF}waF6zPh4VvZ~2k0u*g&}|U z{wBOsv{tYgbFknX$cxj}+wX(5_=8`r-hm3iL3kDPp9U53iDW*JDg~+h!Cc{BIz64r z=F-Uwj{o}>YQf9ESvBf_c_)D4tp>-N;UZAUfbbGJ+-NkH0c2|j^MHR>YfC_E6S5C@ z@*&_bUp{#zSPWV9@WfKBF?)0xaB>31+nt~}Co#(9umga6Fb4z|1Qd8hz$66%dcN7| z08$E+d2MMF#=ji4TELkL>eoTVftzdq(#!uAmgd894e(Ebae?QRF(>kKxv*5qC5g{! zJ9IyA-e~I&+WgkSZBrQ^^cjcqo3?|AmrtB{c{2F!Ng&VwJFJF)*tWt-Jva|sDzVE* zJrKNn`c=)3P^|;B4+LSb+yKyBM&Kn>ux+9-CQ|^Czcv@lRw^K|6z)7RHwTy?v2L-R z94vp4kYVY!P5K^M2$7=TQ1EiIc^)(td~fh7JC^3%SO}^IZkLpoVU0%H3Lyb>3>(vc zp3byumBd_q0f4*q49PG9HZCTz5#fPc1?(170Tdrt;41D+0HX&YbS@;|be{{ewOS>g zuTIZ*78l|S(czw9XEC0PhCsj6WMYKi^b$gG|HX7LYsvW?q#gi(9w_-EVGC7R&$ol} zg`kvuLCA2pxs2+qD7Ff+99n2D18W~vUPqP)3i0n}5f=MmePIEx=RR&}Hx3JZ`sAU1 zG07YVjJ!9cKC4Zv9Id%3uyXLGxx5rqurhEP?p>+XUOx~p@qj!Ac<>v*yC)1r*^*2VKqjv#{2fIM9M4JSAGveC!K`+4Gfo(?w zoX{D`x5Mm2#%93)o)Qk1BWT#2%hi@qWy=SXiMH)6@kTph-wNqtc;kj?bRe+$;9{Z% z`T!!o(#NDTxFI15SLDMh_>|+Pkh{)G)9x#a++zq2u2)Qgk~(<7+yL06 z0NuxepKq?+fuCte6%LPs$^Ft^OG)G}7l9d0$3+Cg3vLx@%yo%w^L^8p}P2o#4@e;e-x zWu7YT!k7Uz*!J`u$q<7LK76AVRMEo{XqTUcTNo=NM0y7FasV3#bUZQ^a0j41IOyLzs_A(j&KL%%9A|B+2A$l7rby_tT4h^y|Go627*pA;mKW*{^Oa^3-%Jae}-hgJ#nx_PAzFHOp8OuV6Po;tY_lgWM*j z>1u#<7BJ5SS``gvZLx%Biy1?%Q^$IPgdout8pV*+f;=#sBg~1?rn&o{%Syv$lTTH{ zEP~=PL)MUL_CYIcgsS>pb2&JK4f;5AuV$g47IFM}jh0YI4R+WX%HONk7ODr31FXZd zL#qZ=fm}ynr_!I_>I}u0#O;3OJV3jfI9f)ZN-vO3JS?`tzVo+D|G^lD$a8H^^4 zIH2h1Sh^Wxaw+*22hzcOJ}*ND5Vc9<%IVKHQ<~1CGNlxgh3q5fNkUXM zWa~E^7&1qtv%hBQ7N<%z$Di(Vh`nNnrfxm_py(mVu%U5@iqcjv1#e(rn`X%oOwk+oD4-{)R~6#nxM+00QeC}$AD1< zVi@Edvaz@*!`H$Ki%q=Jfd*z&A%Z&@n^xL6uRwpAhF?G73N$cu5_%k#bH$QXQ-~OR zNHhl>E`ItMWeiYBbK4MGitAB)B0osd8;!3mgogB-vjQ>)riLp%T*1QfCI64X1xOUX z#KIaQ1=K}=EW0**-z}51B?Y)5>JM*?(UPRoAlwHZhRcWCrWqxa>rtfP#0hNHI5Z-a zSv(ZoHmpIq1HsQ8YQy3iqFgHsaD4X68;big7*5TJg4lg|d8D3Fm=q)&aSzN&M5a`- z5(6$tguuC&6O}vz%0)e-c*I6X8|*7s+m8igi!P*;U4f>09B}s{vB`v<1~^HJCI#t6 zWGJB>#FnD04~##TuOiURFz8H0;PAK^q3$*g7id0?Q1WjK2%-z*9)Su*pEh;P$n;GT zno9rzsSZaTNhmr^!QdE&K(t&!BNEKPUGTR?^bZ$8>7SWqG-_2t=VfM7Q}AzbH4LJ= z`Q>0@C55u8WGY<3Q8}5~+RU zZ0-w!`&(e2dK$-o{Tx_Gf)vr+1VsZFK)S)ELIb#Af(LVc<06wIO{Hc4tQ`{OeRcXGR_<|FbR7y$c8IOJun8ufT z>M0T+$`wMPaXOUj6c`YXj`%RB1i5|p1EWB-6i!k@??>x`l^J}B{knucQ&WJXm5`Q% zjl`6!rb>wN@iZ)w*_OP zP)&?nZABS{MMH_T*e@+c+y}Fm@a&FGhO^iot&ujXXBFEuI?IV_3mGcz#J64ydU!GqNWA)6FkokRO-=Qu@gNUnIjO%P zeluiZRO@7mq`rX0hg}^{%9I?4=1`QKhK1t%|KA4U*pO8qAk=t* z4?UD2S)5qzf-5x&0~^{5btuy(=~p(3hmJm7ku68sH0GMoMO;5_AdDNKlp>u+0Vhu{ zZj=dn^H`qzN|m)}9}95l4;y;wsOh! z5sd@SI?Nl#qGD6T*_Bf{FgVdU&i|V&QG78FQ!~)SYLT0i9hMmrD6mMkN4CENo z9hobdt3;Am#4M7Lrzt)EK&$5_Mx39}APmjekxLHw21PB&*VK|x6V*dmI-!PC7~??b zVvi#J)*ab-xY-;5Gem?d^)#!i=D#vIZcN6l{sW7*qwGEvgh6Vd>(LGbKemcD>X}vz z_L8Lk^Y?w#uCeq_dK%ZvO?JC9$2FeGE!0|!&uXqJHhSzhA`n7 zd1W!^QbmleU7l!Shud-`epsRu|2jupr^WRMs|E`W2QpoyDJ%{WSktFEg^jO_5Tyj# zE4h#dG_X~u=*G_wVcnL_hr;25=A*nAx)SMj!2n6Q{_N6Dwz2U+N8%0$Ek`X=h-rO2 zhv95YX-2jVv?=GXxMdVOjtxnM5oNw+FLD}z7YBbUTs=NpB*xaeFag~h&H6G1s+R>@ zbH~`jVpjL%T?xp^BP5S+&8yJE&m#^HvC%mUkOT%j?)|D$m`RmTrJ*o%Yl>C?TN~KB zwf`vL0>-EFj^`rw7u62xV;3ne`qcPuB2XZd;`4_j#xuSN$)_C4{AHX}5&mBeV*Yts zJ-gOUF!?y#xsl@GH*WqG-LBgRpJby}JMAnPypMy4{4naf9HCap9@(-YUUEdfk7k7R z2D>h@46z>>(K2olNStyWtyRUw)N|&5vm(+K5m2AcXqsfh4|P45=wG!dqw#XM@k5^* z%vV~HdL+_?FG+OKm`jbJ4aXS74}G}~_>Cm^KvrlfO#%E@(J=0HM^Ddq!nc|;LK8Pc zq^3UX%Lak7lDux^gshXAq7d7=syR{9QQinADnsRK5oAO~Y zmePS+lmhJlA^~)R5IPSd$223GcLbpFw|9CUUmL*u#DL2pUGe-RtDggZMg5AGVU%eA zyPs|W>?GMi4D5}to7hv`t%;z0Kx6~q^~&4GR*^p=52b-%dC>@h&1L2`)0CZuTuf(zu}Qi8|< zG6&SNjLt63Rb*HZN>Jz|iv%EC(Wrrmi-x$d*}eKxcF5*#-UBGJche!jNl9kW0lT)4 z?m%QtlFeDZ;k)t3zIAtXQqqLYlWaIe)Dp!&WSWe%(*SqsWi@-B7Cu|ZpmA<+lqD$qTY96D) z>k412q0lYd7+3jn!oR9}fW5(_2yky8+>)@g1N@EWdaAjNPhDP?2$Hwkmkb6*Yfxhj zz)1W(-1r7!w~KcPl!MP#xzbh3lh$a|LpUlDQ3uSXIegq3(OQJjOX$FN0X~I*IFnAu z6InV~Qt=EA5J$yzZ@<0D|CqAE&z~Ve2!%X6Ba#uvU1d;+G)*jn0;Z%cc&o~y^a&~x zYINGG6Y+;N69M)tn?k`nBQEt(&mc7dv5=&u$*Eu*=UzGm7y>tcAqf;HpI6prs8CJv z1ZGXZDhEmzYMZJDjJFN$&IvBNhafC_tI(fOI)#ss%jq6H-T&^L_x`U(Z;6IwHU-zd zO|Q`B)>d5|=v9y-Qh9FBT&$w+0IP(cIdrVy;pkilq?2=R!1-(a(O3smFA%@Ak8z9o zpnZ+vtzPCu=yx{*G>Oy{^ z!ls*TtnY!Hk;9krDpTfus>8BF*46J6AV>%qZfbGXv4GO0QN)=ieo0mbuII=wjWVK*ody)5v(;b72U*suUofCp%Sy% zfPdzKFvj2qum`V<+~5060x~DHvngcE9;nvty+<<22S}!N*Q`FF<>f~(VWTx>w2jR% zK+{RmQ?auc8$}BGGHN(3scR8DVV@=y1gQYKPm(;?V}T)w!{Y~<0S^@G-d}~JGkKik zI{1kiKwsYAI(B*Rkb{E6?Oumw8etT{)n~R*h$J#IaUSB{5W zwu5xYdbFg#eTcYA*rq)07q{UF3GDHaOX@3$%G3-|ejv?B04tCN80=*OZ0Bw(xun#x z5lc_a6`I&!xtNePsT|Xk4I>#^(*?(aMQkB(JsU8%5yO}spU_QUlGNm?MeYR{|Ac~n zbR#ketz96vbWt*x+GVl%JRgSbRNIs^wDYjFfGS5c;p3{tiR)9XP)< z67Qu6hYS*rrKt@f?hJbd3drUxm}Sp_*E8F1M}Yj(XHJ~@-ofDDL2dBh!Pypy!viP> z^d2Tiu%cdRHQ5JHbjv^{#N@0ggRPv{^OK z!L`G>;xjD;PipLkCQxAOcseEP7gtJ#ZZwkf@en(TnQ;y zu!F(rcJ|~elYJb}h9~3_ zfL$zJ9x8?vR7QS;Mi>}O6Mj?Ps<;90?3WI`gv1jpd(6xfo!~Dr=@%44#scM9#atko zne3bLz+w$`#sSauf<8jV z?E<8rOD2RGV8DxBEoTAb0dNe=BqX=;dJqm@0L16DI#3%cngax7x>LX$*##h3KRD2K z0*GwQl7Y+x$_*^!C12@GhwY^|<3vAo=FD+49V2&oktZSwK>jbsp@x}?k(F0i5bO~X*1W$Tc*Hj+=^N^?z4c&*%Awf-Z zv4+~{sSMJY>n+2ut`IcSz)-Af25{D_-$M-RS}NvI*F6Hl4q#mYL(Tg)cv#m=ej0$$ zd_UGTo0>+6_;hL)SXU(odM_37xqKS6&IxBtCo$;^Q-hX(C`qS)70Q67dQq^h>0){s zkxJ=Y5_m*Z=8mwgDF4QX0L3ho(#c$!Kum*mO(C5WO-Ep1Gg;tjOKdR?bt zU@>;bQlWqw{tM$=|MO#91$IdpH&)!aMkrFY-<~JsbtG@lo@OE5&1}&`(P05?p12PBeH2B_1(49fXF%AX0IE1_y+&jf_Z4 zH-fZ5IRAgs*nncAYB6yDc+nhqs*PfL8ggf%EKz$J7o_+H>zCs1yuhBcwiXu>>7uci zTGUUPvgiOWT12D9O!QWS|HjiAE3aZ@C@E#NY|!uUP#DhGA-&LwqDi6Q0$$fi-c z0HATa5}3-sN;;s(WGy%|S4#!RPLD}a*kC;|h8gQ8AOz8b=U`^|Qb>VtVp;#L&J34l znlzH|AcN`73VPNk7L5o}7OM0D9DrU-Oq5P23O&Tw9I#L;^7c)riu4YgfM3mzHP`SQ zP?TnNQq8bjAa;Wxp(KD7;FD^I>I#2S$Ov$0EyV#x3Q7*@ui8-&wm_|A-7OWgLe3(p&^AWOjv-4;hb z+jwSlW5n&gg`}Ld%2n|%HYCd`YQsMRR+aE9Ewvc}zaGuiAs5NOXyf7y-aa8{5VwIi z$X1*I2wDR<4Qw+CBQ=BV$E9VaSzhm}H#8ZSU+ORlASS^zQq5v24A_C|638ROd|DdW z4JZKg2nuv)w|v9Mnqx-Q#^gzt47(O7@FAv*3Xr`+>z;cX8YzmD0T{&~`J>2Xj#4UV z^1Z26Q77z%8{`nM4T4i;1o7R;A342cm|+ZB4u=_fF$>dd-4ZisKsrl}OU7r#F&sx5o#jO~ z;NmN}9+)nf2d^TT??Le>W_UTz7y(FXIwcY;tVT^s+)5N2k6dUlCXoi(g2L?rwehy3 zxJq+E!4b(UxDEP4#`g|D!I65{IGw+&$G%8>Ff>~arm(NDevvG))-Dn$gaY9kodtpVFSoVOsp_nqSCP2h(i4!#3DIa z;#VMTu_;NeOn~4}*huD+p3VbtI8@cLk)dqZfz%pN;YQ?}6jrkdw8Yqr(UR~7$t^q{ z{~c?`gvR%AQTP3kW5!&dS@F|^f;)y|tOn#P1)BBPI1I!itiV1pgM*QqVC)p5dOBu) z=*q64=+}9-inj+3u+Foc%b4&mffDYprE?C<9uCIuxu}}V1?I33btkj2V7F1kJKNX=yeg7k1!`z9YQv_I zJrX_3l2<=~9*3;}TkDVG)(X!^vWE|*zP22=su>=gjkM*ZdbNR98c&4>@Q5sJwm1Z^ zql??1t!1qk<*h5zlx?m6mCX@}ockwfAvojz$;Y_ih#~5*VmKR3K4Ld;F@1&9mWnf&^=yiQv&J z#-imZ7sY5vNa7r2MPP0`nBdgTdmNleH4?sx6w5Sk&?9MBfYM5{!`R~>FTR442llN= zN>DCKJ!Tvx@pK%1#vz~qfi3Zn%g!`Q3@Ft%(f)mDxZi_wmBWl|IbOwM4u_~~>TLTr z#e@CVcfnw^RLCta5o{#z-T2jS(>^n4SmfEY_%A3GDF#P$iVd3*JK9 z5-KtxJon)SW2zZ2r(gyCLZm&biZFWTt#z+>s>XI{zp4Dte!ZAh-bcUf(EB9#5S2YH zum0P=xnZ2M8Z(AaBgFT3?U=xI@xTrSt!pgR48uUBY#-w8#KFo4E;)>%Co~o#1;Q!U z)>u4}&tf3`FsSt1S|GR(3;dHVkqj*?)5%Gf!GHZ z!Rdg3n5Y?c6IFpVBpWpy1q5RtT?||UfPqukS{GD`Uw?Zp_MI66) z3K+1*BBEbhG*zdzuOR^LUw=$rBE^c1M8X(Kf}OedL<$XkY64j`E{tJkKS9-80z0mg z2n?%Rzr@%#7wU)rJ4B#KMb@a}?aP2K(a8Q8-xKI$ex3;-`;~E1v<#AhR*_I#!sjgkbfQd-L zUmi&?Xx5EVs866W!jTCbx}OMCgdR6@!}d?X5>rJ{qD7-wi)8tgTeTLKiMqt|eHA<{ zX?z@sA0VyXF%p6Jp1-_|l`cxaf>;n3Z%|eezR9R`b$j&$;^56O7Wm1@G0t7_2X$nOR5O$DtnT?jccaRVnA?Q2<2*0;T;3PdVdTA0JP6njbJ}QWECM&gS7W$jV*V{5w0@MK5g>4e3RZxn zP$k96bHHGvkRTfmZ5xs@$)e8zMl=Ps5t$8Q*`n4OBdxk)-)I3W#g#NR!{QxX@= z_+Dk4B0^WY4uPg(oW4ibQ67+Z%kh27$dITdiN;-BN0sm%ik9PwL?wGzuOK5X3=w}I zC0M3P~5 zZP>gHTLkM-b^$Y4<1mm3>duWFU&HOGS*74P!Gx=*e~b#-vc^W^Fs$Gibcc)DcPd5t z8C05pK>6C##-q5{TX&+I8Ee*_}`xQp=*5 zeK!(!uM3M}@Ef(}01y_$XR&&^%vgA*H7?C#w2(GUO+4A?RW*6Q4D9^aN6r2i+lEE6 zZ`lnKGh=ObZtnu>K$iq$#K*jEIA+1J#e}H40_-EC2qJj&YjI~if@{;hBO6%y-Q0dDoNIagUJoD)h z0LcJYI}k0o)>u~avj=jpGxrD-=oeJhDA5kvM2!Ahzmk?<3(#C7xb=2S5~3=_zLm)B z-_>YTsu~-!`{-Kt_D1(7Fr6bqQeybIO6)-kR7LGftKy^Ti(aE0i_yZIjUA_=V`Kx2VMqL2747Etx zzzl?_d({MZ3|In5RxNN84-?`hr$__Tz(R%56cY7Ifzb;nL#MbWTquWC0I&Kh^)-R3 zXaz*?DOjOB4CprqHPgW5_KbAfo-vT8_Dm|lrfh{nR0mzYkU;D_g5yi+)U&|NM%M*Z z>Iy)s1hCCfAkGlb4|G^70E*^2O;jre0!Fjvd z{n8KXd-v`6y?=7)-~12%W^wlTcMs$LM-LyJjY$vQqw%GUyhbkPQhb}D@ix1WB(^y$ zN?iNll_An2<4XbBivtQ>dR$=+LKgX9tBKZJA;`9W! zc1+4jzk-D760Z7ktGNR7(uw5`QI&EV@ll+s$J!xN7f8&oI?}P3D)2O zGA<r zo`ld!y}C8*j{if8r4JGE4tJYGrBJC9=F)RK6M zuEvtxL`{;BCmO&}L$X<~R;DdwpZX?v27ov49@V~7MNCkS27j1AFuo}mYqx4atxTm3jG2xJcS6rx{uL00L8vD4@ z5-ZNcXuVjkpxnG!RVyF`{v;`6ziean&#=5kV^R%)UIDc*!54wseX)r$_kfBbTg=hm zaEQ$7hY)XwX`@E)GGNMeQs7ir!3OwSJpb?g`ph2uH~u%Kz?cGK3XCZ*rofm2V+xEZ zFi3&z|M_{i3%+&f?#mCBXPL1^BF_4}1s2rCpg67iu%oYu&CvQCz>zbf3@;3E5bPNF z6h9AZ773exC(txI%=W?DE9jxoxxk}TwYglTjY$6jPCWQJpkoOpJx+ZWf+hGe09 zXtsHa=EFDWWs&3*FrZgh*}dJV)!?ZK;Hjz4%^`mh^2aP{N}AzD_3r%=863p~yDA2B zs0nu@0SrFHyC>3yL)!xlBmHSCZ~&e96kOH(e|r~+m~h{ znxa4+{dgW(cYi!dNyeNZq>%OO#5U;VI0gYfGIP zlc69r7fzj$)+9*fRJtkoaBHOcO8~CeSiYK}}!7h&ddo6W_yX#i?!q?qBhu zz*Q5rUdO@vV(z3&9# zYYG-6JyChYsk;sRv2cOs=jnFG`l6THGm?z15hCl_pGc;l8R5YUwWa>4`GaKSCYwzz zjU+?01QlBXzP*JD%szziB-GQLKzgE1eI{I75&}7A-)R|v@6F(Ko20HdGF_CW39=c7 zm>(#Xa%i?MN!{o)UDAXh7S#(AzBPh53~&w+^#Ss*RU&_Aa9&bA5n~96*F1PtN#dn> zo8$t$$k^V#A?N>p@aKE*-}v8{0%Hn{De(Uv1#a%y`?Wn^|HVK1y}h&h_5mWT@n8r! zN8mZ)dx`&G1TboW=#{6xl$f(%pT0a}Tt7UVE?;qP^$F@pXyD)%n~1SOq-*f<$yXWd zhlPXdwZLVPeyuw#*FbVNn79j4D~Bqp8gGnCQ7l03B2fhVG;pw z2-~trwFjf+5NE%2WBctj8~P|AVThG8pBY33aDiq|W(?s?lqk7u>mHzBhX4ZB5qXTd zCHEL$*SoUGP&>`l&giulv5nQ+^Aa8R);j9YAlMa=es0W&(VRe8<#b-g3J~c8up+6X zl*TdPduWCs`6d=wBTpIK&K%WOFdS=IHgobc;}h~6W)Hm z_t9q_@WWsq_9MEE6F_=okb!eUz~_EK5qcjWF*ZVA4O{x+L}fg<#L5<~N{1##)>PF} zO5)2kiP{8Mf#IN9wg6ZJbSO0wkmY!DZF}uQ(z0s@%^BEeL`lZWCKPKSVT6D4sP`CA zwnNfd6M3+`_7Jg~t})0g)AHDg3APW$C=MH}h@4bV02>fxidscMZ|hS?gB2A;2k0D3 z%p&BCA+6Gn5(~-J4bp@n@4{CRF|1*k$YPAR`Y#@GZJF>&@*Bm1h%R~On&dXt@JzNk zEJ~K6-?)Q$${KU)jtHUc^+&|ibT^S;S+j&|2>+1b4rPEqB{cFD>tE7Vvy72UO&2a_ z!8lF8lkOvQ9Yg<;m>}d*-mptd0;lbCz;BIocXaZxB<#;DZ7*Qj2DM5aBe zS(ZG6#~ApN5Qw_PtX8+^s80wS`uKsTZ*f@+^rk4Us+|Rnc<^Ic2$c6 zmtkr5wxCBfY50&GiF*+jr%{`BH?z18G!fs6umqzd_PT0RyO30rCy2c(^br6HiJdoK z1_yLRdDWB#s$@Axz&rG~-FXO~!>B3E zg8cFK8<)jj0wIW*Lo`Y(l$%Nh>r-|Pp`sVH4k0D1>j7|dlBYf3K6&*q_iKeG>UiE( z9ReuF$k1(F);5^ka0hKe$QV(MJoG9Tq#;px(YT={fUp1=2`!UoZfFS`>n>U0<&u+W zF@UU613d96l$=fFZVdtzZ4N#ny&54DRE)0o;0mM2oj8(6z=N?ns8Q~11JDttxa4`? z+@MNwxG%P3K}3M6+5OFI{GJsvnwA_hU=lZ01yHCwi&2zBgi+A_;vuCPYUm=rF$Tj9 zN=faXk=jO5=tNppl#i+^?!tzVTxJ2FqTBDV3JU%JC>SV*H3|$_j0a%0MaG(2cGLh| zijE}0+o4JNg*DjZZ*hKiphB&tHS!AN&IZh|_3PxYQQ7PVi-a0NAt{&$R41F#3IcY3 zewhav(RuMgCG$wSkFddmImG{`r&FbTJ}>eAdw=`?-h=1)*Y@oFtN#i9r{}~0h0-}!P$(B^7wCc#kh$lCvRBm&C0AW!zGUe)S=(rbKK*qH z3-jbVP=-jI3v@;hEvgV;&+lYRepvLT-kJ4Y!4eWP6b1$TSTF%HQQ6EOUNMvA9Xv%P zEsr`_K+YaBrNotOII?eJRkN6ILn;F6f!!36GpOdiLUZDn+My^b3~4!?z_6uVg@Q{o z!(cAPY#<()BWq00bKOt$Oybv%V-qxtQF@mCEv>;8*}Obo5#cEq`F$H^XKESu7rax*gQVp6 z#2zv6oI;4?8W5`uxG(b515q)qa*Cq2R?)3Kiq-|OCRp2%5&m?vHhW%9M7X#MlQB)aAIcJ zID5K`96i7WPbW*0awOD40$S)VA14QUNjqWLOT%8cWWVrSJ5y6-^~2%@l^wK>bEJBk ztWXJRTuz*F_GXd1r}xdF6KgwOS~K!i_{Gn}ZyU>PfnwB{jyVfYg7fgzpgJ%-mm8}% z>ctOuR096-2he5?ZtF8sNR2uma>j_KSx-iKBH?ijB5$zIih`H+r z`m#YYzcwpQI>0g~zj)!iv{Ugm(kp zf6qPA=THzO61n~TI)4-KGP+Uf7f3cMd*ZAd<$1aD{-hqoy>|psH?UtC@ufN{JcmNR zqTrzH`cy6EmZ_W{vEX=iywqd*9^=F|`$BBpsBcGW*~6ds!yCP;NKgQdnN7uGRVw`# zuGXDo>amL29NRf;^-c1uimhmv?_Bdvcz%)CrYaP(ZyMqYiWBy=p1&c8;v;RbQ9%W= zbJXX9dNujPbaVHo75P?V_lG=gVV@DlgYFly8YI!ngmoku@r|u1q(G!__4BJL~RyYk9k&%>p&?^6~qFAc21l$D!8Qc33L7z z{C_q-&C)U%J^w%d#UA`O{x_z;m;!(IDX{*X=W%M;`XiiLo*#8;L4p;5SLxgHAqN+e zfyL;Ne$7O~O#;qD1>5t0NlP>_m^lrKFFKO+XEnh!yv_dkJ)Rr3af*S{Qr~)y$U-yl zwxT~J4NZXew1zG;p{pr7U~X@}_gHGpx|fErWIVEwyZ0Byhl`gNAc^(G@*JnT2c5F#fx8~^u>7h^tJlaU`%EAeaU)D9&&Acq|LC?*~ z9*TnXv5TS3sX$!S+aA3^$Gd0o+pB3$Uc84KikONXCA0}UWJHS~2^lJg$HbMhHkMa^ zzM{A}F4l)y+rBN+SJ*dV3V5gX8jQ>CztMZKPMc|eK^BS06w9ZNuVG$tj`82j%c-dU;B4ejY1fyimINA--jg%yne)xMF!hIsA=6Z+(V0PC z#7#|cSLH!}AS_~VVU9SCDs$~=Np~R|+ojv191XCu4eWcGWX&vF6I+ENnZF%+`epVg zjw7GpczLmVe-&>rvEq>Db`@zi`DzuZWLA$oWPD{|HMI$P)9 z#P~fC@1f&Wf%riwE(6I!KeNR(2iL{alrZZxL2dW~^X{(vTyaDL3y< za`rq1Rmorg9M<}ey+TxLsu>BRzBIebkl2}i5(?3ED_7oeICf}(%c-a5VBt%w8hz|g z86zL33$LlOf+)59W*+S!1{Q$vHfCQtAm=Iq8AT(dq*P158c1 zTz*)Jt{t-wsDGIB6G_@_Fw!PG5R%J}*J4ThNCacK-o{Qna6~$$he)27V@RgcoLl0S$X5FlYQs^65szP KbEwmo|9=4q$(;%S literal 0 HcmV?d00001