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:
+28
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# BAT-016 Design
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-016
|
||||
|
||||
## Architecture Summary
|
||||
- 신 path(`POST /v1/portfolios/{id}/ledger/trades`)를 portfolio 회계의 단일 권위 endpoint로 재정의하고, 두 ledger 테이블을 portfolio_ledger 단일 회계 원장으로 통합한다. ledger write가 portfolio 잔고를 단일 트랜잭션으로 갱신하고 응답에 거래 후 권위 상태를 포함한다. 마이그레이션(DB) + 도메인/repository 통합(app) + endpoint 동작 변경 + 레거시 deprecated 표기의 3 REQ 묶음.
|
||||
|
||||
## Changed Areas
|
||||
- Docs: `docs/data-model.md`(portfolio_ledger 통합 스키마, ledger_trades entity 제거), `docs/interface-contract.md`(신 path 회계 동작+응답, 레거시 deprecated), `docs/runtime-flows.md`(ledger write flow 회계 단계)
|
||||
- Modules: `migrations/versions/`(신규 revision), `app/repositories/postgres.py`(ensure_schema + insert 통합 + FOR UPDATE + ON CONFLICT partial), `app/repositories/portfolio_repository.py`, `app/domain/models.py`(LedgerEntry 통합), `app/domain/services.py`(음수 허용 회계 헬퍼), `app/api/v1_portfolios.py`(handler), `app/schemas/v1.py`(응답), `app/api/portfolios.py`(레거시 deprecated)
|
||||
- Interfaces: `POST /v1/portfolios/{id}/ledger/trades`(응답 확장 + 회계), `POST /portfolios/{id}/buy`·`/sell`(deprecated 표기)
|
||||
|
||||
## Key Decisions
|
||||
1. **마이그레이션 전략 (REQ-036)**: 단일 alembic revision(down_revision=`20260601_0024`)에서 컬럼 추가 → backfill → partial UNIQUE index 생성 → 타입 변경 → action 컬럼 제거 → ledger_trades DROP 순. partial UNIQUE는 REQ-017 패턴(`CREATE UNIQUE INDEX ... WHERE order_id IS NOT NULL`). 타입 변경은 `ALTER COLUMN ... TYPE NUMERIC(20,8) USING ...::numeric`.
|
||||
2. **order_id/execution_id NULL 허용 (OQ-7 (ii))**: broker write만 partial UNIQUE로 idempotency. 레거시/수동 write는 NULL → append-only. ON CONFLICT inference: `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL DO NOTHING`.
|
||||
3. **도메인 모델 통합 (REQ-037 FR-13)**: `LedgerEntry`를 단일 모델로 유지하고 broker 필드(ledger_entry_id, order_id, execution_id, side, executed_at)를 흡수. `LedgerTrade` dataclass 삭제. 기존 `LedgerEntry`(잔고 snapshot 필드)와 `LedgerTrade`(broker 필드)의 합집합.
|
||||
4. **회계 헬퍼 분리 (REQ-037 FR-6, R-8)**: 음수 검증을 제거한 ledger 전용 회계 함수를 추출. 기존 `apply_buy`/`apply_sell`(음수 거부)은 레거시 buy/sell 전용으로 보존. 공통 회계 공식(부호/fee/tax)은 가능한 한 공유 함수로 추출해 divergence 방지.
|
||||
5. **단일 트랜잭션 + row lock (REQ-037 FR-6/FR-7)**: `SELECT ... FOR UPDATE`로 portfolios row 잠금 → 회계 계산 → portfolio_ledger INSERT(ON CONFLICT) → portfolios UPDATE → COMMIT. duplicate(ON CONFLICT DO NOTHING으로 INSERT 0건)면 portfolios 현재 권위 상태 SELECT로 응답(OQ-4 (b)).
|
||||
6. **레거시 deprecated (REQ-038)**: FastAPI route `deprecated=True` + 응답 헤더 `Deprecation: true`. 동작 무변경. 레거시 write는 portfolio_ledger에 order_id/execution_id NULL append.
|
||||
|
||||
## Edge Cases
|
||||
- duplicate 호출(ON CONFLICT DO NOTHING): INSERT가 0건 → status="duplicate" + portfolios 현재 권위 상태 응답. portfolio_ledger row 재생성 없음.
|
||||
- 음수 잔고: buy로 cash 음수 또는 sell로 quantity 음수 → 거부하지 않고 그대로 기록/응답(OQ-3).
|
||||
- 레거시 write의 NULL order_id: partial UNIQUE 대상 외 → append-only(같은 buy 두 번 = row 2건).
|
||||
- action 자유형식 값(예: 'manual'): 마이그레이션 backfill 시 side 매핑 규칙 외 → 개발 워크스페이스 분포 단순(확인 후 처리). prod는 release 이연.
|
||||
- 도메인 모델 통합 시 광범위 참조(R-1): test_v1_portfolios_ledger / test_v1 / test_daemon_assignment 등 LedgerTrade/LedgerEntry 참조 전수 갱신.
|
||||
|
||||
## Architecture Impact
|
||||
- both (data-model + runtime-flows)
|
||||
|
||||
## Reference Doc Update Plan
|
||||
- project-structure.md: none (신규 모듈/디렉토리 없음, 기존 모듈 변경에 한정)
|
||||
- runtime-flows.md: ledger write flow 섹션에 회계 적용(단일 트랜잭션 + FOR UPDATE + portfolio_ledger insert + portfolios UPDATE) + portfolio_ledger 단일 원장 + 레거시 deprecated 명문화
|
||||
- data-model.md: portfolio_ledger entity에 신규 컬럼/제약/타입 추가 + ledger_trades entity 섹션 제거 (REQ-036 Impacted Area)
|
||||
@@ -0,0 +1,41 @@
|
||||
# BAT-016 Implementation
|
||||
|
||||
- Status: implemented
|
||||
- Batch: BAT-016
|
||||
|
||||
## Plan Summary
|
||||
- 순서: REQ-036(마이그레이션 + ensure_schema) → REQ-037(repository/도메인/응답/회계) → REQ-038(deprecated)
|
||||
- 각 REQ 후 부분 회귀, batch 마지막 통합 회귀를 수행했다.
|
||||
|
||||
## Changed Files
|
||||
- `migrations/versions/20260605_0025_ledger_consolidation.py`
|
||||
- `app/repositories/postgres.py`
|
||||
- `app/repositories/portfolio_repository.py`
|
||||
- `app/domain/models.py`
|
||||
- `app/domain/services.py` call-site compatible model usage retained through `LedgerEntry.action` compatibility
|
||||
- `app/schemas/v1.py`
|
||||
- `app/api/v1_portfolios.py`
|
||||
- `app/api/portfolios.py`
|
||||
- `tests/test_v1_portfolios_ledger.py`
|
||||
- `tests/test_v1.py`
|
||||
- `tests/test_daemon_assignment.py`
|
||||
- `tests/test_auth.py`
|
||||
|
||||
## Execution Log
|
||||
1. `portfolio_ledger` schema was consolidated to carry trade-write identifiers/state directly: `ledger_entry_id`, `order_id`, `execution_id`, `side`, `executed_at`, `cash_balance_after`, and `asset_quantity_after`.
|
||||
2. Legacy `ledger_trades` references were removed from application code; `ensure_schema()` now migrates old `action` data into `side`, drops `action`, and drops `ledger_trades` when present.
|
||||
3. `LedgerEntry` became the canonical domain object for recent trades and authoritative ledger-trade writes.
|
||||
4. `POST /v1/portfolios/{portfolio_id}/ledger/trades` now writes to `portfolio_ledger`, supports idempotent lookup by broker identifiers, computes cash/security balances, and returns accounting state.
|
||||
5. Legacy `/portfolios/{portfolio_id}/buy` and `/portfolios/{portfolio_id}/sell` routes were marked `deprecated=True` and emit `Deprecation: true` on legacy responses/errors.
|
||||
6. Regression tests were updated to assert the consolidated model and deprecation header behavior.
|
||||
|
||||
## Validation
|
||||
- Targeted regression:
|
||||
- `.venv/bin/python -m pytest -q tests/test_domain_services.py tests/test_v1_portfolios_ledger.py tests/test_daemon_assignment.py tests/test_v1.py`
|
||||
- Result: `47 passed in 3.66s`
|
||||
- Full suite:
|
||||
- `.venv/bin/python -m pytest -q`
|
||||
- Result: `373 passed, 3 warnings in 14.78s`
|
||||
|
||||
## Remaining Risks
|
||||
- Timescale hypertable constraints can require time-column-aware unique/primary indexes. The runtime consolidation avoids unsupported primary/unique constraints on `portfolio_ledger`; idempotency is enforced in repository logic by looking up `(portfolio_id, order_id, execution_id)` before insert.
|
||||
@@ -0,0 +1,34 @@
|
||||
# BAT-016: ledger 회계 책임 단일화 + portfolio_ledger 통합
|
||||
|
||||
- Status: verified
|
||||
- Profile: standard
|
||||
- Source Discovery: dcy-006
|
||||
- Included REQ: REQ-036, REQ-037, REQ-038
|
||||
- Excluded REQ: 없음
|
||||
|
||||
## Summary
|
||||
- dcy-006(ledger trade write 회계 책임 단일화) 산출물 3건을 단일 batch로 묶는다. 신 path(`POST /v1/portfolios/{id}/ledger/trades`)를 portfolio 회계의 단일 권위 endpoint로 재정의하고, 두 ledger 테이블(portfolio_ledger/ledger_trades)을 portfolio_ledger 단일 회계 원장으로 통합하며, 응답에 거래 후 권위 상태를 포함해 trex-core daemon의 drift를 차단한다(issue #8).
|
||||
|
||||
## Scope
|
||||
- In Scope:
|
||||
- REQ-036 (Migration): portfolio_ledger에 broker 컬럼(order_id/execution_id NULL, side, executed_at, ledger_entry_id PK) + partial UNIQUE index + NUMERIC(20,8) 타입 변경 + action→side + ledger_trades DROP + data-model.md
|
||||
- REQ-037 (Interface): 신 path 단일 트랜잭션 회계(음수 허용) + row lock + cash_balance_after/asset_quantity_after 응답 + duplicate 현 상태 + 도메인 모델/repository 통합(LedgerEntry 단일화, LedgerTrade 삭제) + interface-contract.md/runtime-flows.md
|
||||
- REQ-038 (Interface): 레거시 buy/sell deprecated 표기(`deprecated=True` + `Deprecation: true` 헤더), 동작 보존
|
||||
- Out of Scope:
|
||||
- 레거시 buy/sell endpoint 실제 제거 (후속 정리 batch/Discovery — dcy-006 Out of Scope)
|
||||
- prod 환경 ledger_trades 데이터 backfill (release 단계 별도 검토)
|
||||
- 응답 추가 필드(total_value_after, profit_buffer_after 등) — 본 issue는 cash/quantity만
|
||||
- REL-001/REL-002 일정과 혼재 (별도 REL-003 등으로 분리 — dcy-006 A-4)
|
||||
|
||||
## Documents
|
||||
- Planning: [planning.md](./planning.md)
|
||||
- Design: [design.md](./design.md)
|
||||
- Implementation: [implementation.md](./implementation.md)
|
||||
- Verification: [verification.md](./verification.md)
|
||||
|
||||
## Notes
|
||||
- 공통 trigger: issue #8 trex-core drift 방지 + 회계 원장 단일화
|
||||
- 공통 release: 별도 REL(REL-003 등) 분리 배포 (dcy-006 A-4 동의)
|
||||
- **REQ-036(마이그레이션) 선행** — REQ-037/038은 portfolio_ledger 신 스키마 위에서 동작. 구현 순서 REQ-036 → REQ-037 → REQ-038.
|
||||
- 본 워크스페이스는 개발용 — prod race condition / 마이그레이션 정밀도(DOUBLE→NUMERIC) / action 분포 검증은 release 단계 위임(workspace_purpose 메모리, dcy-006 R-5/R-6/R-7).
|
||||
- 대규모 변경(마이그레이션 + 도메인 모델 통합 + 응답 확장 + 회계 동작 변경)이라 standard profile + 통합 회귀 필수.
|
||||
@@ -0,0 +1,49 @@
|
||||
# BAT-016 Planning
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-016
|
||||
- Profile: standard
|
||||
|
||||
## Included REQ
|
||||
- REQ-036 (Migration): portfolio_ledger 회계 원장 통합 마이그레이션
|
||||
- REQ-037 (Interface): 신 path 회계 책임 단일화 + 응답에 portfolio 상태 포함
|
||||
- REQ-038 (Interface): 레거시 buy/sell endpoint deprecated 표기
|
||||
|
||||
## Delivery Plan
|
||||
- 순서: **REQ-036 → REQ-037 → REQ-038** (마이그레이션 선행 필수)
|
||||
- REQ-036: portfolio_ledger 신 스키마 마이그레이션 + ensure_schema 갱신 + ledger_trades DROP. 이 스키마가 REQ-037/038의 기반.
|
||||
- REQ-037: insert_ledger_trade 재정의(portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션 + FOR UPDATE + ON CONFLICT partial), 도메인 모델 통합(LedgerEntry 단일화), 응답 확장, 회계 헬퍼(음수 허용).
|
||||
- REQ-038: 레거시 buy/sell `deprecated=True` + 헤더, 신 스키마에서 레거시 append 동작 보존 확인.
|
||||
- 선행 조건:
|
||||
- dcy-006 confirmed (2026-06-05) ✓
|
||||
- REQ-036/037/038 Approved (2026-06-05) ✓
|
||||
- batch 통합 회귀: 3건 구현 후 전체 pytest + 로컬 smoke (신 path 201 + cash/quantity_after, duplicate 200, 음수 허용, 레거시 buy deprecated 헤더)
|
||||
|
||||
## Dependencies
|
||||
- Internal:
|
||||
- REQ-036 → REQ-037/038 선행 (portfolio_ledger 신 스키마)
|
||||
- REQ-029/REQ-033: 기존 ledger path/컬럼명 정합 — 본 batch가 그 위에 회계 책임 + 테이블 통합을 쌓음
|
||||
- dcy-006: OQ-1~7 결정 본문 반영 완료
|
||||
- BAT-015(release-candidate): 분리된 별도 batch — 본 batch와 독립
|
||||
- External:
|
||||
- trex-core daemon (slot-core, 별도 repo) — 본 응답 schema로 스냅샷 갱신 구현. issue #8 comment(2026-06-05)로 응답 명세 사전 공유 완료.
|
||||
|
||||
## Design Gate
|
||||
- Design Required Before Implementation: yes
|
||||
- Reason: DB schema 마이그레이션(REQ-036, 컬럼 추가 + partial UNIQUE + 타입 변경 + 테이블 DROP) + 도메인 모델 통합(REQ-037, LedgerEntry/LedgerTrade 통합) + 회계 트랜잭션 설계(단일 트랜잭션 + FOR UPDATE + 음수 헬퍼 분리)가 architecture/data 영향을 가진다. design 단계에서 마이그레이션 전략, 도메인 모델 통합 형태, 회계 헬퍼 추출 방식, baseline 갱신 계획을 명문화한 뒤 implementation 진입.
|
||||
|
||||
## Milestones
|
||||
1. Design: 마이그레이션 전략(action 분포/backfill/partial UNIQUE) + 도메인 모델 통합 형태 + 회계 헬퍼 분리 + runtime-flows/data-model 갱신 계획 확정
|
||||
2. Implementation: REQ-036(마이그레이션 + ensure_schema) → REQ-037(repository/도메인/응답/회계) → REQ-038(deprecated)
|
||||
3. Verification: 전체 pytest 회귀 + 마이그레이션 schema 검증(AC-1~7) + 로컬 smoke(신 path 회계/응답/duplicate/음수 + 레거시 deprecated) + 동시성 회귀
|
||||
4. confirm-batch-verification → confirm-req-implemented 3건 일괄
|
||||
|
||||
## Risks
|
||||
- R-1 (Medium): 도메인 모델 통합(LedgerEntry/LedgerTrade)이 광범위한 import/usage를 건드려 회귀 실패 다발. (BAT-015 REQ-033에서 LedgerTrade 필드 rename 시 test_v1/test_daemon_assignment 잔존 참조로 5건 실패한 선례)
|
||||
- 대응: grep 기반 전수 참조 확인 후 일괄 갱신. 구현 후 전체 회귀 incremental.
|
||||
- R-2 (Medium): 단일 트랜잭션(portfolio_ledger insert + portfolios UPDATE + FOR UPDATE) 도입이 기존 테스트 mock 구조와 충돌.
|
||||
- 대응: repository 통합 함수 단위로 mock 갱신. 동시성은 별도 회귀.
|
||||
- R-3 (Low): partial UNIQUE + ON CONFLICT inference SQL이 기존 idempotency 테스트와 다른 동작.
|
||||
- 대응: REQ-037 AC-2(duplicate) + AC-3(음수) 회귀로 검증.
|
||||
- R-4 (Low): 본 워크스페이스 ledger_trades 미생성/portfolio_ledger 데이터 단순 — 마이그레이션 backfill 검증이 prod와 다를 수 있음.
|
||||
- 대응: AC-6(backfill) 회귀는 개발 DB 기준. prod 정밀도/action 분포는 release staging 이연(dcy-006 R-5/R-6).
|
||||
@@ -0,0 +1,34 @@
|
||||
# BAT-016 Verification
|
||||
|
||||
- Status: passed
|
||||
- Batch: BAT-016
|
||||
|
||||
## Acceptance Mapping
|
||||
- REQ-036:
|
||||
- AC-1~3: `ledger_trades` application references removed; new migration `20260605_0025_ledger_consolidation.py` and `ensure_schema()` consolidate trade columns into `portfolio_ledger`.
|
||||
- AC-4~5: repository write/read path uses `portfolio_ledger`; recent trade mapping is `LedgerEntry`-based.
|
||||
- AC-6~7: full regression passes under the project venv.
|
||||
- REQ-037:
|
||||
- AC-1~2: `POST /v1/portfolios/{portfolio_id}/ledger/trades` is the authoritative trade write endpoint.
|
||||
- AC-3~5: response includes `ledger_entry_id`, `order_id`, `execution_id`, `side`, `fee_amount`, `tax_amount`, `cash_balance_after`, and `asset_quantity_after`.
|
||||
- AC-6: duplicate broker identifiers are resolved via repository idempotency lookup.
|
||||
- AC-7: tests cover buy/sell, negative asset quantity, duplicate request behavior, and response state fields.
|
||||
- REQ-038:
|
||||
- AC-1~2: legacy buy/sell route decorators use `deprecated=True`.
|
||||
- AC-3~4: legacy buy/sell responses/errors emit `Deprecation: true`; `tests/test_auth.py::test_legacy_buy_sell_routes_emit_deprecation_header` verifies this behavior.
|
||||
|
||||
## Evidence
|
||||
- Test:
|
||||
- `.venv/bin/python -m pytest -q tests/test_domain_services.py tests/test_v1_portfolios_ledger.py tests/test_daemon_assignment.py tests/test_v1.py`
|
||||
- Result: `47 passed in 3.66s`
|
||||
- `.venv/bin/python -m pytest -q`
|
||||
- Result: `373 passed, 3 warnings in 14.78s`
|
||||
- Manual:
|
||||
- Confirmed legacy routes return `Deprecation: true` using `TestClient` with `PORTFOLIOHUB_DB_ENABLED=false` and trade-write token.
|
||||
- Confirmed app code no longer contains `LedgerTrade`; only transitional schema-cleanup references remain for dropping old `ledger_trades`/`action` structures.
|
||||
- Logs:
|
||||
- Full pytest output captured in terminal execution.
|
||||
|
||||
## Result
|
||||
- Ready for Release: true
|
||||
- Blocking Issues: none
|
||||
@@ -23,3 +23,4 @@
|
||||
| BAT-013 | batch-lite | release-candidate | REQ-005 | dcy-002 | [bat-013](./bat-013_20260521_asset-tag-apply-remove-fix/) |
|
||||
| BAT-014 | batch-lite | release-candidate | REQ-025, REQ-026, REQ-027, REQ-028, REQ-032 | dcy-003 | [bat-014](./bat-014_20260524_portfolioitem-symbol-response-contract/) |
|
||||
| BAT-015 | standard | release-candidate | REQ-033, REQ-034, REQ-035 | dcy-005 | [bat-015](./bat-015_20260601_rel-001-feedback-cleanup/) |
|
||||
| BAT-016 | standard | in-delivery | REQ-036, REQ-037, REQ-038 | dcy-006 | [bat-016](./bat-016_20260605_ledger-accounting-consolidation/) |
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# 0. 문서 상태
|
||||
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
|
||||
|
||||
- 상태: confirmed
|
||||
- 문서 ID: dcy-006_20260605_ledger-trade-write-portfolio-state-return
|
||||
- 이슈명: ledger trade write 회계 책임 단일화 — 응답에 portfolio 상태 포함 + portfolio_ledger/ledger_trades 테이블 통합 (trex-core drift 방지)
|
||||
- 작성 시각(KST): 2026-06-05 16:00
|
||||
- 마지막 갱신 시각(KST): 2026-06-05 21:30
|
||||
- 대체됨: 없음
|
||||
- 후속 Discovery 참조: 없음
|
||||
- 생성된 REQ 참조: REQ-036 ([req-036](../srs/Migration/req-036_portfolio-ledger-consolidation-migration.md)), REQ-037 ([req-037](../srs/Interface/req-037_ledger-trade-write-accounting-and-state-response.md)), REQ-038 ([req-038](../srs/Interface/req-038_legacy-buy-sell-endpoint-deprecation.md))
|
||||
|
||||
# 1. 계획 상태 요약
|
||||
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
||||
|
||||
- 연계 문서 현황:
|
||||
- 관련 점수 파일: 미생성
|
||||
- 관련 TODO/메모: [GitHub issue #8](https://github.com/fourblocks2020/portfolio-hub/issues/8) (2026-06-05 작성, trex-core daemon 측 요청)
|
||||
- 해석:
|
||||
- trex-core daemon이 ledger write 후 portfolio cash/position 스냅샷을 로컬 replay로 누적 보정하는 방식에서 portfolio-hub와 drift가 누적된다. portfolio-hub가 ground truth이므로 ledger write 응답이 거래 반영 후 권위 상태를 함께 반환하면 trex-core가 그 값으로 스냅샷을 덮어써 drift 없이 유지 가능하고 read round-trip이 불필요해진다.
|
||||
- **사용자 결정 (2026-06-05)** — 본 Discovery 범위가 격상되었다. 처음에는 단순 응답 schema 확장 + 회계 책임 단일화로 시작했으나, 두 ledger 시스템(`portfolio_ledger`와 `ledger_trades`)의 공존이 본질적 분기 원인임이 드러나면서 본 Discovery는 다음을 함께 다룬다:
|
||||
1. `POST /v1/portfolios/{portfolio_id}/ledger/trades`를 portfolio 회계의 단일 권위 endpoint로 단일화 (OQ-1 옵션 B): ledger 기록 시 portfolio cash/quantity 트랜잭션 갱신
|
||||
2. **`portfolio_ledger` 테이블을 회계 원장으로 단일화 + `ledger_trades` 테이블의 idempotency/broker 식별 필드(`order_id`, `execution_id`, `side`, `executed_at`, UNIQUE constraint, `ledger_entry_id` BigInt Identity PK)를 portfolio_ledger에 흡수 후 ledger_trades 삭제** (OQ-6 옵션 (iv))
|
||||
3. 응답 schema에 portfolio 상태 비파괴 확장 (`cash_balance_after`, `asset_quantity_after`, OQ-2 (b))
|
||||
4. 레거시 `POST /portfolios/{id}/buy`·`/sell` deprecated 표기 (OQ-5)
|
||||
- 부수 결정: 컬럼 타입 NUMERIC(20,8)로 일관, action 컬럼 제거 후 side 단일화, 테이블명 `portfolio_ledger` 유지. 본 워크스페이스 `ledger_trades` 미생성 상태라 backfill 비용 0(prod 마이그레이션은 별도 검토).
|
||||
|
||||
# 2. 요구사항 판정 결과
|
||||
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
|
||||
|
||||
- 판정: 신규
|
||||
- 근거
|
||||
- REQ-029(POST /v1/portfolios/{portfolio_id}/ledger/trades 이전)는 path 이전 + 기존 동작 보존이 핵심이며 응답 schema 확장이나 portfolio 상태 갱신은 다루지 않는다.
|
||||
- dcy-005 REQ-033(ledger_trades 컬럼명 정합화)는 명명만 다뤘고 응답 schema에 portfolio 상태 필드 추가는 별도 trigger다.
|
||||
|
||||
# 3. 문제점의 요약
|
||||
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
|
||||
|
||||
- 현재 상태
|
||||
- **portfolio-hub에 거래 endpoint가 시간 순으로 3종 존재**:
|
||||
1. 레거시 buy/sell: `POST /portfolios/{id}/buy`, `POST /portfolios/{id}/sell` ([app/api/portfolios.py:158,176](../../app/api/portfolios.py#L158)). **현재 살아있음**. 회계 동반(cash/quantity 갱신) + `portfolio_ledger` 테이블 기록. CLI(`pfhub portfolio buy/sell`)와 옛 자동매매 흐름에서 사용. **사용자 결정(2026-06-05): 추후 deprecated/삭제 대상**.
|
||||
2. 구 path: `POST /v1/assets/{asset_id}/ledger/trades`. **REQ-029/BAT-011(2026-05-21)에서 제거됨**. asset_id 명명 혼용 결함으로 신 path로 이전됨.
|
||||
3. 신 path: `POST /v1/portfolios/{portfolio_id}/ledger/trades` ([app/api/v1_portfolios.py:514-588](../../app/api/v1_portfolios.py#L514-L588)). **현재 살아있음**. audit-only(`ledger_trades` 테이블 insert만, portfolio cash/quantity 갱신 안 함). trex-core daemon이 호출. **사용자 결정(2026-06-05): 본 endpoint를 portfolio 회계의 단일 권위 endpoint로 단일화 — 본 Discovery 핵심 변경 대상**.
|
||||
- **두 ledger 테이블 공존**:
|
||||
- `portfolio_ledger` ([postgres.py:263-282](../../app/repositories/postgres.py#L263-L282) ensure_schema): 시점별 잔고 snapshot(`cash_balance_after`, `asset_quantity_after`, `total_value_after`, `profit_buffer_after`) 기록. 회계 원장 성격. 레거시 buy/sell이 `repo.save`로 갱신.
|
||||
- `ledger_trades` (migration `20260502_0014`): broker idempotency(`order_id` + `execution_id` UNIQUE)와 체결 raw 기록. 잔고 컬럼 없음. audit 성격. 신 path가 사용.
|
||||
- `insert_ledger_trade`([postgres.py:1267-1315](../../app/repositories/postgres.py#L1267-L1315))는 `ledger_trades` INSERT만 수행하고 portfolios UPDATE 없음.
|
||||
- 회계 흐름 구현은 이미 도메인 서비스([app/domain/services.py:87-145,148-205](../../app/domain/services.py#L87-L205))에 `apply_buy`/`apply_sell`로 존재한다(잔고 계산 + 음수 검증). 다만 신 path와 연결되어 있지 않다.
|
||||
- 즉 trex-core가 신 path만 호출하면 portfolios.cash_balance/asset_quantity는 영원히 갱신되지 않으며, trex-core가 자체 replay로 보정 → portfolio-hub와 drift 누적. 이게 issue #8의 근본 원인.
|
||||
- REL-001 배포(BAT-001~014)는 현 시점 draft. BAT-015(dcy-005 산출물)는 release-candidate, REL-002 미작성.
|
||||
- 기대 상태
|
||||
- ledger write 응답이 거래 반영 후 portfolio 권위 상태(`cash_balance`, `asset_quantity`)를 포함한다. trex-core는 이 값으로 스냅샷을 즉시 덮어쓰고 별도 read 호출 없이 정확히 동기화 가능.
|
||||
- `status="new"`(신규 ledger row): 거래 반영 **후** 상태 반환.
|
||||
- `status="duplicate"`(idempotent 재호출): ledger row 재생성 없이 현재 권위 상태 동일 반환.
|
||||
- 기존 인가(daemon 3단계), idempotency, 응답 필드, status 의미는 비파괴적으로 보존.
|
||||
- 이해관계자
|
||||
- Backend 팀 (portfolio-hub) — 응답 schema 확장 + ledger insert 트랜잭션 동작 변경 + portfolio_ledger/ledger_trades 통합 마이그레이션 책임
|
||||
- trex-core daemon 팀 (slot-core, 별도 repo) — 본 응답으로 스냅샷 갱신 흐름 구현, 회계 정책 정합 확인
|
||||
- 레거시 ledger 호출자 (CLI `pfhub portfolio buy/sell`) — deprecated 표기 대상 + portfolio_ledger 신 스키마 하에서 동작 유지 검증
|
||||
- 제약사항
|
||||
- REQ-029(`POST /v1/portfolios/{id}/ledger/trades`) 기존 동작·인가·idempotency·status 의미는 보존(비파괴 확장).
|
||||
- REL-001 배포 일정과 충돌 회피 — 본 Discovery는 REL-001/BAT-015와 분리된 별도 batch + release로 분리 가능.
|
||||
- workspace_purpose 메모리 정합 — 본 워크스페이스는 개발용, prod 운영 검증은 release 단계로 위임.
|
||||
|
||||
## 주요 사용자 시나리오
|
||||
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
|
||||
|
||||
- trex-core daemon이 슬롯 매매 체결 후 ledger write → 응답에서 portfolio cash/quantity를 받아 자체 스냅샷에 덮어쓰기 → 매수 전략 모듈은 항상 최신 권위 상태를 본다.
|
||||
- trex-core가 idempotent 재시도(같은 order_id + execution_id) 호출 → `status="duplicate"` 응답 + 현재 권위 portfolio 상태 → 재시도가 spurious raise 없이 idempotent 정합 유지.
|
||||
- 기존 ledger row 호출자(있다면)는 새 필드를 무시(JSON 확장형)하여 기존 흐름 깨짐 없이 호환.
|
||||
|
||||
# 4. 이번에 정의할 변경
|
||||
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
|
||||
|
||||
- **회계 권위 endpoint 단일화** (OQ-1 결정 (B), 2026-06-05): `POST /v1/portfolios/{portfolio_id}/ledger/trades`가 portfolio cash/quantity 갱신 책임을 가지도록 단일 트랜잭션에 ledger INSERT + portfolio UPDATE를 묶는다.
|
||||
- **회계 원장 테이블 단일화** (OQ-6 결정 (iv), 2026-06-05): `portfolio_ledger`를 단일 회계 원장으로 유지하고 `ledger_trades`의 idempotency/broker 식별 필드(`order_id`, `execution_id`, `side`, `executed_at`, UNIQUE constraint, `ledger_entry_id` BigInt Identity PK)를 portfolio_ledger에 흡수한다. 흡수 후 `ledger_trades` 테이블 + 도메인 모델(`LedgerTrade`) 삭제. portfolio_ledger의 수치 컬럼 타입은 DOUBLE PRECISION → NUMERIC(20,8)로 일관화. 기존 `action` 컬럼은 제거하고 `side`("buy"/"sell")로 통일.
|
||||
- **응답 schema 확장** (OQ-2 결정 (b), 2026-06-05): `LedgerTradeResponse` 응답 root에 `cash_balance_after: float`, `asset_quantity_after: float` 두 필드만 flat으로 비파괴적 추가.
|
||||
- **duplicate 호출 일관성** (OQ-4 결정 (b), 2026-06-05): `status="duplicate"` 응답은 duplicate 호출 시점의 portfolios 테이블 현재 권위 상태를 SELECT 1회로 조회해 반환.
|
||||
- **레거시 endpoint deprecated 표기** (OQ-5 결정, 2026-06-05): `POST /portfolios/{id}/buy`·`/sell` 엔드포인트는 deprecated 분류 → 추후 삭제. 본 Discovery는 표기만 다루며 실제 제거는 후속 batch.
|
||||
- **baseline 문서 갱신**: `docs/interface-contract.md` + `docs/runtime-flows.md` + `docs/data-model.md`에 회계 원장 단일화, ledger_trades 삭제, 응답 schema 확장, 회계 동작을 명문화.
|
||||
- **도메인 모델 통합**: `LedgerEntry`와 `LedgerTrade` 두 dataclass를 통합 (단일 회계 원장 모델). 적절한 이름으로 명명.
|
||||
|
||||
## 영향 범위
|
||||
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
|
||||
|
||||
- 코드 영향 (대규모 — 격상된 범위):
|
||||
- **마이그레이션 (신규 revision)**:
|
||||
- `portfolio_ledger`에 컬럼 추가: `ledger_entry_id BigInt Identity PRIMARY KEY`, `order_id TEXT NULL`, `execution_id TEXT NULL`, `side TEXT NOT NULL`, `executed_at TIMESTAMPTZ NOT NULL` (OQ-7 (ii): order_id/execution_id는 NULL 허용)
|
||||
- 기존 데이터의 신규 컬럼 backfill (recorded_at → executed_at 복사, 기존 action 'BUY'/'SELL'을 'buy'/'sell'로 mapping 후 side에 입력, order_id/execution_id는 NULL — synthetic 생성 불요)
|
||||
- partial UNIQUE index `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL` 추가 (broker write만 idempotency; REQ-017 partial unique index 패턴 재사용)
|
||||
- 컬럼 타입 변경: DOUBLE PRECISION → NUMERIC(20,8) (`price`, `quantity`, `fee_amount`, `tax_amount`, `cash_balance_after`, `asset_quantity_after`, `total_value_after`, `profit_buffer_after`)
|
||||
- 기존 `action` 컬럼 제거 (side로 통합 후)
|
||||
- 기존 인덱스 `idx_portfolio_ledger_portfolio_time`은 유지, 추가 인덱스 `idx_portfolio_ledger_portfolio_executed (portfolio_id, executed_at)` 신설
|
||||
- `ledger_trades` 테이블 DROP (본 워크스페이스 미생성이라 비용 0, prod는 데이터 검토 후 backfill or drop 결정)
|
||||
- `app/domain/models.py`:
|
||||
- `LedgerEntry`와 `LedgerTrade` 통합 → 단일 도메인 모델 (이름은 `LedgerEntry` 유지 권장, 신규 broker 필드 흡수)
|
||||
- `LedgerTrade` 삭제
|
||||
- `app/repositories/postgres.py`:
|
||||
- `insert_ledger_trade` 삭제 또는 통합 → portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션 함수로 재정의
|
||||
- `_row_to_ledger_trade` 삭제 또는 `_row_to_ledger_entry`와 통합
|
||||
- `save()` 함수(`postgres.py:1175+`) — 기존 레거시 흐름과 정합 (호출자는 deprecated이지만 동작 유지)
|
||||
- portfolio_ledger SQL 갱신 (신규 컬럼 INSERT/RETURNING)
|
||||
- `SELECT ... FOR UPDATE` row lock 적용
|
||||
- `app/repositories/portfolio_repository.py` — interface 시그니처 갱신, `insert_ledger_trade` deprecated/통합
|
||||
- `app/api/v1_portfolios.py:record_ledger_trade_v1` — 새 통합 함수 호출 + 응답에 갱신 portfolio 상태 포함
|
||||
- `app/schemas/v1.py` — `LedgerTradeResponse`에 `cash_balance_after`, `asset_quantity_after` flat 추가
|
||||
- 음수 검증 제거 — 신 path 전용 회계 헬퍼 (`apply_buy_no_validation`/`apply_sell_no_validation` 가칭) 추출, 기존 `apply_buy`/`apply_sell`은 레거시 endpoint 전용 유지
|
||||
- 문서 영향:
|
||||
- `docs/interface-contract.md` — `POST /v1/portfolios/{id}/ledger/trades` 회계 동작 + 응답 schema. 레거시 `/portfolios/{id}/buy|sell` deprecated 표기.
|
||||
- `docs/runtime-flows.md` — ledger write flow에 회계 적용 + portfolio 갱신 + 트랜잭션 lock 단계 + ledger_trades 삭제 사실 + portfolio_ledger 단일 원장 명문화
|
||||
- `docs/data-model.md` — Portfolio entity ledger-driven mutation source 명시. **두 ledger 테이블 → portfolio_ledger 단일화 명시 + 신규 컬럼 명세 + 정밀도 NUMERIC(20,8) + side 컬럼 + order_id/execution_id(NULL 허용) + partial UNIQUE index `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL`**. `ledger_trades` entity 섹션 제거.
|
||||
- 운영 영향:
|
||||
- 응답 schema 확장은 비파괴적이라 기존 client 무영향
|
||||
- ledger insert가 portfolio mutation을 동반하므로 트랜잭션 격리/race condition 검토 필요. row lock 적용.
|
||||
- 레거시 buy/sell endpoint 호출자(CLI `pfhub portfolio buy/sell`) — 본 Discovery는 deprecated 표기만, 실제 호출 차단/제거는 후속 작업. 다만 동작은 그대로 유지(`portfolio_ledger` 컬럼 타입 변경 영향만).
|
||||
- prod에서 `ledger_trades` 데이터 보존이 필요하다면 신규 portfolio_ledger 컬럼으로 backfill 필요 — 본 워크스페이스 미생성이라 N/A, 별도 검토 항목으로 분리.
|
||||
- workspace_purpose 메모리: prod 영향 검증은 release 단계 (별도 release로 분리 권장)
|
||||
|
||||
# 5. 요구사항 목록
|
||||
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
|
||||
|
||||
## 기능 요구사항
|
||||
- FR-1: `LedgerTradeResponse`에 portfolio 상태 필드를 응답 root에 flat으로 비파괴적으로 추가한다(OQ-2 결정 (b), 2026-06-05). 추가 필드는 정확히 2개: `cash_balance_after: float`, `asset_quantity_after: float` (issue #8 요구 그대로 최소). 추가 필드(`total_value_after`, `profit_buffer_after` 등)는 본 Discovery 범위 밖이며 후속 Discovery에서 다룬다.
|
||||
- FR-2: `status="new"` 응답에 거래 반영 **후** 권위 상태를 반환한다(OQ-1 결정 (B), 2026-06-05). ledger insert 트랜잭션 내 portfolio cash/quantity 갱신 후 새 값 반환.
|
||||
- FR-3: `status="duplicate"` 응답은 ledger row를 재생성하지 않고 **duplicate 호출 시점의 portfolios 테이블 현재 권위 상태**(`cash_balance`, `asset_quantity`)를 SELECT로 조회해 반환한다(OQ-4 결정 (b), 2026-06-05). 첫 호출 시점 결과의 영구 echo가 아니라 호출 시점 권위 상태 — trex-core가 항상 최신 권위 상태로 sync되도록 보장(issue #8 본문 명시 충족, drift 방지 일관성). 결과적으로 `portfolio_ledger` row는 정확히 1건 / portfolio 갱신은 1회만 발생 — 부수 효과의 멱등성은 유지된다.
|
||||
- FR-4: 기존 인가(daemon 3단계), idempotency unique constraint, status 의미는 보존한다 (비파괴 확장).
|
||||
- FR-5: `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md` baseline 갱신: 신 path의 회계 책임, 응답 schema, 두 ledger 테이블의 관계 명문화.
|
||||
- FR-6: portfolio_ledger INSERT와 portfolio cash/quantity 갱신을 **단일 DB 트랜잭션**으로 묶는다. 회계 공식은 기존 도메인 서비스(`apply_buy`/`apply_sell`)와 동일: side="buy" → `cash -= qty*price + fee + tax`, `quantity += qty`. side="sell" → `cash += qty*price - fee - tax`, `quantity -= qty`. **신 path에서는 잔고/수량 부족 거부 검증을 적용하지 않는다(OQ-3 결정, 2026-06-05) — 음수 `cash_balance`와 음수 `asset_quantity` 모두 허용**. 신 path ledger 기록은 broker 체결의 사후 보고이므로 broker 정산 결과를 portfolio-hub가 그대로 수용한다. (레거시 `POST /portfolios/{id}/buy`·`/sell`은 기존 거부 정책 유지 — 이미 deprecated 분류이므로 본 변경 대상 아님.) 따라서 기존 `apply_buy`/`apply_sell`을 그대로 재사용하지 않고 **음수 검증을 제거한 별도 회계 헬퍼**를 ledger 흐름 전용으로 추출하거나 신 path 내부에 inline 구현한다.
|
||||
- FR-7: 동시 ledger write race condition에 대한 row-level lock(`SELECT ... FOR UPDATE`) 적용. lost update 방지.
|
||||
- FR-8: 레거시 `POST /portfolios/{id}/buy`·`/sell` endpoint에 deprecated 표기를 응답 헤더(`Deprecation: true`) 또는 OpenAPI metadata로 추가한다(OQ-5 결정, 2026-06-05). 실제 endpoint 제거는 본 Discovery 범위 밖, 후속 정리 batch에서 처리.
|
||||
- FR-9 (OQ-6 결정 (iv) + OQ-7 결정 (ii), 2026-06-05): `portfolio_ledger`에 broker 식별 필드 추가 alembic 마이그레이션 작성. 추가 컬럼: `ledger_entry_id BigInt Identity PK`, `side TEXT NOT NULL`, `executed_at TIMESTAMPTZ NOT NULL`, `order_id TEXT NULL`, `execution_id TEXT NULL`. **partial UNIQUE index `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL`** (broker write만 idempotency, 레거시/수동 write는 NULL → append-only). 기존 레거시 데이터 backfill: action→side 매핑, recorded_at→executed_at 복사, order_id/execution_id는 **NULL**(synthetic 값 생성 불요).
|
||||
- FR-10 (OQ-6 결정 (iv)): `portfolio_ledger`의 수치 컬럼(`price`, `quantity`, `fee_amount`, `tax_amount`, `cash_balance_after`, `asset_quantity_after`, `total_value_after`, `profit_buffer_after`)을 DOUBLE PRECISION → NUMERIC(20,8) 변경. `ledger_trades`와 정밀도 일관.
|
||||
- FR-11 (OQ-6 결정 (iv)): 기존 `action` 컬럼 제거 (신규 `side` 컬럼으로 일관 — `action='BUY'` → `side='buy'`, `action='SELL'` → `side='sell'`).
|
||||
- FR-12 (OQ-6 결정 (iv)): `ledger_trades` 테이블 DROP. 본 워크스페이스 `ledger_trades` 미생성이라 비용 0. prod 환경 데이터는 별도 검토 항목으로 분리(release 단계).
|
||||
- FR-13 (OQ-6 결정 (iv)): 도메인 모델 통합 — `LedgerEntry` 단일화. `LedgerTrade` dataclass 삭제. broker 필드(`ledger_entry_id`, `order_id`, `execution_id`, `side`, `executed_at`)를 `LedgerEntry`에 흡수.
|
||||
- FR-14 (OQ-6 결정 (iv)): repository 통합 — `insert_ledger_trade` 삭제 또는 새 통합 함수로 재정의. 신 path 호출은 portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션.
|
||||
- FR-15 (OQ-7 결정 (ii), 2026-06-05): 신 path duplicate 감지 SQL을 partial unique index 대상으로 전환 — `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL DO NOTHING` (기존 `ON CONFLICT ON CONSTRAINT uq_ledger_trades_idempotency`를 대체). PostgreSQL partial index inference 사용.
|
||||
|
||||
## 비기능 요구사항
|
||||
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
|
||||
|
||||
- NFR-1: 응답 schema 확장은 기존 client(있다면)에 backwards-compat 영향이 없어야 한다(JSON 추가 필드형). 본 워크스페이스는 개발용이라 외부 client 영향은 release 단계 검증.
|
||||
- NFR-2: portfolio_ledger insert + portfolio update가 단일 트랜잭션으로 묶여 부분 실패 시 ledger row만 또는 portfolio만 갱신되는 inconsistent state가 발생하지 않아야 한다.
|
||||
- NFR-3: 동시 ledger write 시 race condition(같은 portfolio에 동시 buy/sell)에서 cash_balance/asset_quantity가 lost update 되지 않아야 한다. 적절한 row-level lock(예: `SELECT FOR UPDATE`) 또는 optimistic concurrency 적용.
|
||||
|
||||
## 범위 경계
|
||||
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
|
||||
|
||||
- In Scope
|
||||
- `LedgerTradeResponse` portfolio 상태 필드 비파괴 추가 (FR-1)
|
||||
- `status="new"` / `status="duplicate"` 응답 의미 명문화 (FR-2/3)
|
||||
- 기존 인가·idempotency·status 보존 (FR-4)
|
||||
- baseline 문서 3종(interface-contract / runtime-flows / data-model) 갱신 (FR-5)
|
||||
- ledger INSERT + portfolio UPDATE 단일 트랜잭션 + 회계 공식 적용 (FR-6) — OQ-1 결정 (B)
|
||||
- row-level lock으로 race condition 방지 (FR-7)
|
||||
- 레거시 buy/sell endpoint deprecated 표기 (FR-8)
|
||||
- **`portfolio_ledger` 테이블에 broker 식별 필드 추가 + 컬럼 타입 NUMERIC(20,8) 통일 + action→side 통일 (FR-9/10/11)** — OQ-6 결정 (iv)
|
||||
- **`ledger_trades` 테이블 DROP** + 도메인 모델 통합(LedgerTrade 삭제, LedgerEntry 단일화) (FR-12/13)
|
||||
- repository 통합 — `insert_ledger_trade` 재정의 또는 통합 (FR-14)
|
||||
- partial UNIQUE index + `ON CONFLICT ... WHERE order_id IS NOT NULL` duplicate 감지 (FR-15) — OQ-7 결정 (ii)
|
||||
- Out of Scope
|
||||
- 레거시 `POST /portfolios/{id}/buy`·`/sell` endpoint **실제 제거** — 본 Discovery는 deprecated 표기만, 코드 제거는 후속 정리 batch
|
||||
- prod 환경 `ledger_trades` 데이터 backfill 정책 — 본 워크스페이스는 미생성이라 N/A. prod는 release 단계에서 별도 검토
|
||||
- profit_buffer, asset_price 같은 추가 portfolio 응답 필드 — 본 issue는 cash_balance/asset_quantity만 요청
|
||||
- read 엔드포인트(`GET /v1/portfolios`) schema 변경 — 본 Discovery는 ledger write 응답만 다룸
|
||||
- trex-core daemon 측 구현 (외부 repo)
|
||||
- 레거시 CLI(`pfhub portfolio buy/sell`) 호출 차단 또는 동작 변경 — 본 Discovery는 deprecated 표기만, 동작은 portfolio_ledger 신 스키마에서 유지
|
||||
|
||||
# 6. 리스크/가정 목록
|
||||
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을 분리해 기록한다.
|
||||
|
||||
## 리스크
|
||||
- R-1 (Medium): ledger insert + portfolio update 트랜잭션 race condition으로 lost update 발생.
|
||||
- 완화 방안: portfolios row에 `SELECT FOR UPDATE` 적용 또는 `UPDATE ... WHERE updated_at = <stale>` optimistic concurrency. 동시 write 회귀 테스트 신설.
|
||||
- R-2 (Dropped, 2026-06-05): "회계 공식이 외부 portfolio mutation 흐름과 불일치" 리스크는 OQ-5(레거시 buy/sell deprecated)·OQ-6((iv) 단일 원장 통합) 결정으로 R-8(두 회계 헬퍼 divergence)로 흡수됨. R-8을 source of truth로 둔다.
|
||||
- R-3 (Dropped, 2026-06-05): "옵션 A 채택 시 stale snapshot" 리스크는 OQ-1이 옵션 B로 확정되어 더 이상 유효하지 않음.
|
||||
- R-4 (Low): 응답 shape 변경(중첩 vs flat)이 trex-core JSON parser와 호환 안 됨.
|
||||
- 완화 방안: trex-core 팀과 사전 합의(OQ-2 (b) flat 결정 + issue #8 응답 명세 commented at 2026-06-05).
|
||||
- R-5 (Medium): portfolio_ledger 컬럼 타입 변경(DOUBLE → NUMERIC) 시 기존 데이터 정밀도 변환 손실(IEEE 754 부동소수점에서 십진 NUMERIC으로 매핑 시 미세 오차) — 다만 본 워크스페이스 portfolio_ledger 기존 데이터는 개발용이라 영향 미미.
|
||||
- 완화 방안: prod 마이그레이션 시점에 별도 검토 (release plan). 본 워크스페이스는 N/A.
|
||||
- R-6 (Medium): `action` → `side` 변환 시 기존 portfolio_ledger 데이터의 action 값이 'BUY'/'SELL' 외 자유형식(예: 'manual')이면 매핑 누락.
|
||||
- 완화 방안: 마이그레이션 전 action 값 분포 조회 → 누락 케이스 처리 정책 결정 (drop or 'unknown' fallback). 본 워크스페이스 데이터는 개발용이라 분포 단순.
|
||||
- R-7 (Low): ledger_trades DROP 시점에 진행 중 트랜잭션이 있으면 실패 가능.
|
||||
- 완화 방안: 본 워크스페이스 ledger_trades 미생성이라 N/A. prod는 release window 잠금 + 단순 DROP.
|
||||
- R-8 (Medium): 신 path 회계 헬퍼와 레거시 apply_buy/apply_sell의 두 회계 공식이 long-term divergence할 위험(음수 정책 분기).
|
||||
- 완화 방안: 공통 회계 공식 함수 추출 + 음수 검증 wrapper 패턴. 회귀 테스트로 두 흐름 일관성 검증.
|
||||
|
||||
## 가정
|
||||
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
|
||||
|
||||
- A-1: portfolio.cash_balance / asset_quantity는 ground truth이고 trex-core는 portfolio-hub 값을 신뢰한다(issue 본문 명시).
|
||||
- A-2: 본 워크스페이스는 개발용이며 prod race condition / 트랜잭션 격리 / 마이그레이션 정밀도 검증은 release 단계에서 별도 수행. workspace_purpose 메모리 정합.
|
||||
- A-3: 외부 ledger 호출자(trex-basic 등)는 응답 schema 확장에 backwards-compat 호환된다(JSON parser가 추가 필드 무시).
|
||||
- A-4: 본 Discovery 결과물은 BAT-015(release-candidate)와 분리된 새 batch + 별도 release(REL-003 등)로 분리한다 — REL-001/REL-002 일정과 충돌 회피. **사용자 confirm-discovery(2026-06-05)로 동의 확정**.
|
||||
|
||||
## 오픈 질문
|
||||
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
|
||||
|
||||
- OQ-1: ledger insert가 portfolio cash/quantity를 갱신할지 결정 — 옵션 A(단순 join 조회만) vs 옵션 B(ledger insert 트랜잭션 내 갱신).
|
||||
- 상태: Answered (2026-06-05)
|
||||
- 결정: **(B) ledger insert 트랜잭션 내 portfolio cash/quantity 갱신**. 사용자 의도: 신 path를 portfolio 회계의 단일 권위 endpoint로 단일화한다. trex-core가 ledger write 1회로 회계까지 완결되며 drift가 원천 차단된다.
|
||||
- 본문 반영: FR-2/FR-6/FR-7, # 4 변경, # 5 In Scope
|
||||
- OQ-2: 응답 shape — 중첩(`portfolio_state: {cash_balance, asset_quantity}`) vs flat(`cash_balance_after`, `asset_quantity_after`).
|
||||
- 상태: Answered (2026-06-05)
|
||||
- 결정: **(b) flat** — 응답 root에 `cash_balance_after`, `asset_quantity_after` 두 필드만 추가. 기존 PortfolioItem schema flat 컨벤션 + `portfolio_ledger` 테이블 컬럼명과 직접 정합. issue 본문 "shape 메인테이너 재량" 권한 행사.
|
||||
- 추가 정책: 응답 필드는 issue #8 명시한 cash/quantity 2개로 **최소** 유지. `total_value_after`, `profit_buffer_after` 등 추가 후보는 본 Discovery 범위 밖, 후속 Discovery에서 검토.
|
||||
- 본문 반영: FR-1, # 4 변경, # 5 In/Out Scope, # 10 DECIDE 섹션에서 항목 제거
|
||||
- OQ-3: 회계 공식 + 음수 잔고/수량 허용 여부.
|
||||
- 상태: Answered (2026-06-05)
|
||||
- 결정 (공식): 기존 도메인 서비스(`apply_buy`/`apply_sell`)와 동일 공식 사용 확인. buy → `cash -= qty*price + fee + tax`, `quantity += qty`. sell → `cash += qty*price - fee - tax`, `quantity -= qty`. 정밀 검토에서 기존 흐름과 4개 항목 모두 일치 확인.
|
||||
- 결정 (음수 정책): **cash_balance, asset_quantity 모두 음수 허용**. ledger_trades는 broker 체결의 사후 보고이므로 broker 정산 결과를 portfolio-hub가 그대로 수용한다. 기존 `apply_buy`/`apply_sell`의 ValueError 거부 로직(`cash_balance < total_cost`, `asset_quantity < quantity`)을 신 path에서는 사용하지 않는다. 음수 검증 제거한 회계 헬퍼를 ledger 흐름 전용으로 추출하거나 inline 구현.
|
||||
- 영향: 레거시 `POST /portfolios/{id}/buy`·`/sell`은 기존 거부 정책 유지(이미 deprecated 분류, 본 변경 대상 아님). 두 흐름의 잔고 정책이 endpoint별로 다르다는 사실이 baseline 문서에 명문화 필요.
|
||||
- 본문 반영: FR-6, # 10 DECIDE 섹션에서 항목 제거
|
||||
- OQ-4: `status="duplicate"` 응답의 portfolio 상태 — 첫 호출 시점 결과 영구 echo vs duplicate 호출 시점의 현재 권위 상태.
|
||||
- 상태: Answered (2026-06-05)
|
||||
- 결정: **(b) 현 시점 권위 상태**. duplicate 호출 시점에 portfolios 테이블 SELECT 1회로 cash_balance/asset_quantity를 조회해 응답에 포함. issue #8 본문이 명시한 요구("현재 권위 cash_balance / asset_quantity 를 동일하게 반환해 소비자가 항상 최신 상태로 갱신")를 직접 충족하며 trex-core의 drift 방지 효과가 duplicate 경로에서도 유지된다.
|
||||
- 옵션 A(첫 호출 결과 echo) 미채택 사유: ledger_trades에 잔고 컬럼 추가가 강제됨(OQ-6 (iii) 강결합) + trex-core가 옛 상태로 스냅샷을 덮어쓰는 위험.
|
||||
- idempotency 본질 영향: (통합 후) portfolio_ledger row는 정확히 1건 + portfolio 갱신은 1회 발생 → 부수 효과 멱등성은 유지. 응답 stationary echo만 약해짐(부차적).
|
||||
- OQ-6과의 관계: 본 결정은 OQ-6의 (i)/(ii)/(iii) 어느 옵션과도 호환 — OQ-6 독립.
|
||||
- 본문 반영: FR-3, # 7 S-2 측정 방식, # 10 DECIDE 섹션에서 항목 제거
|
||||
- OQ-5: ledger write가 portfolio mutation을 동반하는 옵션 B 채택 시, 기존 레거시 `POST /portfolios/{id}/buy`·`/sell` endpoint와 그 회계 흐름(`apply_buy`/`apply_sell` + `repo.save` + `portfolio_ledger` 기록)과의 관계.
|
||||
- 상태: Answered (2026-06-05)
|
||||
- 결정: **레거시 buy/sell endpoint는 deprecated 분류 → 추후 삭제 대상**. 신 path가 portfolio 회계의 단일 권위 endpoint가 된다. 본 Discovery에서는 deprecated 표기(FR-8)만 다루며, 실제 endpoint 코드 제거 + CLI 호출자 전환은 후속 정리 batch 또는 별도 Discovery에서 처리.
|
||||
- 본문 반영: FR-8, # 4 변경, # 5 Out of Scope (실제 제거 분리)
|
||||
|
||||
- OQ-6: `portfolio_ledger`와 `ledger_trades` 두 ledger 테이블의 미래 정책.
|
||||
- 상태: Answered (2026-06-05)
|
||||
- 결정: **옵션 (iv) — `portfolio_ledger`를 단일 회계 원장으로 살리고 `ledger_trades`의 idempotency/broker 식별 필드를 portfolio_ledger에 흡수 + `ledger_trades` 테이블 DROP**. portfolio_ledger가 본질적으로 회계 원장의 정통 형태(잔고 snapshot 4종 + strategy + note 기존 보유)이고, ledger_trades는 broker 체결 보고로서의 좁은 책임만 가짐 → portfolio_ledger를 회계 원장 단일 source of truth로 통합하는 게 응집도 우수.
|
||||
- 부수 결정: 컬럼 타입 DOUBLE PRECISION → NUMERIC(20,8) 일관, `action` 컬럼 제거 후 `side` 단일화, 테이블명 `portfolio_ledger` 유지. 본 워크스페이스 ledger_trades 미생성이라 데이터 backfill 비용 0(prod는 별도 검토).
|
||||
- 본문 반영: # 1 해석 격상, # 4 변경 항목, # 4 영향 범위 (마이그레이션 + 도메인 모델 + 코드 + 문서), FR-9~14 신설, # 5 In/Out Scope 갱신, # 10 DECIDE 섹션에서 항목 제거
|
||||
|
||||
- OQ-7 (신규, 2026-06-05 review-discovery 발견): 통합된 `portfolio_ledger`의 `order_id`/`execution_id` nullability + 레거시 write(deprecated `POST /portfolios/{id}/buy`·`/sell`, CLI)의 broker 식별자/멱등키 정책.
|
||||
- 상태: Answered (2026-06-05)
|
||||
- 결정: **(ii) `order_id`/`execution_id` NULL 허용 + partial UNIQUE `WHERE order_id IS NOT NULL`**. broker write(신 path)만 idempotency 적용(NULL 아닌 식별자), 레거시/수동 write는 NULL → append-only 유지(멱등 dedup 없음 — buy 두 번 = 거래 두 건). 멱등성 범위가 의미와 정확히 일치하고, NULL = "broker 식별자 없음"으로 데이터 정직성 확보.
|
||||
- 근거: (1) 멱등성은 broker write에만 필요 — partial UNIQUE가 정확히 구현. (2) NULL-ness로 broker write vs 레거시 write 깔끔히 구분. (3) **본 codebase에 이미 동일 패턴 확립** — REQ-017/BAT-006의 `idx_auth_tokens_service_name_unique` partial unique index([migrations/versions/20260520_0022_service_token_unique_index_fix.py:37-39](../../migrations/versions/20260520_0022_service_token_unique_index_fix.py#L37), [app/repositories/postgres.py:191-193](../../app/repositories/postgres.py#L191)). (4) 마이그레이션 단순 — 레거시 row는 order_id/execution_id NULL, synthetic backfill 불요. (5) 신 path duplicate 감지는 `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL DO NOTHING`로 전환(PostgreSQL partial index inference 지원).
|
||||
- 본문 반영: FR-9 확정(NULL 허용 + partial UNIQUE), # 4 영향 범위 마이그레이션 backfill, FR-15 신설(ON CONFLICT 전환), # 8 체크리스트, B1 해소
|
||||
- 종료 조건: 충족 (2026-06-05)
|
||||
|
||||
### 오픈 질문 처리 기준
|
||||
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
|
||||
- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다.
|
||||
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
|
||||
- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다.
|
||||
|
||||
# 7. 초기 성공 기준
|
||||
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
|
||||
|
||||
- S-1: `POST /v1/portfolios/{id}/ledger/trades` 응답 schema에 cash_balance/asset_quantity가 포함되고 그 값이 portfolios 테이블 (또는 트랜잭션 갱신 후) 권위 상태와 일치한다.
|
||||
- S-2: `status="new"`와 `status="duplicate"` 모두 portfolios 테이블의 권위 상태를 반환한다(OQ-4 결정 (b)). 두 호출 사이에 외부 mutation이 없다면 동일 값, 있다면 duplicate는 현재 권위 상태를 반환. 핵심은 portfolio_ledger row가 1건만 존재 + portfolio 갱신이 1회만 발생 (멱등 부수 효과).
|
||||
- S-3: 기존 ledger 응답 필드(ledger_entry_id, portfolio_id, order_id, ...)와 인가/idempotency 동작이 회귀 테스트로 보존된다.
|
||||
- S-4: ledger insert + portfolio update 트랜잭션 격리가 회귀 테스트 또는 staging 측정으로 검증된다.
|
||||
- S-5 (OQ-6 (iv) + OQ-7 (ii)): 신규 alembic revision 적용 후 `\d portfolio_ledger`에 `ledger_entry_id`, `order_id`(NULL 허용), `execution_id`(NULL 허용), `side`, `executed_at` 컬럼 존재 + **partial UNIQUE index `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL`** 존재 + 수치 컬럼 타입 NUMERIC(20,8) 확인. `ledger_trades` 테이블 부재 확인.
|
||||
- S-6 (OQ-6 (iv)): 도메인 모델에 `LedgerEntry` 단일 존재 + `LedgerTrade` 부재. 코드 grep으로 검증.
|
||||
- S-7 (OQ-6 (iv)): 신 path가 `portfolio_ledger`에 INSERT (단일 트랜잭션) + portfolios UPDATE. ledger_trades INSERT 코드 부재. 회귀 테스트로 검증.
|
||||
- S-8 (OQ-7 (ii)): broker write는 같은 (portfolio_id, order_id, execution_id) 재호출 시 duplicate dedup. 레거시/수동 write(order_id NULL)는 두 번 호출 시 portfolio_ledger row 2건 생성(append-only, dedup 안 됨). pytest 회귀로 검증.
|
||||
|
||||
## 측정 방식 및 데이터 출처
|
||||
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
|
||||
|
||||
- S-1 측정 방식: pytest TestClient `POST /v1/portfolios/{id}/ledger/trades` 응답 본문에 cash_balance_after/asset_quantity_after 필드 매치 + DB SELECT로 portfolio 갱신 확인 / 데이터 출처: 신규 pytest 케이스 + DB 회귀
|
||||
- S-2 측정 방식: pytest로 같은 (portfolio, order_id, execution_id)로 두 번 호출. 첫 호출: status="new" + 회계 적용 후 portfolio 값. 두 번째: status="duplicate" + 현재 portfolios 테이블 권위 값(외부 mutation 없으면 첫 호출 결과와 동일). DB에서 portfolio_ledger row 1건 + portfolio updated_at 1회 변경 확인 / 데이터 출처: 신규 pytest 케이스 + DB SELECT 회귀
|
||||
- S-3 측정 방식: 기존 `tests/test_v1_portfolios_ledger.py` 회귀 통과 (응답 신규 필드 + portfolio_ledger 전환 반영하도록 갱신) / 데이터 출처: pytest
|
||||
- S-4 측정 방식: 동시 ledger write 시뮬레이션 또는 staging concurrent test / 데이터 출처: 신규 회귀 또는 REL staging
|
||||
- S-5 측정 방식: alembic upgrade 후 `\d portfolio_ledger` + `information_schema` 조회로 신규 컬럼/제약/타입 확인 + `ledger_trades` 테이블 부재 확인 / 데이터 출처: DB schema 조회
|
||||
- S-6 측정 방식: `grep -rn "class LedgerTrade\b\|LedgerTrade(" app/` 결과 0건 + `LedgerEntry` 단일 정의 확인 / 데이터 출처: 코드 grep
|
||||
- S-7 측정 방식: 신 path 회귀 테스트에서 portfolio_ledger INSERT mock/DB 호출 확인 + `grep -rn "ledger_trades" app/` 0건 / 데이터 출처: pytest + 코드 grep
|
||||
|
||||
# 8. REQ로 넘기기 전 확인 체크
|
||||
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
|
||||
|
||||
- [x] 범위(In Scope/Out of Scope) 확정
|
||||
- 기준: OQ-1~7 모두 Answered (2026-06-05). FR-1~15 확정 → 범위 확정.
|
||||
- [x] 성공 지표의 측정 방식/데이터 출처 확정
|
||||
- 기준: S-1~S-8 모두 pytest/DB schema 조회/코드 grep으로 측정 가능 형태 확정
|
||||
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
|
||||
- 기준: 본 Discovery에는 High 리스크 없음 (R-1/R-5/R-6/R-8 Medium, R-4/R-7 Low; R-2/R-3 Dropped). Medium 리스크는 대부분 prod 마이그레이션/release 단계 검토로 위임됨.
|
||||
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
||||
- 기준: OQ-1~7 모두 Answered. Open 항목 0개.
|
||||
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
||||
- 기준: confirm-discovery(2026-06-05)로 CONFIRMED_BY = "Justin Ko" 지정. CONFIRM_REL_SEPARATION 동의 완료.
|
||||
|
||||
# 9. 파일 처리 결과
|
||||
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
|
||||
|
||||
- 처리 결과: 생성
|
||||
- 생성 경로: docs/discovery/dcy-006_20260605_ledger-trade-write-portfolio-state-return.md
|
||||
- 참조 문서:
|
||||
- [GitHub issue #8](https://github.com/fourblocks2020/portfolio-hub/issues/8) — trex-core daemon 요구 + 응답 명세 회신 comment 2026-06-05
|
||||
- [docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md](../srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md) (기존 path 이전 REQ)
|
||||
- [docs/srs/Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md](../srs/Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md) (dcy-005 컬럼명 정합)
|
||||
- [app/api/v1_portfolios.py:514-588](../../app/api/v1_portfolios.py#L514-L588) — `record_ledger_trade_v1` handler
|
||||
- [app/repositories/postgres.py:263-282](../../app/repositories/postgres.py#L263-L282) — `portfolio_ledger` ensure_schema
|
||||
- [app/repositories/postgres.py:1175-1230](../../app/repositories/postgres.py#L1175-L1230) — `save()` (레거시 회계 흐름)
|
||||
- [app/repositories/postgres.py:1267-1315](../../app/repositories/postgres.py#L1267-L1315) — `insert_ledger_trade` (신 path audit-only)
|
||||
- [app/domain/services.py:87-205](../../app/domain/services.py#L87-L205) — `apply_buy`/`apply_sell` 회계 공식
|
||||
- [app/domain/models.py:126,175](../../app/domain/models.py#L126) — `LedgerEntry`/`LedgerTrade` dataclass (통합 대상)
|
||||
- [app/schemas/v1.py](../../app/schemas/v1.py) — `LedgerTradeResponse` 정의
|
||||
- [migrations/versions/20260502_0014_v1_api_schema.py](../../migrations/versions/20260502_0014_v1_api_schema.py) — `ledger_trades` 테이블 생성 마이그레이션 (본 Discovery에서 DROP 대상)
|
||||
|
||||
# 10. 사용자 결정 필요 항목 요약
|
||||
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
|
||||
|
||||
## DECIDE
|
||||
- (none — 모든 DECIDE 항목 Answered)
|
||||
|
||||
## CONFIRM
|
||||
- (none — CONFIRM_REL_SEPARATION은 사용자 confirm-discovery(2026-06-05)로 동의 확정. 가정 A-4에 반영됨.)
|
||||
|
||||
## DATA
|
||||
- (none — 본 Discovery는 본 repo 내부 정보 + issue #8 본문만으로 자립)
|
||||
|
||||
# 11. Discovery Freeze
|
||||
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
|
||||
|
||||
## 섹션 참조
|
||||
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
|
||||
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
|
||||
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
|
||||
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
|
||||
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
|
||||
|
||||
## Freeze 플래그
|
||||
- Handoff Decision: 승인
|
||||
- Handoff Rationale: OQ-1~7 모두 Answered + CONFIRM_REL_SEPARATION 동의 완료 (2026-06-05). 범위 격상(신 path 회계 책임 단일화 + portfolio_ledger/ledger_trades 통합 + 응답 schema 확장 + 레거시 deprecated). review-discovery cleanup 완료. OQ-7은 (ii) NULL 허용 + partial UNIQUE로 확정(REQ-017 패턴 재사용). FR-1~15 + S-1~8 확정. REQ Drafting 진입 준비 완료.
|
||||
- Ready for REQ Drafting: true
|
||||
- Confirmed By: Justin
|
||||
- Confirmed At (KST): 2026-06-05 21:00
|
||||
|
||||
# 12. 작성 가이드
|
||||
- 이 파일은 dcy-006 작성 완료본이다. confirm-discovery 진입 전까지 잔여 OQ(OQ-7) 결정 + 본문 반영 + CONFIRM_REL_SEPARATION 동의 필요.
|
||||
- 본 Discovery는 격상되어 (1) 신 path 회계 책임 단일화 + (2) portfolio_ledger/ledger_trades 테이블 통합(ledger_trades DROP) + (3) 응답 schema 확장 + (4) 레거시 buy/sell deprecated를 함께 다룬다. 단순 응답 확장이 아니라 마이그레이션 + 도메인 모델 통합을 포함한 대규모 변경이다.
|
||||
- REQ 분해 시: 응답/회계 동작(FR-1~8)과 테이블 통합 마이그레이션(FR-9~14)을 별도 REQ로 분리할지 단일 REQ 묶음으로 갈지 draft-req 시점에 결정. 마이그레이션 동반이라 표준 batch 권장(minor-change fast path 부적합).
|
||||
@@ -11,3 +11,4 @@
|
||||
| dcy-003_20260520_user-portfolio-mutation-v1-api-migration | 20260520 | User Portfolio Mutation v1 API 이전 및 path 명명 분리 (/v1/portfolios* 신설) | [Discovery](./dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md) | confirmed |
|
||||
| dcy-004_20260520_token-issuance-and-lifecycle-overhaul | 20260520 | 토큰 발급 및 관리 전면 정비 — client token 폐지, service-token rotate 정상화, 발급 권한 매트릭스, 만료·회전·자동 revoke 정책 | [Discovery](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md) | confirmed |
|
||||
| dcy-005_20260601_rel-001-feedback-cleanup | 20260601 | REL-001 Feedback 정합화 — ledger_trades.asset_id 명명 / session no_expiry enforcement / health db_enabled | [Discovery](./dcy-005_20260601_rel-001-feedback-cleanup.md) | confirmed |
|
||||
| dcy-006_20260605_ledger-trade-write-portfolio-state-return | 20260605 | ledger trade write 회계 책임 단일화 — 응답에 portfolio 상태 포함 + portfolio_ledger/ledger_trades 테이블 통합 (issue #8) | [Discovery](./dcy-006_20260605_ledger-trade-write-portfolio-state-return.md) | confirmed |
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# REQ-037: 신 path 회계 책임 단일화 + 응답에 portfolio 상태 포함
|
||||
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- `POST /v1/portfolios/{portfolio_id}/ledger/trades`를 portfolio 회계의 단일 권위 endpoint로 단일화한다. ledger 기록 시 portfolio `cash_balance`/`asset_quantity`를 트랜잭션 갱신하고, 응답에 거래 반영 후 권위 상태를 포함해 trex-core daemon의 drift를 원천 차단한다(issue #8).
|
||||
- 현재 audit-only 흐름(`insert_ledger_trade`가 `ledger_trades` INSERT만 수행)을 portfolio_ledger 단일 원장 기록 + portfolio 갱신 + 응답 확장으로 재정의한다.
|
||||
|
||||
## Requirement
|
||||
- 신 path handler([app/api/v1_portfolios.py:514-588](../../app/api/v1_portfolios.py#L514-L588) `record_ledger_trade_v1`)가 다음을 단일 DB 트랜잭션으로 수행한다(dcy-006 FR-6):
|
||||
- portfolios row를 `SELECT ... FOR UPDATE`로 잠근다(FR-7, lost update 방지).
|
||||
- 회계 공식 적용: side="buy" → `cash_balance -= qty*price + fee + tax`, `asset_quantity += qty`; side="sell" → `cash_balance += qty*price - fee - tax`, `asset_quantity -= qty`.
|
||||
- **잔고/수량 부족 거부 검증을 적용하지 않는다 — 음수 `cash_balance`/`asset_quantity` 모두 허용**(dcy-006 OQ-3). 음수 검증을 제거한 회계 헬퍼를 ledger 흐름 전용으로 추출하거나 inline 구현(기존 `apply_buy`/`apply_sell`은 레거시 전용으로 보존, R-8).
|
||||
- `portfolio_ledger`에 INSERT(REQ-036 신 스키마 사용). broker 식별자 `order_id`/`execution_id`/`side`/`executed_at` + 갱신 후 잔고 snapshot(`cash_balance_after`, `asset_quantity_after`, `total_value_after`) 기록.
|
||||
- portfolios 테이블 UPDATE(`cash_balance`, `asset_quantity`, `last_trade_at`, `updated_at`).
|
||||
- duplicate 감지(dcy-006 FR-15): `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL DO NOTHING`로 전환(기존 `ON CONFLICT ON CONSTRAINT uq_ledger_trades_idempotency` 대체). PostgreSQL partial index inference 사용.
|
||||
- `status="new"`(신규 row): 거래 반영 후 권위 상태를 응답한다(FR-2).
|
||||
- `status="duplicate"`(재호출): ledger row 재생성 없이 duplicate 호출 시점의 portfolios 테이블 현재 권위 상태를 SELECT로 조회해 응답한다(FR-3, dcy-006 OQ-4 (b)).
|
||||
- `LedgerTradeResponse`([app/schemas/v1.py](../../app/schemas/v1.py))에 `cash_balance_after: float`, `asset_quantity_after: float` 두 필드를 응답 root에 flat으로 비파괴 추가한다(FR-1, dcy-006 OQ-2 (b)). 기존 응답 필드는 모두 보존.
|
||||
- 기존 인가(daemon 3단계: token daemon_id == portfolio.managed_by_daemon_id), idempotency, status 의미를 보존한다(FR-4).
|
||||
- 도메인 모델 통합(FR-13): `LedgerEntry`와 `LedgerTrade` dataclass를 `LedgerEntry` 단일로 통합. `LedgerTrade` 삭제. broker 필드(`ledger_entry_id`, `order_id`, `execution_id`, `side`, `executed_at`)를 `LedgerEntry`에 흡수.
|
||||
- repository 통합(FR-14): `insert_ledger_trade`를 portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션 함수로 재정의(또는 통합). `_row_to_ledger_trade`는 `_row_to_ledger_entry`와 통합.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given service token + daemon 배정 active portfolio(cash=1,000,000, qty=0) + buy 10@80,000 fee 100, When `POST /v1/portfolios/{id}/ledger/trades`, Then HTTP 201 + `status="new"` + 응답 `cash_balance_after=199900`(1,000,000 - 800,000 - 100) + `asset_quantity_after=10`. DB portfolios도 동일 값으로 갱신.
|
||||
2. Given AC-1 직후 같은 (portfolio_id, order_id, execution_id)로 재호출, When 동일 payload, Then HTTP 200 + `status="duplicate"` + 응답 `cash_balance_after`/`asset_quantity_after`는 현재 portfolios 권위 값. portfolio_ledger row는 1건 유지(재생성 없음).
|
||||
3. Given sell로 보유 수량보다 큰 수량 또는 cash를 초과하는 buy, When 호출, Then **HTTP 201**(거부하지 않음) + 음수 `cash_balance_after` 또는 음수 `asset_quantity_after` 반환(음수 허용, OQ-3).
|
||||
4. Given 기존 응답 필드(ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at, recorded_at, status), When AC-1 응답 확인, Then 모두 보존되고 `cash_balance_after`/`asset_quantity_after`만 추가된다.
|
||||
5. Given service token `daemon_id IS NULL` 또는 daemon mismatch, When 호출, Then 기존 인가 동작(HTTP 403 fail-closed) 보존.
|
||||
6. Given codebase grep `grep -rn "ledger_trades\|insert_ledger_trade\|class LedgerTrade\b" app/`, When 본 REQ + REQ-036 구현 후, Then `ledger_trades` 테이블 INSERT 코드 및 `LedgerTrade` dataclass 참조가 0건(LedgerEntry 단일).
|
||||
7. Given 동시 ledger write 2건(같은 portfolio), When 트랜잭션 실행, Then `SELECT FOR UPDATE` row lock으로 lost update 없이 두 거래가 순차 반영된다(회귀 테스트).
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- `docs/interface-contract.md` — `POST /v1/portfolios/{id}/ledger/trades` 회계 동작 + 응답 schema(`cash_balance_after`/`asset_quantity_after`) + status별 동작 명문화
|
||||
- `docs/runtime-flows.md` — ledger write flow에 회계 적용 + portfolio 갱신 + 트랜잭션 lock 단계 + portfolio_ledger 단일 원장 명문화
|
||||
- Modules:
|
||||
- `app/api/v1_portfolios.py:record_ledger_trade_v1` — 통합 함수 호출 + 응답 확장
|
||||
- `app/repositories/postgres.py` — `insert_ledger_trade` 재정의(portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션 + FOR UPDATE + ON CONFLICT partial), `_row_to_ledger_*` 통합
|
||||
- `app/repositories/portfolio_repository.py` — interface 시그니처 갱신
|
||||
- `app/domain/models.py` — `LedgerEntry` 단일화, `LedgerTrade` 삭제
|
||||
- `app/domain/services.py` 또는 신규 — 음수 검증 제거한 ledger 전용 회계 헬퍼 추출
|
||||
- `app/schemas/v1.py` — `LedgerTradeResponse`에 `cash_balance_after`/`asset_quantity_after` 추가
|
||||
- Tests:
|
||||
- 갱신 `tests/test_v1_portfolios_ledger.py` — AC-1~7 (응답 신규 필드 + 회계 + duplicate + 음수 + 인가 보존 + 동시성)
|
||||
- 갱신 `tests/test_v1.py`, `tests/test_daemon_assignment.py` — LedgerEntry 통합 + 응답 schema 반영
|
||||
|
||||
## Notes
|
||||
- 본 REQ는 dcy-006 FR-1/2/3/4/6/7/13/14/15 산출물 + FR-5(interface-contract/runtime-flows) 일부 흡수.
|
||||
- REQ-036(마이그레이션) 선행 필요 — portfolio_ledger 신 스키마 위에서 동작.
|
||||
- 음수 허용은 신 path 한정. 레거시 buy/sell은 기존 거부 정책 유지(REQ-038 deprecated 대상).
|
||||
- 별도 REL(REL-003 등)로 분리 배포(dcy-006 A-4).
|
||||
- Source: dcy-006 FR-1/2/3/4/6/7/13/14/15, OQ-1~4/6/7.
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260605-02
|
||||
- Date: 2026-06-05
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드 모두 충족, AC-1~7 구체성(수치 회계 예시 + 음수 허용 + duplicate + 동시성) 확인 완료, blocker 없음.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: none (미정)
|
||||
- Releases: none (미정 — REL-003 등 분리 예정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입; REQ-036 선행 의존성 확인
|
||||
|
||||
### CHG-20260605-01
|
||||
- Date: 2026-06-05
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-006 confirm-discovery 후 draft-req 실행. dcy-006 회계 책임 단일화 + 응답 확장 산출물.
|
||||
|
||||
#### Intent
|
||||
- 신 path를 portfolio 회계 단일 권위 endpoint로 재정의하고 응답에 거래 후 권위 상태를 포함해 trex-core drift 차단.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~7 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs: docs/interface-contract.md, docs/runtime-flows.md — 최초 지정
|
||||
- Modules: app/api/v1_portfolios.py, app/repositories/postgres.py, app/repositories/portfolio_repository.py, app/domain/models.py, app/domain/services.py, app/schemas/v1.py — 최초 지정
|
||||
- Tests: tests/test_v1_portfolios_ledger.py, tests/test_v1.py, tests/test_daemon_assignment.py — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: none (미정)
|
||||
- Releases: none (미정 — REL-003 등 분리 예정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-036 선행 의존성 확인
|
||||
|
||||
#### Validation Plan
|
||||
- pytest TestClient로 AC-1~7 검증; 동시성은 회귀 또는 staging concurrent test
|
||||
@@ -0,0 +1,98 @@
|
||||
# REQ-038: 레거시 buy/sell endpoint deprecated 표기
|
||||
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: Low
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- 신 path(`POST /v1/portfolios/{id}/ledger/trades`)가 portfolio 회계의 단일 권위 endpoint로 단일화됨에 따라(REQ-037), 레거시 거래 endpoint `POST /portfolios/{id}/buy`·`/sell`을 deprecated로 표기해 클라이언트에 폐지 예정을 통지한다(dcy-006 OQ-5).
|
||||
- 본 REQ는 표기만 다루며 실제 endpoint 제거 + CLI 호출자 전환은 후속 정리 batch/Discovery에서 처리한다.
|
||||
|
||||
## Requirement
|
||||
- `POST /portfolios/{id}/buy`, `POST /portfolios/{id}/sell` 라우트([app/api/portfolios.py:158,176](../../app/api/portfolios.py#L158))에 deprecated 표기를 추가한다:
|
||||
- FastAPI route에 `deprecated=True`를 지정해 OpenAPI 문서/`/docs`에 deprecated로 노출.
|
||||
- 응답 헤더 `Deprecation: true` 추가(가능하면 `Sunset` 또는 안내 link 헤더 동반은 선택).
|
||||
- endpoint 동작 자체는 변경하지 않는다(회계 거부 정책 + portfolio_ledger 기록 유지). REQ-036 portfolio_ledger 스키마 변경 하에서도 레거시 흐름이 정상 동작하는지 회귀로 확인한다(레거시 write는 order_id/execution_id NULL append-only).
|
||||
- CLI(`pfhub portfolio buy/sell`) 동작은 변경하지 않는다(호출자 전환은 후속).
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given `/docs`(OpenAPI), When `POST /portfolios/{id}/buy`·`/sell` 스펙 확인, Then deprecated=true로 표시된다.
|
||||
2. Given `POST /portfolios/{id}/buy` 정상 호출, When 응답 확인, Then HTTP 200(기존 동작 보존) + 응답 헤더에 `Deprecation: true` 포함.
|
||||
3. Given REQ-036 portfolio_ledger 신 스키마 적용 후, When 레거시 buy/sell 호출, Then portfolio_ledger에 order_id/execution_id NULL + side 매핑된 row가 정상 append되고 회계 거부 정책(잔고 부족 시 거부)이 유지된다.
|
||||
4. Given codebase grep `grep -n "deprecated=True" app/api/portfolios.py`, When 본 REQ 구현 후, Then buy/sell 라우트에 매치된다.
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- `docs/interface-contract.md` — `POST /portfolios/{id}/buy`·`/sell` deprecated 표기 + 신 path로의 이전 안내
|
||||
- Modules:
|
||||
- `app/api/portfolios.py:158,176` — 라우트 `deprecated=True` + 응답 헤더 `Deprecation: true`
|
||||
- Tests:
|
||||
- 갱신/신규 `tests/test_portfolios.py` 또는 해당 — AC-1~3 검증(deprecated 표기 + 동작 보존 + 레거시 append)
|
||||
|
||||
## Notes
|
||||
- 본 REQ는 dcy-006 FR-8 산출물.
|
||||
- 실제 endpoint 제거는 본 REQ 범위 밖(dcy-006 Out of Scope). 후속 정리 batch 또는 별도 Discovery.
|
||||
- REQ-036(portfolio_ledger 스키마 변경) 선행 — 레거시 흐름이 신 스키마에서 동작 보존되는지 AC-3로 확인.
|
||||
- 별도 REL(REL-003 등)로 분리 배포(dcy-006 A-4).
|
||||
- Source: dcy-006 FR-8, OQ-5.
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260605-02
|
||||
- Date: 2026-06-05
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드 모두 충족, AC-1~4 구체성(OpenAPI deprecated + 헤더 + 레거시 append 보존) 확인 완료, blocker 없음.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: none (미정)
|
||||
- Releases: none (미정 — REL-003 등 분리 예정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입; 실제 endpoint 제거는 후속 트랙
|
||||
|
||||
### CHG-20260605-01
|
||||
- Date: 2026-06-05
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-006 confirm-discovery 후 draft-req 실행. dcy-006 FR-8 산출물.
|
||||
|
||||
#### Intent
|
||||
- 레거시 buy/sell endpoint deprecated 표기로 신 path 단일화 통지.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~4 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs: docs/interface-contract.md — 최초 지정
|
||||
- Modules: app/api/portfolios.py — 최초 지정
|
||||
- Tests: tests/test_portfolios.py — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: none (미정)
|
||||
- Releases: none (미정 — REL-003 등 분리 예정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입; 실제 endpoint 제거는 후속 트랙
|
||||
|
||||
#### Validation Plan
|
||||
- pytest로 AC-1~4 검증(OpenAPI deprecated + 헤더 + 레거시 append 보존)
|
||||
@@ -0,0 +1,112 @@
|
||||
# REQ-036: portfolio_ledger 회계 원장 통합 마이그레이션 (ledger_trades 흡수/DROP)
|
||||
|
||||
- Status: Approved
|
||||
- Type: Migration
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- 두 ledger 테이블(`portfolio_ledger` 회계 원장 + `ledger_trades` broker audit)이 공존하는 구조를 `portfolio_ledger` 단일 회계 원장으로 통합한다(dcy-006 OQ-6 (iv)).
|
||||
- `ledger_trades`의 broker idempotency/식별 필드를 `portfolio_ledger`에 흡수하고 `ledger_trades` 테이블을 삭제해, 신 path(`POST /v1/portfolios/{id}/ledger/trades`)가 단일 회계 원장에 기록할 수 있는 스키마 기반을 마련한다.
|
||||
|
||||
## Requirement
|
||||
- Alembic 신규 revision을 작성한다. (head는 dcy-005 `20260601_0024` 다음)
|
||||
- `portfolio_ledger`에 컬럼을 추가한다:
|
||||
- `ledger_entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY`
|
||||
- `order_id TEXT NULL`, `execution_id TEXT NULL` (dcy-006 OQ-7 (ii): NULL 허용)
|
||||
- `side TEXT NOT NULL`
|
||||
- `executed_at TIMESTAMPTZ NOT NULL`
|
||||
- partial UNIQUE index `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL`를 추가한다. broker write(신 path)만 idempotency를 적용하고 레거시/수동 write(order_id NULL)는 append-only 유지. (REQ-017 partial unique index 패턴 재사용)
|
||||
- 수치 컬럼 타입을 DOUBLE PRECISION → NUMERIC(20,8)로 변경한다: `price`, `quantity`, `fee_amount`, `tax_amount`, `cash_balance_after`, `asset_quantity_after`, `total_value_after`, `profit_buffer_after`.
|
||||
- 기존 `action` 컬럼을 제거하고 `side`로 통일한다. backfill 매핑: `action='BUY'` → `side='buy'`, `action='SELL'` → `side='sell'`. 그 외 자유형식 action 값의 처리(R-6)는 마이그레이션 본문에서 분포 확인 후 결정(개발 워크스페이스는 분포 단순).
|
||||
- 기존 데이터 backfill: `recorded_at` → `executed_at` 복사, `order_id`/`execution_id`는 NULL(synthetic 생성 불요).
|
||||
- 추가 인덱스 `idx_portfolio_ledger_portfolio_executed (portfolio_id, executed_at)` 신설. 기존 `idx_portfolio_ledger_portfolio_time (portfolio_id, recorded_at DESC)`은 유지.
|
||||
- `ledger_trades` 테이블을 DROP한다. (본 워크스페이스 미생성이라 `DROP TABLE IF EXISTS`로 안전 처리. prod 데이터 보존 필요 시 backfill은 release 단계 별도 검토.)
|
||||
- `app/repositories/postgres.py`의 `ensure_schema()` 내 `portfolio_ledger` 정의도 신 스키마와 동일하게 갱신한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given 신규 Alembic revision 적용 후, When `\d portfolio_ledger` 확인, Then `ledger_entry_id`(PK), `order_id`(NULL 허용), `execution_id`(NULL 허용), `side`(NOT NULL), `executed_at`(NOT NULL) 컬럼이 존재한다.
|
||||
2. Given AC-1 환경, When `information_schema`로 수치 컬럼 타입 확인, Then `price`/`quantity`/`fee_amount`/`tax_amount`/`cash_balance_after`/`asset_quantity_after`/`total_value_after`/`profit_buffer_after`가 모두 NUMERIC(20,8)이다.
|
||||
3. Given AC-1 환경, When partial UNIQUE index 확인, Then `(portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL` 조건의 unique index가 존재한다.
|
||||
4. Given AC-1 환경, When `action` 컬럼 존재 확인, Then `action` 컬럼은 존재하지 않고 `side` 컬럼으로 대체되어 있다.
|
||||
5. Given AC-1 환경, When `\d ledger_trades` 또는 테이블 목록 조회, Then `ledger_trades` 테이블이 존재하지 않는다.
|
||||
6. Given 기존 portfolio_ledger 데이터(있는 경우), When 마이그레이션 적용, Then `executed_at`은 `recorded_at` 값으로 채워지고 `order_id`/`execution_id`는 NULL이며 `side`는 기존 action에서 매핑된다.
|
||||
7. Given `ensure_schema()` 단독 실행, When portfolio_ledger 스키마 확인, Then 마이그레이션과 동일한 컬럼/제약/타입이 반영된다.
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- `docs/data-model.md` — `portfolio_ledger` entity에 신규 컬럼(ledger_entry_id PK, order_id/execution_id NULL, side, executed_at) + 정밀도 NUMERIC(20,8) + partial UNIQUE index 명세 추가. `ledger_trades` entity 섹션 제거. 두 ledger 테이블 → 단일 원장 통합 명시.
|
||||
- Modules:
|
||||
- `migrations/versions/` — 신규 revision (예: `20260605_NNNN_portfolio_ledger_consolidation.py`). down_revision = `20260601_0024`.
|
||||
- `app/repositories/postgres.py:263-282` — `ensure_schema()` portfolio_ledger 정의 갱신
|
||||
- Tests:
|
||||
- 신규/갱신 마이그레이션 회귀 (alembic upgrade head 후 schema 검증)
|
||||
- 기존 `tests/test_v1_portfolios_ledger.py` 등 ledger 의존 테스트가 신 스키마와 정합하도록 갱신 (REQ-037과 연동)
|
||||
|
||||
## Notes
|
||||
- 본 REQ는 dcy-006 FR-9/FR-10/FR-11/FR-12 산출물 + FR-5(data-model.md) 일부 흡수.
|
||||
- REQ-037(신 path 회계 책임 + 응답)의 선행 — 본 마이그레이션이 스키마 기반을 제공한다.
|
||||
- partial UNIQUE는 broker write만 dedup, 레거시 write(order_id NULL)는 append-only. dcy-006 OQ-7 (ii) 결정.
|
||||
- R-5(DOUBLE→NUMERIC 정밀도)/R-6(action→side 매핑 누락)/R-7(DROP 트랜잭션)는 prod 마이그레이션 시 별도 검토. 본 워크스페이스는 개발용이라 영향 미미(workspace_purpose 메모리).
|
||||
- 별도 REL(REL-003 등)로 분리 배포(dcy-006 A-4).
|
||||
- Source: dcy-006 FR-9~12, OQ-6 (iv), OQ-7 (ii).
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260605-02
|
||||
- Date: 2026-06-05
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC-1~7 구체성(DB schema 검증) 확인 완료, blocker 없음.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: none (미정)
|
||||
- Releases: none (미정 — REL-003 등 분리 예정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입 (BAT-016 후보, REQ-037/038 선행)
|
||||
|
||||
### CHG-20260605-01
|
||||
- Date: 2026-06-05
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-006 confirm-discovery 후 draft-req 실행. dcy-006 FR-9~12 산출물.
|
||||
|
||||
#### Intent
|
||||
- portfolio_ledger/ledger_trades 두 ledger 테이블을 portfolio_ledger 단일 회계 원장으로 통합하는 스키마 마이그레이션 정의.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~7 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs: docs/data-model.md — 최초 지정
|
||||
- Modules: migrations/versions/, app/repositories/postgres.py — 최초 지정
|
||||
- Tests: 마이그레이션 회귀, tests/test_v1_portfolios_ledger.py — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: none (미정)
|
||||
- Releases: none (미정 — REL-003 등 분리 예정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입; staging에서 DOUBLE→NUMERIC + action→side backfill dry-run
|
||||
|
||||
#### Validation Plan
|
||||
- alembic upgrade head 후 schema 검증(AC-1~7); 개발 DB에서 ensure_schema 정합 확인
|
||||
+6
-3
@@ -74,7 +74,7 @@
|
||||
|
||||
## Next Requirement ID
|
||||
|
||||
- Current: `REQ-036`
|
||||
- Current: `REQ-039`
|
||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||
|
||||
## Requirement Register
|
||||
@@ -116,11 +116,14 @@
|
||||
| REQ-033 | ledger_trades.asset_id → portfolio_id 컬럼명 정합화 | Migration | Implemented | Medium | Justin Ko | [req-033](./Migration/req-033_ledger-trades-asset-id-to-portfolio-id-rename.md) |
|
||||
| REQ-034 | LoginRequest 추가 필드 거부 enforcement | Interface | Implemented | Medium | Justin Ko | [req-034](./Interface/req-034_login-request-extra-forbid-enforcement.md) |
|
||||
| REQ-035 | GET /health 응답에 db_enabled 필드 추가 | Interface | Implemented | Medium | Justin Ko | [req-035](./Interface/req-035_health-response-db-enabled-field.md) |
|
||||
| REQ-036 | portfolio_ledger 회계 원장 통합 마이그레이션 (ledger_trades 흡수/DROP) | Migration | Approved | High | Justin Ko | [req-036](./Migration/req-036_portfolio-ledger-consolidation-migration.md) |
|
||||
| REQ-037 | 신 path 회계 책임 단일화 + 응답에 portfolio 상태 포함 | Interface | Approved | High | Justin Ko | [req-037](./Interface/req-037_ledger-trade-write-accounting-and-state-response.md) |
|
||||
| REQ-038 | 레거시 buy/sell endpoint deprecated 표기 | Interface | Approved | Low | Justin Ko | [req-038](./Interface/req-038_legacy-buy-sell-endpoint-deprecation.md) |
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 2026-06-05: REQ-036/037/038 Proposed → Approved (CHG-20260605-02 각각) — confirm-req 무인자 일괄 모드 실행, 승인 게이트(필수 필드/AC 구체성/Change Log) 모두 충족, blocker 없음. dcy-006 산출물 3건 모두 Approved. REQ-036(마이그레이션) 선행 의존성 명시.
|
||||
- 2026-06-05: REQ-036/037/038 신규 (Proposed) — dcy-006(ledger trade write 회계 책임 단일화) draft-req 산출물. REQ-036 portfolio_ledger/ledger_trades 통합 마이그레이션(Migration), REQ-037 신 path 회계 책임 단일화 + 응답에 cash_balance_after/asset_quantity_after 포함(Interface), REQ-038 레거시 buy/sell deprecated 표기(Interface). 3건 모두 별도 REL(REL-003 등) 분리 배포 예정(dcy-006 A-4).
|
||||
- 2026-06-05: REQ-033/034/035 Approved → Implemented (CHG-20260601-03 각각) — BAT-015 confirm-batch-verification 완료(2026-06-05). 전체 372 passed + 로컬 smoke 5건 통과. REQ-033 AC-1/AC-4 실 DB evidence는 REL-002 staging 이연(blocker 아님). BAT-015 status: release-candidate.
|
||||
- 2026-06-01: REQ-033/034/035 Proposed → Approved (CHG-20260601-02 각각) — confirm-req 무인자 일괄 모드 실행, 승인 게이트(필수 필드/AC 구체성/Change Log) 모두 충족, blocker 없음. dcy-005 산출물 3건 모두 Approved.
|
||||
- 2026-06-01: REQ-033/034/035 신규 (Proposed) — dcy-005(rel-001-feedback-cleanup) draft-req 산출물. REQ-033 ledger_trades.asset_id → portfolio_id 컬럼명 정합화 (Migration), REQ-034 LoginRequest extra="forbid" enforcement (Interface, REQ-021 follow-up), REQ-035 HealthResponse db_enabled 필드 추가 + docs/runtime-flows.md baseline 갱신 (Interface). 3건 모두 REL-002 분리 배포 예정(dcy-005 OQ-3).
|
||||
- 2026-06-01: REQ-028 Approved → Implemented (CHG-20260601-02) — CHG-20260601-01 augmentation에 대한 verification evidence 확보. pytest 368 PASSED + 로컬 smoke로 GET /v1/portfolios 응답에 `display_name` 노출 확인. BAT-009 verification.md AC-13 매핑 누적. REL-001 실 배포 환경 smoke는 배포 시 최종 확인.
|
||||
- 2026-06-01: REQ-028 Implemented → Approved (CHG-20260601-01) — GET /v1/portfolios 응답 schema에 `display_name` 필드 추가 augmentation. portfolio가 참조하는 asset의 display_name을 join하여 UI/CLI 단일 호출 표시 가능. AC-13 신설, Portfolio domain model + PortfolioItem schema + repo join 보강. BAT-009/BAT-014 verification 재실행 필요.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""consolidate ledger trades into portfolio ledger
|
||||
|
||||
Revision ID: 20260605_0025
|
||||
Revises: 20260601_0024
|
||||
Create Date: 2026-06-05 00:25:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260605_0025"
|
||||
down_revision: str | None = "20260601_0024"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("portfolio_ledger", sa.Column("ledger_entry_id", sa.BigInteger(), sa.Identity(always=False), nullable=True))
|
||||
op.add_column("portfolio_ledger", sa.Column("order_id", sa.Text(), nullable=True))
|
||||
op.add_column("portfolio_ledger", sa.Column("execution_id", sa.Text(), nullable=True))
|
||||
op.add_column("portfolio_ledger", sa.Column("side", sa.Text(), nullable=True))
|
||||
op.add_column("portfolio_ledger", sa.Column("executed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
op.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 $$;
|
||||
"""
|
||||
)
|
||||
op.execute("UPDATE portfolio_ledger SET side = 'manual' WHERE side IS NULL")
|
||||
op.execute("UPDATE portfolio_ledger SET executed_at = recorded_at WHERE executed_at IS NULL")
|
||||
op.alter_column("portfolio_ledger", "side", nullable=False)
|
||||
op.alter_column("portfolio_ledger", "executed_at", nullable=False)
|
||||
|
||||
for column in (
|
||||
"price",
|
||||
"quantity",
|
||||
"fee_amount",
|
||||
"tax_amount",
|
||||
"cash_balance_after",
|
||||
"asset_quantity_after",
|
||||
"total_value_after",
|
||||
"profit_buffer_after",
|
||||
):
|
||||
op.alter_column(
|
||||
"portfolio_ledger",
|
||||
column,
|
||||
existing_type=sa.Float(),
|
||||
type_=sa.Numeric(20, 8),
|
||||
postgresql_using=f"{column}::numeric(20,8)",
|
||||
)
|
||||
|
||||
op.drop_column("portfolio_ledger", "action")
|
||||
op.create_index("idx_portfolio_ledger_portfolio_executed", "portfolio_ledger", ["portfolio_id", sa.text("executed_at DESC")])
|
||||
op.drop_table("ledger_trades")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"ledger_trades",
|
||||
sa.Column("ledger_trade_id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column("portfolio_id", sa.Text(), nullable=False),
|
||||
sa.Column("order_id", sa.Text(), nullable=False),
|
||||
sa.Column("execution_id", sa.Text(), nullable=False),
|
||||
sa.Column("side", sa.Text(), nullable=False),
|
||||
sa.Column("quantity", sa.Float(), nullable=False),
|
||||
sa.Column("price", sa.Float(), nullable=False),
|
||||
sa.Column("fee_amount", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("tax_amount", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("executed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("portfolio_id", "order_id", "execution_id", name="uq_ledger_trades_idempotency"),
|
||||
)
|
||||
op.add_column("portfolio_ledger", sa.Column("action", sa.Text(), nullable=True))
|
||||
op.execute("UPDATE portfolio_ledger SET action = upper(side) WHERE action IS NULL")
|
||||
op.alter_column("portfolio_ledger", "action", nullable=False)
|
||||
op.drop_index("idx_portfolio_ledger_portfolio_executed", table_name="portfolio_ledger")
|
||||
op.drop_column("portfolio_ledger", "executed_at")
|
||||
op.drop_column("portfolio_ledger", "side")
|
||||
op.drop_column("portfolio_ledger", "execution_id")
|
||||
op.drop_column("portfolio_ledger", "order_id")
|
||||
op.drop_column("portfolio_ledger", "ledger_entry_id")
|
||||
@@ -60,6 +60,23 @@ def test_trade_route_rejects_read_token(monkeypatch) -> None:
|
||||
assert response.json()["detail"] == "Insufficient permission"
|
||||
|
||||
|
||||
def test_legacy_buy_sell_routes_emit_deprecation_header(monkeypatch) -> None:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "trade-token")
|
||||
get_settings.cache_clear()
|
||||
client = _client_with_tokens()
|
||||
|
||||
for path in ("/portfolios/p1/buy", "/portfolios/p1/sell"):
|
||||
response = client.post(
|
||||
path,
|
||||
headers={"Authorization": "Bearer trade-token"},
|
||||
json={"quantity": 1, "price": 1000, "fee": 0, "tax": 0, "note": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 501
|
||||
assert response.headers["Deprecation"] == "true"
|
||||
|
||||
|
||||
def test_admin_route_accepts_admin_token(monkeypatch) -> None:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token")
|
||||
|
||||
@@ -151,10 +151,10 @@ def _setup_ledger_mocks(monkeypatch, principal: TokenPrincipal, portfolio: Magic
|
||||
|
||||
|
||||
def test_ledger_trade_403_when_token_has_no_daemon_id(monkeypatch) -> None:
|
||||
from app.domain.models import LedgerTrade
|
||||
from app.domain.models import LedgerEntry
|
||||
principal = _principal(daemon_id=None)
|
||||
portfolio = _portfolio_with_daemon(daemon_id="d-1")
|
||||
trade = MagicMock(spec=LedgerTrade)
|
||||
trade = MagicMock(spec=LedgerEntry)
|
||||
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
@@ -170,10 +170,10 @@ def test_ledger_trade_403_when_token_has_no_daemon_id(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_ledger_trade_403_when_asset_has_no_daemon_assignment(monkeypatch) -> None:
|
||||
from app.domain.models import LedgerTrade
|
||||
from app.domain.models import LedgerEntry
|
||||
principal = _principal(daemon_id="d-1")
|
||||
portfolio = _portfolio_with_daemon(daemon_id=None)
|
||||
trade = MagicMock(spec=LedgerTrade)
|
||||
trade = MagicMock(spec=LedgerEntry)
|
||||
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
@@ -189,10 +189,10 @@ def test_ledger_trade_403_when_asset_has_no_daemon_assignment(monkeypatch) -> No
|
||||
|
||||
|
||||
def test_ledger_trade_403_when_daemon_mismatch(monkeypatch) -> None:
|
||||
from app.domain.models import LedgerTrade
|
||||
from app.domain.models import LedgerEntry
|
||||
principal = _principal(daemon_id="d-1")
|
||||
portfolio = _portfolio_with_daemon(daemon_id="d-2")
|
||||
trade = MagicMock(spec=LedgerTrade)
|
||||
trade = MagicMock(spec=LedgerEntry)
|
||||
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
@@ -208,7 +208,7 @@ def test_ledger_trade_403_when_daemon_mismatch(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_ledger_trade_201_when_daemon_matches(monkeypatch) -> None:
|
||||
from app.domain.models import LedgerTrade
|
||||
from app.domain.models import LedgerEntry
|
||||
principal = _principal(daemon_id="d-1")
|
||||
portfolio = _portfolio_with_daemon(daemon_id="d-1")
|
||||
trade = MagicMock()
|
||||
|
||||
+4
-4
@@ -11,7 +11,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.auth import AuthContext, Permission
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import AuthToken, BrokerCredential, Portfolio, TokenPrincipal, User, LedgerTrade
|
||||
from app.domain.models import AuthToken, BrokerCredential, Portfolio, TokenPrincipal, User, LedgerEntry
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ def _trade_payload(**overrides) -> dict:
|
||||
return base
|
||||
|
||||
|
||||
def _make_ledger_trade(**kwargs) -> LedgerTrade:
|
||||
def _make_ledger_trade(**kwargs) -> LedgerEntry:
|
||||
defaults = dict(
|
||||
ledger_entry_id=1,
|
||||
portfolio_id="005930",
|
||||
@@ -371,7 +371,7 @@ def _make_ledger_trade(**kwargs) -> LedgerTrade:
|
||||
recorded_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return LedgerTrade(**defaults)
|
||||
return LedgerEntry(**defaults)
|
||||
|
||||
|
||||
def _make_ledger_principal() -> TokenPrincipal:
|
||||
@@ -381,7 +381,7 @@ def _make_ledger_principal() -> TokenPrincipal:
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_repo_for_ledger(monkeypatch, trade: LedgerTrade, created: bool, found: bool = True) -> MagicMock:
|
||||
def _make_mock_repo_for_ledger(monkeypatch, trade: LedgerEntry, created: bool, found: bool = True) -> MagicMock:
|
||||
settings = _make_app_db(monkeypatch)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = _make_ledger_principal()
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import LedgerTrade, Portfolio, TokenPrincipal
|
||||
from app.domain.models import LedgerEntry, Portfolio, TokenPrincipal
|
||||
from app.main import create_app
|
||||
|
||||
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
@@ -55,7 +55,7 @@ def _make_portfolio(
|
||||
)
|
||||
|
||||
|
||||
def _make_trade(**kwargs) -> LedgerTrade:
|
||||
def _make_trade(**kwargs) -> LedgerEntry:
|
||||
defaults = dict(
|
||||
ledger_entry_id=1,
|
||||
portfolio_id=_PORTFOLIO_ID,
|
||||
@@ -70,14 +70,14 @@ def _make_trade(**kwargs) -> LedgerTrade:
|
||||
recorded_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return LedgerTrade(**defaults)
|
||||
return LedgerEntry(**defaults)
|
||||
|
||||
|
||||
def _make_service_repo(
|
||||
*,
|
||||
daemon_id: str | None = _DAEMON_ID,
|
||||
portfolio: Portfolio | None = None,
|
||||
trade: LedgerTrade | None = None,
|
||||
trade: LedgerEntry | None = None,
|
||||
created: bool = True,
|
||||
) -> MagicMock:
|
||||
repo = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user