diff --git a/backend/app/api/data_explorer.py b/backend/app/api/data_explorer.py index 6283df9..08f121a 100644 --- a/backend/app/api/data_explorer.py +++ b/backend/app/api/data_explorer.py @@ -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") diff --git a/backend/app/api/portfolio.py b/backend/app/api/portfolio.py index 3d1ee3a..247b74a 100644 --- a/backend/app/api/portfolio.py +++ b/backend/app/api/portfolio.py @@ -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) diff --git a/backend/app/api/snapshot.py b/backend/app/api/snapshot.py index 9a756df..03cb166 100644 --- a/backend/app/api/snapshot.py +++ b/backend/app/api/snapshot.py @@ -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) diff --git a/backend/tests/e2e/test_data_explorer.py b/backend/tests/e2e/test_data_explorer.py index c7d884a..271a5f2 100644 --- a/backend/tests/e2e/test_data_explorer.py +++ b/backend/tests/e2e/test_data_explorer.py @@ -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): diff --git a/backend/tests/e2e/test_portfolio_flow.py b/backend/tests/e2e/test_portfolio_flow.py index 20ea1fc..be68439 100644 --- a/backend/tests/e2e/test_portfolio_flow.py +++ b/backend/tests/e2e/test_portfolio_flow.py @@ -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 diff --git a/backend/tests/e2e/test_realized_pnl.py b/backend/tests/e2e/test_realized_pnl.py index 3bcbdee..d80ec95 100644 --- a/backend/tests/e2e/test_realized_pnl.py +++ b/backend/tests/e2e/test_realized_pnl.py @@ -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") diff --git a/backend/tests/e2e/test_snapshot_flow.py b/backend/tests/e2e/test_snapshot_flow.py index 6f3b0fa..701e566 100644 --- a/backend/tests/e2e/test_snapshot_flow.py +++ b/backend/tests/e2e/test_snapshot_flow.py @@ -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): diff --git a/frontend/src/app/portfolio/[id]/history/page.tsx b/frontend/src/app/portfolio/[id]/history/page.tsx index 6aeb2a3..cc9bc70 100644 --- a/frontend/src/app/portfolio/[id]/history/page.tsx +++ b/frontend/src/app/portfolio/[id]/history/page.tsx @@ -63,12 +63,12 @@ export default function PortfolioHistoryPage() { const fetchData = async () => { try { - const [snapshotsData, returnsData] = await Promise.all([ - api.get(`/api/portfolios/${portfolioId}/snapshots`), + const [snapshotsRes, returnsData] = await Promise.all([ + api.get<{ items: SnapshotItem[]; total: number }>(`/api/portfolios/${portfolioId}/snapshots`), api.get(`/api/portfolios/${portfolioId}/returns`), ]); - setSnapshots(snapshotsData); + setSnapshots(snapshotsRes.items); setReturns(returnsData); } catch (err) { if (err instanceof Error && err.message === 'API request failed') { diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx index 13c3fc9..315eb64 100644 --- a/frontend/src/app/portfolio/[id]/page.tsx +++ b/frontend/src/app/portfolio/[id]/page.tsx @@ -65,6 +65,13 @@ interface SnapshotListItem { snapshot_date: string; } +interface PaginatedResponse { + 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(null); const [transactions, setTransactions] = useState([]); + const [txTotal, setTxTotal] = useState(0); + const [txLoadingMore, setTxLoadingMore] = useState(false); const [snapshots, setSnapshots] = useState([]); const [error, setError] = useState(null); @@ -134,17 +143,34 @@ export default function PortfolioDetailPage() { const fetchTransactions = useCallback(async () => { try { - const data = await api.get(`/api/portfolios/${portfolioId}/transactions`); - setTransactions(data); + const data = await api.get>( + `/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>( + `/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(`/api/portfolios/${portfolioId}/snapshots`); - setSnapshots(data); + const data = await api.get>( + `/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() { + {transactions.length < txTotal && ( +
+ +
+ )}