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>
258 lines
6.2 KiB
Python
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,
|
|
}
|