- GET /v1/portfolios/{id}/trades and GET /v1/portfolios/{id}/pnl/realized endpoints
- Weighted Average Cost (WAC) realized PnL calculation in app/domain/pnl.py
- cost_basis and data_completeness_warning returned in pnl API response
- Test coverage: 50 passed (WAC 20 + trades 12 + pnl 18), full 427 passed regression ✓
- WAC validated against 199,300 unit e2e test cases
- DB migration: none required
- QC: qc-reviewer PASS-WITH-NOTES; team lead confirm-batch-verification → release-candidate
- Remaining non-blocking: symbol filter edge case testing, HTTP_422 deprecation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""Tests for GET /v1/portfolios/{portfolio_id}/trades (REQ-039, REQ-042).
|
|
|
|
Uses TestClient with MagicMock repo injection — no DB required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.core.config import Settings, get_settings
|
|
from app.domain.models import LedgerEntry, Portfolio, TokenPrincipal
|
|
from app.main import create_app
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
|
_USER_ID = "user-001"
|
|
_OTHER_USER_ID = "user-002"
|
|
_PORTFOLIO_ID = "pf-trades-001"
|
|
_PATH = f"/v1/portfolios/{_PORTFOLIO_ID}/trades"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_settings() -> Settings:
|
|
get_settings.cache_clear()
|
|
return Settings(
|
|
db_enabled=True,
|
|
credential_encryption_key=_ENC_KEY,
|
|
allowed_origins_raw="http://localhost:3000",
|
|
)
|
|
|
|
|
|
def _make_client(settings: Settings) -> TestClient:
|
|
app = create_app(settings_factory=lambda: settings)
|
|
app.dependency_overrides[get_settings] = lambda: settings
|
|
return TestClient(app)
|
|
|
|
|
|
def _make_portfolio(*, owner: str = _USER_ID, status: str = "enabled") -> Portfolio:
|
|
return Portfolio(
|
|
portfolio_id=_PORTFOLIO_ID,
|
|
broker="kis",
|
|
status=status,
|
|
name="test-portfolio",
|
|
symbol="005930",
|
|
asset_id="asset-001",
|
|
owner_user_id=owner,
|
|
cash_balance=1_000_000.0,
|
|
asset_quantity=0.0,
|
|
tag_ids=[],
|
|
managed_by_daemon_id=None,
|
|
)
|
|
|
|
|
|
def _make_ledger_entry(
|
|
*,
|
|
ledger_entry_id: int = 1,
|
|
side: str = "buy",
|
|
quantity: float = 100.0,
|
|
price: float = 10_000.0,
|
|
fee: float = 0.0,
|
|
tax: float = 0.0,
|
|
executed_at: datetime | None = None,
|
|
) -> LedgerEntry:
|
|
if executed_at is None:
|
|
executed_at = datetime(2026, 6, 15, 9, 0, 0, tzinfo=timezone.utc)
|
|
return LedgerEntry(
|
|
ledger_entry_id=ledger_entry_id,
|
|
portfolio_id=_PORTFOLIO_ID,
|
|
order_id=f"ord-{ledger_entry_id:03d}",
|
|
execution_id=f"exec-{ledger_entry_id:03d}",
|
|
side=side,
|
|
quantity=quantity,
|
|
price=price,
|
|
fee_amount=fee,
|
|
tax_amount=tax,
|
|
executed_at=executed_at,
|
|
recorded_at=executed_at,
|
|
)
|
|
|
|
|
|
def _make_repo(
|
|
*,
|
|
portfolio: Portfolio | None = None,
|
|
trades: list[LedgerEntry] | None = None,
|
|
user_id: str = _USER_ID,
|
|
permission: str = "read",
|
|
) -> MagicMock:
|
|
repo = MagicMock()
|
|
principal = TokenPrincipal(
|
|
token_id="t1",
|
|
user_id=user_id,
|
|
permission=permission,
|
|
token_type="db",
|
|
runtime_id=None,
|
|
daemon_id=None,
|
|
)
|
|
repo.get_token_principal.return_value = principal
|
|
repo.get.return_value = portfolio or _make_portfolio()
|
|
repo.list_trades_by_date.return_value = trades if trades is not None else []
|
|
return repo
|
|
|
|
|
|
def _with_repo(repo: MagicMock) -> ExitStack:
|
|
stack = ExitStack()
|
|
stack.enter_context(
|
|
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo)
|
|
)
|
|
stack.enter_context(
|
|
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo)
|
|
)
|
|
return stack
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-1: date=YYYY-MM-DD single date → 200 + items count matches
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_trades_single_date_returns_matching_count() -> None:
|
|
"""AC-1: date param → HTTP 200 + items list."""
|
|
trades = [
|
|
_make_ledger_entry(ledger_entry_id=1, executed_at=datetime(2026, 6, 15, 9, 0, tzinfo=timezone.utc)),
|
|
_make_ledger_entry(ledger_entry_id=2, side="sell", executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc)),
|
|
_make_ledger_entry(ledger_entry_id=3, executed_at=datetime(2026, 6, 15, 11, 0, tzinfo=timezone.utc)),
|
|
]
|
|
settings = _make_settings()
|
|
repo = _make_repo(trades=trades)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["total"] == 3
|
|
assert len(body["items"]) == 3
|
|
|
|
|
|
def test_trades_single_date_item_fields() -> None:
|
|
"""AC-1: each item has required fields."""
|
|
trade = _make_ledger_entry(ledger_entry_id=1, side="buy", quantity=100.0, price=10_000.0,
|
|
fee=50.0, tax=25.0,
|
|
executed_at=datetime(2026, 6, 15, 9, 0, tzinfo=timezone.utc))
|
|
settings = _make_settings()
|
|
repo = _make_repo(trades=[trade])
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
item = resp.json()["items"][0]
|
|
assert item["side"] == "buy"
|
|
assert item["quantity"] == 100.0
|
|
assert item["price"] == 10_000.0
|
|
assert item["fee_amount"] == 50.0
|
|
assert item["tax_amount"] == 25.0
|
|
assert "executed_at" in item
|
|
assert "order_id" in item
|
|
assert "execution_id" in item
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-2: from/to range filter → 200 + items from boundary dates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_trades_range_filter_returns_items() -> None:
|
|
"""AC-2: from/to → HTTP 200 + items count."""
|
|
trades = [_make_ledger_entry(ledger_entry_id=i) for i in range(1, 8)]
|
|
settings = _make_settings()
|
|
repo = _make_repo(trades=trades)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, params={"from": "2026-06-01", "to": "2026-06-15"},
|
|
headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["total"] == 7
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-3: no date params → HTTP 422
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_trades_no_date_params_returns_422() -> None:
|
|
settings = _make_settings()
|
|
repo = _make_repo()
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-4: bad date format → HTTP 422
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_trades_bad_date_format_returns_422() -> None:
|
|
settings = _make_settings()
|
|
repo = _make_repo()
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
# "20260615" is not YYYY-MM-DD
|
|
resp = client.get(_PATH, params={"date": "20260615"}, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_trades_slash_date_format_returns_422() -> None:
|
|
settings = _make_settings()
|
|
repo = _make_repo()
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, params={"date": "2026/06/15"}, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-5: no trades in range → 200 + empty items
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_trades_empty_range_returns_200_empty() -> None:
|
|
settings = _make_settings()
|
|
repo = _make_repo(trades=[])
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["total"] == 0
|
|
assert body["items"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-6: non-owner token → HTTP 403
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_trades_non_owner_returns_403() -> None:
|
|
settings = _make_settings()
|
|
# Repo returns a portfolio owned by _USER_ID, but token belongs to _OTHER_USER_ID
|
|
repo = _make_repo(portfolio=_make_portfolio(owner=_USER_ID), user_id=_OTHER_USER_ID)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-7: non-existent portfolio → HTTP 404
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_trades_nonexistent_portfolio_returns_404() -> None:
|
|
settings = _make_settings()
|
|
repo = _make_repo()
|
|
repo.get.side_effect = ValueError("포트폴리오를 찾을 수 없습니다")
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-8: endpoint is documented in OpenAPI schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_trades_endpoint_in_openapi() -> None:
|
|
settings = _make_settings()
|
|
repo = _make_repo()
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get("/openapi.json")
|
|
assert resp.status_code == 200
|
|
openapi = resp.json()
|
|
paths = openapi.get("paths", {})
|
|
trade_path = f"/v1/portfolios/{{portfolio_id}}/trades"
|
|
assert trade_path in paths, f"Expected {trade_path!r} in OpenAPI paths"
|
|
assert "get" in paths[trade_path]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Partial range: from-only or to-only → 422
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_trades_from_only_without_to_returns_422() -> None:
|
|
settings = _make_settings()
|
|
repo = _make_repo()
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, params={"from": "2026-06-01"}, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_trades_to_only_without_from_returns_422() -> None:
|
|
settings = _make_settings()
|
|
repo = _make_repo()
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, params={"to": "2026-06-15"}, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 422
|