galaxis-po/backend/app/api/data_explorer.py
머니페니 741b7fa7dd feat: add skip/limit pagination to prices, snapshots, and transactions APIs
Add paginated responses (items/total/skip/limit) to:
- GET /api/data/stocks/{ticker}/prices (default limit=365)
- GET /api/data/etfs/{ticker}/prices (default limit=365)
- GET /api/portfolios/{id}/snapshots (default limit=100)
- GET /api/portfolios/{id}/transactions (default limit=50)

Frontend: update snapshot/transaction consumers to handle new response
shape, add "Load more" button to transaction table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:32:34 +09:00

258 lines
6.2 KiB
Python

"""
Data explorer API endpoints for viewing collected stock/ETF data.
"""
from datetime import date
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
from app.schemas.portfolio import FloatDecimal
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: FloatDecimal | 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: FloatDecimal | None = None
class Config:
from_attributes = True
class PriceItem(BaseModel):
date: date
open: FloatDecimal | None = None
high: FloatDecimal | None = None
low: FloatDecimal | None = None
close: FloatDecimal
volume: int | None = None
class Config:
from_attributes = True
class ETFPriceItem(BaseModel):
date: date
close: FloatDecimal
nav: FloatDecimal | 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: FloatDecimal | None = None
pbr: FloatDecimal | None = None
psr: FloatDecimal | None = None
pcr: FloatDecimal | None = None
dividend_yield: FloatDecimal | 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),
skip: int = Query(0, ge=0),
limit: int = Query(365, ge=1, le=3000),
):
"""Get daily prices for a stock with pagination."""
base_query = db.query(Price).filter(Price.ticker == ticker)
total = base_query.count()
prices = (
base_query
.order_by(Price.date.desc())
.offset(skip)
.limit(limit)
.all()
)
return {
"items": [PriceItem.model_validate(p) for p in prices],
"total": total,
"skip": skip,
"limit": limit,
}
@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),
skip: int = Query(0, ge=0),
limit: int = Query(365, ge=1, le=3000),
):
"""Get daily prices for an ETF with pagination."""
base_query = db.query(ETFPrice).filter(ETFPrice.ticker == ticker)
total = base_query.count()
prices = (
base_query
.order_by(ETFPrice.date.desc())
.offset(skip)
.limit(limit)
.all()
)
return {
"items": [ETFPriceItem.model_validate(p) for p in prices],
"total": total,
"skip": skip,
"limit": limit,
}
@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,
}