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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user