get_holdings_cost_basis 핸들러의 row dict에 fee_amount 누락으로 /holdings/cost-basis의 cost_basis_per_unit이 매수 수수료 미편입값을 반환하던 버그 수정. /pnl/realized 핸들러와 동일하게 e.fee_amount 포함. 테스트: buy fee>0 케이스(1000.5 단언) + fee=0 회귀 케이스 추가. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
604 lines
21 KiB
Python
604 lines
21 KiB
Python
"""Tests for GET /v1/portfolios/{portfolio_id}/holdings/cost-basis (REQ-043).
|
|
|
|
Uses TestClient with MagicMock repo injection — no DB required.
|
|
Covers AC-1~11 from REQ-043.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
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-holdings-001"
|
|
_DAEMON_ID = "daemon-001"
|
|
_OTHER_DAEMON_ID = "daemon-002"
|
|
_PATH = f"/v1/portfolios/{_PORTFOLIO_ID}/holdings/cost-basis"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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,
|
|
symbol: str = "005930",
|
|
asset_quantity: float = 0.0,
|
|
managed_by_daemon_id: str | None = _DAEMON_ID,
|
|
) -> Portfolio:
|
|
return Portfolio(
|
|
portfolio_id=_PORTFOLIO_ID,
|
|
broker="kis",
|
|
status="enabled",
|
|
name="test-holdings-portfolio",
|
|
symbol=symbol,
|
|
asset_id="asset-001",
|
|
owner_user_id=owner,
|
|
cash_balance=1_000_000.0,
|
|
asset_quantity=asset_quantity,
|
|
tag_ids=[],
|
|
managed_by_daemon_id=managed_by_daemon_id,
|
|
)
|
|
|
|
|
|
def _make_entry(
|
|
*,
|
|
ledger_entry_id: int,
|
|
side: str,
|
|
quantity: float,
|
|
price: float,
|
|
executed_at: datetime,
|
|
fee_amount: float = 0.0,
|
|
) -> LedgerEntry:
|
|
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_amount,
|
|
tax_amount=0.0,
|
|
executed_at=executed_at,
|
|
recorded_at=executed_at,
|
|
)
|
|
|
|
|
|
def _make_service_repo(
|
|
*,
|
|
daemon_id: str | None = _DAEMON_ID,
|
|
portfolio: Portfolio | None = None,
|
|
trades: list[LedgerEntry] | None = None,
|
|
) -> MagicMock:
|
|
"""Repo mock with service token principal."""
|
|
repo = MagicMock()
|
|
principal = TokenPrincipal(
|
|
token_id="t-svc",
|
|
user_id=_USER_ID,
|
|
permission="read",
|
|
token_type="service",
|
|
runtime_id="rt-1",
|
|
daemon_id=daemon_id,
|
|
)
|
|
repo.get_token_principal.return_value = principal
|
|
repo.get.return_value = portfolio or _make_portfolio()
|
|
repo.get_all_trades_for_pnl.return_value = trades if trades is not None else []
|
|
return repo
|
|
|
|
|
|
def _make_user_repo(
|
|
*,
|
|
user_id: str = _USER_ID,
|
|
permission: str = "read",
|
|
portfolio: Portfolio | None = None,
|
|
trades: list[LedgerEntry] | None = None,
|
|
) -> MagicMock:
|
|
"""Repo mock with user (DB) token principal."""
|
|
repo = MagicMock()
|
|
principal = TokenPrincipal(
|
|
token_id="t-usr",
|
|
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.get_all_trades_for_pnl.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: service token + buy 100@10,000 + buy 100@12,000 → WAC=11,000, qty=200
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAC1WacBuysOnly:
|
|
"""AC-1: buy-only portfolio → WAC = weighted average, no warning."""
|
|
|
|
def _make_trades(self) -> list[LedgerEntry]:
|
|
return [
|
|
_make_entry(
|
|
ledger_entry_id=1, side="buy", quantity=100, price=10_000,
|
|
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
_make_entry(
|
|
ledger_entry_id=2, side="buy", quantity=100, price=12_000,
|
|
executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
|
|
def _make_portfolio_with_qty(self) -> Portfolio:
|
|
return _make_portfolio(asset_quantity=200.0)
|
|
|
|
def test_status_200(self) -> None:
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=self._make_portfolio_with_qty(),
|
|
trades=self._make_trades(),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
|
|
def test_current_quantity_200(self) -> None:
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=self._make_portfolio_with_qty(),
|
|
trades=self._make_trades(),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.json()["current_quantity"] == pytest.approx(200.0)
|
|
|
|
def test_cost_basis_per_unit_is_11000(self) -> None:
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=self._make_portfolio_with_qty(),
|
|
trades=self._make_trades(),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.json()["cost_basis_per_unit"] == pytest.approx(11_000.0)
|
|
|
|
def test_total_cost_basis_is_2_200_000(self) -> None:
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=self._make_portfolio_with_qty(),
|
|
trades=self._make_trades(),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.json()["total_cost_basis"] == pytest.approx(2_200_000.0)
|
|
|
|
def test_data_completeness_warning_is_null(self) -> None:
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=self._make_portfolio_with_qty(),
|
|
trades=self._make_trades(),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.json()["data_completeness_warning"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-2: buy 200@10,000 + sell 150 → remaining qty=50, WAC unchanged=10,000
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAC2PartialSellWacUnchanged:
|
|
"""AC-2: partial sell → WAC unchanged, quantity reduced."""
|
|
|
|
def _make_trades(self) -> list[LedgerEntry]:
|
|
return [
|
|
_make_entry(
|
|
ledger_entry_id=1, side="buy", quantity=200, price=10_000,
|
|
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
_make_entry(
|
|
ledger_entry_id=2, side="sell", quantity=150, price=12_000,
|
|
executed_at=datetime(2026, 6, 10, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
|
|
def _make_portfolio_with_qty(self) -> Portfolio:
|
|
return _make_portfolio(asset_quantity=50.0)
|
|
|
|
def test_current_quantity_50(self) -> None:
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=self._make_portfolio_with_qty(),
|
|
trades=self._make_trades(),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["current_quantity"] == pytest.approx(50.0)
|
|
|
|
def test_wac_unchanged_at_10000(self) -> None:
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=self._make_portfolio_with_qty(),
|
|
trades=self._make_trades(),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.json()["cost_basis_per_unit"] == pytest.approx(10_000.0)
|
|
|
|
def test_total_cost_basis_is_500_000(self) -> None:
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=self._make_portfolio_with_qty(),
|
|
trades=self._make_trades(),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.json()["total_cost_basis"] == pytest.approx(500_000.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-3: user read_token (owner) → 200 + normal response
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ac3_user_owner_read_token_returns_200() -> None:
|
|
"""AC-3: portfolio owner with user read token → 200."""
|
|
settings = _make_settings()
|
|
trades = [
|
|
_make_entry(
|
|
ledger_entry_id=1, side="buy", quantity=100, price=10_000,
|
|
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
repo = _make_user_repo(
|
|
user_id=_USER_ID,
|
|
portfolio=_make_portfolio(owner=_USER_ID, asset_quantity=100.0),
|
|
trades=trades,
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["current_quantity"] == pytest.approx(100.0)
|
|
assert body["cost_basis_per_unit"] == pytest.approx(10_000.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-4: user read_token non-owner → 403
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ac4_user_non_owner_returns_403() -> None:
|
|
"""AC-4: non-owner user token → 403."""
|
|
settings = _make_settings()
|
|
repo = _make_user_repo(
|
|
user_id=_OTHER_USER_ID,
|
|
portfolio=_make_portfolio(owner=_USER_ID),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-5: service token daemon_id != managed_by_daemon_id → 403
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ac5_daemon_mismatch_returns_403() -> None:
|
|
"""AC-5: service token daemon_id != portfolio.managed_by_daemon_id → 403."""
|
|
settings = _make_settings()
|
|
# Portfolio is assigned to DAEMON_ID, but token is for OTHER_DAEMON_ID
|
|
repo = _make_service_repo(
|
|
daemon_id=_OTHER_DAEMON_ID,
|
|
portfolio=_make_portfolio(managed_by_daemon_id=_DAEMON_ID),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-6: service token, portfolio.managed_by_daemon_id=null → 403
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ac6_null_daemon_returns_403() -> None:
|
|
"""AC-6: managed_by_daemon_id=null → 403 (daemon 미배정)."""
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
daemon_id=_DAEMON_ID,
|
|
portfolio=_make_portfolio(managed_by_daemon_id=None),
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-7: non-existent portfolio_id → 404
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ac7_nonexistent_portfolio_returns_404() -> None:
|
|
"""AC-7: portfolio not found → 404."""
|
|
settings = _make_settings()
|
|
repo = _make_service_repo()
|
|
repo.get.side_effect = ValueError("포트폴리오를 찾을 수 없습니다")
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-8: no buy/sell history (current_quantity=0) → 200 + null fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ac8_empty_ledger_returns_200_null_fields() -> None:
|
|
"""AC-8: no history → 200 + current_quantity=0 + null cost basis fields."""
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=_make_portfolio(asset_quantity=0.0),
|
|
trades=[],
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["current_quantity"] == pytest.approx(0.0)
|
|
assert body["cost_basis_per_unit"] is None
|
|
assert body["total_cost_basis"] is None
|
|
assert body["data_completeness_warning"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-9: sell without prior buy → data_completeness_warning non-null
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ac9_sell_without_buy_triggers_warning() -> None:
|
|
"""AC-9: sell before any buy → data_completeness_warning non-null."""
|
|
settings = _make_settings()
|
|
trades = [
|
|
_make_entry(
|
|
ledger_entry_id=1, side="sell", quantity=50, price=15_000,
|
|
executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
repo = _make_service_repo(
|
|
portfolio=_make_portfolio(asset_quantity=0.0),
|
|
trades=trades,
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["data_completeness_warning"] is not None
|
|
assert len(body["data_completeness_warning"]) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-10: no auth token → 401
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ac10_no_auth_token_returns_401() -> None:
|
|
"""AC-10: missing Authorization header → 401."""
|
|
settings = _make_settings()
|
|
repo = _make_service_repo()
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-11: endpoint documented in OpenAPI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ac11_endpoint_in_openapi() -> None:
|
|
"""AC-11: GET /v1/portfolios/{portfolio_id}/holdings/cost-basis is in OpenAPI spec."""
|
|
settings = _make_settings()
|
|
repo = _make_service_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", {})
|
|
endpoint_path = "/v1/portfolios/{portfolio_id}/holdings/cost-basis"
|
|
assert endpoint_path in paths, f"Expected {endpoint_path!r} in OpenAPI paths"
|
|
assert "get" in paths[endpoint_path]
|
|
# Verify response schema contains expected fields
|
|
get_op = paths[endpoint_path]["get"]
|
|
assert "responses" in get_op
|
|
assert "200" in get_op["responses"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Additional: response contains all required fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_response_contains_all_required_fields() -> None:
|
|
"""Response payload contains all REQ-043 specified fields."""
|
|
settings = _make_settings()
|
|
trades = [
|
|
_make_entry(
|
|
ledger_entry_id=1, side="buy", quantity=100, price=10_000,
|
|
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
repo = _make_service_repo(
|
|
portfolio=_make_portfolio(asset_quantity=100.0),
|
|
trades=trades,
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
for field in (
|
|
"portfolio_id",
|
|
"symbol",
|
|
"current_quantity",
|
|
"cost_basis_per_unit",
|
|
"total_cost_basis",
|
|
"cost_basis_from",
|
|
"data_completeness_warning",
|
|
):
|
|
assert field in body, f"Missing field in response: {field}"
|
|
|
|
|
|
def test_cost_basis_from_is_first_buy_date() -> None:
|
|
"""cost_basis_from reflects the earliest buy executed_at."""
|
|
settings = _make_settings()
|
|
first_buy = datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)
|
|
trades = [
|
|
_make_entry(
|
|
ledger_entry_id=1, side="buy", quantity=100, price=10_000,
|
|
executed_at=first_buy,
|
|
),
|
|
_make_entry(
|
|
ledger_entry_id=2, side="buy", quantity=50, price=12_000,
|
|
executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
repo = _make_service_repo(
|
|
portfolio=_make_portfolio(asset_quantity=150.0),
|
|
trades=trades,
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
body = resp.json()
|
|
assert body["cost_basis_from"] is not None
|
|
|
|
|
|
def test_portfolio_id_in_response_matches_request() -> None:
|
|
"""portfolio_id in response equals the path parameter."""
|
|
settings = _make_settings()
|
|
repo = _make_service_repo(
|
|
portfolio=_make_portfolio(asset_quantity=0.0),
|
|
trades=[],
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["portfolio_id"] == _PORTFOLIO_ID
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# QC보정: buy fee>0 케이스 — cost_basis_per_unit이 fee 편입값인지 확인
|
|
# buy 10@1000 fee=5 → cost_basis_per_unit = (10*1000 + 5) / 10 = 1000.5
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_cost_basis_per_unit_includes_buy_fee() -> None:
|
|
"""buy fee>0 케이스: cost_basis_per_unit이 매수 fee 편입값을 반환한다.
|
|
|
|
buy 10주 @ 1,000원, fee=5원 → WAC = (10*1000 + 5) / 10 = 1000.5
|
|
REQ-043 Notes: 매수 fee를 cost basis에 포함.
|
|
"""
|
|
settings = _make_settings()
|
|
trades = [
|
|
_make_entry(
|
|
ledger_entry_id=1,
|
|
side="buy",
|
|
quantity=10,
|
|
price=1_000,
|
|
fee_amount=5.0,
|
|
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
repo = _make_service_repo(
|
|
portfolio=_make_portfolio(asset_quantity=10.0),
|
|
trades=trades,
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
# (10 * 1000 + 5) / 10 = 1000.5
|
|
assert body["cost_basis_per_unit"] == pytest.approx(1000.5)
|
|
assert body["total_cost_basis"] == pytest.approx(10_005.0)
|
|
|
|
|
|
def test_cost_basis_per_unit_zero_fee_unchanged() -> None:
|
|
"""buy fee=0 케이스: fee 편입 추가 후에도 fee=0이면 기존 값 그대로.
|
|
|
|
buy 10주 @ 1,000원, fee=0 → WAC = 1000.0 (회귀 확인)
|
|
"""
|
|
settings = _make_settings()
|
|
trades = [
|
|
_make_entry(
|
|
ledger_entry_id=1,
|
|
side="buy",
|
|
quantity=10,
|
|
price=1_000,
|
|
fee_amount=0.0,
|
|
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
repo = _make_service_repo(
|
|
portfolio=_make_portfolio(asset_quantity=10.0),
|
|
trades=trades,
|
|
)
|
|
with _with_repo(repo):
|
|
client = _make_client(settings)
|
|
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["cost_basis_per_unit"] == pytest.approx(1000.0)
|
|
assert body["total_cost_basis"] == pytest.approx(10_000.0)
|