""" 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