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 (
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)
# ---------------------------------------------------------------------------
+4
View File
@@ -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:
+20
View File
@@ -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: ...
+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 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
View File
@@ -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)
# ---------------------------------------------------------------------------