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, ticker: str,
current_user: CurrentUser, current_user: CurrentUser,
db: Session = Depends(get_db), 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 = ( prices = (
db.query(Price) base_query
.filter(Price.ticker == ticker) .order_by(Price.date.desc())
.order_by(Price.date.asc()) .offset(skip)
.limit(limit)
.all() .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") @router.get("/etfs")
@ -171,15 +181,25 @@ async def get_etf_prices(
ticker: str, ticker: str,
current_user: CurrentUser, current_user: CurrentUser,
db: Session = Depends(get_db), 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 = ( prices = (
db.query(ETFPrice) base_query
.filter(ETFPrice.ticker == ticker) .order_by(ETFPrice.date.desc())
.order_by(ETFPrice.date.asc()) .offset(skip)
.limit(limit)
.all() .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") @router.get("/sectors")

View File

@ -4,7 +4,7 @@ Portfolio management API endpoints.
from decimal import Decimal from decimal import Decimal
from typing import List 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 sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@ -218,19 +218,26 @@ async def set_holdings(
return new_holdings return new_holdings
@router.get("/{portfolio_id}/transactions", response_model=List[TransactionResponse]) @router.get("/{portfolio_id}/transactions")
async def get_transactions( async def get_transactions(
portfolio_id: int, portfolio_id: int,
current_user: CurrentUser, current_user: CurrentUser,
db: Session = Depends(get_db), 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) _get_portfolio(db, portfolio_id, current_user.id)
transactions = (
base_query = (
db.query(Transaction) db.query(Transaction)
.filter(Transaction.portfolio_id == portfolio_id) .filter(Transaction.portfolio_id == portfolio_id)
)
total = base_query.count()
transactions = (
base_query
.order_by(Transaction.executed_at.desc()) .order_by(Transaction.executed_at.desc())
.offset(skip)
.limit(limit) .limit(limit)
.all() .all()
) )
@ -240,20 +247,25 @@ async def get_transactions(
service = RebalanceService(db) service = RebalanceService(db)
names = service.get_stock_names(tickers) names = service.get_stock_names(tickers)
return [ return {
TransactionResponse( "items": [
id=tx.id, TransactionResponse(
ticker=tx.ticker, id=tx.id,
name=names.get(tx.ticker), ticker=tx.ticker,
tx_type=tx.tx_type.value, name=names.get(tx.ticker),
quantity=tx.quantity, tx_type=tx.tx_type.value,
price=tx.price, quantity=tx.quantity,
executed_at=tx.executed_at, price=tx.price,
memo=tx.memo, executed_at=tx.executed_at,
realized_pnl=tx.realized_pnl, memo=tx.memo,
) realized_pnl=tx.realized_pnl,
for tx in transactions )
] for tx in transactions
],
"total": total,
"skip": skip,
"limit": limit,
}
@router.post("/{portfolio_id}/transactions", response_model=TransactionResponse, status_code=status.HTTP_201_CREATED) @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 decimal import Decimal
from typing import List 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 sqlalchemy.orm import Session
from app.core.database import get_db 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 return portfolio
@router.get("/{portfolio_id}/snapshots", response_model=List[SnapshotListItem]) @router.get("/{portfolio_id}/snapshots")
async def list_snapshots( async def list_snapshots(
portfolio_id: int, portfolio_id: int,
current_user: CurrentUser, current_user: CurrentUser,
db: Session = Depends(get_db), 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) _get_portfolio(db, portfolio_id, current_user.id)
snapshots = ( base_query = (
db.query(PortfolioSnapshot) db.query(PortfolioSnapshot)
.filter(PortfolioSnapshot.portfolio_id == portfolio_id) .filter(PortfolioSnapshot.portfolio_id == portfolio_id)
)
total = base_query.count()
snapshots = (
base_query
.order_by(PortfolioSnapshot.snapshot_date.desc()) .order_by(PortfolioSnapshot.snapshot_date.desc())
.offset(skip)
.limit(limit)
.all() .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) @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) resp = client.get("/api/data/stocks/005930/prices", headers=auth_headers)
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() 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): 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) _seed_etf(db)
resp = client.get("/api/data/etfs/069500/prices", headers=auth_headers) resp = client.get("/api/data/etfs/069500/prices", headers=auth_headers)
assert resp.status_code == 200 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): 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, headers=auth_headers,
) )
assert response.status_code == 200 assert response.status_code == 200
txs = response.json() txs = response.json()["items"]
assert len(txs) == 2 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) resp = client.get(f"/api/portfolios/{pid}/transactions", headers=auth_headers)
assert resp.status_code == 200 assert resp.status_code == 200
txs = resp.json() txs = resp.json()["items"]
assert len(txs) == 2 assert len(txs) == 2
# Most recent first (sell) # Most recent first (sell)
sell_tx = next(t for t in txs if t["tx_type"] == "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, headers=auth_headers,
) )
assert response.status_code == 200 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): def test_returns_empty(client: TestClient, auth_headers):

View File

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

View File

@ -65,6 +65,13 @@ interface SnapshotListItem {
snapshot_date: string; snapshot_date: string;
} }
interface PaginatedResponse<T> {
items: T[];
total: number;
skip: number;
limit: number;
}
interface PortfolioDetail { interface PortfolioDetail {
id: number; id: number;
name: string; name: string;
@ -107,6 +114,8 @@ export default function PortfolioDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null); const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]); const [transactions, setTransactions] = useState<Transaction[]>([]);
const [txTotal, setTxTotal] = useState(0);
const [txLoadingMore, setTxLoadingMore] = useState(false);
const [snapshots, setSnapshots] = useState<SnapshotListItem[]>([]); const [snapshots, setSnapshots] = useState<SnapshotListItem[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -134,17 +143,34 @@ export default function PortfolioDetailPage() {
const fetchTransactions = useCallback(async () => { const fetchTransactions = useCallback(async () => {
try { try {
const data = await api.get<Transaction[]>(`/api/portfolios/${portfolioId}/transactions`); const data = await api.get<PaginatedResponse<Transaction>>(
setTransactions(data); `/api/portfolios/${portfolioId}/transactions?skip=0&limit=50`
);
setTransactions(data.items);
setTxTotal(data.total);
} catch { } catch {
setTransactions([]); setTransactions([]);
} }
}, [portfolioId]); }, [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 () => { const fetchSnapshots = useCallback(async () => {
try { try {
const data = await api.get<SnapshotListItem[]>(`/api/portfolios/${portfolioId}/snapshots`); const data = await api.get<PaginatedResponse<SnapshotListItem>>(
setSnapshots(data); `/api/portfolios/${portfolioId}/snapshots`
);
setSnapshots(data.items);
} catch { } catch {
setSnapshots([]); 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 () => { const handleAddTransaction = async () => {
if (!txForm.ticker || !txForm.quantity || !txForm.price) return; if (!txForm.ticker || !txForm.quantity || !txForm.price) return;
setTxSubmitting(true); setTxSubmitting(true);
@ -606,6 +641,18 @@ export default function PortfolioDetailPage() {
</tbody> </tbody>
</table> </table>
</div> </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> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>