feat: consolidate ledger trade writes into portfolio_ledger

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