feat: consolidate ledger trade writes into portfolio_ledger

- add ledger consolidation migration and remove ledger_trades usage
- return portfolio state after v1 ledger trade writes
- deprecate legacy buy/sell endpoints with deprecation headers
- add BAT-016, DCY-006, and REQ-036~038 documentation
- update repository models and tests for unified ledger entries
This commit is contained in:
2026-06-05 20:55:24 +09:00
parent 2470c0a00c
commit 44f2102d26
23 changed files with 1254 additions and 115 deletions
+28 -2
View File
@@ -1,9 +1,12 @@
"""Portfolio API routes."""
from typing import NoReturn
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status
from app.core.auth import AuthContext, Permission, authorize_request
from app.core.config import Settings, get_settings
from app.core.errors import FeatureNotReadyError
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.portfolio import (
BuyRequest,
@@ -49,6 +52,21 @@ def _ownership_required(settings: Settings) -> bool:
return settings.db_enabled
def _raise_with_deprecation_header(exc: Exception) -> NoReturn:
if isinstance(exc, HTTPException):
headers = dict(exc.headers or {})
headers["Deprecation"] = "true"
exc.headers = headers
raise exc
if isinstance(exc, FeatureNotReadyError):
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=str(exc) or "Feature not implemented yet",
headers={"Deprecation": "true"},
) from exc
raise exc
def _require_owner_context(auth_context: AuthContext) -> str:
if auth_context.user_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ownership context is required")
@@ -155,38 +173,46 @@ def update_portfolio(
_close_repository(repository)
@router.post("/{portfolio_id}/buy", response_model=PortfolioDetailResponse)
@router.post("/{portfolio_id}/buy", response_model=PortfolioDetailResponse, deprecated=True)
def buy_portfolio_asset(
portfolio_id: str,
payload: BuyRequest,
request: Request,
response: Response,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
response.headers["Deprecation"] = "true"
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.buy(portfolio_id, payload)
except Exception as exc:
_raise_with_deprecation_header(exc)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/sell", response_model=PortfolioDetailResponse)
@router.post("/{portfolio_id}/sell", response_model=PortfolioDetailResponse, deprecated=True)
def sell_portfolio_asset(
portfolio_id: str,
payload: SellRequest,
request: Request,
response: Response,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
response.headers["Deprecation"] = "true"
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.sell(portfolio_id, payload)
except Exception as exc:
_raise_with_deprecation_header(exc)
finally:
_close_repository(repository)
+2
View File
@@ -583,5 +583,7 @@ def record_ledger_trade_v1(
tax_amount=trade.tax_amount,
executed_at=trade.executed_at,
recorded_at=trade.recorded_at,
cash_balance_after=trade.cash_balance_after,
asset_quantity_after=trade.asset_quantity_after,
status=trade_status,
)
+19 -20
View File
@@ -126,18 +126,31 @@ class Portfolio:
@dataclass
class LedgerEntry:
portfolio_id: str
recorded_at: datetime
action: str
price: float
quantity: float
cash_balance_after: float
asset_quantity_after: float
total_value_after: float
price: float
ledger_entry_id: int = 0
order_id: str | None = None
execution_id: str | None = None
side: str | None = None
fee_amount: float = 0.0
tax_amount: float = 0.0
executed_at: datetime | None = None
recorded_at: datetime | None = None
cash_balance_after: float = 0.0
asset_quantity_after: float = 0.0
total_value_after: float = 0.0
profit_buffer_after: float = 0.0
strategy_set_id: str = "legacy"
note: str = ""
action: str | None = None
def __post_init__(self) -> None:
if self.side is None and self.action:
self.side = str(self.action).lower()
if self.action is None and self.side:
self.action = str(self.side).upper()
if self.executed_at is None:
self.executed_at = self.recorded_at
@dataclass
@@ -170,17 +183,3 @@ class BrokerCredential:
credential_version: int = 1
last_rotated_at: datetime | None = None
@dataclass
class LedgerTrade:
ledger_entry_id: int
portfolio_id: str
order_id: str
execution_id: str
side: str
quantity: float
price: float
fee_amount: float
tax_amount: float
executed_at: datetime
recorded_at: datetime | None = None
+2 -2
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Protocol
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
class PortfolioRepository(Protocol):
@@ -262,7 +262,7 @@ class PortfolioRepository(Protocol):
executed_at: datetime,
fee_amount: float = 0.0,
tax_amount: float = 0.0,
) -> tuple[LedgerTrade, bool]: ...
) -> tuple[LedgerEntry, bool]: ...
def reset(self, portfolio_id: str) -> Portfolio: ...
+216 -71
View File
@@ -261,28 +261,79 @@ class PostgresPortfolioRepository:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_ledger (
ledger_entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
portfolio_id TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
action TEXT NOT NULL,
price DOUBLE PRECISION NOT NULL,
quantity DOUBLE PRECISION NOT NULL,
fee_amount DOUBLE PRECISION NOT NULL DEFAULT 0,
tax_amount DOUBLE PRECISION NOT NULL DEFAULT 0,
cash_balance_after DOUBLE PRECISION NOT NULL,
asset_quantity_after DOUBLE PRECISION NOT NULL,
total_value_after DOUBLE PRECISION NOT NULL,
profit_buffer_after DOUBLE PRECISION NOT NULL DEFAULT 0,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
order_id TEXT NULL,
execution_id TEXT NULL,
side TEXT NOT NULL,
price NUMERIC(20,8) NOT NULL,
quantity NUMERIC(20,8) NOT NULL,
fee_amount NUMERIC(20,8) NOT NULL DEFAULT 0,
tax_amount NUMERIC(20,8) NOT NULL DEFAULT 0,
cash_balance_after NUMERIC(20,8) NOT NULL,
asset_quantity_after NUMERIC(20,8) NOT NULL,
total_value_after NUMERIC(20,8) NOT NULL,
profit_buffer_after NUMERIC(20,8) NOT NULL DEFAULT 0,
strategy_set_id TEXT NOT NULL DEFAULT 'legacy',
note TEXT NOT NULL DEFAULT ''
note TEXT NOT NULL DEFAULT '',
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS ledger_entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS order_id TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_id TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS side TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ")
cur.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'portfolio_ledger'
AND column_name = 'action'
) THEN
UPDATE portfolio_ledger SET side = lower(action) WHERE side IS NULL;
END IF;
END $$;
"""
)
cur.execute("UPDATE portfolio_ledger SET executed_at = recorded_at WHERE executed_at IS NULL")
cur.execute("UPDATE portfolio_ledger SET side = 'manual' WHERE side IS NULL")
cur.execute("ALTER TABLE portfolio_ledger ALTER COLUMN side SET NOT NULL")
cur.execute("ALTER TABLE portfolio_ledger ALTER COLUMN executed_at SET NOT NULL")
for column in (
"price",
"quantity",
"fee_amount",
"tax_amount",
"cash_balance_after",
"asset_quantity_after",
"total_value_after",
"profit_buffer_after",
):
cur.execute(
f"ALTER TABLE portfolio_ledger ALTER COLUMN {column} TYPE NUMERIC(20,8) USING {column}::numeric(20,8)"
)
cur.execute("ALTER TABLE portfolio_ledger DROP COLUMN IF EXISTS action")
cur.execute("DROP TABLE IF EXISTS ledger_trades")
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_ledger_portfolio_time
ON portfolio_ledger (portfolio_id, recorded_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_ledger_portfolio_executed
ON portfolio_ledger (portfolio_id, executed_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolios_broker_status_updated
@@ -506,15 +557,19 @@ class PostgresPortfolioRepository:
return portfolios
@staticmethod
def _row_to_ledger_entry(row: Any) -> LedgerEntry:
def _row_to_ledger_entry_legacy(row: Any) -> LedgerEntry:
return LedgerEntry(
ledger_entry_id=0,
portfolio_id=row[0],
recorded_at=row[1],
action=row[2],
price=float(row[3]),
order_id=None,
execution_id=None,
side=row[2],
quantity=float(row[4]),
price=float(row[3]),
fee_amount=float(row[5]),
tax_amount=float(row[6]),
executed_at=row[1],
recorded_at=row[1],
cash_balance_after=float(row[7]),
asset_quantity_after=float(row[8]),
total_value_after=float(row[9]),
@@ -1223,15 +1278,15 @@ class PostgresPortfolioRepository:
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, action, price, quantity, fee_amount,
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
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
portfolio.portfolio_id,
trade_time,
action,
str(action).lower(),
float(trade_price),
float(quantity),
float(fee_amount),
@@ -1242,6 +1297,7 @@ class PostgresPortfolioRepository:
float(portfolio.profit_buffer),
strategy_set_id,
note,
trade_time,
),
)
@@ -1251,12 +1307,16 @@ class PostgresPortfolioRepository:
cur.execute(
"""
SELECT
portfolio_id, recorded_at, action, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
FROM portfolio_ledger
WHERE portfolio_id = %s
ORDER BY recorded_at DESC
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s
""",
(portfolio_id, int(limit)),
@@ -1276,49 +1336,128 @@ class PostgresPortfolioRepository:
executed_at,
fee_amount: float = 0.0,
tax_amount: float = 0.0,
) -> tuple:
) -> tuple[LedgerEntry, bool]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
INSERT INTO ledger_trades
(portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT ON CONSTRAINT uq_ledger_trades_idempotency DO NOTHING
RETURNING
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at
""",
(portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at),
)
row = cur.fetchone()
if row is not None:
return self._row_to_ledger_trade(row), True
# Conflict: fetch existing record
cur.execute(
"""
SELECT
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at
FROM ledger_trades
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
""",
(portfolio_id, order_id, execution_id),
)
row = cur.fetchone()
return self._row_to_ledger_trade(row), False
side_normalized = str(side).lower()
if side_normalized not in {"buy", "sell"}:
raise ValueError("side는 buy 또는 sell 이어야 합니다")
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
portfolio_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id
FROM portfolios
WHERE portfolio_id = %s
FOR UPDATE
""",
(portfolio_id,),
)
portfolio_row = cur.fetchone()
if portfolio_row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cash_balance = float(portfolio_row[1])
asset_quantity = float(portfolio_row[2])
price_float = float(price)
qty_float = float(quantity)
fee_float = float(fee_amount)
tax_float = float(tax_amount)
if side_normalized == "buy":
cash_balance_after = cash_balance - (qty_float * price_float + fee_float + tax_float)
asset_quantity_after = asset_quantity + qty_float
else:
cash_balance_after = cash_balance + (qty_float * price_float - fee_float - tax_float)
asset_quantity_after = asset_quantity - qty_float
total_value_after = cash_balance_after + asset_quantity_after * price_float
strategy_set_id = portfolio_row[5] or "manual"
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, ledger_entry_id, order_id, execution_id,
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
) VALUES (
%s, NOW(), DEFAULT, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s,
%s, %s, %s, %s
)
ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL
DO NOTHING
RETURNING
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
""",
(
portfolio_id,
order_id,
execution_id,
side_normalized,
price_float,
qty_float,
fee_float,
tax_float,
cash_balance_after,
asset_quantity_after,
total_value_after,
float(portfolio_row[4]),
strategy_set_id,
"",
executed_at,
),
)
row = cur.fetchone()
if row is None:
cur.execute(
"""
SELECT
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
FROM portfolio_ledger
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
""",
(portfolio_id, order_id, execution_id),
)
row = cur.fetchone()
return self._row_to_ledger_entry(row), False
cur.execute(
"""
UPDATE portfolios
SET cash_balance = %s,
asset_quantity = %s,
asset_price = %s,
updated_at = NOW(),
last_trade_at = %s
WHERE portfolio_id = %s
""",
(cash_balance_after, asset_quantity_after, price_float, executed_at, portfolio_id),
)
return self._row_to_ledger_entry(row), True
@staticmethod
def _row_to_ledger_trade(row) -> "LedgerTrade":
from app.domain.models import LedgerTrade
return LedgerTrade(
ledger_entry_id=row[0],
def _row_to_ledger_entry(row: Any) -> LedgerEntry:
if row is None:
raise ValueError("원장 항목을 찾을 수 없습니다")
return LedgerEntry(
ledger_entry_id=int(row[0]),
portfolio_id=row[1],
order_id=row[2],
execution_id=row[3],
@@ -1329,6 +1468,12 @@ class PostgresPortfolioRepository:
tax_amount=float(row[8]),
executed_at=row[9],
recorded_at=row[10],
cash_balance_after=float(row[11]),
asset_quantity_after=float(row[12]),
total_value_after=float(row[13]),
profit_buffer_after=float(row[14]),
strategy_set_id=row[15] or "legacy",
note=row[16] or "",
)
def reset(self, portfolio_id: str) -> Portfolio:
@@ -1386,14 +1531,14 @@ class PostgresPortfolioRepository:
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, action, price, quantity, fee_amount,
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
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
""",
(
portfolio_id,
normalized,
normalized.lower(),
0.0,
0.0,
0.0,
@@ -1450,14 +1595,14 @@ class PostgresPortfolioRepository:
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, action, price, quantity, fee_amount,
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
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
""",
(
portfolio_id,
ledger_action,
ledger_action.lower(),
0.0,
0.0,
0.0,
+4 -2
View File
@@ -122,8 +122,8 @@ class LedgerTradeRequest(BaseModel):
class LedgerTradeResponse(BaseModel):
ledger_entry_id: int
portfolio_id: str
order_id: str
execution_id: str
order_id: str | None
execution_id: str | None
side: str
quantity: float
price: float
@@ -131,6 +131,8 @@ class LedgerTradeResponse(BaseModel):
tax_amount: float
executed_at: datetime
recorded_at: datetime | None = None
cash_balance_after: float = 0.0
asset_quantity_after: float = 0.0
status: Literal["new", "duplicate"] = "new"