fix: qualify corporate-action ledger reads (#36)
This commit is contained in:
@@ -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을 검토한다.
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
id: "line-0007"
|
||||||
|
status: discovery
|
||||||
|
---
|
||||||
@@ -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 데이터 보정.
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user