fix: qualify corporate-action ledger reads (#36)

This commit is contained in:
2026-09-13 22:02:43 +09:00
committed by GitHub
parent c7d8235bde
commit 8841bdd441
12 changed files with 290 additions and 22 deletions
+16
View File
@@ -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을 검토한다.
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"schema_version": 1, "schema_version": 1,
"next_line_number": 7, "next_line_number": 8,
"next_ac_number": 37 "next_ac_number": 38
} }
+48
View File
@@ -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.<column>`으로 한정하여 기업행동 원장이 포함된 포트폴리오의 보유 원가 replay가 정상 동작하게 한다. 이 동작은 기존 active AC가 직접 소유하지 않으므로 지속적인 독립 검증이 가능한 AC를 `create`한다.
- `S-002` `list_ledger_entries()`의 base ledger projection, dynamic predicate, keyset cursor tuple과 ordering을 `ledger.<column>`으로 한정하고 `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에서 추가 제품·정책 결정을 요구하는 항목은 발견되지 않았다.
@@ -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`였다.
@@ -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`
+4
View File
@@ -0,0 +1,4 @@
---
id: "line-0007"
status: discovery
---
+40
View File
@@ -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 데이터 보정.
+19 -17
View File
@@ -1970,13 +1970,13 @@ class PostgresPortfolioRepository:
cur.execute( cur.execute(
""" """
SELECT SELECT
ledger_entry_id, portfolio_id, order_id, execution_id, side, ledger.ledger_entry_id, ledger.portfolio_id, ledger.order_id, ledger.execution_id, ledger.side,
quantity::float8, price::float8, ledger.quantity::float8, ledger.price::float8,
fee_amount::float8, tax_amount::float8, ledger.fee_amount::float8, ledger.tax_amount::float8,
executed_at, recorded_at, ledger.executed_at, ledger.recorded_at,
cash_balance_after::float8, asset_quantity_after::float8, ledger.cash_balance_after::float8, ledger.asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8, ledger.total_value_after::float8, ledger.profit_buffer_after::float8,
strategy_set_id, note, source, ledger.strategy_set_id, ledger.note, ledger.source,
ca.corporate_action_id, ca.action_type, ca.effective_date, ca.corporate_action_id, ca.action_type, ca.effective_date,
ca.quantity_before, ca.quantity_after, ca.average_price_before, ca.quantity_before, ca.quantity_after, ca.average_price_before,
ca.average_price_after, ca.total_cost_basis, ca.cash_balance, ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
@@ -2643,30 +2643,32 @@ class PostgresPortfolioRepository:
limit: int = 50, limit: int = 50,
) -> list[LedgerEntry]: ) -> list[LedgerEntry]:
self.ensure_schema() self.ensure_schema()
clauses = ["portfolio_id = %s"] clauses = ["ledger.portfolio_id = %s"]
params: list[Any] = [portfolio_id] params: list[Any] = [portfolio_id]
if source is not None: if source is not None:
clauses.append("source = %s") clauses.append("ledger.source = %s")
params.append(source) params.append(source)
if before_recorded_at is not None and before_ledger_entry_id is not None: 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.extend([before_recorded_at, before_ledger_entry_id])
params.append(int(limit)) params.append(int(limit))
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
cur.execute( cur.execute(
""" """
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price, SELECT ledger.ledger_entry_id, ledger.portfolio_id, ledger.order_id, ledger.execution_id, ledger.side,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after, ledger.quantity, ledger.price, ledger.fee_amount, ledger.tax_amount,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id, ledger.executed_at, ledger.recorded_at, ledger.cash_balance_after,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id, 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.corporate_action_id, ca.action_type, ca.effective_date,
ca.quantity_before, ca.quantity_after, ca.average_price_before, ca.quantity_before, ca.quantity_after, ca.average_price_before,
ca.average_price_after, ca.total_cost_basis, ca.cash_balance, ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
ca.profit_buffer, ca.evidence_ref, ca.actor_user_id ca.profit_buffer, ca.evidence_ref, ca.actor_user_id
FROM portfolio_ledger FROM portfolio_ledger AS ledger
LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = portfolio_ledger.ledger_entry_id LEFT JOIN portfolio_corporate_actions AS ca ON ca.ledger_entry_id = ledger.ledger_entry_id
WHERE """ + " AND ".join(clauses) + """ 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 LIMIT %s
""", """,
params, params,
@@ -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: def _state(repo: PostgresPortfolioRepository) -> tuple:
with repo._connect().cursor() as cur: with repo._connect().cursor() as cur:
cur.execute( cur.execute(
+1 -1
View File
@@ -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(): def test_runtime_schema_has_one_corporate_action_ddl_and_replay_mapping_starts_at_18():
source = Path("app/repositories/postgres.py").read_text() source = Path("app/repositories/postgres.py").read_text()
assert source.count("CREATE TABLE IF NOT EXISTS portfolio_corporate_actions") == 1 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 "ca.corporate_action_id, ca.action_type" in source
assert 'row[18] is not None' in source assert 'row[18] is not None' in source
+35 -1
View File
@@ -7,7 +7,7 @@ Covers AC-1~11 from REQ-043.
from __future__ import annotations from __future__ import annotations
from contextlib import ExitStack from contextlib import ExitStack
from datetime import datetime, timezone from datetime import UTC, datetime, timezone
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -286,6 +286,40 @@ class TestAC2PartialSellWacUnchanged:
assert resp.json()["total_cost_basis"] == pytest.approx(500_000.0) 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 # AC-3: user read_token (owner) → 200 + normal response
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+57 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import os import os
import time import time
from contextlib import ExitStack from contextlib import ExitStack
from datetime import UTC, datetime from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from unittest.mock import patch 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"] 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: def test_unified_ledger_openapi_contains_nullable_amount() -> None:
repo = _FakeRepo(_portfolio()) repo = _FakeRepo(_portfolio())
client = _make_client(repo) client = _make_client(repo)