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
+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: