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,
|
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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user