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:
parent
98a161574e
commit
741b7fa7dd
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user