fix: ledger write idempotency for TimescaleDB hypertable (BAT-016 rework)

BAT-016's initial implementation passed only against mocked repositories and
broke on a real database. Two defects fixed:

1. insert_ledger_trade used `ON CONFLICT (portfolio_id, order_id, execution_id)
   WHERE order_id IS NOT NULL` but portfolio_ledger is a TimescaleDB hypertable,
   which cannot have that partial unique index (must include the partitioning
   column recorded_at). The statement errored against Postgres. Replaced with
   application-level idempotency: SELECT-before-insert inside the existing
   `SELECT ... FOR UPDATE` portfolio row lock, which serializes concurrent
   same-portfolio writes (race-safe). Duplicate returns current authoritative
   portfolio state (dcy-006 OQ-4 b).

2. `with self._connect() as conn:` closes the shared psycopg3 connection on exit,
   leaving the reused repository connection unusable. Switched to
   `with conn.transaction():` (commits without closing).

Added tests/test_ledger_consolidation_integration.py — real-Postgres integration
tests (new / duplicate / negative-balance) that close the mock-only verification
gap. Full suite: 376 passed.

SDLC: REQ-036/037 CHG-20260605-03 reconcile OQ-7 (ii) (DB partial unique →
application-level idempotency) since the hypertable makes the DB constraint
infeasible. REQ-036/037/038 → Implemented; BAT-016 → release-candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 22:11:17 +09:00
co-authored by Claude Opus 4.8
parent 44f2102d26
commit 2a19b7f058
11 changed files with 414 additions and 63 deletions
+39 -21
View File
@@ -1342,7 +1342,11 @@ class PostgresPortfolioRepository:
if side_normalized not in {"buy", "sell"}:
raise ValueError("side는 buy 또는 sell 이어야 합니다")
with self._connect() as conn:
conn = self._connect()
# Use a transaction block (not `with conn:`) so the shared connection stays open:
# psycopg3 closes the connection when it is used as a context manager, which would
# leave the repository's reused connection unusable for subsequent calls.
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"""
@@ -1376,6 +1380,40 @@ class PostgresPortfolioRepository:
total_value_after = cash_balance_after + asset_quantity_after * price_float
strategy_set_id = portfolio_row[5] or "manual"
# Application-level idempotency under the portfolios row lock.
# portfolio_ledger is a TimescaleDB hypertable, so a DB-level partial unique
# index on (portfolio_id, order_id, execution_id) is impossible (a unique index
# must include the partitioning column recorded_at). The `SELECT ... FOR UPDATE`
# on the portfolios row above serializes concurrent writes for the same portfolio,
# so this check-then-insert is race-safe for broker writes (order_id present).
if order_id is not None and execution_id is not 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
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT 1
""",
(portfolio_id, order_id, execution_id),
)
existing = cur.fetchone()
if existing is not None:
# Duplicate: do not insert a new row, do not mutate the portfolio.
# OQ-4 (b): return the CURRENT authoritative portfolio state so the
# caller always syncs to the latest balances, not the first-call snapshot.
entry = self._row_to_ledger_entry(existing)
entry.cash_balance_after = cash_balance
entry.asset_quantity_after = asset_quantity
return entry, False
cur.execute(
"""
INSERT INTO portfolio_ledger (
@@ -1389,8 +1427,6 @@ class PostgresPortfolioRepository:
%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,
@@ -1419,24 +1455,6 @@ class PostgresPortfolioRepository:
),
)
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(
"""
@@ -12,17 +12,19 @@
- 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`.
> **갱신 (2026-06-05, verify 후 재구현)**: `portfolio_ledger`가 TimescaleDB hypertable임이 확인되어 partial UNIQUE index/PK 기반 idempotency가 불가능 → application-level idempotency로 재정의. 아래 결정 1/2/5 + Edge Cases가 이를 반영하도록 수정됨.
1. **마이그레이션 전략 (REQ-036)**: 단일 alembic revision(down_revision=`20260601_0024`)에서 컬럼 추가 → backfill(action→side, recorded_at→executed_at) → 타입 변경(Float→NUMERIC(20,8)) → action 컬럼 제거 → 보조 인덱스 신설 → ledger_trades DROP 순. **partial UNIQUE index는 생성하지 않는다(hypertable 제약).** `ledger_entry_id`는 Identity 컬럼(비-PK).
2. **order_id/execution_id NULL 허용 + application-level idempotency (OQ-7 (ii) 재정의)**: `portfolio_ledger`는 TimescaleDB hypertable(파티션 컬럼 `recorded_at`)이라 `(portfolio_id, order_id, execution_id)` partial unique index/PK를 만들 수 없다(`cannot create a unique index without the column recorded_at`). 따라서 OQ-7 (ii)의 "DB partial UNIQUE + ON CONFLICT"는 불가 → **`SELECT ... FOR UPDATE`(portfolios row 락) 안에서 SELECT-before-insert**로 idempotency 구현. broker write(order_id 존재)만 기존 row 조회 후 dedup, 레거시/수동 write(order_id NULL)는 append-only. portfolios row lock이 동시 write를 직렬화하여 race-safe. REQ-036/037 CHG-20260605-03 정합.
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)).
5. **단일 트랜잭션 + row lock (REQ-037 FR-6/FR-7)**: `SELECT ... FOR UPDATE`로 portfolios row 잠금 → (broker write면) 기존 ledger row SELECT-before-insert → 없으면 회계 계산 + portfolio_ledger INSERT + portfolios UPDATE → COMMIT. 트랜잭션은 `conn.transaction()` 사용(psycopg3 `with conn:`는 connection을 close하므로 공유 connection 유지를 위해 transaction 블록 사용). duplicate(기존 row 존재)면 INSERT/UPDATE 없이 portfolios 현재 권위 상태로 응답(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 재생성 없음.
- duplicate 호출(SELECT-before-insert로 기존 row 발견): INSERT/UPDATE 없음 → status="duplicate" + portfolios 현재 권위 상태 응답. portfolio_ledger row 재생성 없음.
- 음수 잔고: buy로 cash 음수 또는 sell로 quantity 음수 → 거부하지 않고 그대로 기록/응답(OQ-3).
- 레거시 write의 NULL order_id: partial UNIQUE 대상 외 → append-only(같은 buy 두 번 = row 2건).
- 레거시 write의 NULL order_id: idempotency 조회 대상 외 → append-only(같은 buy 두 번 = row 2건).
- action 자유형식 값(예: 'manual'): 마이그레이션 backfill 시 side 매핑 규칙 외 → 개발 워크스페이스 분포 단순(확인 후 처리). prod는 release 이연.
- 도메인 모델 통합 시 광범위 참조(R-1): test_v1_portfolios_ledger / test_v1 / test_daemon_assignment 등 LedgerTrade/LedgerEntry 참조 전수 갱신.
@@ -37,5 +37,27 @@
- `.venv/bin/python -m pytest -q`
- Result: `373 passed, 3 warnings in 14.78s`
## Rework (2026-06-05, post-verification defect fix)
초기 구현이 mock-only 검증을 통과했으나 실 DB에서 신 path가 깨지는 결함 2건이 verify 단계에서 발견되어 재구현했다.
### 결함 1 — hypertable에서 불가능한 `ON CONFLICT` (치명)
- `insert_ledger_trade`가 `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL DO NOTHING`를 사용했으나, `portfolio_ledger`는 TimescaleDB hypertable(파티션 컬럼 `recorded_at`)이라 해당 partial unique index를 만들 수 없다(`cannot create a unique index without the column "recorded_at"`). 인덱스가 없으니 ON CONFLICT는 실 DB에서 `there is no unique or exclusion constraint matching the ON CONFLICT specification` 에러. 모든 ledger 테스트가 MagicMock repo라 실 SQL이 실행되지 않아 미검출.
- 수정: ON CONFLICT 제거. 기존 `SELECT ... FOR UPDATE` 락 안에서 **application-level idempotency(SELECT-before-insert)**로 교체. broker write(order_id 존재)만 `(portfolio_id, order_id, execution_id)`로 기존 row 조회 후 있으면 duplicate 처리, 없으면 INSERT. portfolios row lock이 동시 write를 직렬화하므로 race-safe. duplicate는 OQ-4(b)에 따라 현재 권위 portfolio 상태를 반환.
- 설계 정합: dcy-006 OQ-7 (ii) "partial UNIQUE index"는 hypertable에서 불가 → REQ-036/REQ-037 change-req(CHG-20260605-03)로 application-level idempotency로 재정의.
### 결함 2 — 공유 connection close (잠재)
- `with self._connect() as conn:`는 psycopg3에서 블록 종료 시 connection을 **close**한다. repo는 connection을 재사용(self._connection)하므로 insert_ledger_trade 후 repo가 unusable(다음 호출 `the connection is closed`). 앱은 request당 repo를 새로 만들어 가렸지만 잠재 결함.
- 수정: `with conn.transaction():`(commit하되 connection 유지)로 교체. repo 다른 메서드들과 connection 수명 일관.
### 실 DB 검증 추가
- mock-only 검증의 맹점을 닫기 위해 `tests/test_ledger_consolidation_integration.py` 신규(실 Postgres 의존, DB 미가용 시 skip). new(회계+portfolio 갱신) / duplicate(재insert 없음 + 현 권위 상태) / 음수 허용을 실 DB에서 검증.
## Validation (rework)
- mock 회귀: `tests/test_v1_portfolios_ledger.py tests/test_daemon_assignment.py tests/test_domain_services.py` → 24 passed
- 실 DB 통합: `tests/test_ledger_consolidation_integration.py` → 3 passed (실 Postgres)
- 전체: `.venv/bin/python -m pytest -q` → **376 passed** (기존 373 + 통합 3)
- 실 DB 직접 검증: 수정 전 `ON CONFLICT ... WHERE order_id IS NOT NULL` → `there is no unique or exclusion constraint matching...` 에러 재현 확인. 수정 후 통합 테스트로 정상 동작 확인.
## 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.
- alembic 마이그레이션(`20260605_0025`)은 원본 portfolio_ledger 스키마(action/Float 컬럼) 기준으로 작성되어 fresh DB(alembic 경로)에서 1회 정상 적용된다. 단 ensure_schema가 이미 실행된 개발 DB에서는 컬럼 중복으로 충돌 — ensure_schema vs alembic 이중 경로의 기존 패턴. prod 적용은 release staging에서 별도 검증(workspace_purpose 메모리).
- `ledger_entry_id`는 hypertable 제약상 단독 PRIMARY KEY 불가 → Identity 컬럼(비-PK)으로 유지. REQ-036 AC-1 change-req로 정합화.
@@ -1,6 +1,6 @@
# BAT-016: ledger 회계 책임 단일화 + portfolio_ledger 통합
- Status: verified
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-006
- Included REQ: REQ-036, REQ-037, REQ-038
@@ -1,34 +1,43 @@
# BAT-016 Verification
- Status: passed
- Status: confirmed
- Batch: BAT-016
> **재검증 (2026-06-05)**: 초기 검증은 mock-only 테스트만 근거로 "passed" 표기했으나, verify 단계에서 신 path가 실 DB에서 깨지는 결함 2건이 발견됨(ON CONFLICT on hypertable 불가, 공유 connection close). 재구현 + 실 DB 통합 테스트 후 아래 재검증.
## 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.
- AC-1: `\d portfolio_ledger`에 `ledger_entry_id`(Identity, 비-PK — CHG-20260605-03), `order_id`/`execution_id`(NULL), `side`/`executed_at`(NOT NULL) 존재. 마이그레이션 `20260605_0025` + `ensure_schema()` 일치.
- AC-2: 수치 8컬럼 NUMERIC(20,8) — 실 DB `\d portfolio_ledger`로 확인.
- AC-3 (CHG-20260605-03 재정의): partial unique index/PK는 **존재하지 않음**(hypertable 제약). idempotency는 application layer + `tests/test_ledger_consolidation_integration.py::test_duplicate_broker_write_is_idempotent`(실 DB)로 검증 — duplicate 시 row 1건 유지 확인.
- AC-4: `action` 컬럼 제거 + `side` 대체 — 확인.
- AC-5: `ledger_trades` 테이블 부재 — 실 DB 확인.
- AC-6~7: ensure_schema/마이그레이션 backfill 정합 + 전체 회귀 통과.
- 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.
- AC-1: `test_new_trade_updates_portfolio_and_returns_state`(실 DB) — buy 10@80,000 fee 100 → cash_balance_after=199,900, asset_quantity_after=10, portfolios 동일 갱신.
- AC-2 (OQ-4 b): `test_duplicate_broker_write_is_idempotent`(실 DB) — 재호출 시 row 재생성 없이 현재 권위 상태 반환.
- AC-3 (OQ-3 음수 허용): `test_negative_balance_allowed`(실 DB) — oversell → asset_quantity_after=-999, 거부 없음.
- AC-4: 응답에 기존 필드 + cash_balance_after/asset_quantity_after 추가 — schema + 실 DB 응답 확인.
- AC-5: daemon 3단계 인가 보존 — `tests/test_daemon_assignment.py` 회귀.
- AC-6: `LedgerTrade` 참조 0건 — grep 확인, `LedgerEntry` 단일.
- AC-7: row lock(`SELECT ... FOR UPDATE`) + `conn.transaction()`로 원자성/직렬화.
- 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.
- AC-1~2: 레거시 buy/sell `deprecated=True` + `Deprecation: true` 헤더 — `app/api/portfolios.py` 확인.
- AC-3~4: `tests/test_auth.py::test_legacy_buy_sell_routes_emit_deprecation_header` 통과.
## 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.
- 실 DB 통합: `.venv/bin/python -m pytest -q tests/test_ledger_consolidation_integration.py` → `3 passed` (실 Postgres — new/duplicate/음수)
- mock 회귀: `tests/test_v1_portfolios_ledger.py tests/test_daemon_assignment.py tests/test_domain_services.py` → 24 passed
- 전체: `.venv/bin/python -m pytest -q` → **376 passed, 3 warnings** (기존 373 + 통합 3)
- Manual / 실 DB 직접 검증:
- 수정 전: `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL` → 실 DB에서 `there is no unique or exclusion constraint matching the ON CONFLICT specification` 에러 재현.
- hypertable 제약 확인: `CREATE UNIQUE INDEX ... WHERE order_id IS NOT NULL` → `cannot create a unique index without the column "recorded_at"`.
- 수정 후: 통합 테스트로 new/duplicate/음수 정상 동작.
- Logs:
- Full pytest output captured in terminal execution.
- pytest 출력 + docker psql 검증 출력 (터미널).
## Result
- Ready for Release: true
- Ready for Release: true (실 DB 통합 검증 기준)
- Blocking Issues: none
- Deferred (prod staging): alembic `20260605_0025`의 fresh-DB 적용 + DOUBLE→NUMERIC 정밀도/action 분포는 release staging에서 검증(workspace_purpose 메모리). ensure_schema가 이미 적용된 개발 DB에서는 alembic 경로 충돌 가능 — 기존 ensure_schema vs alembic 이중 경로 패턴.
+1 -1
View File
@@ -23,4 +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/) |
| BAT-016 | standard | release-candidate | REQ-036, REQ-037, REQ-038 | dcy-006 | [bat-016](./bat-016_20260605_ledger-accounting-consolidation/) |
@@ -1,6 +1,6 @@
# REQ-037: 신 path 회계 책임 단일화 + 응답에 portfolio 상태 포함
- Status: Approved
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: Justin Ko
@@ -16,7 +16,7 @@
- **잔고/수량 부족 거부 검증을 적용하지 않는다 — 음수 `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 사용.
- duplicate 감지(dcy-006 FR-15, CHG-20260605-03 재정의): **application-level idempotency** — `SELECT ... FOR UPDATE`로 portfolios row를 잠근 상태에서 `(portfolio_id, order_id, execution_id)`로 기존 portfolio_ledger row를 SELECT한다. 존재하면 duplicate(INSERT/UPDATE 없이 현재 권위 상태 응답), 없으면 INSERT. portfolio_ledger가 TimescaleDB hypertable이라 DB partial UNIQUE index/`ON CONFLICT`가 불가능하므로(REQ-036 CHG-20260605-03) DB 제약 대신 row lock 직렬화로 race-safe 보장. order_id NULL(레거시/수동)은 dedup 조회 생략 → append-only. 트랜잭션은 `conn.transaction()` 사용(psycopg3 `with conn:`는 공유 connection을 close하므로).
- `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)). 기존 응답 필드는 모두 보존.
@@ -39,7 +39,7 @@
- `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/postgres.py` — `insert_ledger_trade` 재정의(portfolio_ledger insert + portfolios UPDATE 단일 트랜잭션 + FOR UPDATE + application-level SELECT-before-insert idempotency + `conn.transaction()`), `_row_to_ledger_*` 통합
- `app/repositories/portfolio_repository.py` — interface 시그니처 갱신
- `app/domain/models.py` — `LedgerEntry` 단일화, `LedgerTrade` 삭제
- `app/domain/services.py` 또는 신규 — 음수 검증 제거한 ledger 전용 회계 헬퍼 추출
@@ -57,6 +57,67 @@
## Change Log
### CHG-20260605-04
- Date: 2026-06-05
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-016 confirm-batch-verification 완료. 신 path 회계 책임 단일화 + 응답 cash_balance_after/asset_quantity_after + duplicate 현 상태 + 도메인/repository 통합 구현. 실 DB 통합 테스트(new/duplicate/음수) 통과, AC-1~7 검증. 전체 376 passed. CHG-20260605-03(ON CONFLICT→application-level idempotency + conn.transaction()) 정합 반영.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-016 (release-candidate)
- Releases:
- REL-003 (예정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL-003 배포 시 trex-core 응답 명세 정합 최종 확인
#### Validation Plan
- 실 DB 통합 테스트 + pytest 376 passed (완료)
### CHG-20260605-03
- Date: 2026-06-05
- Author: Justin Ko
#### Change Summary
- Requirement 재정의: duplicate 감지를 `ON CONFLICT ... WHERE order_id IS NOT NULL`(DB partial index)에서 **application-level SELECT-before-insert(under FOR UPDATE lock)**로 변경. portfolio_ledger가 TimescaleDB hypertable이라 partial unique index/ON CONFLICT가 불가능(REQ-036 CHG-20260605-03). 추가로 트랜잭션을 `conn.transaction()`으로 처리(psycopg3 `with conn:` connection close 결함 회피). Status Approved 유지.
#### Intent
- BAT-016 verify에서 실 DB 미동작(ON CONFLICT 에러 + 공유 connection close) 2건 결함 발견. hypertable 현실에 맞춰 idempotency 설계 정합 + 결함 수정.
#### Acceptance Criteria Delta
- No change (AC-1~7 그대로; AC-2 duplicate idempotency는 application layer로 동일 충족). 검증 방식만 mock → 실 DB 통합 테스트 추가.
#### Impacted Area Delta
- Modules: `app/repositories/postgres.py` insert_ledger_trade — ON CONFLICT 제거 + SELECT-before-insert + `conn.transaction()` 반영
- Tests: `tests/test_ledger_consolidation_integration.py`(실 DB) 추가
#### Delivery Trace Delta
- Batches: BAT-016 (in-delivery)
- Releases: none (REL-003 등 분리 예정)
#### Revalidation Impact
- Reverification Needed: yes (실 DB 통합 테스트로 재검증 — 376 passed)
- Existing Implementation Invalidated: no (구현이 본 재정의에 맞춰 수정됨)
- Status Recommendation: keep-current
- Follow-up Action: BAT-016 verification 실 DB evidence 반영
#### Validation Plan
- pytest 376 passed(통합 3 포함) + 실 DB new/duplicate/음수 통합 테스트
### CHG-20260605-02
- Date: 2026-06-05
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-038: 레거시 buy/sell endpoint deprecated 표기
- Status: Approved
- Status: Implemented
- Type: Interface
- Priority: Low
- Owner: Justin Ko
@@ -39,6 +39,34 @@
## Change Log
### CHG-20260605-03
- Date: 2026-06-05
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-016 confirm-batch-verification 완료. 레거시 buy/sell `deprecated=True` + `Deprecation: true` 헤더 구현, 동작 보존. AC-1~4 검증(`tests/test_auth.py::test_legacy_buy_sell_routes_emit_deprecation_header` 포함). 전체 376 passed.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-016 (release-candidate)
- Releases:
- REL-003 (예정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: 실제 endpoint 제거는 후속 정리 트랙
### CHG-20260605-02
- Date: 2026-06-05
- Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-036: portfolio_ledger 회계 원장 통합 마이그레이션 (ledger_trades 흡수/DROP)
- Status: Approved
- Status: Implemented
- Type: Migration
- Priority: High
- Owner: Justin Ko
@@ -12,11 +12,11 @@
## Requirement
- Alembic 신규 revision을 작성한다. (head는 dcy-005 `20260601_0024` 다음)
- `portfolio_ledger`에 컬럼을 추가한다:
- `ledger_entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY`
- `ledger_entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY` (CHG-20260605-03: hypertable 제약상 단독 PRIMARY KEY 불가 — Identity 컬럼, 비-PK)
- `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 패턴 재사용)
- **idempotency는 DB partial UNIQUE index가 아니라 application layer(REQ-037)에서 보장한다** (CHG-20260605-03). `portfolio_ledger`는 TimescaleDB hypertable(파티션 컬럼 `recorded_at`)이라 `(portfolio_id, order_id, execution_id)` partial unique index/PK 생성이 불가능하다(`cannot create a unique index without the column recorded_at`). 따라서 본 마이그레이션은 broker 식별 컬럼만 추가하고 unique 제약은 만들지 않는다. broker write dedup은 REQ-037의 `SELECT ... FOR UPDATE` 락 하 SELECT-before-insert로 처리.
- 수치 컬럼 타입을 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 생성 불요).
@@ -25,9 +25,9 @@
- `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) 컬럼이 존재한다.
1. Given 신규 Alembic revision 적용 후, When `\d portfolio_ledger` 확인, Then `ledger_entry_id`(Identity, 비-PK — CHG-20260605-03), `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가 존재한다.
3. (CHG-20260605-03 재정의) Given AC-1 환경, When `portfolio_ledger` 인덱스/제약 확인, Then `(portfolio_id, order_id, execution_id)` 기반 **partial unique index/PK는 존재하지 않는다**(hypertable 제약). broker write idempotency는 REQ-037 application layer에서 보장되며, 실 DB 회귀(`tests/test_ledger_consolidation_integration.py::test_duplicate_broker_write_is_idempotent`)로 검증한다.
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에서 매핑된다.
@@ -35,7 +35,7 @@
## 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 테이블 → 단일 원장 통합 명시.
- `docs/data-model.md` — `portfolio_ledger` entity에 신규 컬럼(ledger_entry_id Identity 비-PK, order_id/execution_id NULL, side, executed_at) + 정밀도 NUMERIC(20,8) 명세 추가. **idempotency는 application layer(REQ-037)이며 DB partial unique index/PK는 hypertable 제약상 없음**을 명시. `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 정의 갱신
@@ -46,13 +46,76 @@
## 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) 결정.
- idempotency(broker write만 dedup, 레거시 write append-only)는 dcy-006 OQ-7 (ii) 결정이나, hypertable 제약으로 DB partial unique가 불가하여 application layer(REQ-037)에서 구현(CHG-20260605-03).
- 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-04
- Date: 2026-06-05
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-016 confirm-batch-verification 완료. portfolio_ledger 통합 마이그레이션 + ensure_schema 갱신 + ledger_trades DROP 구현. AC-1/2/4/5/6/7 검증, AC-3는 CHG-20260605-03 재정의(application-layer idempotency)로 실 DB 통합 테스트(`test_duplicate_broker_write_is_idempotent`)로 검증. 전체 376 passed.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-016 (release-candidate)
- Releases:
- REL-003 (예정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL-003 staging에서 fresh-DB alembic 적용 + DOUBLE→NUMERIC 정밀도/action 분포 최종 검증
#### Validation Plan
- 실 DB 통합 테스트 + alembic schema 검증 (완료); prod staging은 release 단계
### CHG-20260605-03
- Date: 2026-06-05
- Author: Justin Ko
#### Change Summary
- AC/Requirement 재정의: `portfolio_ledger`가 TimescaleDB hypertable임이 BAT-016 verify에서 확인되어, dcy-006 OQ-7 (ii)의 "DB partial UNIQUE index + ledger_entry_id PK"가 물리적으로 불가능(`cannot create a unique index without the column recorded_at`). idempotency를 application layer(REQ-037 SELECT-before-insert under FOR UPDATE)로 이관. Status는 Approved 유지(구현 진행 중 설계 정합 보정).
#### Intent
- 설계(DB 제약 idempotency)와 hypertable 현실의 충돌 해소. 마이그레이션은 broker 컬럼만 추가하고 unique 제약/PK는 생성하지 않으며, dedup은 application layer가 담당하도록 정합화.
#### Acceptance Criteria Delta
- Updated:
- AC-1: `ledger_entry_id(PK)` → `ledger_entry_id(Identity, 비-PK)`
- AC-3: "partial UNIQUE index 존재" → "partial unique index/PK 미존재(hypertable 제약), idempotency는 REQ-037 application layer + 실 DB 통합 테스트로 검증"
#### Impacted Area Delta
- Modules: 변경 없음(이미 신규 revision + ensure_schema 지정). 마이그레이션 본문에서 partial unique index 생성 제거 반영.
- Tests: `tests/test_ledger_consolidation_integration.py`(실 DB) 추가 — idempotency 검증 이관.
#### Delivery Trace Delta
- Batches: BAT-016 (in-delivery)
- Releases: none (REL-003 등 분리 예정)
#### Revalidation Impact
- Reverification Needed: yes (BAT-016 verification은 실 DB 통합 테스트로 재수행)
- Existing Implementation Invalidated: no (구현이 본 재정의에 맞춰 수정됨)
- Status Recommendation: keep-current (Approved 유지)
- Follow-up Action: BAT-016 verification 실 DB evidence 반영
#### Validation Plan
- alembic schema 검증(AC-1/2/4/5/6/7) + 실 DB idempotency 통합 테스트(AC-3 대체)
### CHG-20260605-02
- Date: 2026-06-05
- Author: Justin Ko
+4 -4
View File
@@ -116,14 +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) |
| REQ-036 | portfolio_ledger 회계 원장 통합 마이그레이션 (ledger_trades 흡수/DROP) | Migration | Implemented | High | Justin Ko | [req-036](./Migration/req-036_portfolio-ledger-consolidation-migration.md) |
| REQ-037 | 신 path 회계 책임 단일화 + 응답에 portfolio 상태 포함 | Interface | Implemented | High | Justin Ko | [req-037](./Interface/req-037_ledger-trade-write-accounting-and-state-response.md) |
| REQ-038 | 레거시 buy/sell endpoint deprecated 표기 | Interface | Implemented | 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 Approved → Implemented (CHG-20260605-04/04/03) — BAT-016 confirm-batch-verification 완료. 전체 376 passed(실 DB 통합 3건 포함). verify 단계에서 hypertable ON CONFLICT 불가 + 공유 connection close 결함 2건 발견·수정, REQ-036/037 CHG-20260605-03으로 idempotency를 DB partial unique → application-level(SELECT-before-insert under FOR UPDATE)로 재정의. BAT-016 release-candidate. REL-003 staging 이연: alembic fresh-DB 적용 + 정밀도/action 분포.
- 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).
@@ -0,0 +1,148 @@
"""Real-DB integration tests for BAT-016 ledger consolidation (REQ-036/037).
These exercise `insert_ledger_trade` against an actual PostgreSQL/TimescaleDB
instance (docker-compose.local-test). The mock-based suite cannot catch DB-level
defects such as the hypertable `ON CONFLICT` regression that BAT-016 originally
shipped; this module closes that gap.
Skipped automatically when the local DB is unreachable.
"""
from __future__ import annotations
import os
import uuid
from datetime import datetime, timezone
import pytest
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
def _db_config() -> PortfolioDBConfig:
return PortfolioDBConfig(
host=os.environ.get("TIMESCALEDB_HOST", "localhost"),
port=int(os.environ.get("TIMESCALEDB_PORT", "5432")),
dbname=os.environ.get("TIMESCALEDB_NAME", "portfolio_hub"),
user=os.environ.get("TIMESCALEDB_USER", "portfolio_hub_user"),
password=os.environ.get("TIMESCALEDB_PASSWORD", "change-me"),
sslmode=os.environ.get("TIMESCALEDB_SSLMODE", "prefer"),
connect_timeout_sec=3,
application_name="ledger-integration-test",
)
@pytest.fixture()
def repo():
config = _db_config()
repository = PostgresPortfolioRepository(config)
try:
repository.ensure_schema()
except Exception as exc: # pragma: no cover - environment dependent
pytest.skip(f"local DB unreachable: {exc}")
yield repository
if callable(getattr(type(repository), "close", None)):
repository.close()
@pytest.fixture()
def seeded_portfolio(repo):
"""Seed a unique asset + portfolio, yield portfolio_id, then clean up."""
pid = f"itest-{uuid.uuid4()}"
symbol = f"ITEST-{uuid.uuid4().hex[:8]}"
conn = repo._connect()
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"INSERT INTO assets (symbol, markets) VALUES (%s, ARRAY[]::text[]) RETURNING asset_id",
(symbol,),
)
asset_id = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO portfolios (portfolio_id, name, cash_balance, asset_quantity, asset_id, broker, status)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(pid, "itest-pf", 1_000_000.0, 0.0, asset_id, "itest-broker", "enabled"),
)
yield pid
conn = repo._connect()
with conn.transaction():
with conn.cursor() as cur:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (pid,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (pid,))
cur.execute("DELETE FROM assets WHERE symbol = %s", (symbol,))
def _now():
return datetime.now(timezone.utc)
def test_new_trade_updates_portfolio_and_returns_state(repo, seeded_portfolio):
"""REQ-037 AC-1: new broker write applies accounting and returns post-trade state."""
entry, created = repo.insert_ledger_trade(
portfolio_id=seeded_portfolio,
order_id="ord-1",
execution_id="exec-1",
side="buy",
quantity=10.0,
price=80_000.0,
executed_at=_now(),
fee_amount=100.0,
tax_amount=0.0,
)
assert created is True
assert entry.cash_balance_after == pytest.approx(1_000_000.0 - 800_000.0 - 100.0)
assert entry.asset_quantity_after == pytest.approx(10.0)
portfolio = repo.get(seeded_portfolio)
assert float(portfolio.cash_balance) == pytest.approx(199_900.0)
assert float(portfolio.asset_quantity) == pytest.approx(10.0)
def test_duplicate_broker_write_is_idempotent(repo, seeded_portfolio):
"""REQ-037 AC-2 + OQ-4(b): duplicate does not re-insert; returns current authoritative state."""
args = dict(
portfolio_id=seeded_portfolio,
order_id="ord-dup",
execution_id="exec-dup",
side="buy",
quantity=5.0,
price=10_000.0,
executed_at=_now(),
fee_amount=0.0,
tax_amount=0.0,
)
first, created1 = repo.insert_ledger_trade(**args)
assert created1 is True
assert first.cash_balance_after == pytest.approx(950_000.0)
second, created2 = repo.insert_ledger_trade(**args)
assert created2 is False
# duplicate returns CURRENT authoritative portfolio state (OQ-4 b)
assert second.cash_balance_after == pytest.approx(950_000.0)
assert second.asset_quantity_after == pytest.approx(5.0)
# exactly one ledger row for this broker key
conn = repo._connect()
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM portfolio_ledger WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s",
(seeded_portfolio, "ord-dup", "exec-dup"),
)
assert cur.fetchone()[0] == 1
def test_negative_balance_allowed(repo, seeded_portfolio):
"""REQ-037 AC-3 + OQ-3: oversell produces negative asset_quantity without rejection."""
entry, created = repo.insert_ledger_trade(
portfolio_id=seeded_portfolio,
order_id="ord-neg",
execution_id="exec-neg",
side="sell",
quantity=999.0,
price=1_000.0,
executed_at=_now(),
)
assert created is True
assert entry.asset_quantity_after == pytest.approx(-999.0)