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>
This commit is contained in:
머니페니 2026-03-18 22:32:34 +09:00
parent 98a161574e
commit 741b7fa7dd
9 changed files with 143 additions and 46 deletions

View File

@ -129,15 +129,25 @@ 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."""
"""Get daily prices for a stock with pagination."""
base_query = db.query(Price).filter(Price.ticker == ticker)
total = base_query.count()
prices = (
db.query(Price)
.filter(Price.ticker == ticker)
.order_by(Price.date.asc())
base_query
.order_by(Price.date.desc())
.offset(skip)
.limit(limit)
.all()
)
return [PriceItem.model_validate(p) for p in prices]
return {
"items": [PriceItem.model_validate(p) for p in prices],
"total": total,
"skip": skip,
"limit": limit,
}
@router.get("/etfs")
@ -171,15 +181,25 @@ 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."""
"""Get daily prices for an ETF with pagination."""
base_query = db.query(ETFPrice).filter(ETFPrice.ticker == ticker)
total = base_query.count()
prices = (
db.query(ETFPrice)
.filter(ETFPrice.ticker == ticker)
.order_by(ETFPrice.date.asc())
base_query
.order_by(ETFPrice.date.desc())
.offset(skip)
.limit(limit)
.all()
)
return [ETFPriceItem.model_validate(p) for p in prices]
return {
"items": [ETFPriceItem.model_validate(p) for p in prices],
"total": total,
"skip": skip,
"limit": limit,
}
@router.get("/sectors")

View File

