From 8841bdd4410346ae302202eaf3bf7e864d1b87c1 Mon Sep 17 00:00:00 2001 From: Jinhwan Ko Date: Sun, 13 Sep 2026 22:02:43 +0900 Subject: [PATCH] fix: qualify corporate-action ledger reads (#36) --- .proofline/criteria/ac-0037.md | 16 +++++ .proofline/identities.json | 4 +- .proofline/lines/line-0007/dcy-0007.md | 48 +++++++++++++++ .../lines/line-0007/evidence/activity-log.md | 6 ++ .../lines/line-0007/evidence/test-report.md | 35 +++++++++++ .proofline/lines/line-0007/line-0007.md | 4 ++ .proofline/lines/line-0007/req-0007.md | 40 +++++++++++++ app/repositories/postgres.py | 36 ++++++------ ...rporate_action_atomic_recovery_postgres.py | 27 +++++++++ tests/test_issue34_regressions.py | 2 +- tests/test_v1_portfolios_holdings.py | 36 +++++++++++- tests/test_v1_portfolios_manual_ledger.py | 58 ++++++++++++++++++- 12 files changed, 290 insertions(+), 22 deletions(-) create mode 100644 .proofline/criteria/ac-0037.md create mode 100644 .proofline/lines/line-0007/dcy-0007.md create mode 100644 .proofline/lines/line-0007/evidence/activity-log.md create mode 100644 .proofline/lines/line-0007/evidence/test-report.md create mode 100644 .proofline/lines/line-0007/line-0007.md create mode 100644 .proofline/lines/line-0007/req-0007.md diff --git a/.proofline/criteria/ac-0037.md b/.proofline/criteria/ac-0037.md new file mode 100644 index 0000000..2e801cf --- /dev/null +++ b/.proofline/criteria/ac-0037.md @@ -0,0 +1,16 @@ +--- +id: "ac-0037" +status: active +--- + +# 기업행동 원장이 포함된 joined ledger read 안정성 + +## Criterion + +Portfolio owner의 holdings cost-basis와 unified ledger 조회는 같은 portfolio에 기업행동 원장과 일반 거래 원장이 함께 있어도 PostgreSQL column ambiguity 없이 기존 ledger·기업행동 사실을 읽어야 한다. Holdings path는 기존 WAC·실현손익·보유수량·원가 의미와 corporate-action replay mapping을 보존하고, unified ledger path는 기존 latest-first ordering·source filter·keyset cursor·canonical response 의미를 보존해야 한다. + +## Verification + +- 성공 조건: 실제 PostgreSQL에 일반 BUY/SELL과 `stock_consolidation` 기업행동을 함께 구성했을 때 `get_all_trades_for_pnl()`과 `list_ledger_entries()`가 모두 성공하고 기업행동 metadata를 해당 ledger entry에 결합한다. 같은 fixture의 `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis`와 `GET /v1/portfolios/{portfolio_id}/ledger`가 HTTP 200을 반환하며 unified ledger의 기본 조회·source filter·다음 cursor page가 기존 정렬과 행 구성을 유지한다. +- 실패·경계 조건: JOIN으로 `ledger_entry_id`·`recorded_at` 등 base column이 ambiguous해지거나, SELECT 순서·positional mapping이 바뀌거나, 기업행동이 없는 기존 거래 replay·회계 결과·authorization·API schema·pagination이 달라지면 FAIL이다. +- 검사 방법: 실제 PostgreSQL repository integration과 holdings/unified-ledger API 회귀를 실행하고 `app/repositories/postgres.py`의 joined projection·predicate·ordering qualification 및 반환 mapping을 검토한다. diff --git a/.proofline/identities.json b/.proofline/identities.json index 04739c8..10bcca4 100644 --- a/.proofline/identities.json +++ b/.proofline/identities.json @@ -1,5 +1,5 @@ { "schema_version": 1, - "next_line_number": 7, - "next_ac_number": 37 + "next_line_number": 8, + "next_ac_number": 38 } diff --git a/.proofline/lines/line-0007/dcy-0007.md b/.proofline/lines/line-0007/dcy-0007.md new file mode 100644 index 0000000..3030136 --- /dev/null +++ b/.proofline/lines/line-0007/dcy-0007.md @@ -0,0 +1,48 @@ +--- +id: "dcy-0007" +status: confirmed +--- + +# 기업행동 원장이 포함된 보유 원가·전체 원장 조회 복구 (#19) + +## Problem + +기업행동 원장이 존재하는 포트폴리오에서 `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis`와 `GET /v1/portfolios/{portfolio_id}/ledger?limit=50`가 PostgreSQL ambiguous-column 오류로 HTTP 500을 반환한다. 기업행동 metadata를 읽기 위해 `portfolio_ledger`와 `portfolio_corporate_actions`를 JOIN한 뒤 base ledger 컬럼 일부를 table alias 없이 참조한 것이 원인이다. 그 결과 보유 원가 replay와 전체 원장 조회라는 서로 다른 read path가 함께 중단되고, downstream인 trex-web 포트폴리오 상세에서 보유 정보와 원장 정보를 표시할 수 없다. + +## Evidence + +- `E-001` Source issue `https://github.com/fourblocks2020/trex-web/issues/19`는 두 API의 HTTP 500과 `psycopg.errors.AmbiguousColumn: column reference "ledger_entry_id" is ambiguous` 운영 관찰을 기록한다. 확인 시각의 issue body SHA-256은 `8e382d87dd3358ac516cdd8e5c683f4fa673253c6eb7fc4d0699ed572378ad5b`, `updated_at`은 `2026-09-13T11:28:56Z`다. +- `E-002` 기준 source는 `portfolio-hub` `c7d8235bdebfa8c2459b0b3300d8ed40a9f465e5`다. `app/repositories/postgres.py`의 `get_all_trades_for_pnl()`은 `portfolio_ledger AS ledger`와 `portfolio_corporate_actions ca`를 JOIN하지만 첫 18개 base projection의 `ledger_entry_id`, `portfolio_id`, `recorded_at` 등을 alias 없이 선택한다. +- `E-003` 같은 파일의 `list_ledger_entries()`는 두 table을 JOIN하면서 base projection, 동적 `WHERE`의 `portfolio_id`·`source`·cursor tuple, `ORDER BY recorded_at, ledger_entry_id`를 alias 없이 참조한다. 현재 source에서 기업행동 table을 JOIN하는 repository query는 이 두 곳뿐이다. +- `E-004` `.proofline/lines/line-0006/req-0006.md`는 approved 상태이며 unified ledger read를 소유한다. Active `AC-0032`는 source-neutral latest-first 조회, `AC-0034`는 source별 canonical representation, `AC-0036`은 PostgreSQL query 회귀를 요구한다. 보유 원가/PnL replay read는 현재 active AC가 직접 소유하지 않는다. +- `E-005` `tests/test_ledger_consolidation_integration.py`는 실제 PostgreSQL repository path를 실행하지만 기업행동 행과 일반 거래 행을 함께 만든 뒤 `get_all_trades_for_pnl()`과 `list_ledger_entries()`를 호출하는 회귀는 없다. Mock 기반 테스트만으로는 PostgreSQL column resolution 오류를 검출할 수 없다. + +## Scope + +- `S-001` `get_all_trades_for_pnl()`의 모든 base ledger projection을 `ledger.`으로 한정하여 기업행동 원장이 포함된 포트폴리오의 보유 원가 replay가 정상 동작하게 한다. 이 동작은 기존 active AC가 직접 소유하지 않으므로 지속적인 독립 검증이 가능한 AC를 `create`한다. +- `S-002` `list_ledger_entries()`의 base ledger projection, dynamic predicate, keyset cursor tuple과 ordering을 `ledger.`으로 한정하고 `portfolio_ledger AS ledger`를 일관되게 사용한다. Unified ledger의 응답 구조·정렬·cursor·권한 의미를 변경하지 않고 기존 `AC-0032`, `AC-0034`, `AC-0036`을 `satisfy`한다. +- `S-003` 실제 PostgreSQL에서 기업행동 원장과 일반 거래 원장이 공존하는 fixture로 두 repository read를 실행한다. Holdings cost-basis API와 unified ledger API가 HTTP 200을 반환하고 기업행동 metadata 및 일반 거래 행을 기존 schema대로 보존하는 focused API/repository 회귀를 둔다. +- `S-004` 변경은 ambiguous-column을 제거하는 query qualification과 직접 회귀에 한정한다. 회계 수치, WAC·replay 규칙, source/action 의미, authorization, pagination 또는 API schema를 바꾸지 않는다. + +## Out of Scope + +- `OS-001` 기업행동 원장의 생성·수정·삭제, reversal 또는 recovery 동작 변경. +- `OS-002` broker 주문·취소·체결 처리, producer/outbox 변경 또는 기존 원장 row mutation/backfill. +- `OS-003` holdings cost basis, PnL, corporate-action 회계 산식의 변경이나 임의 보정. +- `OS-004` trex-web UI 변경, 배포·release·production mutation 및 특정 production portfolio 데이터 수정. + +## Risks and Unknowns + +### Risks + +- `R-001` SELECT column 순서가 `_row_to_ledger_entry()`와 corporate-action metadata positional mapping에 결속돼 있다. Qualification 과정에서 순서를 바꾸면 값 mapping이 깨질 수 있으므로 기존 순서를 보존하고 반환 객체를 검증한다. +- `R-002` mock cursor 테스트는 PostgreSQL의 ambiguous-column 판정을 재현하지 못한다. 실제 PostgreSQL 회귀가 없으면 같은 결함이 다시 유입될 수 있으므로 disposable/local integration을 필수 검증 경계로 둔다. +- `R-003` unified ledger의 dynamic keyset predicate qualification이 누락되면 첫 page는 통과해도 source filter 또는 다음 page가 실패할 수 있다. 기본 조회, source filter와 cursor 경계를 함께 검증한다. + +### Assumptions + +- `A-001` Issue #19에 기록된 운영 오류와 현재 source의 unqualified JOIN query가 같은 결함이라는 판단은 SQL text와 PostgreSQL column-name 충돌에 근거한다. 실제 PostgreSQL pre-fix 회귀로 이를 확인한다. + +### Open Questions + +없음. Source issue가 query qualification, 유지할 계약, 회귀 범위와 제외 범위를 구체적으로 정했고 현재 source에서 추가 제품·정책 결정을 요구하는 항목은 발견되지 않았다. diff --git a/.proofline/lines/line-0007/evidence/activity-log.md b/.proofline/lines/line-0007/evidence/activity-log.md new file mode 100644 index 0000000..21e1a05 --- /dev/null +++ b/.proofline/lines/line-0007/evidence/activity-log.md @@ -0,0 +1,6 @@ +# Activity Log + +- 2026-09-13T21:26:26+09:00 — 사용자가 `REQ-0007`, 신규 `AC-0037`, 기존 `AC-0032`·`AC-0034`·`AC-0036` satisfy 집합을 승인했다. `REQ-0007`은 `approved`, `AC-0037`은 `active`로 전환했고 `proofline validate`가 기존 line status warning만 남기고 통과했다. +- 2026-09-13T21:26:26+09:00 — 직접 구현 branch `fix/issue-19-corporate-action-reads`를 생성했다. 계획: 실제 PostgreSQL 실패 회귀 추가 → joined query base column qualification → focused repository/API 회귀 → 전체 회귀·정적 검사 → 독립 QC → commit/PR. +- 2026-09-13T21:40:00+09:00 — `test_joined_ledger_reads_qualify_base_columns` 실제 PostgreSQL 회귀를 추가하고 joined PnL/unified-ledger SQL의 ledger projection·predicate·cursor/order reference를 `ledger.`로 최소 한정했다. API holdings/ledger/PNL focused suite는 50 passed; Ruff는 기존 SIM117 4건만 보고했다. +- 2026-09-13T21:44:31+09:00 — 로컬 TimescaleDB를 기동해 실제 PostgreSQL에서 unqualified JOIN이 SQLSTATE `42702` (`AmbiguousColumn`)를 내는 RED와 qualified JOIN 성공을 확인했다. Mixed BUY+`stock_consolidation` repository 회귀는 source filter·keyset cursor를 포함해 통과했고, 두 API corporate-action 회귀까지 합쳐 `3 passed`였다. \ No newline at end of file diff --git a/.proofline/lines/line-0007/evidence/test-report.md b/.proofline/lines/line-0007/evidence/test-report.md new file mode 100644 index 0000000..a3abf89 --- /dev/null +++ b/.proofline/lines/line-0007/evidence/test-report.md @@ -0,0 +1,35 @@ +# AC-0037 Test Report + +- 실행 시각: 2026-09-13T21:53:39+09:00 +- 기준 Git HEAD: `c7d8235bdebfa8c2459b0b3300d8ed40a9f465e5` +- 검증 대상: 위 HEAD의 `fix/issue-19-corporate-action-reads` working-tree diff +- 실제 backend: `timescale/timescaledb-ha:pg17` (`docker-compose.local-test.yml`, PostgreSQL DSN은 기록하지 않음) +- 결과: PASS + +## AC mapping + +- Joined projection·metadata mapping·기존 BUY 동작: `tests/test_corporate_action_atomic_recovery_postgres.py::test_joined_ledger_reads_qualify_base_columns` +- Unified ledger source filter·latest-first·keyset cursor: 같은 실제 PostgreSQL test의 `source="corporate_action"` 및 `before_recorded_at`/`before_ledger_entry_id` assertions +- Holdings HTTP 200 및 기존 WAC/total cost basis: `tests/test_v1_portfolios_holdings.py::test_corporate_action_row_keeps_holdings_cost_basis_http_200` +- Unified ledger HTTP 200 및 corporate-action response schema: `tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_includes_corporate_action_fields` +- Positional mapping drift guard: `tests/test_issue34_regressions.py::test_runtime_schema_has_one_corporate_action_ddl_and_replay_mapping_starts_at_18` +- Authorization·기존 API schema·기업행동 없는 경로: 전체 repository suite + +## Executed evidence + +1. Unqualified query probe on real PostgreSQL: SQLSTATE `42702`, `AmbiguousColumn` (RED). +2. Same JOIN with `ledger.ledger_entry_id`: success (GREEN). +3. Real PostgreSQL repository regression plus two API regressions: `3 passed, 1 warning`. +4. Full suite with real-PostgreSQL selector enabled: `711 passed, 25 skipped, 17 warnings`. +5. Python compile, `proofline validate`, and `git diff --check`: PASS. ProofLine emitted only pre-existing line-0001–0006 status warnings. +6. Ruff: no finding was introduced by the changed lines; repository has four pre-existing `SIM117` findings in `app/repositories/postgres.py` and pre-existing `UP017` findings in `tests/test_v1_portfolios_holdings.py`. + +## Artifact identities + +- `app/repositories/postgres.py`: `2fa5ecbdc29a41bbc1379069b15e7cf43c7bf62cf8f00a3fa824ff8c182f410e` +- `tests/test_corporate_action_atomic_recovery_postgres.py`: `0d0c436efe443b53c11b792885ca17e983320afe70d13122c7b89d3d73cf0cdd` +- `tests/test_v1_portfolios_holdings.py`: `daff8ce2bec9f161e1a399d9bb3bea3cbf97707c6d8805cfedb66900b7b036e2` +- `tests/test_v1_portfolios_manual_ledger.py`: `45bb77f66708b0b76e133031b3f35ac06714558c9603ec56c1b642906ca2e681` +- `tests/test_issue34_regressions.py`: `661742e430522af58ebc15a8f97c8385e1f65126bf71a2783716c269a76ac45f` +- `.proofline/lines/line-0007/req-0007.md`: `a3a6ae501f7635cc80a5a96416e61aa3ecce326a732126feba3b9ad1a7c8df73` +- `.proofline/criteria/ac-0037.md`: `99dab6c2d70367c236dc08a65b24d6ddf94e131500e06cd52e9ab89784708d2d` diff --git a/.proofline/lines/line-0007/line-0007.md b/.proofline/lines/line-0007/line-0007.md new file mode 100644 index 0000000..a085524 --- /dev/null +++ b/.proofline/lines/line-0007/line-0007.md @@ -0,0 +1,4 @@ +--- +id: "line-0007" +status: discovery +--- diff --git a/.proofline/lines/line-0007/req-0007.md b/.proofline/lines/line-0007/req-0007.md new file mode 100644 index 0000000..771617a --- /dev/null +++ b/.proofline/lines/line-0007/req-0007.md @@ -0,0 +1,40 @@ +--- +id: "req-0007" +status: approved +discovery: "dcy-0007" +criteria: + create: + - "ac-0037" + update: [] + retire: [] + satisfy: + - "ac-0032" + - "ac-0034" + - "ac-0036" +--- + +# 기업행동 원장이 포함된 보유 원가·전체 원장 조회 복구 (#19) + +## Objective + +기업행동 metadata를 JOIN하는 포트폴리오 read query가 PostgreSQL column ambiguity 없이 기존 보유 원가 replay와 source-neutral 전체 원장 계약을 제공하도록 복구한다. + +## Scope + +- 기업행동 원장이 있는 포트폴리오의 holdings cost-basis path가 모든 거래와 기업행동 metadata를 기존 순서·의미로 읽어 원가 replay 결과를 반환한다. +- `GET /v1/portfolios/{portfolio_id}/ledger`가 기업행동 행과 일반 거래 행을 함께 기존 latest-first·source filter·keyset pagination 계약으로 반환한다. +- Joined query의 base ledger column reference를 table alias로 한정하고 corporate-action metadata projection은 명시적으로 유지한다. +- 실제 PostgreSQL repository 회귀와 API 회귀로 holdings cost-basis 및 unified ledger HTTP 200, 혼합 행, metadata mapping과 cursor 경계를 검증한다. + +## Constraints + +- SELECT column 순서와 `_row_to_ledger_entry()` positional mapping을 유지한다. +- API endpoint, response schema, authorization, source/action 의미, ordering 및 cursor 형식을 변경하지 않는다. +- Holdings cost basis, WAC, PnL 또는 corporate-action replay 회계 규칙을 변경하지 않는다. +- 기존 active `AC-0032`, `AC-0034`, `AC-0036`의 의미를 변경하지 않고 satisfy binding으로 적용한다. + +## Non-Goals + +- 기업행동 또는 일반 ledger row의 생성·수정·삭제·backfill. +- broker 주문·취소·체결, producer/outbox 또는 reversal/recovery 동작 변경. +- trex-web UI 구현, 배포, release, production mutation 또는 특정 production portfolio 데이터 보정. diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 9d21679..6a79bd8 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -1970,13 +1970,13 @@ class PostgresPortfolioRepository: 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, source, + ledger.ledger_entry_id, ledger.portfolio_id, ledger.order_id, ledger.execution_id, ledger.side, + ledger.quantity::float8, ledger.price::float8, + ledger.fee_amount::float8, ledger.tax_amount::float8, + ledger.executed_at, ledger.recorded_at, + ledger.cash_balance_after::float8, ledger.asset_quantity_after::float8, + ledger.total_value_after::float8, ledger.profit_buffer_after::float8, + ledger.strategy_set_id, ledger.note, ledger.source, ca.corporate_action_id, ca.action_type, ca.effective_date, ca.quantity_before, ca.quantity_after, ca.average_price_before, ca.average_price_after, ca.total_cost_basis, ca.cash_balance, @@ -2643,30 +2643,32 @@ class PostgresPortfolioRepository: limit: int = 50, ) -> list[LedgerEntry]: self.ensure_schema() - clauses = ["portfolio_id = %s"] + clauses = ["ledger.portfolio_id = %s"] params: list[Any] = [portfolio_id] if source is not None: - clauses.append("source = %s") + clauses.append("ledger.source = %s") params.append(source) if before_recorded_at is not None and before_ledger_entry_id is not None: - clauses.append("(recorded_at, ledger_entry_id) < (%s, %s)") + clauses.append("(ledger.recorded_at, ledger.ledger_entry_id) < (%s, %s)") params.extend([before_recorded_at, before_ledger_entry_id]) params.append(int(limit)) with self._connect().cursor() as cur: cur.execute( """ - SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price, - fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after, - asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id, - note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id, + SELECT ledger.ledger_entry_id, ledger.portfolio_id, ledger.order_id, ledger.execution_id, ledger.side, + ledger.quantity, ledger.price, ledger.fee_amount, ledger.tax_amount, + ledger.executed_at, ledger.recorded_at, ledger.cash_balance_after, + ledger.asset_quantity_after, ledger.total_value_after, ledger.profit_buffer_after, + ledger.strategy_set_id, ledger.note, ledger.source, ledger.target_ledger_entry_id, + ledger.confirmation_evidence, ledger.actor_user_id, ca.corporate_action_id, ca.action_type, ca.effective_date, ca.quantity_before, ca.quantity_after, ca.average_price_before, ca.average_price_after, ca.total_cost_basis, ca.cash_balance, ca.profit_buffer, ca.evidence_ref, ca.actor_user_id - FROM portfolio_ledger - LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = portfolio_ledger.ledger_entry_id + FROM portfolio_ledger AS ledger + LEFT JOIN portfolio_corporate_actions AS ca ON ca.ledger_entry_id = ledger.ledger_entry_id WHERE """ + " AND ".join(clauses) + """ - ORDER BY recorded_at DESC, ledger_entry_id DESC + ORDER BY ledger.recorded_at DESC, ledger.ledger_entry_id DESC LIMIT %s """, params, diff --git a/tests/test_corporate_action_atomic_recovery_postgres.py b/tests/test_corporate_action_atomic_recovery_postgres.py index 613d4e7..a736cba 100644 --- a/tests/test_corporate_action_atomic_recovery_postgres.py +++ b/tests/test_corporate_action_atomic_recovery_postgres.py @@ -165,6 +165,33 @@ def _record(repo: PostgresPortfolioRepository, asset_id: str, request: Corporate ) +def test_joined_ledger_reads_qualify_base_columns(repository): + """Mixed trade/corporate-action rows must be readable through both joins.""" + asset_id = _seed_daekyo(repository, quantity=Decimal(1452)) + _record(repository, asset_id, _request(), "ca-joined-read") + + pnl_entries = repository.get_all_trades_for_pnl(_PORTFOLIO_ID) + ledger_entries = repository.list_ledger_entries(_PORTFOLIO_ID, limit=50) + + assert len(pnl_entries) == 2 + assert len(ledger_entries) == 2 + assert any(entry.corporate_action for entry in pnl_entries) + assert any(entry.corporate_action for entry in ledger_entries) + assert ledger_entries[0].recorded_at >= ledger_entries[1].recorded_at + + corporate_entries = repository.list_ledger_entries(_PORTFOLIO_ID, source="corporate_action", limit=50) + assert len(corporate_entries) == 1 + assert corporate_entries[0].corporate_action is not None + + next_page = repository.list_ledger_entries( + _PORTFOLIO_ID, + before_recorded_at=ledger_entries[0].recorded_at, + before_ledger_entry_id=ledger_entries[0].ledger_entry_id, + limit=50, + ) + assert [entry.source for entry in next_page] == ["broker"] + + def _state(repo: PostgresPortfolioRepository) -> tuple: with repo._connect().cursor() as cur: cur.execute( diff --git a/tests/test_issue34_regressions.py b/tests/test_issue34_regressions.py index 62823f9..6a136fb 100644 --- a/tests/test_issue34_regressions.py +++ b/tests/test_issue34_regressions.py @@ -64,7 +64,7 @@ def test_default_resume_flag_preserves_pre_recovery_idempotency_hash(): def test_runtime_schema_has_one_corporate_action_ddl_and_replay_mapping_starts_at_18(): source = Path("app/repositories/postgres.py").read_text() assert source.count("CREATE TABLE IF NOT EXISTS portfolio_corporate_actions") == 1 - assert "strategy_set_id, note, source," in source + assert "ledger.strategy_set_id, ledger.note, ledger.source," in source assert "ca.corporate_action_id, ca.action_type" in source assert 'row[18] is not None' in source diff --git a/tests/test_v1_portfolios_holdings.py b/tests/test_v1_portfolios_holdings.py index 8ee0aec..e34a556 100644 --- a/tests/test_v1_portfolios_holdings.py +++ b/tests/test_v1_portfolios_holdings.py @@ -7,7 +7,7 @@ Covers AC-1~11 from REQ-043. from __future__ import annotations from contextlib import ExitStack -from datetime import datetime, timezone +from datetime import UTC, datetime, timezone from unittest.mock import MagicMock, patch import pytest @@ -286,6 +286,40 @@ class TestAC2PartialSellWacUnchanged: assert resp.json()["total_cost_basis"] == pytest.approx(500_000.0) +def test_corporate_action_row_keeps_holdings_cost_basis_http_200() -> None: + buy = _make_entry( + ledger_entry_id=1, + side="buy", + quantity=20, + price=100, + executed_at=datetime(2026, 9, 1, tzinfo=UTC), + ) + corporate_action = LedgerEntry( + ledger_entry_id=2, + portfolio_id=_PORTFOLIO_ID, + source="corporate_action", + side="stock_consolidation", + quantity=10, + price=200, + recorded_at=datetime(2026, 9, 2, tzinfo=UTC), + corporate_action={"action_type": "stock_consolidation"}, + ) + repo = _make_service_repo( + portfolio=_make_portfolio(asset_quantity=10), + trades=[buy, corporate_action], + ) + + with _with_repo(repo): + response = _make_client(_make_settings()).get( + _PATH, + headers={"Authorization": "Bearer tok"}, + ) + + assert response.status_code == 200 + assert response.json()["cost_basis_per_unit"] == pytest.approx(100) + assert response.json()["total_cost_basis"] == pytest.approx(1000) + + # --------------------------------------------------------------------------- # AC-3: user read_token (owner) → 200 + normal response # --------------------------------------------------------------------------- diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py index 6c904b5..03af9f0 100644 --- a/tests/test_v1_portfolios_manual_ledger.py +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -5,7 +5,7 @@ from __future__ import annotations import os import time from contextlib import ExitStack -from datetime import UTC, datetime +from datetime import UTC, date, datetime from decimal import Decimal from typing import Any from unittest.mock import patch @@ -977,6 +977,62 @@ def test_unified_ledger_includes_dividend_fields() -> None: assert item["profit_buffer_after"] == created.json()["profit_buffer_after"] +def test_unified_ledger_includes_corporate_action_fields() -> None: + repo = _FakeRepo(_portfolio()) + repo.entries.append( + LedgerEntry( + ledger_entry_id=7, + portfolio_id=_PORTFOLIO_ID, + source="corporate_action", + side="stock_consolidation", + quantity=10, + price=200, + recorded_at=datetime(2026, 9, 2, tzinfo=UTC), + corporate_action={ + "corporate_action_id": "ca-7", + "action_type": "stock_consolidation", + "effective_date": date(2026, 9, 2), + "quantity_before": Decimal(20), + "quantity_after": Decimal(10), + "average_price_before": Decimal(100), + "average_price_after": Decimal(200), + "total_cost_basis": Decimal(2000), + "cash_balance": Decimal(1000), + "profit_buffer": Decimal(500), + "evidence_ref": "notice://ca-7", + "actor_user_id": _USER_ID, + }, + ) + ) + client = _make_client(repo) + + response = client.get( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=corporate_action", + headers=_auth(), + ) + + assert response.status_code == 200 + item = response.json()["items"][0] + assert item == { + "ledger_entry_id": 7, + "portfolio_id": _PORTFOLIO_ID, + "source": "corporate_action", + "event_id": "ca-7", + "event_type": "stock_consolidation", + "effective_date": "2026-09-02", + "quantity_before": "20", + "quantity_after": "10", + "average_price_before": "100", + "average_price_after": "200", + "total_cost_basis": "2000", + "cash_balance": "1000", + "profit_buffer": "500", + "evidence_ref": "notice://ca-7", + "actor_user_id": _USER_ID, + "recorded_at": "2026-09-02T00:00:00Z", + } + + def test_unified_ledger_openapi_contains_nullable_amount() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo)