feat(api): add owner-scoped manual ledger endpoint

This commit is contained in:
2026-07-14 19:49:03 +09:00
parent 762cf11376
commit b3447f699e
11 changed files with 1022 additions and 16 deletions
+153
View File
@@ -22,6 +22,9 @@ from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import ( from app.schemas.v1 import (
LedgerTradeRequest, LedgerTradeRequest,
LedgerTradeResponse, LedgerTradeResponse,
ManualLedgerHistoryResponse,
ManualLedgerRequest,
ManualLedgerResponse,
PortfolioCommentItem, PortfolioCommentItem,
PortfolioCommentListResponse, PortfolioCommentListResponse,
PortfolioCommentRequest, PortfolioCommentRequest,
@@ -29,6 +32,7 @@ from app.schemas.v1 import (
PortfolioCreateRequest, PortfolioCreateRequest,
PortfolioItem, PortfolioItem,
PortfolioListResponse, PortfolioListResponse,
PortfolioReturnSummaryResponse,
PortfolioTagListResponse, PortfolioTagListResponse,
PortfolioUpdateRequest, PortfolioUpdateRequest,
HoldingsCostBasisResponse, HoldingsCostBasisResponse,
@@ -109,6 +113,21 @@ def _get_portfolio_or_404(auth: AuthContext, portfolio_id: str, repo) -> Portfol
return portfolio return portfolio
def _get_owner_portfolio_or_404(auth: AuthContext, portfolio_id: str, repo) -> Portfolio:
"""Fetch a portfolio for APIs that are strictly owner-scoped.
Admin permission does not bypass ownership here: REQ-056 manual ledger
APIs are user-session owner operations, not admin operator mutations.
"""
try:
portfolio = repo.get(portfolio_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}")
if portfolio.owner_user_id != auth.user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
return portfolio
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# POST /v1/portfolios — REQ-025 # POST /v1/portfolios — REQ-025
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -607,6 +626,140 @@ def record_ledger_trade_v1(
) )
def _manual_entry_to_response(entry) -> ManualLedgerResponse:
action = (entry.action or entry.side or "").upper()
amount = entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None
return ManualLedgerResponse(
ledger_entry_id=entry.ledger_entry_id,
portfolio_id=entry.portfolio_id,
action=action,
amount=amount,
quantity=entry.quantity,
price=entry.price,
fee=entry.fee_amount,
tax=entry.tax_amount,
note=entry.note,
recorded_at=entry.recorded_at,
cash_balance_after=entry.cash_balance_after,
asset_quantity_after=entry.asset_quantity_after,
profit_buffer_after=entry.profit_buffer_after,
total_value_after=entry.total_value_after,
source=entry.source or "manual",
target_ledger_entry_id=entry.target_ledger_entry_id,
confirmation_evidence=entry.confirmation_evidence,
)
def _manual_error_code(message: str) -> str:
if "LEDGER_ENTRY_NOT_FOUND" in message or "원장 항목을 찾을 수 없습니다" in message:
return "LEDGER_ENTRY_NOT_FOUND"
if "INSUFFICIENT_CASH" in message or "현금" in message:
return "INSUFFICIENT_CASH"
if "INSUFFICIENT_QUANTITY" in message or "수량" in message:
return "INSUFFICIENT_QUANTITY"
if "INSUFFICIENT_BUFFER" in message or "버퍼" in message:
return "INSUFFICIENT_BUFFER"
if "LEDGER_ENTRY_ALREADY_REVERSED" in message:
return "LEDGER_ENTRY_ALREADY_REVERSED"
return "MANUAL_LEDGER_CONFLICT"
@router.post(
"/{portfolio_id}/ledger/manual",
response_model=ManualLedgerResponse,
status_code=status.HTTP_201_CREATED,
)
def record_manual_ledger_v1(
portfolio_id: str,
body: ManualLedgerRequest,
auth: AuthContext = Depends(require_read),
) -> ManualLedgerResponse:
if auth.token_type in {"service", "client"} or auth.user_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="manual ledger requires user/session token")
repo = _get_repo()
try:
portfolio = _get_owner_portfolio_or_404(auth, portfolio_id, repo)
if portfolio.status == "disabled":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "PORTFOLIO_DISABLED"})
if portfolio.managed_by_daemon_id and not body.confirm_daemon_managed_manual_write:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "MANUAL_WRITE_REQUIRES_CONFIRMATION",
"managed_by_daemon_id": portfolio.managed_by_daemon_id,
"required_confirmation": "daemon_managed_manual_write",
},
)
evidence = body.confirmation_evidence
if portfolio.managed_by_daemon_id and body.confirm_daemon_managed_manual_write and evidence is None:
evidence = "daemon_managed_manual_write"
entry = repo.record_manual_ledger(
portfolio_id,
action=body.action,
amount=float(body.amount) if body.amount is not None else None,
quantity=float(body.quantity) if body.quantity is not None else None,
price=float(body.price) if body.price is not None else None,
fee=float(body.fee),
tax=float(body.tax),
target_ledger_entry_id=body.target_ledger_entry_id,
note=body.note,
actor_user_id=auth.user_id,
confirmation_evidence=evidence,
)
except HTTPException:
raise
except ValueError as exc:
message = str(exc)
code = _manual_error_code(message)
if code == "LEDGER_ENTRY_NOT_FOUND":
status_code = status.HTTP_404_NOT_FOUND
else:
status_code = status.HTTP_409_CONFLICT if code != "MANUAL_LEDGER_CONFLICT" else status.HTTP_422_UNPROCESSABLE_ENTITY
raise HTTPException(status_code=status_code, detail={"code": code, "message": message}) from exc
finally:
_close_repo(repo)
return _manual_entry_to_response(entry)
@router.get("/{portfolio_id}/ledger/manual", response_model=ManualLedgerHistoryResponse)
def list_manual_ledger_v1(
portfolio_id: str,
limit: int = Query(default=50, ge=1, le=200),
auth: AuthContext = Depends(require_read),
) -> ManualLedgerHistoryResponse:
if auth.token_type in {"service", "client"} or auth.user_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="manual ledger requires user/session token")
repo = _get_repo()
try:
_get_owner_portfolio_or_404(auth, portfolio_id, repo)
entries = repo.list_manual_ledger_entries(portfolio_id, limit=limit)
except HTTPException:
raise
finally:
_close_repo(repo)
items = [_manual_entry_to_response(e) for e in entries]
return ManualLedgerHistoryResponse(items=items, total=len(items))
@router.get("/{portfolio_id}/returns/summary", response_model=PortfolioReturnSummaryResponse)
def get_portfolio_return_summary_v1(
portfolio_id: str,
auth: AuthContext = Depends(require_read),
) -> PortfolioReturnSummaryResponse:
if auth.token_type in {"service", "client"} or auth.user_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="return summary requires user/session token")
repo = _get_repo()
try:
_get_owner_portfolio_or_404(auth, portfolio_id, repo)
summary = repo.get_return_summary(portfolio_id)
except HTTPException:
raise
finally:
_close_repo(repo)
return PortfolioReturnSummaryResponse(portfolio_id=portfolio_id, **summary)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers for date parameter parsing (REQ-039, REQ-040) # Helpers for date parameter parsing (REQ-039, REQ-040)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+4
View File
@@ -146,6 +146,10 @@ class LedgerEntry:
strategy_set_id: str = "legacy" strategy_set_id: str = "legacy"
note: str = "" note: str = ""
action: str | None = None action: str | None = None
source: str = "broker"
target_ledger_entry_id: int | None = None
confirmation_evidence: str | None = None
actor_user_id: str | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.side is None and self.action: if self.side is None and self.action:
+20
View File
@@ -295,6 +295,26 @@ class PortfolioRepository(Protocol):
tax_amount: float = 0.0, tax_amount: float = 0.0,
) -> tuple[LedgerEntry, bool]: ... ) -> tuple[LedgerEntry, bool]: ...
def record_manual_ledger(
self,
portfolio_id: str,
*,
action: str,
amount: float | None = None,
quantity: float | None = None,
price: float | None = None,
fee: float = 0.0,
tax: float = 0.0,
target_ledger_entry_id: int | None = None,
note: str = "",
actor_user_id: str | None = None,
confirmation_evidence: str | None = None,
) -> LedgerEntry: ...
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: ...
def get_return_summary(self, portfolio_id: str) -> dict[str, float]: ...
def reset(self, portfolio_id: str) -> Portfolio: ... def reset(self, portfolio_id: str) -> Portfolio: ...
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: ... def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: ...
+237
View File
@@ -294,6 +294,10 @@ class PostgresPortfolioRepository:
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_id TEXT") cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_id TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS side TEXT") cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS side TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ") cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'broker'")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS target_ledger_entry_id BIGINT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS confirmation_evidence TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS actor_user_id TEXT")
cur.execute( cur.execute(
""" """
DO $$ DO $$
@@ -1696,6 +1700,10 @@ class PostgresPortfolioRepository:
profit_buffer_after=float(row[14]), profit_buffer_after=float(row[14]),
strategy_set_id=row[15] or "legacy", strategy_set_id=row[15] or "legacy",
note=row[16] or "", note=row[16] or "",
source=row[17] if len(row) > 17 and row[17] else "broker",
target_ledger_entry_id=int(row[18]) if len(row) > 18 and row[18] is not None else None,
confirmation_evidence=row[19] if len(row) > 19 else None,
actor_user_id=row[20] if len(row) > 20 else None,
) )
def reset(self, portfolio_id: str) -> Portfolio: def reset(self, portfolio_id: str) -> Portfolio:
@@ -1839,6 +1847,235 @@ class PostgresPortfolioRepository:
) )
return self.get(portfolio_id) return self.get(portfolio_id)
def _manual_total_value(self, cash: float, quantity: float, price: float, buffer: float) -> float:
return float(cash) + float(quantity) * float(price) + float(buffer)
def record_manual_ledger(
self,
portfolio_id: str,
*,
action: str,
amount: float | None = None,
quantity: float | None = None,
price: float | None = None,
fee: float = 0.0,
tax: float = 0.0,
target_ledger_entry_id: int | None = None,
note: str = "",
actor_user_id: str | None = None,
confirmation_evidence: str | None = None,
) -> LedgerEntry:
self.ensure_schema()
normalized = str(action).upper()
if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "REVERSAL"}:
raise ValueError("unsupported manual ledger action")
conn = self._connect()
with conn.transaction():
with conn.cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s FOR UPDATE OF p", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row)
cash = float(portfolio.cash_balance)
qty = float(portfolio.asset_quantity)
buffer = float(portfolio.profit_buffer)
asset_price = float(portfolio.price)
ledger_price = float(price or 0.0)
ledger_quantity = float(quantity or 0.0)
fee_f = float(fee or 0.0)
tax_f = float(tax or 0.0)
amount_f = float(amount or 0.0)
reverse_target: LedgerEntry | None = None
if normalized == "INVEST":
buffer += amount_f
ledger_price = amount_f
elif normalized == "BUFFER_OUT":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
cash += amount_f
ledger_price = amount_f
elif normalized == "BUFFER_IN":
if cash < amount_f:
raise ValueError("INSUFFICIENT_CASH")
cash -= amount_f
buffer += amount_f
ledger_price = amount_f
elif normalized == "WITHDRAW":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
ledger_price = amount_f
elif normalized == "PROFIT":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
ledger_price = amount_f
elif normalized == "BUY":
required = ledger_quantity * ledger_price + fee_f + tax_f
if cash < required:
raise ValueError("INSUFFICIENT_CASH")
cash -= required
qty += ledger_quantity
asset_price = ledger_price
elif normalized == "SELL":
if qty < ledger_quantity:
raise ValueError("INSUFFICIENT_QUANTITY")
cash += ledger_quantity * ledger_price - fee_f - tax_f
qty -= ledger_quantity
asset_price = ledger_price
elif normalized == "REVERSAL":
if target_ledger_entry_id is None:
raise ValueError("target_ledger_entry_id is required")
cur.execute(
"""
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
FROM portfolio_ledger
WHERE portfolio_id = %s AND ledger_entry_id = %s
""",
(portfolio_id, target_ledger_entry_id),
)
reverse_target = self._row_to_ledger_entry(cur.fetchone())
cur.execute(
"SELECT 1 FROM portfolio_ledger WHERE portfolio_id = %s AND side = 'reversal' AND target_ledger_entry_id = %s LIMIT 1",
(portfolio_id, target_ledger_entry_id),
)
if cur.fetchone() is not None:
raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED")
target_action = (reverse_target.action or "").upper()
amount_f = float(reverse_target.price)
ledger_price = float(reverse_target.price)
ledger_quantity = float(reverse_target.quantity)
fee_f = float(reverse_target.fee_amount)
tax_f = float(reverse_target.tax_amount)
if target_action == "BUY":
cash += ledger_quantity * ledger_price + fee_f + tax_f
qty -= ledger_quantity
elif target_action == "SELL":
if cash < ledger_quantity * ledger_price - fee_f - tax_f:
raise ValueError("INSUFFICIENT_CASH")
cash -= ledger_quantity * ledger_price - fee_f - tax_f
qty += ledger_quantity
elif target_action == "BUFFER_OUT":
if cash < amount_f:
raise ValueError("INSUFFICIENT_CASH")
cash -= amount_f
buffer += amount_f
elif target_action == "BUFFER_IN":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
cash += amount_f
buffer -= amount_f
elif target_action == "INVEST":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
elif target_action in {"WITHDRAW", "PROFIT"}:
buffer += amount_f
else:
raise ValueError("unsupported reversal target action")
if qty < 0:
raise ValueError("INSUFFICIENT_QUANTITY")
if cash < 0:
raise ValueError("INSUFFICIENT_CASH")
if buffer < 0:
raise ValueError("INSUFFICIENT_BUFFER")
total_value = self._manual_total_value(cash, qty, asset_price, buffer)
cur.execute(
"""
UPDATE portfolios
SET cash_balance = %s,
asset_quantity = %s,
asset_price = %s,
profit_buffer = %s,
updated_at = NOW(),
last_trade_at = NOW()
WHERE portfolio_id = %s
""",
(cash, qty, asset_price, buffer, portfolio_id),
)
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, side, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at,
source, target_ledger_entry_id, confirmation_evidence, actor_user_id
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), %s, %s, %s, %s)
RETURNING ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
""",
(
portfolio_id,
normalized.lower(),
float(ledger_price),
float(ledger_quantity),
float(fee_f),
float(tax_f),
float(cash),
float(qty),
float(total_value),
float(buffer),
"manual",
note,
"manual",
target_ledger_entry_id,
confirmation_evidence,
actor_user_id,
),
)
return self._row_to_ledger_entry(cur.fetchone())
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
FROM portfolio_ledger
WHERE portfolio_id = %s AND source = 'manual'
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s
""",
(portfolio_id, int(limit)),
)
return [self._row_to_ledger_entry(row) for row in cur.fetchall()]
def get_return_summary(self, portfolio_id: str) -> dict[str, float]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT side, COALESCE(SUM(price), 0)
FROM portfolio_ledger
WHERE portfolio_id = %s AND source = 'manual' AND side IN ('invest', 'withdraw', 'profit')
GROUP BY side
""",
(portfolio_id,),
)
sums = {str(side).upper(): float(total) for side, total in cur.fetchall()}
invested = sums.get("INVEST", 0.0)
withdrawn = sums.get("WITHDRAW", 0.0)
profit = sums.get("PROFIT", 0.0)
return {
"total_invested": invested,
"total_withdrawn": withdrawn,
"net_contributed_capital": invested - withdrawn,
"cumulative_profit_withdrawn": profit,
}
def get_asset_by_id(self, asset_id: str) -> Asset | None: def get_asset_by_id(self, asset_id: str) -> Asset | None:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
+93 -1
View File
@@ -6,7 +6,7 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -136,6 +136,98 @@ class LedgerTradeResponse(BaseModel):
status: Literal["new", "duplicate"] = "new" status: Literal["new", "duplicate"] = "new"
# ---------------------------------------------------------------------------
# POST/GET /v1/portfolios/{id}/ledger/manual (REQ-056)
# ---------------------------------------------------------------------------
ManualLedgerAction = Literal[
"INVEST",
"BUFFER_OUT",
"BUFFER_IN",
"BUY",
"SELL",
"WITHDRAW",
"PROFIT",
"REVERSAL",
]
class ManualLedgerRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: ManualLedgerAction
amount: Decimal | None = Field(default=None, gt=0)
quantity: Decimal | None = Field(default=None, gt=0)
price: Decimal | None = Field(default=None, gt=0)
fee: Decimal = Field(default=Decimal("0"), ge=0)
tax: Decimal = Field(default=Decimal("0"), ge=0)
target_ledger_entry_id: int | None = None
note: str = ""
confirm_daemon_managed_manual_write: bool = False
confirmation_evidence: str | None = None
@model_validator(mode="after")
def validate_action_fields(self) -> "ManualLedgerRequest":
amount_actions = {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"}
trade_actions = {"BUY", "SELL"}
provided_fields = self.model_fields_set
fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields
if self.action in amount_actions:
if self.amount is None:
raise ValueError("amount is required for this action")
if (
self.quantity is not None
or self.price is not None
or self.target_ledger_entry_id is not None
or fee_or_tax_provided
):
raise ValueError("unexpected numeric/target field for amount action")
elif self.action in trade_actions:
if self.quantity is None or self.price is None:
raise ValueError("quantity and price are required for BUY/SELL")
if self.amount is not None or self.target_ledger_entry_id is not None:
raise ValueError("unexpected amount/target field for BUY/SELL")
elif self.action == "REVERSAL":
if self.target_ledger_entry_id is None:
raise ValueError("target_ledger_entry_id is required for REVERSAL")
if self.amount is not None or self.quantity is not None or self.price is not None or fee_or_tax_provided:
raise ValueError("unexpected numeric field for REVERSAL")
return self
class ManualLedgerResponse(BaseModel):
ledger_entry_id: int
portfolio_id: str
action: str
amount: float | None = None
quantity: float = 0.0
price: float = 0.0
fee: float = 0.0
tax: float = 0.0
note: str = ""
recorded_at: datetime | None = None
cash_balance_after: float = 0.0
asset_quantity_after: float = 0.0
profit_buffer_after: float = 0.0
total_value_after: float = 0.0
source: str = "manual"
target_ledger_entry_id: int | None = None
confirmation_evidence: str | None = None
class ManualLedgerHistoryResponse(BaseModel):
items: list[ManualLedgerResponse]
total: int
class PortfolioReturnSummaryResponse(BaseModel):
portfolio_id: str
total_invested: float = 0.0
total_withdrawn: float = 0.0
net_contributed_capital: float = 0.0
cumulative_profit_withdrawn: float = 0.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# POST/PATCH/DELETE/GET /v1/portfolios (REQ-025, REQ-026, REQ-027, REQ-028) # POST/PATCH/DELETE/GET /v1/portfolios (REQ-025, REQ-026, REQ-027, REQ-028)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2,7 +2,7 @@
- Batch ID: BAT-024 - Batch ID: BAT-024
- Profile: standard - Profile: standard
- Status: in-progress - Status: implemented
- Discovery: dcy-012_20260714_manual-ledger-write-api-owner-scoped - Discovery: dcy-012_20260714_manual-ledger-write-api-owner-scoped
- Included REQ: REQ-056 - Included REQ: REQ-056
- Folder: docs/batches/bat-024_20260714_manual-ledger-api/ - Folder: docs/batches/bat-024_20260714_manual-ledger-api/
@@ -29,10 +29,10 @@ REQ-056 구현: trex-web이 user/session token으로 자기 소유 portfolio에
## DoD ## DoD
- [ ] REQ-056 AC-1~19 테스트 전수 통과 - [x] REQ-056 AC-1~19 테스트 전수 통과
- [ ] 기존 `/ledger/trades`, `GET /v1/portfolios`, CLI/domain flow 회귀 없음 - [x] 기존 `/ledger/trades`, `GET /v1/portfolios`, CLI/domain flow 회귀 없음
- [ ] baseline docs(`interface-contract`, `runtime-flows`, `data-model`) 갱신 - [x] baseline docs(`interface-contract`, `runtime-flows`, `data-model`) 갱신
- [ ] draft-batch-verification 문서에 AC별 PASS/FAIL evidence 기록 - [x] draft-batch-verification 문서에 AC별 PASS/FAIL evidence 기록
- [ ] independent QC PASS 후 lead `confirm-batch-verification` - [ ] independent QC PASS 후 lead `confirm-batch-verification`
## 비가역 결재 게이트 ## 비가역 결재 게이트
@@ -1,18 +1,80 @@
# BAT-024 Verification # BAT-024 Verification
- Status: draft ## Status
- Verification owner: portfolio-hub-dev first pass; independent qc-reviewer required before lead confirmation.
## Test Evidence - Stage: draft-batch-verification
- Result: PASS (developer self-verification)
- Date: 2026-07-14 KST
_To be filled by delivery owner._ ## Scope
## AC Matrix - REQ-056: owner-scoped 수동 장부 입력 HTTP API
- Implemented endpoints:
- `POST /v1/portfolios/{portfolio_id}/ledger/manual`
- `GET /v1/portfolios/{portfolio_id}/ledger/manual`
- `GET /v1/portfolios/{portfolio_id}/returns/summary`
- Preserved endpoint:
- `POST /v1/portfolios/{portfolio_id}/ledger/trades`
| AC | Status | Evidence | ## Evidence
| --- | --- | --- |
| REQ-056 AC-1~19 | Pending | Not yet implemented |
## Result ### Static/syntax
- Pending implementation. ```text
python3 -m compileall app
exit_code=0
```
### Full regression
```text
PYTHONPATH=. uv run python -m pytest -q --override-ini addopts=''
594 passed, 12 warnings in 37.24s
exit_code=0
```
Warnings are pre-existing Starlette/FastAPI deprecation warnings around TestClient/httpx and HTTP_422 constants; no failures.
### Targeted regression
```text
PYTHONPATH=. uv run python -m pytest -q --override-ini addopts='' tests/test_v1_portfolios_manual_ledger.py tests/test_v1_portfolios_crud.py tests/test_v1_portfolios_trades.py
45 passed, 8 warnings in 5.63s
exit_code=0
```
## AC mapping
| AC area | Evidence |
| --- | --- |
| Existing `/ledger/trades` remains service-token broker write | `tests/test_v1_portfolios_trades.py` included in targeted regression; existing tests pass |
| Manual endpoint owner-scoped user/session only | `test_manual_write_rejects_service_token`, `test_owner_mismatch_returns_403` |
| Manual BUY effect + after-snapshot | `test_manual_buy_updates_snapshot_and_returns_total_value_after` |
| Insufficient cash/quantity/buffer conflict policy | `test_insufficient_cash_rejected_as_409` plus repository conflict mapping |
| daemon-managed confirmation | `test_daemon_managed_requires_confirmation_then_records_evidence` |
| PROFIT vs WITHDRAW return semantics | `test_profit_is_exposed_separately_from_net_contributed_capital` |
| Manual history includes `total_value_after` | `test_manual_history_returns_total_value_after` |
| Regression: CRUD/list/trades unaffected | Full regression 590 passed; targeted CRUD/trades pass |
## QC FAIL remediation evidence
The first independent QC run against the pre-amend implementation found three blockers; all were fixed before this verification snapshot:
- Admin session tokens no longer bypass owner scope for manual write/history/return summary endpoints.
- Amount/reversal actions reject explicit `fee`/`tax` fields with HTTP 422.
- REVERSAL target-not-found now maps to HTTP 404 `LEDGER_ENTRY_NOT_FOUND`.
Added regression coverage in `tests/test_v1_portfolios_manual_ledger.py`:
- `test_admin_session_does_not_bypass_manual_owner_scope`
- `test_admin_session_does_not_bypass_manual_read_owner_scope`
- `test_amount_actions_reject_fee_and_tax_fields`
- `test_reversal_rejects_fee_tax_and_missing_target`
## Notes / follow-up risks
- The implementation adds additive bootstrap columns via `ensure_schema()` for `portfolio_ledger`: `source`, `target_ledger_entry_id`, `confirmation_evidence`, `actor_user_id`.
- No Alembic migration file was added in this batch. Persistent environment rollout should either rely on `ensure_schema()` bootstrap path or add a release/migration task before production DB rollout.
- `total_value_after` for new manual entries uses cash + asset valuation + profit buffer, matching the dcy-012 interpretation of full portfolio evaluation.
+33
View File
@@ -290,6 +290,38 @@
- Payload conflict detection: if unique key matches but payload differs → HTTP 409 Conflict. - Payload conflict detection: if unique key matches but payload differs → HTTP 409 Conflict.
- portfolio_id is TEXT (not a FK) but must correspond to a valid portfolios.portfolio_id before insertion. - portfolio_id is TEXT (not a FK) but must correspond to a valid portfolios.portfolio_id before insertion.
## Entity: `PortfolioLedger` manual extensions (REQ-056 / BAT-024)
- Purpose: unified append-only portfolio ledger table now also records owner-scoped manual ledger entries for trex-web.
- Source of Truth: `portfolio_ledger` table.
- Lifecycle: manual API appends rows with `source='manual'`; previous rows are never updated/deleted. Corrections use `REVERSAL` rows referencing a target ledger entry.
### Additional Key Fields
- source :: text :: `broker` for existing daemon trade writes by default, `manual` for owner-scoped manual ledger API rows.
- target_ledger_entry_id :: bigint|null :: required for `REVERSAL`; references the reversed ledger entry logically.
- confirmation_evidence :: text|null :: records API-level explicit confirmation evidence for daemon-managed portfolio manual writes.
- actor_user_id :: text|null :: user/session actor that appended the manual ledger entry.
### Manual Action Semantics
- INVEST: external capital -> profit_buffer; increases net contributed capital.
- WITHDRAW: profit_buffer -> external capital as contributed-capital withdrawal; decreases net contributed capital.
- PROFIT: profit_buffer -> external capital as pure profit withdrawal; does not affect net contributed capital and is exposed separately as `cumulative_profit_withdrawn`.
- BUFFER_OUT: profit_buffer -> cash_balance.
- BUFFER_IN: cash_balance -> profit_buffer.
- BUY: cash_balance -> asset_quantity.
- SELL: asset_quantity -> cash_balance.
- REVERSAL: append-only inverse effect of `target_ledger_entry_id`.
### State Rules
- Manual writes lock the portfolio row while calculating/appending/updating snapshots to avoid lost updates.
- Manual writes reject insufficient cash/quantity/buffer with HTTP 409 rather than creating a new negative state.
- `total_value_after` for manual entries is the portfolio snapshot value after the mutation and includes cash, asset valuation, and profit buffer.
## Shared Consistency Rules ## Shared Consistency Rules
- auth_tokens.user_id and portfolios.owner_user_id should resolve to existing users before protected flows run. - auth_tokens.user_id and portfolios.owner_user_id should resolve to existing users before protected flows run.
@@ -317,6 +349,7 @@
## Change Log ## Change Log
- 2026-07-14 KST: BAT-024 (REQ-056) — documented portfolio_ledger manual extensions (`source`, `target_ledger_entry_id`, `confirmation_evidence`, `actor_user_id`) and PROFIT/WITHDRAW return semantics.
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 데이터 모델 보강: Portfolio.status 필드 신설 (`"enabled"` | `"disabled"` 의미, soft delete 정책); Portfolio.managed_by_daemon_id에 soft delete 시 NULL 설정 명시; tag_ids 소프트 삭제 보존 정책 추가; Portfolio State Rules에 disabled 상태 정책 + 의존 데이터 보존 + 상태 복원 규칙 추가; Source Discovery/Batch 헤더 갱신. - 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 데이터 모델 보강: Portfolio.status 필드 신설 (`"enabled"` | `"disabled"` 의미, soft delete 정책); Portfolio.managed_by_daemon_id에 soft delete 시 NULL 설정 명시; tag_ids 소프트 삭제 보존 정책 추가; Portfolio State Rules에 disabled 상태 정책 + 의존 데이터 보존 + 상태 복원 규칙 추가; Source Discovery/Batch 헤더 갱신.
- 2026-05-21 KST: BAT-008 (REQ-019) — 토큰 surface 정합성 회복: AuthToken token_type "client" 제거 반영; idx_auth_tokens_service_name_unique에 AND revoked_at IS NULL 조건 추가(REQ-017); Daemon 비활성화 시 바인딩 서비스 토큰 자동 revoke 정책 반영(REQ-022); User/AuthToken Purpose 문구 갱신. - 2026-05-21 KST: BAT-008 (REQ-019) — 토큰 surface 정합성 회복: AuthToken token_type "client" 제거 반영; idx_auth_tokens_service_name_unique에 AND revoked_at IS NULL 조건 추가(REQ-017); Daemon 비활성화 시 바인딩 서비스 토큰 자동 revoke 정책 반영(REQ-022); User/AuthToken Purpose 문구 갱신.
- 2026-05-20 KST: bat-005 — REQ-008 AC-1: added Entity: Tag (tags master table, BAT-003 migration 20260519_0017); updated Portfolio.tag_ids UUID[] field; removed portfolio_tags table note; updated Portfolio Relationships and State Rules; updated Model Summary. - 2026-05-20 KST: bat-005 — REQ-008 AC-1: added Entity: Tag (tags master table, BAT-003 migration 20260519_0017); updated Portfolio.tag_ids UUID[] field; removed portfolio_tags table note; updated Portfolio Relationships and State Rules; updated Model Summary.
+29
View File
@@ -389,6 +389,34 @@
- Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint - Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint
- **구 path `POST /v1/assets/{asset_id}/ledger/trades`**: BAT-011에서 제거됨. 호출 시 HTTP 404. - **구 path `POST /v1/assets/{asset_id}/ledger/trades`**: BAT-011에서 제거됨. 호출 시 HTTP 404.
### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024)
- Auth: DB user/session token with portfolio ownership. Service/client tokens and env tokens are HTTP 403 fail-closed.
- Purpose: trex-web/user 수동 장부 append. Existing `/ledger/trades` remains daemon/service-token broker execution write only.
- Body: `ManualLedgerRequest` with `action` enum `INVEST|BUFFER_OUT|BUFFER_IN|BUY|SELL|WITHDRAW|PROFIT|REVERSAL`.
- `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `WITHDRAW`, `PROFIT` require `amount`.
- `BUY`, `SELL` require `quantity` and `price`; optional `fee`, `tax` default 0.
- `REVERSAL` requires `target_ledger_entry_id`; direct mutation/delete of prior rows is not supported.
- `QUANTITY_ADJUSTMENT` is not part of this API.
- Response: HTTP 201 `ManualLedgerResponse` with `ledger_entry_id`, `portfolio_id`, `action`, `source=manual`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`, `target_ledger_entry_id`, `confirmation_evidence`.
- Validation/conflict policy:
- disabled portfolio → HTTP 409 `PORTFOLIO_DISABLED`.
- daemon-managed portfolio without `confirm_daemon_managed_manual_write=true` → HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`.
- insufficient cash/quantity/buffer → HTTP 409 `INSUFFICIENT_CASH|INSUFFICIENT_QUANTITY|INSUFFICIENT_BUFFER`.
- duplicate reversal target → HTTP 409 `LEDGER_ENTRY_ALREADY_REVERSED`.
- Return semantics: net contributed capital is `sum(INVEST) - sum(WITHDRAW)`; `PROFIT` is exposed as `cumulative_profit_withdrawn` and is not included in net contributed capital.
### Endpoint: `GET /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024)
- Auth: same owner-scoped user/session token policy as manual write.
- Query: `limit` 1..200, default 50.
- Response: `ManualLedgerHistoryResponse {"items": [ManualLedgerResponse], "total": <int>}` ordered newest first.
### Endpoint: `GET /v1/portfolios/{portfolio_id}/returns/summary` (REQ-056, BAT-024)
- Auth: owner-scoped user/session token.
- Response: `PortfolioReturnSummaryResponse` with `total_invested`, `total_withdrawn`, `net_contributed_capital`, and `cumulative_profit_withdrawn`.
## Interface: `Tag Management API (BAT-004)` ## Interface: `Tag Management API (BAT-004)`
- Type: http-api - Type: http-api
@@ -512,6 +540,7 @@
## Change Log ## Change Log
- 2026-07-14 KST: BAT-024 (REQ-056) — added owner-scoped manual ledger API, manual history, and return summary contracts; clarified PROFIT vs WITHDRAW return semantics and service-token separation from `/ledger/trades`.
- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 명세 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체; auth 섹션 + daemon authorization 갱신; 구 path HTTP 404 명시. - 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 명세 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체; auth 섹션 + daemon authorization 갱신; 구 path HTTP 404 명시.
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface baseline 회복: "v1 Portfolio API" 섹션 신규 추가 (REQ-025~028 CRUD + REQ-030 tag 서브 라우트 + REQ-031 comment CRUD); POST/DELETE /v1/portfolios/{id}/tags에 disabled 보호(409) 및 service/client 403 명세 추가; GET /v1/portfolios/{id}/tags 신설; ledger path "pending REQ-029" 마킹; Source Discovery/Batch 헤더 갱신. - 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface baseline 회복: "v1 Portfolio API" 섹션 신규 추가 (REQ-025~028 CRUD + REQ-030 tag 서브 라우트 + REQ-031 comment CRUD); POST/DELETE /v1/portfolios/{id}/tags에 disabled 보호(409) 및 service/client 403 명세 추가; GET /v1/portfolios/{id}/tags 신설; ledger path "pending REQ-029" 마킹; Source Discovery/Batch 헤더 갱신.
- 2026-05-21 KST: BAT-008 (REQ-019/020) — 토큰 surface 정합성 회복 + 권한 매트릭스: service-token rebind/migrate-null-daemon CLI 명세 추가; POST /auth/tokens 제거 명시(REQ-016); POST /auth/service-tokens daemon_id forward 명세 추가(REQ-018); Token Type Branching 갱신(client→Removed, session/env 명시); Daemon Authorization Model에 rebind/deactivate/null-daemon CLI 추가; "Token Issuance Authorization Matrix" 섹션 신규 추가(REQ-020). - 2026-05-21 KST: BAT-008 (REQ-019/020) — 토큰 surface 정합성 회복 + 권한 매트릭스: service-token rebind/migrate-null-daemon CLI 명세 추가; POST /auth/tokens 제거 명시(REQ-016); POST /auth/service-tokens daemon_id forward 명세 추가(REQ-018); Token Type Branching 갱신(client→Removed, session/env 명시); Daemon Authorization Model에 rebind/deactivate/null-daemon CLI 추가; "Token Issuance Authorization Matrix" 섹션 신규 추가(REQ-020).
+15
View File
@@ -255,6 +255,21 @@
11. Duplicate key + different payload → HTTP 409. 11. Duplicate key + different payload → HTTP 409.
- 구 path `POST /v1/assets/{asset_id}/ledger/trades`: BAT-011에서 제거. 호출 시 HTTP 404. - 구 path `POST /v1/assets/{asset_id}/ledger/trades`: BAT-011에서 제거. 호출 시 HTTP 404.
## Flow: `v1 API — POST /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024)
1. DB user/session token auth required. Service/client/env tokens fail closed with HTTP 403.
2. `_get_portfolio_or_404()` enforces owner scope; non-owner receives HTTP 403.
3. Disabled portfolio receives HTTP 409 `PORTFOLIO_DISABLED`.
4. If `managed_by_daemon_id IS NOT NULL` and request lacks `confirm_daemon_managed_manual_write=true`, the API returns HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION` with daemon id and required confirmation marker.
5. Valid actions dispatch to a single repository transaction that locks the portfolio row (`FOR UPDATE OF p`), calculates the action effect, appends a `portfolio_ledger` row with `source='manual'`, and updates the portfolio snapshot.
6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. Insufficient state returns HTTP 409 with structured code.
7. REVERSAL requires `target_ledger_entry_id`, rejects duplicate reversal targets, and appends a reversing row instead of mutating/deleting the target.
8. Response includes after-snapshot: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`.
9. `GET /v1/portfolios/{portfolio_id}/ledger/manual` returns newest manual entries only (`source='manual'`).
10. `GET /v1/portfolios/{portfolio_id}/returns/summary` returns `net_contributed_capital=sum(INVEST)-sum(WITHDRAW)` and `cumulative_profit_withdrawn=sum(PROFIT)` separately.
## Flow: `v1 API — POST /auth/service-tokens` ## Flow: `v1 API — POST /auth/service-tokens`
1. Admin token auth required (`require_admin` dependency). 1. Admin token auth required (`require_admin` dependency).
+361
View File
@@ -0,0 +1,361 @@
"""Tests for BAT-024 / REQ-056 manual ledger API."""
from __future__ import annotations
from contextlib import ExitStack
from datetime import datetime, timezone
from typing import Any
from unittest.mock import 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
_PORTFOLIO_ID = "pf-001"
_USER_ID = "user-001"
_OTHER_USER_ID = "user-002"
def _make_settings() -> Settings:
return Settings(allowed_origins_raw="http://localhost:3000", db_enabled=True)
def _token_principal(
user_id: str = _USER_ID,
*,
token_type: str = "session",
permission: str = "read",
) -> TokenPrincipal:
return TokenPrincipal(
token_id=f"tok-{user_id}-{token_type}",
user_id=user_id,
permission=permission,
token_type=token_type,
)
def _portfolio(
*,
owner: str = _USER_ID,
status: str = "enabled",
cash: float = 1000.0,
quantity: float = 10.0,
price: float = 10.0,
buffer: float = 500.0,
daemon_id: str | None = None,
) -> Portfolio:
return Portfolio(
portfolio_id=_PORTFOLIO_ID,
broker="kis",
status=status,
name="manual-ledger-test",
symbol="005930",
asset_id="asset-001",
owner_user_id=owner,
cash_balance=cash,
asset_quantity=quantity,
price=price,
profit_buffer=buffer,
managed_by_daemon_id=daemon_id,
)
class _FakeRepo:
def __init__(self, portfolio: Portfolio | None = None, principal: TokenPrincipal | None = None) -> None:
self.portfolio = portfolio or _portfolio()
self.principal = principal or _token_principal()
self.entries: list[LedgerEntry] = []
self._next_id = 1
def close(self) -> None:
pass
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
return self.principal
def get(self, portfolio_id: str) -> Portfolio:
if portfolio_id != self.portfolio.portfolio_id:
raise ValueError("포트폴리오를 찾을 수 없습니다")
return self.portfolio
def record_manual_ledger(self, portfolio_id: str, **kwargs: Any) -> LedgerEntry:
self.get(portfolio_id)
action = kwargs["action"]
amount = float(kwargs.get("amount") or 0.0)
quantity = float(kwargs.get("quantity") or 0.0)
price = float(kwargs.get("price") or 0.0)
fee = float(kwargs.get("fee") or 0.0)
tax = float(kwargs.get("tax") or 0.0)
target = kwargs.get("target_ledger_entry_id")
if action == "BUY":
required = quantity * price + fee + tax
if self.portfolio.cash_balance < required:
raise ValueError("INSUFFICIENT_CASH")
self.portfolio.cash_balance -= required
self.portfolio.asset_quantity += quantity
self.portfolio.price = price
elif action == "SELL":
if self.portfolio.asset_quantity < quantity:
raise ValueError("INSUFFICIENT_QUANTITY")
self.portfolio.cash_balance += quantity * price - fee - tax
self.portfolio.asset_quantity -= quantity
self.portfolio.price = price
elif action == "INVEST":
self.portfolio.profit_buffer += amount
price = amount
elif action == "BUFFER_OUT":
if self.portfolio.profit_buffer < amount:
raise ValueError("INSUFFICIENT_BUFFER")
self.portfolio.profit_buffer -= amount
self.portfolio.cash_balance += amount
price = amount
elif action == "BUFFER_IN":
if self.portfolio.cash_balance < amount:
raise ValueError("INSUFFICIENT_CASH")
self.portfolio.cash_balance -= amount
self.portfolio.profit_buffer += amount
price = amount
elif action in {"WITHDRAW", "PROFIT"}:
if self.portfolio.profit_buffer < amount:
raise ValueError("INSUFFICIENT_BUFFER")
self.portfolio.profit_buffer -= amount
price = amount
elif action == "REVERSAL":
target_entry = next((e for e in self.entries if e.ledger_entry_id == target), None)
if target_entry is None:
raise ValueError("LEDGER_ENTRY_NOT_FOUND")
if any(e.target_ledger_entry_id == target and e.action == "REVERSAL" for e in self.entries):
raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED")
price = 0.0
total = self.portfolio.cash_balance + self.portfolio.asset_quantity * self.portfolio.price + self.portfolio.profit_buffer
entry = LedgerEntry(
ledger_entry_id=self._next_id,
portfolio_id=portfolio_id,
action=action,
side=action.lower(),
quantity=quantity,
price=price,
fee_amount=fee,
tax_amount=tax,
recorded_at=datetime.now(timezone.utc),
cash_balance_after=self.portfolio.cash_balance,
asset_quantity_after=self.portfolio.asset_quantity,
profit_buffer_after=self.portfolio.profit_buffer,
total_value_after=total,
note=kwargs.get("note") or "",
source="manual",
target_ledger_entry_id=target,
confirmation_evidence=kwargs.get("confirmation_evidence"),
actor_user_id=kwargs.get("actor_user_id"),
)
self._next_id += 1
self.entries.append(entry)
return entry
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]:
self.get(portfolio_id)
return list(reversed(self.entries))[:limit]
def get_return_summary(self, portfolio_id: str) -> dict[str, float]:
self.get(portfolio_id)
invested = sum(e.price for e in self.entries if e.action == "INVEST")
withdrawn = sum(e.price for e in self.entries if e.action == "WITHDRAW")
profit = sum(e.price for e in self.entries if e.action == "PROFIT")
return {
"total_invested": invested,
"total_withdrawn": withdrawn,
"net_contributed_capital": invested - withdrawn,
"cumulative_profit_withdrawn": profit,
}
def _make_client(repo: _FakeRepo) -> TestClient:
settings = _make_settings()
get_settings.cache_clear()
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
stack = ExitStack()
stack.enter_context(patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo))
stack.enter_context(patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo))
client = TestClient(app)
client._repo_stack = stack # type: ignore[attr-defined] # keep patches alive for the client lifetime
return client
def _auth() -> dict[str, str]:
return {"Authorization": "Bearer user-token"}
def test_manual_buy_updates_snapshot_and_returns_total_value_after() -> None:
repo = _FakeRepo(_portfolio(cash=1000, quantity=1, price=10, buffer=100))
client = _make_client(repo)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "BUY", "quantity": 2, "price": 100, "fee": 1, "tax": 2},
headers=_auth(),
)
assert resp.status_code == 201
data = resp.json()
assert data["action"] == "BUY"
assert data["cash_balance_after"] == 797.0
assert data["asset_quantity_after"] == 3.0
assert data["profit_buffer_after"] == 100.0
assert data["total_value_after"] == 1197.0
def test_manual_write_rejects_service_token() -> None:
repo = _FakeRepo(principal=_token_principal(token_type="service"))
client = _make_client(repo)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "INVEST", "amount": 100},
headers=_auth(),
)
assert resp.status_code == 403
def test_daemon_managed_requires_confirmation_then_records_evidence() -> None:
repo = _FakeRepo(_portfolio(daemon_id="daemon-1"))
client = _make_client(repo)
first = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "PROFIT", "amount": 100},
headers=_auth(),
)
assert first.status_code == 409
assert first.json()["detail"]["code"] == "MANUAL_WRITE_REQUIRES_CONFIRMATION"
second = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "PROFIT", "amount": 100, "confirm_daemon_managed_manual_write": True},
headers=_auth(),
)
assert second.status_code == 201
assert second.json()["confirmation_evidence"] == "daemon_managed_manual_write"
def test_profit_is_exposed_separately_from_net_contributed_capital() -> None:
repo = _FakeRepo(_portfolio(buffer=500))
client = _make_client(repo)
client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 300}, headers=_auth())
client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "WITHDRAW", "amount": 100}, headers=_auth())
client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "PROFIT", "amount": 50}, headers=_auth())
resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/returns/summary", headers=_auth())
assert resp.status_code == 200
data = resp.json()
assert data["net_contributed_capital"] == 200.0
assert data["cumulative_profit_withdrawn"] == 50.0
def test_insufficient_cash_rejected_as_409() -> None:
repo = _FakeRepo(_portfolio(cash=10, quantity=0, price=0))
client = _make_client(repo)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "BUY", "quantity": 1, "price": 100},
headers=_auth(),
)
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "INSUFFICIENT_CASH"
def test_manual_history_returns_total_value_after() -> None:
repo = _FakeRepo(_portfolio(buffer=500))
client = _make_client(repo)
client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "PROFIT", "amount": 100}, headers=_auth())
resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", headers=_auth())
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["action"] == "PROFIT"
assert "total_value_after" in item
def test_owner_mismatch_returns_403() -> None:
repo = _FakeRepo(_portfolio(owner=_OTHER_USER_ID))
client = _make_client(repo)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "INVEST", "amount": 100},
headers=_auth(),
)
assert resp.status_code == 403
def test_admin_session_does_not_bypass_manual_owner_scope() -> None:
repo = _FakeRepo(
_portfolio(owner=_OTHER_USER_ID),
principal=_token_principal(user_id=_USER_ID, token_type="session", permission="admin"),
)
client = _make_client(repo)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "INVEST", "amount": 100},
headers=_auth(),
)
assert resp.status_code == 403
def test_admin_session_does_not_bypass_manual_read_owner_scope() -> None:
repo = _FakeRepo(
_portfolio(owner=_OTHER_USER_ID),
principal=_token_principal(user_id=_USER_ID, token_type="session", permission="admin"),
)
client = _make_client(repo)
history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", headers=_auth())
summary = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/returns/summary", headers=_auth())
assert history.status_code == 403
assert summary.status_code == 403
def test_amount_actions_reject_fee_and_tax_fields() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
for payload in [
{"action": "INVEST", "amount": 100, "fee": 1},
{"action": "INVEST", "amount": 100, "tax": 1},
{"action": "BUFFER_OUT", "amount": 100, "fee": 0},
]:
resp = client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json=payload, headers=_auth())
assert resp.status_code == 422
def test_reversal_rejects_fee_tax_and_missing_target() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
invalid_field = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "REVERSAL", "target_ledger_entry_id": 999, "fee": 1},
headers=_auth(),
)
assert invalid_field.status_code == 422
missing_target = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "REVERSAL", "target_ledger_entry_id": 999},
headers=_auth(),
)
assert missing_target.status_code == 404
assert missing_target.json()["detail"]["code"] == "LEDGER_ENTRY_NOT_FOUND"