diff --git a/.gitignore b/.gitignore index 3d041c9..59c4247 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,65 @@ # Serena .serena -# Python +# ── Python ──────────────────────────────────────────────────────────── __pycache__/ *.py[cod] *.pyo -.eggs/ +*.pyd +*.so +*.egg *.egg-info/ dist/ build/ +.eggs/ +MANIFEST -# Virtual environments +# ── 가상환경 ────────────────────────────────────────────────────────── .venv/ venv/ env/ +.env/ + +# ── 의존성 잠금 파일 ────────────────────────────────────────────────── uv.lock -# Environment variables +# ── 환경변수 ────────────────────────────────────────────────────────── .env .env.local +.env.*.local -# pytest +# ── 테스트 / 커버리지 ───────────────────────────────────────────────── .pytest_cache/ .coverage +.coverage.* htmlcov/ +coverage.xml -# mypy +# ── 타입 체크 / 린트 ────────────────────────────────────────────────── .mypy_cache/ +.ruff_cache/ + +# ── Jupyter Notebook ────────────────────────────────────────────────── +.ipynb_checkpoints/ -# IDE +# ── Docker / 인프라 ─────────────────────────────────────────────────── +postgres_data/ +neo4j_data/ +qdrant_data/ + +# ── Alembic ─────────────────────────────────────────────────────────── +alembic/versions/*.pyc + +# ── IDE / 에디터 ────────────────────────────────────────────────────── .idea/ .vscode/ *.swp +*.swo +.DS_Store + +# ── 로그 ───────────────────────────────────────────────────────────── +*.log +logs/ + +# ── pyenv ───────────────────────────────────────────────────────────── +.python-version diff --git a/app/apis/__init__.py b/app/apis/__init__.py index e69de29..c8eff73 100644 --- a/app/apis/__init__.py +++ b/app/apis/__init__.py @@ -0,0 +1,5 @@ +"""API 라우터 패키지.""" + +from app.apis import health, stocks + +__all__ = ["health", "stocks"] diff --git a/app/apis/dependencies.py b/app/apis/dependencies.py new file mode 100644 index 0000000..ca13d63 --- /dev/null +++ b/app/apis/dependencies.py @@ -0,0 +1,33 @@ +"""FastAPI 의존성 주입 설정. + +⚠️ DB 교체 지점: + 현재는 StubStockRepository(메모리 stub)를 사용합니다. + DB 담당자가 PostgresStockRepository 를 구현하면 + 아래 get_stock_service() 의 repo 인스턴스만 교체하면 됩니다. +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends + +from app.repositories.postgres.stub_stock_repository import StubStockRepository +from app.services.stock_service import StockService + + +def get_stock_service() -> StockService: + """StockService 인스턴스를 제공합니다. + + ── DB 교체 방법 ────────────────────────────────────────────────── + from app.repositories.postgres.postgres_stock_repository import PostgresStockRepository + repo = PostgresStockRepository(session=...) # DB 세션 주입 + return StockService(repo=repo) + ────────────────────────────────────────────────────────────────── + """ + repo = StubStockRepository() + return StockService(repo=repo) + + +# 라우터에서 Annotated 타입으로 간결하게 사용할 수 있도록 별칭 제공 +StockServiceDep = Annotated[StockService, Depends(get_stock_service)] diff --git a/app/apis/dtos/__init__.py b/app/apis/dtos/__init__.py index e69de29..4b024f7 100644 --- a/app/apis/dtos/__init__.py +++ b/app/apis/dtos/__init__.py @@ -0,0 +1,21 @@ +"""API 응답 DTO 패키지.""" + +from app.apis.dtos.common import DateRangeParams, Page, PaginationParams +from app.apis.dtos.stock import ( + DividendResponse, + FinancialStatementResponse, + MetricResponse, + PriceResponse, + SecurityResponse, +) + +__all__ = [ + "Page", + "PaginationParams", + "DateRangeParams", + "SecurityResponse", + "PriceResponse", + "FinancialStatementResponse", + "DividendResponse", + "MetricResponse", +] diff --git a/app/apis/dtos/common.py b/app/apis/dtos/common.py new file mode 100644 index 0000000..32512dc --- /dev/null +++ b/app/apis/dtos/common.py @@ -0,0 +1,48 @@ +"""공통 쿼리 파라미터 및 제네릭 페이지 응답 모델.""" + +from __future__ import annotations + +from datetime import date +from typing import Generic, TypeVar + +from fastapi import Query +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class Page(BaseModel, Generic[T]): + """페이지네이션된 응답 래퍼.""" + + items: list[T] + total: int = Field(description="전체 항목 수") + limit: int = Field(description="페이지 크기") + offset: int = Field(description="시작 오프셋") + + +class PaginationParams: + """공통 페이지네이션 쿼리 파라미터 (FastAPI Depends용).""" + + def __init__( + self, + limit: int = Query(default=50, ge=1, le=500, description="반환 최대 건수"), + offset: int = Query(default=0, ge=0, description="시작 오프셋"), + ) -> None: + self.limit = limit + self.offset = offset + + +class DateRangeParams: + """날짜 범위 쿼리 파라미터 (FastAPI Depends용).""" + + def __init__( + self, + from_date: date | None = Query( + default=None, alias="from", description="시작일 (YYYY-MM-DD)" + ), + to_date: date | None = Query( + default=None, alias="to", description="종료일 (YYYY-MM-DD)" + ), + ) -> None: + self.from_date = from_date + self.to_date = to_date diff --git a/app/apis/dtos/stock.py b/app/apis/dtos/stock.py new file mode 100644 index 0000000..8b31336 --- /dev/null +++ b/app/apis/dtos/stock.py @@ -0,0 +1,84 @@ +"""주가·재무·종목 관련 API 응답 DTO. + +기존 pipeline/collectors/dtos/stock.py 의 OhlcvRecord 필드 구조를 API 계약으로 계승합니다. + +Note: `from __future__ import annotations` 미사용. + Pydantic v2에서 `date: date` 처럼 필드명과 타입명이 같으면 "unevaluable type annotation" 오류가 + 발생하므로, datetime.date 를 Date 별칭으로 임포트합니다. +""" + +from datetime import date as Date # noqa: N812 +from typing import Literal + +from pydantic import BaseModel, Field + + +class SecurityResponse(BaseModel): + """종목/기업 마스터 정보.""" + + symbol: str = Field(description="티커 심볼 (예: AAPL, 005930.KS)") + name: str = Field(description="회사명") + exchange: str = Field(description="거래소 (예: NASDAQ, KRX)") + sector: str | None = Field(default=None, description="섹터") + industry: str | None = Field(default=None, description="산업") + currency: str = Field(description="거래 통화 (예: USD, KRW)") + country: str | None = Field(default=None, description="국가 코드 (예: US, KR)") + market_cap: float | None = Field(default=None, description="시가총액") + + model_config = {"from_attributes": True} + + +class PriceResponse(BaseModel): + """일봉(OHLCV) 주가 데이터. + + pipeline/collectors/dtos/stock.py 의 OhlcvRecord와 동일한 스키마입니다. + """ + + symbol: str = Field(description="티커 심볼") + date: Date = Field(description="거래일") + open: float = Field(ge=0, description="시가") + high: float = Field(ge=0, description="고가") + low: float = Field(ge=0, description="저가") + close: float = Field(ge=0, description="종가") + volume: int = Field(ge=0, description="거래량") + + model_config = {"from_attributes": True} + + +class FinancialStatementResponse(BaseModel): + """재무제표 (손익계산서·재무상태표·현금흐름표). + + line_items에 항목명 → 금액(KRW 또는 USD) 딕셔너리로 제공합니다. + """ + + symbol: str = Field(description="티커 심볼") + statement_type: Literal["income", "balance", "cashflow"] = Field(description="재무제표 종류") + period_type: Literal["annual", "quarterly"] = Field(description="기간 종류") + fiscal_date: Date = Field(description="회계 기준일") + line_items: dict[str, float | None] = Field(description="재무 항목 딕셔너리") + + model_config = {"from_attributes": True} + + +class DividendResponse(BaseModel): + """배당 이력.""" + + symbol: str = Field(description="티커 심볼") + ex_date: Date = Field(description="배당락일") + amount: float = Field(ge=0, description="주당 배당금") + + model_config = {"from_attributes": True} + + +class MetricResponse(BaseModel): + """주요 밸류에이션 지표.""" + + symbol: str = Field(description="티커 심볼") + as_of_date: Date = Field(description="기준일") + per: float | None = Field(default=None, description="주가수익비율(PER)") + pbr: float | None = Field(default=None, description="주가순자산비율(PBR)") + eps: float | None = Field(default=None, description="주당순이익(EPS)") + dividend_yield: float | None = Field(default=None, description="배당수익률 (0~1)") + market_cap: float | None = Field(default=None, description="시가총액") + + model_config = {"from_attributes": True} diff --git a/app/apis/health.py b/app/apis/health.py new file mode 100644 index 0000000..23d1391 --- /dev/null +++ b/app/apis/health.py @@ -0,0 +1,20 @@ +"""헬스체크 라우터.""" + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(tags=["health"]) + + +class HealthResponse(BaseModel): + status: str + version: str + + +@router.get("/health", response_model=HealthResponse, summary="헬스체크") +async def health() -> HealthResponse: + """서비스 상태를 반환합니다.""" + from app.core.config import get_settings + + settings = get_settings() + return HealthResponse(status="ok", version=settings.app_version) diff --git a/app/apis/stocks.py b/app/apis/stocks.py new file mode 100644 index 0000000..2fe4763 --- /dev/null +++ b/app/apis/stocks.py @@ -0,0 +1,212 @@ +"""주가·재무 조회 라우터. + +모든 엔드포인트는 읽기(GET) 전용입니다. +prefix /api/v1 은 main.py 에서 등록 시 적용됩니다. +""" + +from __future__ import annotations + +from datetime import date +from typing import Annotated, Literal + +import structlog +from fastapi import APIRouter, Depends, Query + +from app.apis.dependencies import StockServiceDep +from app.apis.dtos.common import DateRangeParams, Page, PaginationParams +from app.apis.dtos.stock import ( + DividendResponse, + FinancialStatementResponse, + MetricResponse, + PriceResponse, + SecurityResponse, +) + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix="/stocks", tags=["stocks"]) + + +# ────────────────────────────────────────────────────────────────────── +# 종목 마스터 +# ────────────────────────────────────────────────────────────────────── + + +@router.get( + "", + response_model=Page[SecurityResponse], + summary="종목 목록 조회", + description=( + "종목 목록을 조회합니다. 이름/심볼 검색, 섹터·거래소 필터, 페이지네이션을 지원합니다." + ), + responses={422: {"description": "잘못된 쿼리 파라미터"}}, +) +async def list_stocks( + service: StockServiceDep, + pagination: Annotated[PaginationParams, Depends()], + search: str | None = Query(default=None, description="심볼 또는 회사명 부분 검색"), + sector: str | None = Query(default=None, description="섹터 필터 (예: Technology)"), + exchange: str | None = Query(default=None, description="거래소 필터 (예: NASDAQ, KRX)"), +) -> Page[SecurityResponse]: + return await service.list_securities( + search=search, + sector=sector, + exchange=exchange, + limit=pagination.limit, + offset=pagination.offset, + ) + + +@router.get( + "/{symbol}", + response_model=SecurityResponse, + summary="종목 프로필 조회", + description="단일 종목의 기업 프로필(거래소·섹터·시가총액 등)을 반환합니다.", + responses={ + 404: {"description": "해당 심볼의 종목이 존재하지 않습니다"}, + }, +) +async def get_stock(symbol: str, service: StockServiceDep) -> SecurityResponse: + return await service.get_security(symbol) + + +# ────────────────────────────────────────────────────────────────────── +# 주가(OHLCV) +# ────────────────────────────────────────────────────────────────────── + + +@router.get( + "/{symbol}/prices", + response_model=Page[PriceResponse], + summary="주가 시계열 조회", + description=( + "일봉(OHLCV) 주가 데이터를 반환합니다. " + "`from`/`to` 날짜 범위, 정렬 방향, 페이지네이션을 지원합니다." + ), + responses={ + 404: {"description": "해당 심볼의 종목이 존재하지 않습니다"}, + 422: {"description": "잘못된 쿼리 파라미터"}, + }, +) +async def get_prices( + symbol: str, + service: StockServiceDep, + date_range: Annotated[DateRangeParams, Depends()], + pagination: Annotated[PaginationParams, Depends()], + order: Literal["asc", "desc"] = Query(default="desc", description="날짜 정렬 방향"), +) -> Page[PriceResponse]: + return await service.get_prices( + symbol=symbol, + from_date=date_range.from_date, + to_date=date_range.to_date, + order=order, + limit=pagination.limit, + offset=pagination.offset, + ) + + +@router.get( + "/{symbol}/prices/latest", + response_model=PriceResponse, + summary="최신 주가 조회", + description="가장 최근 거래일의 OHLCV 봉 데이터 1건을 반환합니다.", + responses={ + 404: {"description": "해당 심볼의 종목 또는 주가 데이터가 존재하지 않습니다"}, + }, +) +async def get_latest_price(symbol: str, service: StockServiceDep) -> PriceResponse: + return await service.get_latest_price(symbol) + + +# ────────────────────────────────────────────────────────────────────── +# 재무제표 +# ────────────────────────────────────────────────────────────────────── + + +@router.get( + "/{symbol}/financials", + response_model=list[FinancialStatementResponse], + summary="재무제표 조회", + description=( + "손익계산서(income)·재무상태표(balance)·현금흐름표(cashflow)를 반환합니다. " + "`statement` 미지정 시 전체, `period`(annual|quarterly) 필터 가능." + ), + responses={ + 404: {"description": "해당 심볼의 종목이 존재하지 않습니다"}, + 422: {"description": "잘못된 쿼리 파라미터"}, + }, +) +async def get_financials( + symbol: str, + service: StockServiceDep, + date_range: Annotated[DateRangeParams, Depends()], + statement: Literal["income", "balance", "cashflow"] | None = Query( + default=None, description="재무제표 종류" + ), + period: Literal["annual", "quarterly"] | None = Query( + default=None, description="기간 종류 (annual | quarterly)" + ), +) -> list[FinancialStatementResponse]: + return await service.get_financials( + symbol=symbol, + statement_type=statement, + period_type=period, + from_date=date_range.from_date, + to_date=date_range.to_date, + ) + + +# ────────────────────────────────────────────────────────────────────── +# 배당 +# ────────────────────────────────────────────────────────────────────── + + +@router.get( + "/{symbol}/dividends", + response_model=Page[DividendResponse], + summary="배당 이력 조회", + description="배당락일 기준 배당 이력을 반환합니다. 날짜 범위·페이지네이션 지원.", + responses={ + 404: {"description": "해당 심볼의 종목이 존재하지 않습니다"}, + 422: {"description": "잘못된 쿼리 파라미터"}, + }, +) +async def get_dividends( + symbol: str, + service: StockServiceDep, + date_range: Annotated[DateRangeParams, Depends()], + pagination: Annotated[PaginationParams, Depends()], +) -> Page[DividendResponse]: + return await service.get_dividends( + symbol=symbol, + from_date=date_range.from_date, + to_date=date_range.to_date, + limit=pagination.limit, + offset=pagination.offset, + ) + + +# ────────────────────────────────────────────────────────────────────── +# 주요지표 +# ────────────────────────────────────────────────────────────────────── + + +@router.get( + "/{symbol}/metrics", + response_model=MetricResponse, + summary="주요 밸류에이션 지표 조회", + description=( + "PER·PBR·EPS·배당수익률·시가총액 등 밸류에이션 지표를 반환합니다. " + "`as_of` 미지정 시 최신 기준입니다." + ), + responses={ + 404: {"description": "해당 심볼의 종목 또는 지표 데이터가 존재하지 않습니다"}, + 422: {"description": "잘못된 쿼리 파라미터"}, + }, +) +async def get_metrics( + symbol: str, + service: StockServiceDep, + as_of: date | None = Query(default=None, description="기준일 (YYYY-MM-DD), 미지정 시 최신"), +) -> MetricResponse: + return await service.get_metrics(symbol=symbol, as_of=as_of) diff --git a/app/core/config.py b/app/core/config.py index 142b783..1273831 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,17 +1,32 @@ +"""앱 설정 모듈. + +pydantic-settings로 환경 변수를 로드합니다. +""" + from functools import lru_cache from pydantic import Field - from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + """애플리케이션 설정.""" - database_url: str = Field( - default="", - description="Supabase Postgres connection string.", + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", ) + + # 앱 메타 + app_title: str = "FinBridge GraphRAG API" + app_version: str = "0.1.0" + app_description: str = "주가·재무 데이터 조회 및 AI 기반 금융 리서치 질의응답 API" + debug: bool = False + + # PostgreSQL (DB 담당자가 설정) + database_url: str = Field(default="", description="PostgreSQL connection string.") database_pool_size: int = 1 database_max_overflow: int = 3 database_echo: bool = False @@ -19,4 +34,5 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: + """설정 싱글톤을 반환합니다.""" return Settings() diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..a5424d8 --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,10 @@ +"""도메인 공통 예외 클래스.""" + + +class EntityNotFoundError(Exception): + """요청한 엔티티(종목·데이터)가 존재하지 않을 때 발생합니다.""" + + def __init__(self, entity: str, identifier: str) -> None: + self.entity = entity + self.identifier = identifier + super().__init__(f"{entity} not found: {identifier!r}") diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000..6a2e272 --- /dev/null +++ b/app/core/logging.py @@ -0,0 +1,32 @@ +"""structlog 설정 초기화. + +pipeline/collectors/stock_data_collector.py 의 로깅 컨벤션을 따릅니다. +""" + +import logging + +import structlog + + +def setup_logging(debug: bool = False) -> None: + """structlog을 초기화합니다.""" + log_level = logging.DEBUG if debug else logging.INFO + + logging.basicConfig( + format="%(message)s", + level=log_level, + ) + + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.StackInfoRenderer(), + structlog.dev.ConsoleRenderer() if debug else structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger(log_level), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + ) diff --git a/app/main.py b/app/main.py index 8b13789..893fa21 100644 --- a/app/main.py +++ b/app/main.py @@ -1 +1,72 @@ +"""FinBridge GraphRAG API 엔트리포인트. +docker-compose.yml / Dockerfile 이 기대하는 app.main:app 심볼을 제공합니다. +실행: uvicorn app.main:app --reload +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +import structlog +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from app.apis import health, stocks +from app.core.config import get_settings +from app.core.exceptions import EntityNotFoundError +from app.core.logging import setup_logging + +logger = structlog.get_logger(__name__) + + +@asynccontextmanager +async def lifespan(application: FastAPI) -> AsyncGenerator[None, None]: + """앱 시작/종료 시 실행되는 라이프사이클 훅.""" + settings = get_settings() + setup_logging(debug=settings.debug) + logger.info("finbridge_api.startup", version=settings.app_version) + yield + logger.info("finbridge_api.shutdown") + + +def create_app() -> FastAPI: + """FastAPI 애플리케이션 팩토리.""" + settings = get_settings() + + application = FastAPI( + title=settings.app_title, + version=settings.app_version, + description=settings.app_description, + lifespan=lifespan, + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + ) + + # ── 예외 핸들러 ───────────────────────────────────────────────── + @application.exception_handler(EntityNotFoundError) + async def entity_not_found_handler(request: Request, exc: EntityNotFoundError) -> JSONResponse: + """RFC 7807 Problem Details 형식으로 404 응답을 반환합니다.""" + return JSONResponse( + status_code=404, + content={ + "type": "https://finbridge.local/errors/not-found", + "title": "Not Found", + "status": 404, + "detail": str(exc), + "entity": exc.entity, + "identifier": exc.identifier, + }, + media_type="application/problem+json", + ) + + # ── 라우터 등록 ────────────────────────────────────────────────── + application.include_router(health.router) + application.include_router(stocks.router, prefix="/api/v1") + + return application + + +app = create_app() diff --git a/app/repositories/postgres/__init__.py b/app/repositories/postgres/__init__.py index e69de29..b4871e4 100644 --- a/app/repositories/postgres/__init__.py +++ b/app/repositories/postgres/__init__.py @@ -0,0 +1,6 @@ +"""PostgreSQL 저장소 패키지.""" + +from app.repositories.postgres.stock_repository import StockRepository +from app.repositories.postgres.stub_stock_repository import StubStockRepository + +__all__ = ["StockRepository", "StubStockRepository"] diff --git a/app/repositories/postgres/stock_repository.py b/app/repositories/postgres/stock_repository.py new file mode 100644 index 0000000..e36feca --- /dev/null +++ b/app/repositories/postgres/stock_repository.py @@ -0,0 +1,99 @@ +"""StockRepository 인터페이스 (Protocol). + +이 Protocol이 DB 담당자와의 계약 지점입니다. +DB 담당자는 이 인터페이스를 구현하는 PostgresStockRepository 클래스를 만들고 +app/apis/dependencies.py 의 get_stock_service() 에서 주입만 교체하면 됩니다. +""" + +from __future__ import annotations + +from datetime import date +from typing import Literal, Protocol, runtime_checkable + +from app.apis.dtos.stock import ( + DividendResponse, + FinancialStatementResponse, + MetricResponse, + PriceResponse, + SecurityResponse, +) + + +@runtime_checkable +class StockRepository(Protocol): + """주가·재무 데이터 접근 인터페이스.""" + + # ─── 종목 마스터 ───────────────────────────────────────────────── + + async def symbol_exists(self, symbol: str) -> bool: + """심볼이 DB에 존재하는지 확인합니다.""" + ... + + async def get_security(self, symbol: str) -> SecurityResponse | None: + """단일 종목 프로필을 반환합니다. 없으면 None.""" + ... + + async def list_securities( + self, + search: str | None = None, + sector: str | None = None, + exchange: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[SecurityResponse], int]: + """종목 목록과 전체 건수를 반환합니다. (items, total)""" + ... + + # ─── 주가(OHLCV) ───────────────────────────────────────────────── + + async def get_prices( + self, + symbol: str, + from_date: date | None = None, + to_date: date | None = None, + order: Literal["asc", "desc"] = "desc", + limit: int = 50, + offset: int = 0, + ) -> tuple[list[PriceResponse], int]: + """주가 시계열과 전체 건수를 반환합니다. (items, total)""" + ... + + async def get_latest_price(self, symbol: str) -> PriceResponse | None: + """가장 최근 봉 데이터를 반환합니다. 없으면 None.""" + ... + + # ─── 재무제표 ───────────────────────────────────────────────────── + + async def get_financials( + self, + symbol: str, + statement_type: Literal["income", "balance", "cashflow"] | None = None, + period_type: Literal["annual", "quarterly"] | None = None, + from_date: date | None = None, + to_date: date | None = None, + ) -> list[FinancialStatementResponse]: + """재무제표 목록을 반환합니다.""" + ... + + # ─── 배당 ───────────────────────────────────────────────────────── + + async def get_dividends( + self, + symbol: str, + from_date: date | None = None, + to_date: date | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[DividendResponse], int]: + """배당 이력과 전체 건수를 반환합니다. (items, total)""" + ... + + # ─── 주요지표 ───────────────────────────────────────────────────── + + async def get_metrics( + self, + symbol: str, + as_of: date | None = None, + ) -> MetricResponse | None: + """밸류에이션 지표를 반환합니다. as_of 미지정 시 최신 기준. 없으면 None.""" + ... diff --git a/app/repositories/postgres/stub_stock_repository.py b/app/repositories/postgres/stub_stock_repository.py new file mode 100644 index 0000000..0321f94 --- /dev/null +++ b/app/repositories/postgres/stub_stock_repository.py @@ -0,0 +1,303 @@ +"""StockRepository 의 Stub(임시) 구현체. + +⚠️ DB 미구현 — 하드코딩된 샘플 데이터를 메모리에서 반환합니다. + DB 담당자는 이 파일 대신 PostgresStockRepository 를 구현하고 + app/apis/dependencies.py 의 get_stock_service() 주입만 교체하면 됩니다. + +샘플 종목: AAPL (Apple Inc.), 005930.KS (Samsung Electronics) +""" + +from __future__ import annotations + +from datetime import date +from typing import Literal + +import structlog + +from app.apis.dtos.stock import ( + DividendResponse, + FinancialStatementResponse, + MetricResponse, + PriceResponse, + SecurityResponse, +) + +logger = structlog.get_logger(__name__) + +# ────────────────────────────────────────────────────────────────────── +# 샘플 데이터 +# ────────────────────────────────────────────────────────────────────── + +_SECURITIES: dict[str, SecurityResponse] = { + "AAPL": SecurityResponse( + symbol="AAPL", + name="Apple Inc.", + exchange="NASDAQ", + sector="Technology", + industry="Consumer Electronics", + currency="USD", + country="US", + market_cap=3_000_000_000_000.0, + ), + "005930.KS": SecurityResponse( + symbol="005930.KS", + name="Samsung Electronics Co., Ltd.", + exchange="KRX", + sector="Technology", + industry="Semiconductors", + currency="KRW", + country="KR", + market_cap=350_000_000_000_000.0, + ), +} + +_PRICES: dict[str, list[PriceResponse]] = { + "AAPL": [ + PriceResponse( + symbol="AAPL", date=date(2024, 1, 2), + open=185.0, high=188.5, low=184.3, close=187.1, volume=52_000_000, + ), + PriceResponse( + symbol="AAPL", date=date(2024, 1, 3), + open=187.1, high=190.0, low=186.0, close=189.3, volume=48_000_000, + ), + PriceResponse( + symbol="AAPL", date=date(2024, 1, 4), + open=189.0, high=191.5, low=188.0, close=190.7, volume=45_000_000, + ), + PriceResponse( + symbol="AAPL", date=date(2024, 1, 5), + open=190.5, high=193.0, low=189.5, close=192.1, volume=47_000_000, + ), + PriceResponse( + symbol="AAPL", date=date(2024, 1, 8), + open=192.0, high=195.5, low=191.0, close=194.5, volume=55_000_000, + ), + ], + "005930.KS": [ + PriceResponse( + symbol="005930.KS", + date=date(2024, 1, 2), + open=72_800, + high=73_500, + low=72_400, + close=73_100, + volume=12_000_000, + ), + PriceResponse( + symbol="005930.KS", + date=date(2024, 1, 3), + open=73_000, + high=74_000, + low=72_800, + close=73_800, + volume=11_500_000, + ), + ], +} + +_FINANCIALS: dict[str, list[FinancialStatementResponse]] = { + "AAPL": [ + FinancialStatementResponse( + symbol="AAPL", + statement_type="income", + period_type="annual", + fiscal_date=date(2023, 9, 30), + line_items={ + "Total Revenue": 383_285_000_000.0, + "Gross Profit": 169_148_000_000.0, + "Operating Income": 114_301_000_000.0, + "Net Income": 96_995_000_000.0, + "EBITDA": 125_820_000_000.0, + }, + ), + FinancialStatementResponse( + symbol="AAPL", + statement_type="balance", + period_type="annual", + fiscal_date=date(2023, 9, 30), + line_items={ + "Total Assets": 352_583_000_000.0, + "Total Liabilities": 290_437_000_000.0, + "Total Equity": 62_146_000_000.0, + "Cash And Cash Equivalents": 29_965_000_000.0, + "Total Debt": 111_088_000_000.0, + }, + ), + FinancialStatementResponse( + symbol="AAPL", + statement_type="cashflow", + period_type="annual", + fiscal_date=date(2023, 9, 30), + line_items={ + "Operating Cash Flow": 113_040_000_000.0, + "Capital Expenditure": -10_959_000_000.0, + "Free Cash Flow": 102_081_000_000.0, + }, + ), + ], + "005930.KS": [ + FinancialStatementResponse( + symbol="005930.KS", + statement_type="income", + period_type="annual", + fiscal_date=date(2023, 12, 31), + line_items={ + "Total Revenue": 258_935_000_000_000.0, + "Gross Profit": 50_090_000_000_000.0, + "Operating Income": 6_566_000_000_000.0, + "Net Income": 15_487_000_000_000.0, + }, + ), + ], +} + +_DIVIDENDS: dict[str, list[DividendResponse]] = { + "AAPL": [ + DividendResponse(symbol="AAPL", ex_date=date(2024, 2, 9), amount=0.24), + DividendResponse(symbol="AAPL", ex_date=date(2023, 11, 10), amount=0.24), + DividendResponse(symbol="AAPL", ex_date=date(2023, 8, 11), amount=0.24), + DividendResponse(symbol="AAPL", ex_date=date(2023, 5, 12), amount=0.24), + ], + "005930.KS": [ + DividendResponse(symbol="005930.KS", ex_date=date(2023, 12, 28), amount=361.0), + ], +} + +_METRICS: dict[str, MetricResponse] = { + "AAPL": MetricResponse( + symbol="AAPL", + as_of_date=date(2024, 1, 8), + per=29.5, + pbr=48.3, + eps=6.56, + dividend_yield=0.0049, + market_cap=3_000_000_000_000.0, + ), + "005930.KS": MetricResponse( + symbol="005930.KS", + as_of_date=date(2024, 1, 3), + per=22.1, + pbr=1.3, + eps=3_344.0, + dividend_yield=0.0049, + market_cap=350_000_000_000_000.0, + ), +} + + +# ────────────────────────────────────────────────────────────────────── +# Stub 구현 +# ────────────────────────────────────────────────────────────────────── + + +class StubStockRepository: + """메모리 기반 stub 구현. DB 연결 없이 샘플 객체를 반환합니다.""" + + async def symbol_exists(self, symbol: str) -> bool: + return symbol.upper() in _SECURITIES or symbol in _SECURITIES + + async def get_security(self, symbol: str) -> SecurityResponse | None: + logger.debug("stub.get_security", symbol=symbol) + return _SECURITIES.get(symbol) or _SECURITIES.get(symbol.upper()) + + async def list_securities( + self, + search: str | None = None, + sector: str | None = None, + exchange: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[SecurityResponse], int]: + logger.debug("stub.list_securities", search=search, sector=sector, exchange=exchange) + items = list(_SECURITIES.values()) + + if search: + s = search.lower() + items = [ + i for i in items if s in i.symbol.lower() or s in i.name.lower() + ] + if sector: + items = [i for i in items if i.sector and sector.lower() in i.sector.lower()] + if exchange: + items = [i for i in items if exchange.lower() in i.exchange.lower()] + + total = len(items) + return items[offset : offset + limit], total + + async def get_prices( + self, + symbol: str, + from_date: date | None = None, + to_date: date | None = None, + order: Literal["asc", "desc"] = "desc", + limit: int = 50, + offset: int = 0, + ) -> tuple[list[PriceResponse], int]: + logger.debug("stub.get_prices", symbol=symbol, from_date=from_date, to_date=to_date) + rows = list(_PRICES.get(symbol, [])) + + if from_date: + rows = [r for r in rows if r.date >= from_date] + if to_date: + rows = [r for r in rows if r.date <= to_date] + + rows.sort(key=lambda r: r.date, reverse=(order == "desc")) + total = len(rows) + return rows[offset : offset + limit], total + + async def get_latest_price(self, symbol: str) -> PriceResponse | None: + rows = _PRICES.get(symbol) + if not rows: + return None + return max(rows, key=lambda r: r.date) + + async def get_financials( + self, + symbol: str, + statement_type: Literal["income", "balance", "cashflow"] | None = None, + period_type: Literal["annual", "quarterly"] | None = None, + from_date: date | None = None, + to_date: date | None = None, + ) -> list[FinancialStatementResponse]: + logger.debug("stub.get_financials", symbol=symbol, statement_type=statement_type) + rows = list(_FINANCIALS.get(symbol, [])) + + if statement_type: + rows = [r for r in rows if r.statement_type == statement_type] + if period_type: + rows = [r for r in rows if r.period_type == period_type] + if from_date: + rows = [r for r in rows if r.fiscal_date >= from_date] + if to_date: + rows = [r for r in rows if r.fiscal_date <= to_date] + + return rows + + async def get_dividends( + self, + symbol: str, + from_date: date | None = None, + to_date: date | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[DividendResponse], int]: + logger.debug("stub.get_dividends", symbol=symbol) + rows = list(_DIVIDENDS.get(symbol, [])) + + if from_date: + rows = [r for r in rows if r.ex_date >= from_date] + if to_date: + rows = [r for r in rows if r.ex_date <= to_date] + + rows.sort(key=lambda r: r.ex_date, reverse=True) + total = len(rows) + return rows[offset : offset + limit], total + + async def get_metrics( + self, + symbol: str, + as_of: date | None = None, + ) -> MetricResponse | None: + logger.debug("stub.get_metrics", symbol=symbol, as_of=as_of) + return _METRICS.get(symbol) diff --git a/app/services/stock_service.py b/app/services/stock_service.py new file mode 100644 index 0000000..eb1e25e --- /dev/null +++ b/app/services/stock_service.py @@ -0,0 +1,157 @@ +"""StockService — 주가·재무 조회 비즈니스 로직. + +Repository 인터페이스를 주입받아 동작하므로 DB 구현과 독립적입니다. +""" + +from __future__ import annotations + +from datetime import date +from typing import Literal + +import structlog + +from app.apis.dtos.common import Page +from app.apis.dtos.stock import ( + DividendResponse, + FinancialStatementResponse, + MetricResponse, + PriceResponse, + SecurityResponse, +) +from app.core.exceptions import EntityNotFoundError +from app.repositories.postgres.stock_repository import StockRepository + +logger = structlog.get_logger(__name__) + + +class StockService: + """주가·재무 데이터 조회 서비스.""" + + def __init__(self, repo: StockRepository) -> None: + self._repo = repo + + # ────────────────────────────────────────────────────────────── + # 내부 헬퍼 + # ────────────────────────────────────────────────────────────── + + async def _require_symbol(self, symbol: str) -> None: + """심볼이 존재하지 않으면 EntityNotFoundError를 발생시킵니다.""" + if not await self._repo.symbol_exists(symbol): + logger.warning("stock_service.symbol_not_found", symbol=symbol) + raise EntityNotFoundError("Security", symbol) + + # ────────────────────────────────────────────────────────────── + # 종목 마스터 + # ────────────────────────────────────────────────────────────── + + async def list_securities( + self, + search: str | None = None, + sector: str | None = None, + exchange: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> Page[SecurityResponse]: + logger.info("stock_service.list_securities", search=search, sector=sector) + items, total = await self._repo.list_securities( + search=search, sector=sector, exchange=exchange, limit=limit, offset=offset + ) + return Page(items=items, total=total, limit=limit, offset=offset) + + async def get_security(self, symbol: str) -> SecurityResponse: + logger.info("stock_service.get_security", symbol=symbol) + await self._require_symbol(symbol) + security = await self._repo.get_security(symbol) + if security is None: + raise EntityNotFoundError("Security", symbol) + return security + + # ────────────────────────────────────────────────────────────── + # 주가(OHLCV) + # ────────────────────────────────────────────────────────────── + + async def get_prices( + self, + symbol: str, + from_date: date | None = None, + to_date: date | None = None, + order: Literal["asc", "desc"] = "desc", + limit: int = 50, + offset: int = 0, + ) -> Page[PriceResponse]: + logger.info("stock_service.get_prices", symbol=symbol) + await self._require_symbol(symbol) + items, total = await self._repo.get_prices( + symbol=symbol, + from_date=from_date, + to_date=to_date, + order=order, + limit=limit, + offset=offset, + ) + return Page(items=items, total=total, limit=limit, offset=offset) + + async def get_latest_price(self, symbol: str) -> PriceResponse: + logger.info("stock_service.get_latest_price", symbol=symbol) + await self._require_symbol(symbol) + price = await self._repo.get_latest_price(symbol) + if price is None: + raise EntityNotFoundError("Price", symbol) + return price + + # ────────────────────────────────────────────────────────────── + # 재무제표 + # ────────────────────────────────────────────────────────────── + + async def get_financials( + self, + symbol: str, + statement_type: Literal["income", "balance", "cashflow"] | None = None, + period_type: Literal["annual", "quarterly"] | None = None, + from_date: date | None = None, + to_date: date | None = None, + ) -> list[FinancialStatementResponse]: + logger.info("stock_service.get_financials", symbol=symbol, statement=statement_type) + await self._require_symbol(symbol) + return await self._repo.get_financials( + symbol=symbol, + statement_type=statement_type, + period_type=period_type, + from_date=from_date, + to_date=to_date, + ) + + # ────────────────────────────────────────────────────────────── + # 배당 + # ────────────────────────────────────────────────────────────── + + async def get_dividends( + self, + symbol: str, + from_date: date | None = None, + to_date: date | None = None, + limit: int = 50, + offset: int = 0, + ) -> Page[DividendResponse]: + logger.info("stock_service.get_dividends", symbol=symbol) + await self._require_symbol(symbol) + items, total = await self._repo.get_dividends( + symbol=symbol, from_date=from_date, to_date=to_date, limit=limit, offset=offset + ) + return Page(items=items, total=total, limit=limit, offset=offset) + + # ────────────────────────────────────────────────────────────── + # 주요지표 + # ────────────────────────────────────────────────────────────── + + async def get_metrics( + self, + symbol: str, + as_of: date | None = None, + ) -> MetricResponse: + logger.info("stock_service.get_metrics", symbol=symbol, as_of=as_of) + await self._require_symbol(symbol) + metrics = await self._repo.get_metrics(symbol=symbol, as_of=as_of) + if metrics is None: + raise EntityNotFoundError("Metrics", symbol) + return metrics diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..80415df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""pytest 공통 fixture.""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.fixture +async def async_client() -> AsyncClient: + """FastAPI 앱을 대상으로 하는 비동기 HTTP 클라이언트.""" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + yield client diff --git a/tests/integration/test_api_stocks.py b/tests/integration/test_api_stocks.py index e69de29..fabda26 100644 --- a/tests/integration/test_api_stocks.py +++ b/tests/integration/test_api_stocks.py @@ -0,0 +1,188 @@ +"""주가·재무 API 통합 테스트. + +conftest.py 의 async_client fixture를 사용합니다. +Stub 데이터(AAPL, 005930.KS)를 기반으로 각 엔드포인트의 응답 형식, 상태 코드, +페이지네이션, 날짜 필터, 404 처리를 검증합니다. +""" + +from __future__ import annotations + +from httpx import AsyncClient + +# ────────────────────────────────────────────────────────────────────── +# 헬스체크 +# ────────────────────────────────────────────────────────────────────── + + +async def test_health(async_client: AsyncClient) -> None: + res = await async_client.get("/health") + assert res.status_code == 200 + body = res.json() + assert body["status"] == "ok" + assert "version" in body + + +# ────────────────────────────────────────────────────────────────────── +# 종목 마스터 +# ────────────────────────────────────────────────────────────────────── + + +async def test_list_stocks(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks") + assert res.status_code == 200 + body = res.json() + assert "items" in body + assert body["total"] >= 2 + + +async def test_list_stocks_search(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks", params={"search": "apple"}) + assert res.status_code == 200 + body = res.json() + assert body["total"] >= 1 + assert any("AAPL" in item["symbol"] for item in body["items"]) + + +async def test_list_stocks_pagination(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks", params={"limit": 1, "offset": 0}) + assert res.status_code == 200 + body = res.json() + assert len(body["items"]) == 1 + assert body["limit"] == 1 + + +async def test_get_stock_found(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/AAPL") + assert res.status_code == 200 + body = res.json() + assert body["symbol"] == "AAPL" + assert body["exchange"] == "NASDAQ" + + +async def test_get_stock_not_found(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/INVALID_XYZ") + assert res.status_code == 404 + body = res.json() + assert body["status"] == 404 + assert "INVALID_XYZ" in body["identifier"] + + +# ────────────────────────────────────────────────────────────────────── +# 주가(OHLCV) +# ────────────────────────────────────────────────────────────────────── + + +async def test_get_prices(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/AAPL/prices") + assert res.status_code == 200 + body = res.json() + assert body["total"] > 0 + assert all(item["symbol"] == "AAPL" for item in body["items"]) + + +async def test_get_prices_date_range(async_client: AsyncClient) -> None: + res = await async_client.get( + "/api/v1/stocks/AAPL/prices", + params={"from": "2024-01-03", "to": "2024-01-04"}, + ) + assert res.status_code == 200 + body = res.json() + for item in body["items"]: + assert "2024-01-03" <= item["date"] <= "2024-01-04" + + +async def test_get_prices_not_found(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/NOEXIST/prices") + assert res.status_code == 404 + + +async def test_get_latest_price(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/AAPL/prices/latest") + assert res.status_code == 200 + body = res.json() + assert body["symbol"] == "AAPL" + assert body["date"] == "2024-01-08" + + +async def test_get_latest_price_not_found(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/NOEXIST/prices/latest") + assert res.status_code == 404 + + +# ────────────────────────────────────────────────────────────────────── +# 재무제표 +# ────────────────────────────────────────────────────────────────────── + + +async def test_get_financials_all(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/AAPL/financials") + assert res.status_code == 200 + body = res.json() + assert len(body) >= 3 + + +async def test_get_financials_income(async_client: AsyncClient) -> None: + res = await async_client.get( + "/api/v1/stocks/AAPL/financials", params={"statement": "income"} + ) + assert res.status_code == 200 + body = res.json() + assert all(item["statement_type"] == "income" for item in body) + + +async def test_get_financials_invalid_statement(async_client: AsyncClient) -> None: + res = await async_client.get( + "/api/v1/stocks/AAPL/financials", params={"statement": "invalid"} + ) + assert res.status_code == 422 + + +async def test_get_financials_not_found(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/NOEXIST/financials") + assert res.status_code == 404 + + +# ────────────────────────────────────────────────────────────────────── +# 배당 +# ────────────────────────────────────────────────────────────────────── + + +async def test_get_dividends(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/AAPL/dividends") + assert res.status_code == 200 + body = res.json() + assert body["total"] >= 4 + + +async def test_get_dividends_date_filter(async_client: AsyncClient) -> None: + res = await async_client.get( + "/api/v1/stocks/AAPL/dividends", + params={"from": "2024-01-01"}, + ) + assert res.status_code == 200 + body = res.json() + for item in body["items"]: + assert item["ex_date"] >= "2024-01-01" + + +async def test_get_dividends_not_found(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/NOEXIST/dividends") + assert res.status_code == 404 + + +# ────────────────────────────────────────────────────────────────────── +# 주요지표 +# ────────────────────────────────────────────────────────────────────── + + +async def test_get_metrics(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/AAPL/metrics") + assert res.status_code == 200 + body = res.json() + assert body["symbol"] == "AAPL" + assert body["per"] is not None + + +async def test_get_metrics_not_found(async_client: AsyncClient) -> None: + res = await async_client.get("/api/v1/stocks/NOEXIST/metrics") + assert res.status_code == 404 diff --git a/tests/unit/test_stock_service.py b/tests/unit/test_stock_service.py index e69de29..18cb042 100644 --- a/tests/unit/test_stock_service.py +++ b/tests/unit/test_stock_service.py @@ -0,0 +1,167 @@ +"""StockService 단위 테스트. + +StubStockRepository를 직접 주입해 DB 없이 서비스 로직을 검증합니다. +""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from app.core.exceptions import EntityNotFoundError +from app.repositories.postgres.stub_stock_repository import StubStockRepository +from app.services.stock_service import StockService + + +@pytest.fixture +def service() -> StockService: + return StockService(repo=StubStockRepository()) + + +# ────────────────────────────────────────────────────────────────────── +# 종목 마스터 +# ────────────────────────────────────────────────────────────────────── + + +async def test_list_securities_returns_all(service: StockService) -> None: + page = await service.list_securities() + assert page.total >= 2 + symbols = [s.symbol for s in page.items] + assert "AAPL" in symbols + assert "005930.KS" in symbols + + +async def test_list_securities_search(service: StockService) -> None: + page = await service.list_securities(search="apple") + assert page.total >= 1 + assert all("apple" in s.name.lower() or "apple" in s.symbol.lower() for s in page.items) + + +async def test_list_securities_sector_filter(service: StockService) -> None: + page = await service.list_securities(sector="Technology") + assert page.total >= 1 + assert all(s.sector == "Technology" for s in page.items) + + +async def test_list_securities_pagination(service: StockService) -> None: + page = await service.list_securities(limit=1, offset=0) + assert len(page.items) == 1 + assert page.limit == 1 + assert page.offset == 0 + + +async def test_get_security_found(service: StockService) -> None: + security = await service.get_security("AAPL") + assert security.symbol == "AAPL" + assert security.exchange == "NASDAQ" + + +async def test_get_security_not_found(service: StockService) -> None: + with pytest.raises(EntityNotFoundError) as exc_info: + await service.get_security("INVALID_XYZ") + assert exc_info.value.entity == "Security" + assert "INVALID_XYZ" in exc_info.value.identifier + + +# ────────────────────────────────────────────────────────────────────── +# 주가(OHLCV) +# ────────────────────────────────────────────────────────────────────── + + +async def test_get_prices_returns_data(service: StockService) -> None: + page = await service.get_prices("AAPL") + assert page.total > 0 + assert all(p.symbol == "AAPL" for p in page.items) + + +async def test_get_prices_date_range_filter(service: StockService) -> None: + page = await service.get_prices( + "AAPL", + from_date=date(2024, 1, 3), + to_date=date(2024, 1, 4), + ) + assert all(date(2024, 1, 3) <= p.date <= date(2024, 1, 4) for p in page.items) + + +async def test_get_prices_desc_order(service: StockService) -> None: + page = await service.get_prices("AAPL", order="desc") + dates = [p.date for p in page.items] + assert dates == sorted(dates, reverse=True) + + +async def test_get_prices_symbol_not_found(service: StockService) -> None: + with pytest.raises(EntityNotFoundError): + await service.get_prices("NOEXIST") + + +async def test_get_latest_price(service: StockService) -> None: + price = await service.get_latest_price("AAPL") + assert price.symbol == "AAPL" + assert price.date == date(2024, 1, 8) + + +async def test_get_latest_price_not_found(service: StockService) -> None: + with pytest.raises(EntityNotFoundError): + await service.get_latest_price("NOEXIST") + + +# ────────────────────────────────────────────────────────────────────── +# 재무제표 +# ────────────────────────────────────────────────────────────────────── + + +async def test_get_financials_all(service: StockService) -> None: + rows = await service.get_financials("AAPL") + assert len(rows) >= 3 + + +async def test_get_financials_income_only(service: StockService) -> None: + rows = await service.get_financials("AAPL", statement_type="income") + assert all(r.statement_type == "income" for r in rows) + + +async def test_get_financials_not_found(service: StockService) -> None: + with pytest.raises(EntityNotFoundError): + await service.get_financials("NOEXIST") + + +# ────────────────────────────────────────────────────────────────────── +# 배당 +# ────────────────────────────────────────────────────────────────────── + + +async def test_get_dividends(service: StockService) -> None: + page = await service.get_dividends("AAPL") + assert page.total >= 4 + assert all(d.symbol == "AAPL" for d in page.items) + + +async def test_get_dividends_date_filter(service: StockService) -> None: + page = await service.get_dividends( + "AAPL", + from_date=date(2024, 1, 1), + to_date=date(2024, 12, 31), + ) + assert all(d.ex_date >= date(2024, 1, 1) for d in page.items) + + +async def test_get_dividends_not_found(service: StockService) -> None: + with pytest.raises(EntityNotFoundError): + await service.get_dividends("NOEXIST") + + +# ────────────────────────────────────────────────────────────────────── +# 주요지표 +# ────────────────────────────────────────────────────────────────────── + + +async def test_get_metrics(service: StockService) -> None: + metrics = await service.get_metrics("AAPL") + assert metrics.symbol == "AAPL" + assert metrics.per is not None + + +async def test_get_metrics_not_found(service: StockService) -> None: + with pytest.raises(EntityNotFoundError): + await service.get_metrics("NOEXIST")