galaxis-po/backend/tests/e2e/test_realized_pnl.py
머니페니 741b7fa7dd 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>
2026-03-18 22:32:34 +09:00

266 lines
8.0 KiB
Python

"""
E2E tests for realized/unrealized PnL tracking and position sizing.
"""
import pytest
from fastapi.testclient import TestClient
def _create_portfolio(client: TestClient, auth_headers: dict, name: str = "PnL Test") -> int:
"""Helper to create a portfolio and return its ID."""
resp = client.post(
"/api/portfolios",
json={"name": name, "portfolio_type": "general"},
headers=auth_headers,
)
assert resp.status_code == 201
return resp.json()["id"]
def test_sell_transaction_records_realized_pnl(client: TestClient, auth_headers):
"""매도 거래 시 realized_pnl이 계산되어 저장된다."""
pid = _create_portfolio(client, auth_headers)
# Buy 10 shares at 70,000
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
# Sell 5 shares at 80,000 → realized_pnl = (80000 - 70000) * 5 = 50,000
resp = client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "sell",
"quantity": 5,
"price": 80000,
"executed_at": "2024-01-20T10:00:00",
},
headers=auth_headers,
)
assert resp.status_code == 201
tx = resp.json()
assert tx["realized_pnl"] == 50000.0
def test_sell_transaction_loss_realized_pnl(client: TestClient, auth_headers):
"""매도 손실 시 음수 realized_pnl이 기록된다."""
pid = _create_portfolio(client, auth_headers)
# Buy 10 shares at 70,000
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
# Sell 5 shares at 60,000 → realized_pnl = (60000 - 70000) * 5 = -50,000
resp = client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "sell",
"quantity": 5,
"price": 60000,
"executed_at": "2024-01-20T10:00:00",
},
headers=auth_headers,
)
assert resp.status_code == 201
tx = resp.json()
assert tx["realized_pnl"] == -50000.0
def test_buy_transaction_no_realized_pnl(client: TestClient, auth_headers):
"""매수 거래에는 realized_pnl이 없다."""
pid = _create_portfolio(client, auth_headers)
resp = client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
assert resp.status_code == 201
tx = resp.json()
assert tx["realized_pnl"] is None
def test_transaction_list_includes_realized_pnl(client: TestClient, auth_headers):
"""거래 목록 조회 시 realized_pnl이 포함된다."""
pid = _create_portfolio(client, auth_headers)
# Buy then sell
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "sell",
"quantity": 5,
"price": 75000,
"executed_at": "2024-01-20T10:00:00",
},
headers=auth_headers,
)
resp = client.get(f"/api/portfolios/{pid}/transactions", headers=auth_headers)
assert resp.status_code == 200
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")
buy_tx = next(t for t in txs if t["tx_type"] == "buy")
assert sell_tx["realized_pnl"] == 25000.0
assert buy_tx["realized_pnl"] is None
def test_portfolio_detail_includes_realized_unrealized_pnl(client: TestClient, auth_headers):
"""포트폴리오 상세에 실현/미실현 수익이 포함된다."""
pid = _create_portfolio(client, auth_headers)
# Buy 10 shares at 70,000
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "buy",
"quantity": 10,
"price": 70000,
"executed_at": "2024-01-15T10:00:00",
},
headers=auth_headers,
)
# Sell 5 shares at 80,000
client.post(
f"/api/portfolios/{pid}/transactions",
json={
"ticker": "005930",
"tx_type": "sell",
"quantity": 5,
"price": 80000,
"executed_at": "2024-01-20T10:00:00",
},
headers=auth_headers,
)
resp = client.get(f"/api/portfolios/{pid}/detail", headers=auth_headers)
assert resp.status_code == 200
detail = resp.json()
assert detail["total_realized_pnl"] == 50000.0
# unrealized_pnl depends on current prices but should be present
assert "total_unrealized_pnl" in detail
def test_rebalance_apply_records_realized_pnl(client: TestClient, auth_headers):
"""리밸런싱 적용 시 매도 거래에 realized_pnl이 기록된다."""
pid = _create_portfolio(client, auth_headers)
# Setup initial holdings
client.put(
f"/api/portfolios/{pid}/holdings",
json=[{"ticker": "005930", "quantity": 10, "avg_price": 70000}],
headers=auth_headers,
)
# Apply rebalance with a sell
resp = client.post(
f"/api/portfolios/{pid}/rebalance/apply",
json={
"items": [
{"ticker": "005930", "action": "sell", "quantity": 3, "price": 75000},
]
},
headers=auth_headers,
)
assert resp.status_code == 201
data = resp.json()
sell_tx = data["transactions"][0]
assert sell_tx["realized_pnl"] == 15000.0 # (75000 - 70000) * 3
def test_position_size_endpoint(client: TestClient, auth_headers):
"""포지션 사이징 가이드 API가 올바르게 동작한다."""
pid = _create_portfolio(client, auth_headers)
# Set holdings and targets
client.put(
f"/api/portfolios/{pid}/holdings",
json=[
{"ticker": "005930", "quantity": 10, "avg_price": 70000},
],
headers=auth_headers,
)
client.put(
f"/api/portfolios/{pid}/targets",
json=[
{"ticker": "005930", "target_ratio": 50},
{"ticker": "000660", "target_ratio": 50},
],
headers=auth_headers,
)
# Get position size for a new ticker
resp = client.get(
f"/api/portfolios/{pid}/position-size?ticker=000660&price=150000",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "000660"
assert data["current_holding_quantity"] == 0
assert data["target_ratio"] == 50.0
assert data["recommended_quantity"] >= 0
assert data["max_quantity"] >= 0
def test_position_size_no_targets(client: TestClient, auth_headers):
"""목표 비중 없을 때 포지션 사이징이 기본값으로 동작한다."""
pid = _create_portfolio(client, auth_headers)
client.put(
f"/api/portfolios/{pid}/holdings",
json=[{"ticker": "005930", "quantity": 10, "avg_price": 70000}],
headers=auth_headers,
)
resp = client.get(
f"/api/portfolios/{pid}/position-size?ticker=000660&price=150000",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "000660"
assert data["target_ratio"] is None
# Without targets, max should use 20% default
assert data["max_quantity"] >= 0