feat(api): add owner-scoped manual ledger endpoint
This commit is contained in:
@@ -22,6 +22,9 @@ from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import (
|
||||
LedgerTradeRequest,
|
||||
LedgerTradeResponse,
|
||||
ManualLedgerHistoryResponse,
|
||||
ManualLedgerRequest,
|
||||
ManualLedgerResponse,
|
||||
PortfolioCommentItem,
|
||||
PortfolioCommentListResponse,
|
||||
PortfolioCommentRequest,
|
||||
@@ -29,6 +32,7 @@ from app.schemas.v1 import (
|
||||
PortfolioCreateRequest,
|
||||
PortfolioItem,
|
||||
PortfolioListResponse,
|
||||
PortfolioReturnSummaryResponse,
|
||||
PortfolioTagListResponse,
|
||||
PortfolioUpdateRequest,
|
||||
HoldingsCostBasisResponse,
|
||||
@@ -109,6 +113,21 @@ def _get_portfolio_or_404(auth: AuthContext, portfolio_id: str, repo) -> Portfol
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -146,6 +146,10 @@ class LedgerEntry:
|
||||
strategy_set_id: str = "legacy"
|
||||
note: str = ""
|
||||
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:
|
||||
if self.side is None and self.action:
|
||||
|
||||
@@ -295,6 +295,26 @@ class PortfolioRepository(Protocol):
|
||||
tax_amount: float = 0.0,
|
||||
) -> 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 adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: ...
|
||||
|
||||
@@ -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 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 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(
|
||||
"""
|
||||
DO $$
|
||||
@@ -1696,6 +1700,10 @@ class PostgresPortfolioRepository:
|
||||
profit_buffer_after=float(row[14]),
|
||||
strategy_set_id=row[15] or "legacy",
|
||||
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:
|
||||
@@ -1839,6 +1847,235 @@ class PostgresPortfolioRepository:
|
||||
)
|
||||
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:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
|
||||
+93
-1
@@ -6,7 +6,7 @@ from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- Batch ID: BAT-024
|
||||
- Profile: standard
|
||||
- Status: in-progress
|
||||
- Status: implemented
|
||||
- Discovery: dcy-012_20260714_manual-ledger-write-api-owner-scoped
|
||||
- Included REQ: REQ-056
|
||||
- Folder: docs/batches/bat-024_20260714_manual-ledger-api/
|
||||
@@ -29,10 +29,10 @@ REQ-056 구현: trex-web이 user/session token으로 자기 소유 portfolio에
|
||||
|
||||
## DoD
|
||||
|
||||
- [ ] REQ-056 AC-1~19 테스트 전수 통과
|
||||
- [ ] 기존 `/ledger/trades`, `GET /v1/portfolios`, CLI/domain flow 회귀 없음
|
||||
- [ ] baseline docs(`interface-contract`, `runtime-flows`, `data-model`) 갱신
|
||||
- [ ] draft-batch-verification 문서에 AC별 PASS/FAIL evidence 기록
|
||||
- [x] REQ-056 AC-1~19 테스트 전수 통과
|
||||
- [x] 기존 `/ledger/trades`, `GET /v1/portfolios`, CLI/domain flow 회귀 없음
|
||||
- [x] baseline docs(`interface-contract`, `runtime-flows`, `data-model`) 갱신
|
||||
- [x] draft-batch-verification 문서에 AC별 PASS/FAIL evidence 기록
|
||||
- [ ] independent QC PASS 후 lead `confirm-batch-verification`
|
||||
|
||||
## 비가역 결재 게이트
|
||||
|
||||
@@ -1,18 +1,80 @@
|
||||
# BAT-024 Verification
|
||||
|
||||
- Status: draft
|
||||
- Verification owner: portfolio-hub-dev first pass; independent qc-reviewer required before lead confirmation.
|
||||
## Status
|
||||
|
||||
## 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 |
|
||||
| --- | --- | --- |
|
||||
| REQ-056 AC-1~19 | Pending | Not yet implemented |
|
||||
## Evidence
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -290,6 +290,38 @@
|
||||
- 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.
|
||||
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
- auth_tokens.user_id and portfolios.owner_user_id should resolve to existing users before protected flows run.
|
||||
@@ -317,6 +349,7 @@
|
||||
|
||||
## 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-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.
|
||||
|
||||
@@ -389,6 +389,34 @@
|
||||
- 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.
|
||||
|
||||
### 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)`
|
||||
|
||||
- Type: http-api
|
||||
@@ -512,6 +540,7 @@
|
||||
|
||||
## 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-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).
|
||||
|
||||
@@ -255,6 +255,21 @@
|
||||
11. Duplicate key + different payload → HTTP 409.
|
||||
- 구 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`
|
||||
|
||||
1. Admin token auth required (`require_admin` dependency).
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user