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>
266 lines
8.0 KiB
Python
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
|