@ -4,7 +4,7 @@ Portfolio management API endpoints.
from decimal import Decimal
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.database import get_db
@ -218,19 +218,26 @@ async def set_holdings(
return new_holdings
@router.get("/{portfolio_id}/transactions", response_model=List[TransactionResponse])
@router.get("/{portfolio_id}/transactions")
async def get_transactions(
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
limit: int = 50,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
):
"""Get transaction history for a portfolio."""
"""Get transaction history for a portfolio with pagination."""
_get_portfolio(db, portfolio_id, current_user.id)
transactions = (
base_query = (
db.query(Transaction)
.filter(Transaction.portfolio_id == portfolio_id)
)
total = base_query.count()
transactions = (
base_query
.order_by(Transaction.executed_at.desc())
.offset(skip)
.limit(limit)
.all()
)
@ -240,20 +247,25 @@ async def get_transactions(
service = RebalanceService(db)
names = service.get_stock_names(tickers)
return [
TransactionResponse(
id=tx.id,
ticker=tx.ticker,
name=names.get(tx.ticker),
tx_type=tx.tx_type.value,
quantity=tx.quantity,
price=tx.price,
executed_at=tx.executed_at,
memo=tx.memo,
realized_pnl=tx.realized_pnl,
)
for tx in transactions
]
return {
"items": [
TransactionResponse(
id=tx.id,
ticker=tx.ticker,
name=names.get(tx.ticker),
tx_type=tx.tx_type.value,
quantity=tx.quantity,
price=tx.price,
executed_at=tx.executed_at,
memo=tx.memo,
realized_pnl=tx.realized_pnl,
)
for tx in transactions
],
"total": total,
"skip": skip,
"limit": limit,
}
@router.post("/{portfolio_id}/transactions", response_model=TransactionResponse, status_code=status.HTTP_201_CREATED)

View File

@ -5,7 +5,7 @@ from datetime import date
from decimal import Decimal
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.database import get_db
@ -33,23 +33,36 @@ def _get_portfolio(db: Session, portfolio_id: int, user_id: int) -> Portfolio:
return portfolio
@router.get("/{portfolio_id}/snapshots", response_model=List[SnapshotListItem])
@router.get("/{portfolio_id}/snapshots")
async def list_snapshots(
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
):
"""Get all snapshots for a portfolio."""
"""Get snapshots for a portfolio with pagination."""
_get_portfolio(db, portfolio_id, current_user.id)
snapshots = (
base_query = (
db.query(PortfolioSnapshot)
.filter(PortfolioSnapshot.portfolio_id == portfolio_id)
)
total = base_query.count()
snapshots = (
base_query
.order_by(PortfolioSnapshot.snapshot_date.desc())
.offset(skip)
.limit(limit)
.all()
)
return snapshots
return {
"items": [SnapshotListItem.model_validate(s) for s in snapshots],
"total": total,
"skip": skip,
"limit": limit,
}
@router.post("/{portfolio_id}/snapshots", response_model=SnapshotResponse, status_code=status.HTTP_201_CREATED)

View File

@ -62,7 +62,8 @@ def test_stock_prices(client: TestClient, auth_headers, db: Session):
resp = client.get("/api/data/stocks/005930/prices", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert len(data["items"]) == 2
assert data["total"] == 2
def test_list_etfs(client: TestClient, auth_headers, db: Session):
@ -76,7 +77,9 @@ 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
data = resp.json()
assert len(data["items"]) == 2
assert data["total"] == 2
def test_list_sectors(client: TestClient, auth_headers, db: Session):

View File

@ -198,7 +198,7 @@ def test_transaction_flow(client: TestClient, auth_headers):
headers=auth_headers,
)
assert response.status_code == 200
txs = response.json()
txs = response.json()["items"]
assert len(txs) == 2

View File

@ -134,7 +134,7 @@ def test_transaction_list_includes_realized_pnl(client: TestClient, auth_headers
resp = client.get(f"/api/portfolios/{pid}/transactions", headers=auth_headers)
assert resp.status_code == 200
txs = resp.json()
txs = resp.json()["items"]
assert len(txs) == 2
# Most recent first (sell)
sell_tx = next(t for t in txs if t["tx_type"] == "sell")

View File

@ -40,7 +40,9 @@ def test_snapshot_list_empty(client: TestClient, auth_headers):
headers=auth_headers,
)
assert response.status_code == 200
assert response.json() == []
data = response.json()
assert data["items"] == []
assert data["total"] == 0
def test_returns_empty(client: TestClient, auth_headers):

View File

@ -63,12 +63,12 @@ export default function PortfolioHistoryPage() {
const fetchData = async () => {
try {
const [snapshotsData, returnsData] = await Promise.all([
api.get<SnapshotItem[]>(`/api/portfolios/${portfolioId}/snapshots`),
const [snapshotsRes, returnsData] = await Promise.all([
api.get<{ items: SnapshotItem[]; total: number }>(`/api/portfolios/${portfolioId}/snapshots`),
api.get<ReturnsData>(`/api/portfolios/${portfolioId}/returns`),
]);
setSnapshots(snapshotsData);
setSnapshots(snapshotsRes.items);
setReturns(returnsData);
} catch (err) {
if (err instanceof Error && err.message === 'API request failed') {

View File

@ -65,6 +65,13 @@ interface SnapshotListItem {
snapshot_date: string;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
skip: number;
limit: number;
}
interface PortfolioDetail {
id: number;
name: string;
@ -107,6 +114,8 @@ export default function PortfolioDetailPage() {
const [loading, setLoading] = useState(true);
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [txTotal, setTxTotal] = useState(0);
const [txLoadingMore, setTxLoadingMore] = useState(false);
const [snapshots, setSnapshots] = useState<SnapshotListItem[]>([]);
const [error, setError] = useState<string | null>(null);
@ -134,17 +143,34 @@ export default function PortfolioDetailPage() {
const fetchTransactions = useCallback(async () => {
try {
const data = await api.get<Transaction[]>(`/api/portfolios/${portfolioId}/transactions`);
setTransactions(data);
const data = await api.get<PaginatedResponse<Transaction>>(
`/api/portfolios/${portfolioId}/transactions?skip=0&limit=50`
);
setTransactions(data.items);
setTxTotal(data.total);
} catch {
setTransactions([]);
}
}, [portfolioId]);
const fetchMoreTransactions = useCallback(async (currentCount: number) => {
try {
const data = await api.get<PaginatedResponse<Transaction>>(
`/api/portfolios/${portfolioId}/transactions?skip=${currentCount}&limit=50`
);
setTransactions((prev) => [...prev, ...data.items]);
setTxTotal(data.total);
} catch {
// ignore load-more errors
}
}, [portfolioId]);
const fetchSnapshots = useCallback(async () => {
try {
const data = await api.get<SnapshotListItem[]>(`/api/portfolios/${portfolioId}/snapshots`);
setSnapshots(data);
const data = await api.get<PaginatedResponse<SnapshotListItem>>(
`/api/portfolios/${portfolioId}/snapshots`
);
setSnapshots(data.items);
} catch {
setSnapshots([]);
}
@ -201,6 +227,15 @@ export default function PortfolioDetailPage() {
}));
};
const handleLoadMoreTransactions = async () => {
setTxLoadingMore(true);
try {
await fetchMoreTransactions(transactions.length);
} finally {
setTxLoadingMore(false);
}
};
const handleAddTransaction = async () => {
if (!txForm.ticker || !txForm.quantity || !txForm.price) return;
setTxSubmitting(true);
@ -606,6 +641,18 @@ export default function PortfolioDetailPage() {
</tbody>
</table>
</div>
{transactions.length < txTotal && (
<div className="flex justify-center py-4 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={handleLoadMoreTransactions}
disabled={txLoadingMore}
>
{txLoadingMore ? '불러오는 중...' : `더 보기 (${transactions.length}/${txTotal})`}
</Button>
</div>
)}
</CardContent>
</Card>
</TabsContent>