diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index b2d2274..2387385 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -5,6 +5,7 @@ from app.api.strategy import router as strategy_router from app.api.market import router as market_router from app.api.backtest import router as backtest_router from app.api.snapshot import router as snapshot_router +from app.api.data_explorer import router as data_explorer_router __all__ = [ "auth_router", @@ -14,4 +15,5 @@ __all__ = [ "market_router", "backtest_router", "snapshot_router", + "data_explorer_router", ] diff --git a/backend/app/api/data_explorer.py b/backend/app/api/data_explorer.py new file mode 100644 index 0000000..ae17c27 --- /dev/null +++ b/backend/app/api/data_explorer.py @@ -0,0 +1,237 @@ +""" +Data explorer API endpoints for viewing collected stock/ETF data. +""" +from datetime import date +from decimal import Decimal +from typing import Optional, List + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import or_ +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.database import get_db +from app.api.deps import CurrentUser +from app.models.stock import Stock, ETF, Price, ETFPrice, Sector, Valuation + +router = APIRouter(prefix="/api/data", tags=["data-explorer"]) + + +# --- Response schemas --- + +class PaginatedResponse(BaseModel): + items: list + total: int + page: int + size: int + + +class StockItem(BaseModel): + ticker: str + name: str + market: str + close_price: Decimal | None = None + market_cap: int | None = None + + class Config: + from_attributes = True + + +class ETFItem(BaseModel): + ticker: str + name: str + asset_class: str + market: str + expense_ratio: Decimal | None = None + + class Config: + from_attributes = True + + +class PriceItem(BaseModel): + date: date + open: Decimal | None = None + high: Decimal | None = None + low: Decimal | None = None + close: Decimal + volume: int | None = None + + class Config: + from_attributes = True + + +class ETFPriceItem(BaseModel): + date: date + close: Decimal + nav: Decimal | None = None + volume: int | None = None + + class Config: + from_attributes = True + + +class SectorItem(BaseModel): + ticker: str + company_name: str + sector_code: str + sector_name: str + + class Config: + from_attributes = True + + +class ValuationItem(BaseModel): + ticker: str + base_date: date + per: Decimal | None = None + pbr: Decimal | None = None + psr: Decimal | None = None + pcr: Decimal | None = None + dividend_yield: Decimal | None = None + + class Config: + from_attributes = True + + +# --- Endpoints --- + +@router.get("/stocks") +async def list_stocks( + current_user: CurrentUser, + db: Session = Depends(get_db), + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + search: Optional[str] = None, + market: Optional[str] = None, +): + """List collected stocks with pagination and search.""" + query = db.query(Stock) + if search: + query = query.filter( + or_(Stock.ticker.contains(search), Stock.name.contains(search)) + ) + if market: + query = query.filter(Stock.market == market) + + total = query.count() + items = query.order_by(Stock.ticker).offset((page - 1) * size).limit(size).all() + + return { + "items": [StockItem.model_validate(s) for s in items], + "total": total, + "page": page, + "size": size, + } + + +@router.get("/stocks/{ticker}/prices") +async def get_stock_prices( + ticker: str, + current_user: CurrentUser, + db: Session = Depends(get_db), +): + """Get daily prices for a stock.""" + prices = ( + db.query(Price) + .filter(Price.ticker == ticker) + .order_by(Price.date.asc()) + .all() + ) + return [PriceItem.model_validate(p) for p in prices] + + +@router.get("/etfs") +async def list_etfs( + current_user: CurrentUser, + db: Session = Depends(get_db), + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + search: Optional[str] = None, +): + """List collected ETFs with pagination and search.""" + query = db.query(ETF) + if search: + query = query.filter( + or_(ETF.ticker.contains(search), ETF.name.contains(search)) + ) + + total = query.count() + items = query.order_by(ETF.ticker).offset((page - 1) * size).limit(size).all() + + return { + "items": [ETFItem.model_validate(e) for e in items], + "total": total, + "page": page, + "size": size, + } + + +@router.get("/etfs/{ticker}/prices") +async def get_etf_prices( + ticker: str, + current_user: CurrentUser, + db: Session = Depends(get_db), +): + """Get daily prices for an ETF.""" + prices = ( + db.query(ETFPrice) + .filter(ETFPrice.ticker == ticker) + .order_by(ETFPrice.date.asc()) + .all() + ) + return [ETFPriceItem.model_validate(p) for p in prices] + + +@router.get("/sectors") +async def list_sectors( + current_user: CurrentUser, + db: Session = Depends(get_db), + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + search: Optional[str] = None, +): + """List sector classification data.""" + query = db.query(Sector) + if search: + query = query.filter( + or_(Sector.ticker.contains(search), Sector.company_name.contains(search), Sector.sector_name.contains(search)) + ) + + total = query.count() + items = query.order_by(Sector.ticker).offset((page - 1) * size).limit(size).all() + + return { + "items": [SectorItem.model_validate(s) for s in items], + "total": total, + "page": page, + "size": size, + } + + +@router.get("/valuations") +async def list_valuations( + current_user: CurrentUser, + db: Session = Depends(get_db), + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + search: Optional[str] = None, +): + """List valuation metrics data.""" + query = db.query(Valuation) + if search: + query = query.filter(Valuation.ticker.contains(search)) + + total = query.count() + items = ( + query.order_by(Valuation.ticker, Valuation.base_date.desc()) + .offset((page - 1) * size) + .limit(size) + .all() + ) + + return { + "items": [ValuationItem.model_validate(v) for v in items], + "total": total, + "page": page, + "size": size, + } diff --git a/backend/app/main.py b/backend/app/main.py index 081b8f4..a19a059 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api import ( auth_router, admin_router, portfolio_router, strategy_router, - market_router, backtest_router, snapshot_router, + market_router, backtest_router, snapshot_router, data_explorer_router, ) # Configure logging @@ -111,6 +111,7 @@ app.include_router(strategy_router) app.include_router(market_router) app.include_router(backtest_router) app.include_router(snapshot_router) +app.include_router(data_explorer_router) @app.get("/health") diff --git a/backend/tests/e2e/test_data_explorer.py b/backend/tests/e2e/test_data_explorer.py new file mode 100644 index 0000000..c7d884a --- /dev/null +++ b/backend/tests/e2e/test_data_explorer.py @@ -0,0 +1,93 @@ +""" +E2E tests for data explorer API. +""" +from datetime import date +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models.stock import Stock, ETF, Price, ETFPrice, Sector, Valuation, AssetClass + + +def _seed_stock(db: Session): + """Add test stock data.""" + stock = Stock( + ticker="005930", name="삼성전자", market="KOSPI", + close_price=70000, market_cap=400000000000000, + stock_type="common", base_date=date(2025, 1, 1), + ) + db.add(stock) + db.add(Price(ticker="005930", date=date(2025, 1, 2), open=69000, high=71000, low=68500, close=70000, volume=10000000)) + db.add(Price(ticker="005930", date=date(2025, 1, 3), open=70000, high=72000, low=69000, close=71000, volume=12000000)) + db.commit() + + +def _seed_etf(db: Session): + """Add test ETF data.""" + etf = ETF(ticker="069500", name="TIGER 200", asset_class=AssetClass.EQUITY, market="ETF") + db.add(etf) + db.add(ETFPrice(ticker="069500", date=date(2025, 1, 2), close=43000, volume=500000)) + db.add(ETFPrice(ticker="069500", date=date(2025, 1, 3), close=43500, volume=600000)) + db.commit() + + +def _seed_sector(db: Session): + db.add(Sector(ticker="005930", sector_code="G45", company_name="삼성전자", sector_name="반도체", base_date=date(2025, 1, 1))) + db.commit() + + +def _seed_valuation(db: Session): + db.add(Valuation(ticker="005930", base_date=date(2025, 1, 1), per=12.5, pbr=1.3, dividend_yield=2.1)) + db.commit() + + +def test_list_stocks(client: TestClient, auth_headers, db: Session): + _seed_stock(db) + resp = client.get("/api/data/stocks", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 1 + assert len(data["items"]) >= 1 + assert data["items"][0]["ticker"] == "005930" + + +def test_list_stocks_search(client: TestClient, auth_headers, db: Session): + _seed_stock(db) + resp = client.get("/api/data/stocks?search=삼성", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["total"] >= 1 + + +def test_stock_prices(client: TestClient, auth_headers, db: Session): + _seed_stock(db) + resp = client.get("/api/data/stocks/005930/prices", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + + +def test_list_etfs(client: TestClient, auth_headers, db: Session): + _seed_etf(db) + resp = client.get("/api/data/etfs", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["total"] >= 1 + + +def test_etf_prices(client: TestClient, auth_headers, db: Session): + _seed_etf(db) + resp = client.get("/api/data/etfs/069500/prices", headers=auth_headers) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +def test_list_sectors(client: TestClient, auth_headers, db: Session): + _seed_sector(db) + resp = client.get("/api/data/sectors", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["total"] >= 1 + + +def test_list_valuations(client: TestClient, auth_headers, db: Session): + _seed_valuation(db) + resp = client.get("/api/data/valuations", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["total"] >= 1