Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f79f3c79b5 | ||
|
|
8841bdd441 | ||
|
|
c7d8235bde | ||
|
|
2b470c735f | ||
|
|
4520fbbfbd | ||
|
|
1010d257ad | ||
|
|
5b443f718e | ||
|
|
a4e4951ed6 | ||
|
|
54e4fe4213 | ||
|
|
fa824dfd36 | ||
|
|
925c6b4fa4 | ||
|
|
cb19c35d23 | ||
|
|
14c118f503 | ||
|
|
48735bbb4a | ||
|
|
a4664f5991 | ||
|
|
77468631b1 | ||
|
|
7a45bec3cb | ||
|
|
dc755889b3 | ||
|
|
dfcafade94 | ||
|
|
caaf7ce68c | ||
|
|
044688b485 | ||
|
|
308a1ea59e | ||
|
|
db35d083de | ||
|
|
8368e7ba16 | ||
|
|
42297030ca | ||
|
|
e261a41b7f | ||
|
|
d3601d32dc | ||
|
|
fde5b33f05 | ||
|
|
7fc3386f3a | ||
|
|
7aa3de524a | ||
|
|
64975033e6 | ||
|
|
1015b465bb | ||
|
|
f193aa6da3 | ||
|
|
a36b451a3d | ||
|
|
78e347703e | ||
|
|
6df3e4ba02 | ||
|
|
b74284e626 | ||
|
|
aa40c7fdff | ||
|
|
a71c91f6ef | ||
|
|
ccde81b7a0 | ||
|
|
9d2c2aea5f | ||
|
|
5d6a0ad013 |
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
- run: python scripts/image_release.py validate-checkout --actual "$(git rev-parse HEAD)" --expected "${GITHUB_SHA}"
|
||||
- run: python scripts/image_release.py validate-event --event "${GITHUB_EVENT_NAME}" --ref "${GITHUB_REF}" --commit "${GITHUB_SHA}" --project pyproject.toml
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
@@ -39,9 +39,14 @@ jobs:
|
||||
python-version: "3.11"
|
||||
- run: python -m pip install "uv==0.11.14"
|
||||
- run: uv sync --extra dev --locked
|
||||
- run: uv run pytest -q
|
||||
- run: uv run python -m pytest -q
|
||||
- name: Honest scoped lint
|
||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py tests/release
|
||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
|
||||
- name: Reject Ruff findings introduced by v0.3.0 or later patches
|
||||
run: |
|
||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.1
|
||||
- name: Record exact source migration inventory
|
||||
run: uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
|
||||
- name: Create excluded synthetic canary
|
||||
|
||||
@@ -11,20 +11,47 @@ permissions:
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
timescaledb:
|
||||
image: timescale/timescaledb@sha256:252a443e2936039b83dd8da1373d01e59e932d1054fa6adf1bc061f1d56ae60a
|
||||
env:
|
||||
POSTGRES_DB: portfolio_hub
|
||||
POSTGRES_USER: portfolio_hub_user
|
||||
POSTGRES_PASSWORD: change-me
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U portfolio_hub_user -d portfolio_hub"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: python -m pip install "uv==0.11.14"
|
||||
- run: uv sync --extra dev --locked
|
||||
- run: uv run pytest -q
|
||||
- run: uv run python -m pytest -q
|
||||
- name: Verify corporate-action atomicity and guarded recovery in PostgreSQL
|
||||
env:
|
||||
POSTGRES_PASSWORD: change-me
|
||||
run: |
|
||||
export PORTFOLIOHUB_TEST_POSTGRES_DSN="postgresql://portfolio_hub_user:${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres"
|
||||
uv run python -m pytest -q tests/test_corporate_action_atomic_recovery_postgres.py
|
||||
- name: Honest scoped lint for release additions
|
||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py tests/release
|
||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
|
||||
- name: Reject Ruff findings introduced by v0.3.0 or later patches
|
||||
run: |
|
||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.1
|
||||
- name: Validate container contracts without registry writes
|
||||
run: uv run pytest -q tests/release
|
||||
run: uv run python -m pytest -q tests/release
|
||||
- name: Build and smoke exact pull-request or main source image
|
||||
run: |
|
||||
uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
|
||||
|
||||
@@ -7,7 +7,7 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
신규 포트폴리오 생성·수정은 전역 카탈로그의 기존 active `strategy_id`만 선택할 수 있어야 하며, 전략을 inactive로 전환해도 기존 포트폴리오 참조는 보존되고 신규 선택만 차단되어야 한다.
|
||||
신규 포트폴리오 생성·수정에서 `strategy_id`를 지정하는 경우 전역 카탈로그의 기존 active ID만 선택할 수 있어야 하며, 전략을 inactive로 전환해도 기존 포트폴리오 참조는 보존되고 신규 선택만 차단되어야 한다. 전략 미지정 또는 `null`은 선택이 아니며 전략 참조를 만들지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
`/v1/portfolios`와 legacy `/portfolios`의 생성에는 active `strategy_id`가 필수이고 수정도 ID-only여야 하며, 요청의 `strategy_name`·`strategy_params`는 `422`로 거부해야 한다. v1 `PortfolioItem` 응답은 현재 참조 전략의 `active_strategy_id`·`strategy_nick`·`strategy_name`·`strategy_params`·`strategy_revision`을 같은 committed row에서 반환해야 하고 참조가 없거나 해석할 수 없으면 `strategy_nick`은 `null`이어야 하며, legacy 응답의 기존 호환 필드는 유지되어야 한다.
|
||||
`/v1/portfolios`와 legacy `/portfolios`의 생성은 `strategy_id`를 생략하거나 `null`로 전달해도 가능해야 하며, 이 경우 전략 참조를 만들거나 기본 전략을 암묵적으로 선택해서는 안 된다. `strategy_id`가 제공된 생성·수정은 ID-only여야 하고 기존 active 전략 ID만 선택할 수 있으며, 요청의 `strategy_name`·`strategy_params`는 `422`로 거부해야 한다. v1 `PortfolioItem` 응답은 현재 참조 전략의 `active_strategy_id`·`strategy_nick`·`strategy_name`·`strategy_params`·`strategy_revision`을 같은 committed row에서 반환해야 하고 참조가 없거나 해석할 수 없으면 이 전략 메타데이터는 `null`이어야 하며, legacy 응답의 기존 호환 필드는 유지되어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 명시한 active ID로만 포트폴리오가 생성·수정되고 v1 create/PATCH/list 응답에 현재 전략 ID·`nick`·이름·config·revision이 나타나며 참조가 없으면 `strategy_nick`은 `null`이다.
|
||||
- 실패·경계 조건: strategy ID 누락, 제거 필드 전달 또는 malformed 요청은 새 전략 row를 만들지 않고 `422`다.
|
||||
- 검사 방법: v1·legacy create/update schema와 저장소 row count, v1 create/PATCH/list의 `strategy_nick` read-back, unbound `null` 및 기존 응답 호환 필드를 API/CLI 회귀로 검사한다.
|
||||
- 현재 구현 평가: schemas와 양쪽 portfolio API가 ID-only 입력 및 `extra="forbid"` 계약을 구현하고 v1 mapper가 `strategy_nick`을 반환한다. 다만 `strategy_nick` read-back, 제거된 `strategy_name`·`strategy_params` 각각의 `422`와 응답 호환 필드 전체를 직접 단언하는 전용 test가 없어 executable coverage는 `PARTIAL`이다.
|
||||
- 성공 조건: strategy ID 없이 또는 `null`로 포트폴리오가 생성되고 전략 참조와 전략 메타데이터가 모두 `null`이며, 기본 전략이 자동 연결되지 않는다. 명시한 active ID로 생성·수정하면 v1 create/PATCH/list 응답에 현재 전략 ID·`nick`·이름·config·revision이 나타난다.
|
||||
- 실패·경계 조건: 제공된 unknown ID는 `404`, inactive ID는 `409`이며, 제거 필드·빈 문자열·malformed 요청은 새 전략 row를 만들지 않고 `422`다.
|
||||
- 검사 방법: v1·legacy create/update schema와 저장소 row count, strategy 미지정·explicit `null`·active/unknown/inactive ID의 create read-back, v1 create/PATCH/list 전략 메타데이터 및 기존 응답 호환 필드를 API/CLI 회귀로 검사한다.
|
||||
- 현재 구현 평가: v1 schema와 API는 strategy 미지정·`null` 생성을 구현하고 회귀 테스트가 이를 검사한다. legacy `/portfolios` schema와 생성 경로는 아직 `strategy_id`를 필수로 유지하므로 전체 계약의 executable coverage는 `PARTIAL`이며, legacy 동등 구현과 read-back 회귀가 필요하다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
Alembic data migration은 active `name="target_value"`, `config={}` 전략을 중복 없이 보장하고, 동일 `(name, config)` row가 있으면 재사용해야 하며 포트폴리오 요청의 `strategy_id` 필수 조건을 완화하지 않아야 한다.
|
||||
Alembic data migration은 active `name="target_value"`, `config={}` 전략을 중복 없이 보장하고 동일 `(name, config)` row가 있으면 재사용해야 한다. 기본 전략의 존재는 strategy 미지정 또는 `null` 포트폴리오 생성에서 암묵적 전략 선택을 유발해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 빈 DB upgrade 후 기본 전략 한 건이 active, revision 1과 runtime 일치 derived identity로 존재한다.
|
||||
- 실패·경계 조건: migration 재실행이나 기존 동일 row가 중복 row를 만들지 않고, 기본 전략 존재가 implicit portfolio selection을 허용하지 않는다.
|
||||
- 검사 방법: migration unit/idempotency test와 신규 PostgreSQL DB full upgrade/head 재실행, portfolio create negative test를 수행한다.
|
||||
- 현재 구현 평가: migration unit test가 scoped 재실행에서 PASS했고, 2026-08-15 실제 PostgreSQL integration `4 passed`에서 신규 DB full upgrade와 head 재실행을 재검증했다.
|
||||
- 실패·경계 조건: migration 재실행이나 기존 동일 row가 중복 row를 만들지 않고, 기본 전략 존재가 implicit portfolio selection을 유발하지 않는다.
|
||||
- 검사 방법: migration unit/idempotency test와 신규 PostgreSQL DB full upgrade/head 재실행, strategy 미지정·explicit `null` portfolio create read-back을 수행한다.
|
||||
- 현재 구현 평가: migration unit test가 scoped 재실행에서 PASS했고, v1 route에는 strategy 미지정 생성 회귀가 있다. legacy `/portfolios`에도 동일한 생성·read-back 검증이 추가되기 전까지 executable coverage는 `PARTIAL`이다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
유효한 `strategy_id`와 portfolio owner가 소유한 active daemon의 server-generated UUID를 포함해 포트폴리오를 생성하면 HTTP 201 응답과 후속 portfolio 조회의 `managed_by_daemon_id`가 요청한 daemon ID와 같아야 한다.
|
||||
strategy ID의 지정 여부와 무관하게, portfolio owner가 소유한 active daemon의 server-generated UUID를 포함해 포트폴리오를 생성하면 HTTP 201 응답과 후속 portfolio 조회의 `managed_by_daemon_id`가 요청한 daemon ID와 같아야 한다. strategy ID를 생략하거나 `null`로 전달한 경우 전략 참조는 만들지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: POST 응답과 저장소 조회 및 API 후속 조회가 모두 같은 non-null daemon ID를 반환한다.
|
||||
- 성공 조건: strategy ID를 지정한 경우와 생략하거나 `null`로 전달한 경우 모두 POST 응답·저장소 조회·API 후속 조회가 같은 non-null daemon ID를 반환하며, strategy 미지정 생성의 `active_strategy_id`는 `null`이다.
|
||||
- 실패·경계 조건: persistence 경계에서 daemon ID가 누락되거나 `null`로 바뀌면 실패한다.
|
||||
- 검사 방법: API test와 migration head가 적용된 disposable PostgreSQL에서 `POST → GET` read-back을 실행한다.
|
||||
- 현재 구현 평가: `e2102b6`의 생성 handler·repository INSERT 경로와 PostgreSQL integration test가 대응하며 2차 QC에서 관련 suite가 통과했다.
|
||||
|
||||
@@ -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,
|
||||
"next_line_number": 7,
|
||||
"next_ac_number": 37
|
||||
"next_line_number": 8,
|
||||
"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 데이터 보정.
|
||||
+185
-24
@@ -16,16 +16,25 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, Permission, require_read, require_trade_write
|
||||
from app.core.config import get_settings
|
||||
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
|
||||
from app.domain.models import Portfolio, PortfolioComment
|
||||
from app.domain.models import CorporateAction, Portfolio, PortfolioComment
|
||||
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
|
||||
from app.domain.services import PORTFOLIO_NOT_FOUND_MESSAGE
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.corporate_actions import (
|
||||
CorporateActionRequest,
|
||||
CorporateActionResponse,
|
||||
)
|
||||
from app.schemas.v1 import (
|
||||
CorporateActionLedgerHistoryItem,
|
||||
DividendLedgerHistoryItem,
|
||||
DividendManualLedgerResponse,
|
||||
HoldingsCostBasisResponse,
|
||||
LedgerHistoryItem,
|
||||
LedgerHistoryResponse,
|
||||
@@ -186,11 +195,13 @@ def create_portfolio_v1(
|
||||
)
|
||||
|
||||
name = body.name or f"{body.broker}-{body.asset_id[:8]}"
|
||||
strategy_set = repo.get_strategy_set(body.strategy_id)
|
||||
if strategy_set is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {body.strategy_id}")
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}")
|
||||
strategy_set = None
|
||||
if body.strategy_id is not None:
|
||||
strategy_set = repo.get_strategy_set(body.strategy_id)
|
||||
if strategy_set is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {body.strategy_id}")
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}")
|
||||
|
||||
_require_valid_daemon_uuid(body.managed_by_daemon_id)
|
||||
|
||||
@@ -205,8 +216,8 @@ def create_portfolio_v1(
|
||||
asset_quantity=body.asset_quantity,
|
||||
asset_price=body.asset_price,
|
||||
profit_buffer=body.profit_buffer,
|
||||
active_strategy_id=strategy_set.id,
|
||||
avabile_strategies=[strategy_set.id],
|
||||
active_strategy_id=strategy_set.id if strategy_set is not None else None,
|
||||
avabile_strategies=[strategy_set.id] if strategy_set is not None else None,
|
||||
managed_by_daemon_id=body.managed_by_daemon_id,
|
||||
)
|
||||
except HTTPException:
|
||||
@@ -683,18 +694,41 @@ def record_ledger_trade_v1(
|
||||
)
|
||||
|
||||
|
||||
def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
||||
def _manual_entry_to_response(entry) -> ManualLedgerResponse | DividendManualLedgerResponse:
|
||||
action = (entry.action or entry.side or "").upper()
|
||||
amount = entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None
|
||||
if action == "DIVIDEND":
|
||||
amount_decimal = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price))
|
||||
tax_decimal = entry.tax_amount_decimal if entry.tax_amount_decimal is not None else Decimal(str(entry.tax_amount))
|
||||
return DividendManualLedgerResponse(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
action=action,
|
||||
amount=str(amount_decimal),
|
||||
quantity=entry.quantity,
|
||||
price=entry.price,
|
||||
fee=entry.fee_amount,
|
||||
tax=str(tax_decimal),
|
||||
executed_at=entry.executed_at,
|
||||
note=entry.note,
|
||||
recorded_at=entry.recorded_at,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
asset_quantity_after=entry.asset_quantity_after,
|
||||
profit_buffer_after=entry.profit_buffer_after,
|
||||
total_value_after=entry.total_value_after,
|
||||
source=entry.source or "manual",
|
||||
target_ledger_entry_id=entry.target_ledger_entry_id,
|
||||
confirmation_evidence=entry.confirmation_evidence,
|
||||
)
|
||||
return ManualLedgerResponse(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
action=action,
|
||||
amount=amount,
|
||||
amount=float(entry.price) if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None,
|
||||
quantity=entry.quantity,
|
||||
price=entry.price,
|
||||
fee=entry.fee_amount,
|
||||
tax=entry.tax_amount,
|
||||
tax=float(entry.tax_amount),
|
||||
executed_at=entry.executed_at,
|
||||
note=entry.note,
|
||||
recorded_at=entry.recorded_at,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
@@ -708,6 +742,8 @@ def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
||||
|
||||
|
||||
def _manual_error_code(message: str) -> str:
|
||||
if "POTENTIAL_DUPLICATE_DIVIDEND" in message:
|
||||
return "POTENTIAL_DUPLICATE_DIVIDEND"
|
||||
if "LEDGER_ENTRY_NOT_FOUND" in message or "원장 항목을 찾을 수 없습니다" in message:
|
||||
return "LEDGER_ENTRY_NOT_FOUND"
|
||||
if "INSUFFICIENT_CASH" in message or "현금" in message:
|
||||
@@ -721,6 +757,32 @@ def _manual_error_code(message: str) -> str:
|
||||
return "MANUAL_LEDGER_CONFLICT"
|
||||
|
||||
|
||||
def _extract_duplicate_dividend_entry_id(message: str) -> int | None:
|
||||
prefix = "POTENTIAL_DUPLICATE_DIVIDEND:"
|
||||
if not message.startswith(prefix):
|
||||
return None
|
||||
try:
|
||||
return int(message.removeprefix(prefix))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _manual_error_message(code: str) -> str:
|
||||
if code == "LEDGER_ENTRY_NOT_FOUND":
|
||||
return "Target ledger entry not found"
|
||||
if code == "POTENTIAL_DUPLICATE_DIVIDEND":
|
||||
return "Potential duplicate dividend entry detected"
|
||||
if code == "INSUFFICIENT_CASH":
|
||||
return "Insufficient cash balance"
|
||||
if code == "INSUFFICIENT_QUANTITY":
|
||||
return "Insufficient asset quantity"
|
||||
if code == "INSUFFICIENT_BUFFER":
|
||||
return "Insufficient profit buffer"
|
||||
if code == "LEDGER_ENTRY_ALREADY_REVERSED":
|
||||
return "Ledger entry is already reversed"
|
||||
return "Manual ledger request could not be processed"
|
||||
|
||||
|
||||
def _encode_ledger_cursor(*, portfolio_id: str, source: str | None, recorded_at: datetime, ledger_entry_id: int) -> str:
|
||||
payload = {
|
||||
"portfolio_id": portfolio_id,
|
||||
@@ -753,20 +815,59 @@ def _decode_ledger_cursor(cursor: str, *, portfolio_id: str, source: str | None)
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid ledger cursor") from None
|
||||
|
||||
|
||||
def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
|
||||
def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem | DividendLedgerHistoryItem | CorporateActionLedgerHistoryItem:
|
||||
if getattr(entry, "corporate_action", None):
|
||||
event = entry.corporate_action
|
||||
return CorporateActionLedgerHistoryItem(
|
||||
ledger_entry_id=entry.ledger_entry_id, portfolio_id=entry.portfolio_id,
|
||||
source="corporate_action", event_id=str(event["corporate_action_id"]),
|
||||
event_type=event["action_type"], effective_date=event["effective_date"],
|
||||
quantity_before=event["quantity_before"], quantity_after=event["quantity_after"],
|
||||
average_price_before=event["average_price_before"], average_price_after=event["average_price_after"],
|
||||
total_cost_basis=event["total_cost_basis"], cash_balance=event["cash_balance"],
|
||||
profit_buffer=event["profit_buffer"], evidence_ref=event["evidence_ref"],
|
||||
actor_user_id=event.get("actor_user_id"), recorded_at=entry.recorded_at,
|
||||
)
|
||||
action = (entry.action or entry.side or "").upper()
|
||||
if action == "DIVIDEND":
|
||||
amount_decimal = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price))
|
||||
tax_decimal = entry.tax_amount_decimal if entry.tax_amount_decimal is not None else Decimal(str(entry.tax_amount))
|
||||
return DividendLedgerHistoryItem(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
source=entry.source or "broker",
|
||||
action=action,
|
||||
recorded_at=entry.recorded_at,
|
||||
executed_at=entry.executed_at,
|
||||
order_id=entry.order_id,
|
||||
execution_id=entry.execution_id,
|
||||
quantity=entry.quantity,
|
||||
amount=str(amount_decimal),
|
||||
price=entry.price,
|
||||
fee_amount=entry.fee_amount,
|
||||
tax_amount=str(tax_decimal),
|
||||
note=entry.note,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
asset_quantity_after=entry.asset_quantity_after,
|
||||
profit_buffer_after=entry.profit_buffer_after,
|
||||
total_value_after=entry.total_value_after,
|
||||
target_ledger_entry_id=entry.target_ledger_entry_id,
|
||||
confirmation_evidence=entry.confirmation_evidence,
|
||||
)
|
||||
return LedgerHistoryItem(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
source=entry.source or "broker",
|
||||
action=(entry.action or entry.side or "").upper(),
|
||||
action=action,
|
||||
recorded_at=entry.recorded_at,
|
||||
executed_at=entry.executed_at,
|
||||
order_id=entry.order_id,
|
||||
execution_id=entry.execution_id,
|
||||
quantity=entry.quantity,
|
||||
amount=float(entry.price) if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None,
|
||||
price=entry.price,
|
||||
fee_amount=entry.fee_amount,
|
||||
tax_amount=entry.tax_amount,
|
||||
tax_amount=float(entry.tax_amount),
|
||||
note=entry.note,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
asset_quantity_after=entry.asset_quantity_after,
|
||||
@@ -779,14 +880,14 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
|
||||
|
||||
@router.post(
|
||||
"/{portfolio_id}/ledger/manual",
|
||||
response_model=ManualLedgerResponse,
|
||||
response_model=ManualLedgerResponse | DividendManualLedgerResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def record_manual_ledger_v1(
|
||||
portfolio_id: str,
|
||||
body: ManualLedgerRequest,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
) -> ManualLedgerResponse:
|
||||
) -> ManualLedgerResponse | DividendManualLedgerResponse:
|
||||
if auth.token_type in {"service", "client"} or auth.user_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="manual ledger requires user/session token")
|
||||
|
||||
@@ -810,15 +911,26 @@ def record_manual_ledger_v1(
|
||||
entry = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action=body.action,
|
||||
amount=float(body.amount) if body.amount is not None else None,
|
||||
quantity=float(body.quantity) if body.quantity is not None else None,
|
||||
price=float(body.price) if body.price is not None else None,
|
||||
fee=float(body.fee),
|
||||
tax=float(body.tax),
|
||||
amount=body.amount,
|
||||
quantity=body.quantity,
|
||||
price=body.price,
|
||||
fee=body.fee,
|
||||
tax=body.tax,
|
||||
executed_at=(
|
||||
datetime(
|
||||
body.executed_at.year,
|
||||
body.executed_at.month,
|
||||
body.executed_at.day,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
if body.executed_at is not None
|
||||
else None
|
||||
),
|
||||
target_ledger_entry_id=body.target_ledger_entry_id,
|
||||
note=body.note,
|
||||
actor_user_id=auth.user_id,
|
||||
confirmation_evidence=evidence,
|
||||
confirm_duplicate=body.confirm_duplicate,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -827,9 +939,18 @@ def record_manual_ledger_v1(
|
||||
code = _manual_error_code(message)
|
||||
if code == "LEDGER_ENTRY_NOT_FOUND":
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
detail: str | dict[str, object] = {"code": code, "message": _manual_error_message(code)}
|
||||
elif code == "POTENTIAL_DUPLICATE_DIVIDEND":
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
detail = {
|
||||
"code": code,
|
||||
"message": _manual_error_message(code),
|
||||
"matched_ledger_entry_id": _extract_duplicate_dividend_entry_id(message),
|
||||
}
|
||||
else:
|
||||
status_code = status.HTTP_409_CONFLICT if code != "MANUAL_LEDGER_CONFLICT" else status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
raise HTTPException(status_code=status_code, detail={"code": code, "message": message}) from exc
|
||||
detail = {"code": code, "message": _manual_error_message(code)}
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
return _manual_entry_to_response(entry)
|
||||
@@ -1192,3 +1313,43 @@ def get_holdings_cost_basis(
|
||||
cost_basis_from=wac_result.cost_basis_from,
|
||||
data_completeness_warning=wac_result.data_completeness_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{portfolio_id}/corporate-actions", response_model=CorporateActionResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_corporate_action(
|
||||
portfolio_id: str,
|
||||
payload: CorporateActionRequest,
|
||||
response: Response,
|
||||
auth: AuthContext = Depends(require_trade_write), # noqa: B008 - FastAPI dependency declaration
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
||||
):
|
||||
if not idempotency_key or not idempotency_key.strip():
|
||||
raise HTTPException(status_code=422, detail="Idempotency-Key must not be empty")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
portfolio = repo.get(portfolio_id)
|
||||
if portfolio is None:
|
||||
raise HTTPException(status_code=404, detail="Portfolio not found")
|
||||
if portfolio.owner_user_id != auth.user_id and auth.permission != Permission.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Portfolio ownership mismatch")
|
||||
if portfolio.managed_by_daemon_id and not payload.confirm_daemon_managed:
|
||||
raise HTTPException(status_code=409, detail="DAEMON_MANAGED_CONFIRMATION_REQUIRED")
|
||||
from app.domain.corporate_actions import canonical_payload_hash
|
||||
digest = canonical_payload_hash(payload)
|
||||
# The repository derives all WAC and immutable snapshot fields under its row lock.
|
||||
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, payload.action_type, payload.effective_date, payload.quantity_before, payload.quantity_after, Decimal(0), Decimal(0), Decimal(0), Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), payload.evidence_ref, idempotency_key, digest, 0, auth.user_id)
|
||||
saved = repo.record_corporate_action(
|
||||
portfolio_id,
|
||||
action=action,
|
||||
idempotency_key=idempotency_key,
|
||||
payload_hash=digest,
|
||||
resume_partial=payload.resume_partial,
|
||||
)
|
||||
return CorporateActionResponse(**saved.__dict__)
|
||||
except ValueError as exc:
|
||||
code = str(exc)
|
||||
if code == PORTFOLIO_NOT_FOUND_MESSAGE:
|
||||
raise HTTPException(status_code=404, detail="Portfolio not found") from exc
|
||||
raise HTTPException(status_code=409 if code.startswith(("IDEMPOTENCY", "CORPORATE_ACTION_")) else 400, detail=code) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
|
||||
@@ -274,6 +274,66 @@ def sell_portfolio(
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("corporate-action")
|
||||
def corporate_action_portfolio(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(...),
|
||||
user: str = typer.Option(..., "--user"),
|
||||
action_type: str = typer.Option("stock_consolidation", "--type"),
|
||||
effective_date: str = typer.Option(..., "--effective-date"),
|
||||
quantity_before: float = typer.Option(..., "--quantity-before", min=0.0000001),
|
||||
quantity_after: float = typer.Option(..., "--quantity-after", min=0.0000001),
|
||||
evidence_ref: str = typer.Option(..., "--evidence-ref"),
|
||||
idempotency_key: str = typer.Option(..., "--idempotency-key"),
|
||||
resume_partial: bool = typer.Option(
|
||||
False,
|
||||
"--resume-partial",
|
||||
help="Resume only a verified quantity-only partial corporate action.",
|
||||
),
|
||||
force: bool = typer.Option(False, "--force"),
|
||||
) -> None:
|
||||
"""Record an auditable stock consolidation without a trade."""
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domain.corporate_actions import (
|
||||
calculate_stock_consolidation,
|
||||
canonical_payload_hash,
|
||||
)
|
||||
from app.domain.models import CorporateAction
|
||||
if action_type != "stock_consolidation":
|
||||
ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_TYPE")
|
||||
raise typer.Exit(code=1)
|
||||
try:
|
||||
day = date.fromisoformat(effective_date)
|
||||
except ValueError:
|
||||
ctx.obj.console.print("Error: INVALID_EFFECTIVE_DATE")
|
||||
raise typer.Exit(code=1)
|
||||
if quantity_after >= quantity_before:
|
||||
ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_RATIO")
|
||||
raise typer.Exit(code=1)
|
||||
_confirm(force, f"Record stock consolidation {quantity_before:g} → {quantity_after:g}; cash/profit buffer unchanged?")
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
portfolio = repository.get(portfolio_id)
|
||||
if not idempotency_key.strip():
|
||||
raise ValueError("Idempotency-Key must not be empty")
|
||||
payload = {"action_type": action_type, "effective_date": day.isoformat(), "quantity_before": str(quantity_before), "quantity_after": str(quantity_after), "evidence_ref": evidence_ref, "confirm_daemon_managed": False, "resume_partial": resume_partial}
|
||||
digest = canonical_payload_hash(payload)
|
||||
calc = calculate_stock_consolidation(Decimal(str(quantity_before)), Decimal(str(quantity_after)), Decimal(0))
|
||||
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, action_type, day, calc.quantity_before, calc.quantity_after, calc.average_price_before, calc.average_price_after, calc.total_cost_basis, Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), evidence_ref, idempotency_key, digest, 0, user)
|
||||
return repository.record_corporate_action(
|
||||
portfolio_id,
|
||||
action=action,
|
||||
idempotency_key=idempotency_key,
|
||||
payload_hash=digest,
|
||||
resume_partial=resume_partial,
|
||||
)
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("invest")
|
||||
def invest_portfolio(
|
||||
ctx: typer.Context,
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ if env_file.exists():
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "PortfolioHub"
|
||||
app_version: str = "0.2.0"
|
||||
app_version: str = "0.3.4"
|
||||
environment: str = "development"
|
||||
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
|
||||
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Additive storage for auditable, idempotent stock consolidations."""
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "20260913_0031"
|
||||
down_revision = "20260820_0030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_corporate_actions (
|
||||
corporate_action_id UUID PRIMARY KEY,
|
||||
portfolio_id TEXT NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE RESTRICT,
|
||||
asset_id UUID NOT NULL REFERENCES assets(asset_id) ON DELETE RESTRICT,
|
||||
action_type TEXT NOT NULL CHECK (action_type = 'stock_consolidation'),
|
||||
effective_date DATE NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
actor_user_id TEXT, evidence_ref TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL, payload_hash TEXT NOT NULL,
|
||||
quantity_before NUMERIC(20,8) NOT NULL, quantity_after NUMERIC(20,8) NOT NULL,
|
||||
average_price_before NUMERIC(20,8) NOT NULL, average_price_after NUMERIC(20,8) NOT NULL,
|
||||
total_cost_basis NUMERIC(20,8) NOT NULL, cash_balance NUMERIC(20,8) NOT NULL,
|
||||
profit_buffer NUMERIC(20,8) NOT NULL, ledger_entry_id BIGINT,
|
||||
UNIQUE (portfolio_id, idempotency_key)
|
||||
)"""))
|
||||
op.get_bind().execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_corporate_actions_portfolio "
|
||||
"ON portfolio_corporate_actions (portfolio_id, effective_date)"
|
||||
))
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().execute(text("DROP TABLE IF EXISTS portfolio_corporate_actions"))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pure domain rules for portfolio stock consolidations."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
STORAGE_QUANTUM = Decimal("0.00000001")
|
||||
|
||||
|
||||
def quantize_stored_decimal(value: Decimal) -> Decimal:
|
||||
"""Round a monetary value to the NUMERIC(*, 8) persistence contract."""
|
||||
return value.quantize(STORAGE_QUANTUM, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def canonical_payload_hash(payload: object) -> str:
|
||||
if hasattr(payload, "model_dump"):
|
||||
excluded_fields = (
|
||||
{"resume_partial"} if not getattr(payload, "resume_partial", False) else set()
|
||||
)
|
||||
payload = payload.model_dump(mode="json", exclude_none=True, exclude=excluded_fields)
|
||||
elif isinstance(payload, dict) and payload.get("resume_partial") is False:
|
||||
payload = {key: value for key, value in payload.items() if key != "resume_partial"}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
class CorporateActionError(ValueError):
|
||||
code = "INVALID_CORPORATE_ACTION_RATIO"
|
||||
def __init__(self, message: str, code: str | None = None):
|
||||
super().__init__(message); self.code = code or self.code
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StockConsolidation:
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
ratio: Decimal
|
||||
|
||||
def calculate_stock_consolidation(quantity_before: Decimal, quantity_after: Decimal, average_price_before: Decimal) -> StockConsolidation:
|
||||
q0, q1, wac = map(Decimal, (quantity_before, quantity_after, average_price_before))
|
||||
if q0 <= 0 or q1 <= 0 or wac < 0:
|
||||
raise CorporateActionError("quantities must be positive and price non-negative")
|
||||
ratio = q1 / q0
|
||||
if ratio >= 1 or ratio <= 0:
|
||||
raise CorporateActionError("stock consolidation must reduce quantity")
|
||||
if q1 != q1.to_integral_value() or q0 != q0.to_integral_value():
|
||||
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
|
||||
# Preserve the authoritative pre-action total independently. A per-unit WAC
|
||||
# rounded to the storage scale cannot always multiply back to that total
|
||||
# exactly, and that harmless decimal residue is not a fractional share.
|
||||
total = quantize_stored_decimal(q0 * wac)
|
||||
before = quantize_stored_decimal(wac)
|
||||
after = quantize_stored_decimal(total / q1)
|
||||
return StockConsolidation(q0, q1, before, after, total, ratio)
|
||||
|
||||
def apply_corporate_action_to_wac(quantity: Decimal, wac: Decimal, quantity_after: Decimal) -> tuple[Decimal, Decimal]:
|
||||
result = calculate_stock_consolidation(quantity, quantity_after, wac)
|
||||
return result.quantity_after, result.average_price_after
|
||||
+28
-2
@@ -3,7 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
@@ -131,17 +132,42 @@ class Portfolio:
|
||||
managed_by_daemon_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorporateAction:
|
||||
corporate_action_id: str
|
||||
portfolio_id: str
|
||||
asset_id: str | None
|
||||
action_type: str
|
||||
effective_date: date
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
cash_balance: Decimal
|
||||
profit_buffer: Decimal
|
||||
evidence_ref: str
|
||||
idempotency_key: str
|
||||
payload_hash: str
|
||||
ledger_entry_id: int
|
||||
actor_user_id: str | None = None
|
||||
recorded_at: datetime | None = None
|
||||
idempotent_replay: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerEntry:
|
||||
portfolio_id: str
|
||||
quantity: float
|
||||
price: float
|
||||
price_decimal: Decimal | None = None
|
||||
ledger_entry_id: int = 0
|
||||
order_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
side: str | None = None
|
||||
fee_amount: float = 0.0
|
||||
tax_amount: float = 0.0
|
||||
tax_amount_decimal: Decimal | None = None
|
||||
executed_at: datetime | None = None
|
||||
recorded_at: datetime | None = None
|
||||
cash_balance_after: float = 0.0
|
||||
@@ -155,6 +181,7 @@ class LedgerEntry:
|
||||
target_ledger_entry_id: int | None = None
|
||||
confirmation_evidence: str | None = None
|
||||
actor_user_id: str | None = None
|
||||
corporate_action: dict[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.side is None and self.action:
|
||||
@@ -195,4 +222,3 @@ class BrokerCredential:
|
||||
credential_version: int = 1
|
||||
last_rotated_at: datetime | None = None
|
||||
daemon_id: str | None = None
|
||||
|
||||
|
||||
@@ -170,6 +170,21 @@ def calculate_realized_pnl(
|
||||
if running_qty < 0:
|
||||
running_qty = 0.0
|
||||
|
||||
elif side == "stock_consolidation":
|
||||
before = float(row.get("quantity_before", running_qty))
|
||||
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
|
||||
if before <= 0 or after <= 0 or running_qty <= 0:
|
||||
warning_triggered = True
|
||||
continue
|
||||
if abs(running_qty - before) > 1e-9:
|
||||
warning_triggered = True
|
||||
running_qty = after
|
||||
total_cost_basis = row.get("total_cost_basis")
|
||||
if total_cost_basis is not None:
|
||||
running_wac = float(total_cost_basis) / after
|
||||
else:
|
||||
running_wac = float(row.get("average_price_after", running_wac * before / after))
|
||||
|
||||
warning: str | None = None
|
||||
if warning_triggered:
|
||||
warning = (
|
||||
@@ -252,6 +267,21 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
if running_qty < 0:
|
||||
running_qty = 0.0
|
||||
|
||||
elif side == "stock_consolidation":
|
||||
before = float(row.get("quantity_before", running_qty))
|
||||
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
|
||||
if before <= 0 or after <= 0 or running_qty <= 0:
|
||||
warning_triggered = True
|
||||
continue
|
||||
if abs(running_qty - before) > 1e-9:
|
||||
warning_triggered = True
|
||||
running_qty = after
|
||||
total_cost_basis = row.get("total_cost_basis")
|
||||
if total_cost_basis is not None:
|
||||
running_wac = float(total_cost_basis) / after
|
||||
else:
|
||||
running_wac = float(row.get("average_price_after", running_wac * before / after))
|
||||
|
||||
warning: str | None = None
|
||||
if warning_triggered:
|
||||
warning = (
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Callable, Protocol
|
||||
|
||||
from app.domain.models import LedgerEntry, LiveAssetSource, Portfolio, StrategySet
|
||||
|
||||
_NOT_FOUND_MSG = "포트폴리오를 찾을 수 없습니다"
|
||||
PORTFOLIO_NOT_FOUND_MESSAGE = "포트폴리오를 찾을 수 없습니다"
|
||||
_INVEST_ACTION_MAP = {
|
||||
"deposit": "INVEST",
|
||||
"profit": "PROFIT",
|
||||
@@ -250,7 +250,7 @@ def apply_reset(
|
||||
portfolio = repo.reset(portfolio_id)
|
||||
return portfolio, False
|
||||
except ValueError as exc:
|
||||
if str(exc) != _NOT_FOUND_MSG:
|
||||
if str(exc) != PORTFOLIO_NOT_FOUND_MESSAGE:
|
||||
raise
|
||||
symbol = infer_symbol(portfolio_id)
|
||||
portfolio = repo.get_or_create(portfolio_id, symbol, initial_cash=0.0)
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, CorporateAction, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
||||
|
||||
|
||||
class PortfolioRepository(Protocol):
|
||||
@@ -283,6 +284,16 @@ class PortfolioRepository(Protocol):
|
||||
note: str = "",
|
||||
) -> None: ...
|
||||
|
||||
def record_corporate_action(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: CorporateAction,
|
||||
idempotency_key: str,
|
||||
payload_hash: str,
|
||||
resume_partial: bool = False,
|
||||
) -> CorporateAction: ...
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ...
|
||||
|
||||
def list_trades_by_date(
|
||||
@@ -320,15 +331,17 @@ class PortfolioRepository(Protocol):
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: str,
|
||||
amount: float | None = None,
|
||||
quantity: float | None = None,
|
||||
price: float | None = None,
|
||||
fee: float = 0.0,
|
||||
tax: float = 0.0,
|
||||
amount: Decimal | None = None,
|
||||
quantity: Decimal | None = None,
|
||||
price: Decimal | None = None,
|
||||
fee: Decimal = Decimal("0"),
|
||||
tax: Decimal = Decimal("0"),
|
||||
executed_at: datetime | None = None,
|
||||
target_ledger_entry_id: int | None = None,
|
||||
note: str = "",
|
||||
actor_user_id: str | None = None,
|
||||
confirmation_evidence: str | None = None,
|
||||
confirm_duplicate: bool = False,
|
||||
) -> LedgerEntry: ...
|
||||
|
||||
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: ...
|
||||
@@ -362,4 +375,3 @@ class PortfolioRepository(Protocol):
|
||||
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: ...
|
||||
|
||||
def delete_comment(self, portfolio_id: str, comment_id: str) -> None: ...
|
||||
|
||||
|
||||
+520
-84
@@ -7,16 +7,44 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.errors import DaemonInactiveError, DaemonInUseError, DaemonNotFoundError, StrategyInactiveError, StrategyNotFoundError
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
||||
from app.domain.corporate_actions import (
|
||||
calculate_stock_consolidation,
|
||||
quantize_stored_decimal,
|
||||
)
|
||||
from app.domain.errors import (
|
||||
DaemonInactiveError,
|
||||
DaemonInUseError,
|
||||
DaemonNotFoundError,
|
||||
StrategyInactiveError,
|
||||
StrategyNotFoundError,
|
||||
)
|
||||
from app.domain.models import (
|
||||
Asset,
|
||||
AuthToken,
|
||||
BrokerCredential,
|
||||
CorporateAction,
|
||||
Daemon,
|
||||
LedgerEntry,
|
||||
Portfolio,
|
||||
PortfolioComment,
|
||||
PortfolioTag,
|
||||
StrategySet,
|
||||
Tag,
|
||||
TokenPrincipal,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_LEDGER_NUMERIC_SCALE = 8
|
||||
_LEDGER_MAX_INTEGER_DIGITS = 12
|
||||
_LEDGER_QUANT = Decimal("0.00000001")
|
||||
|
||||
_SELECT_USER = """
|
||||
SELECT
|
||||
@@ -183,6 +211,7 @@ class PostgresPortfolioRepository:
|
||||
def ensure_schema(self) -> None:
|
||||
conn = self._connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_lock(hashtext('portfoliohub.ensure_schema.v1'))")
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -308,6 +337,32 @@ class PostgresPortfolioRepository:
|
||||
)
|
||||
cur.execute("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
|
||||
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_corporate_actions (
|
||||
corporate_action_id UUID PRIMARY KEY,
|
||||
portfolio_id TEXT NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE RESTRICT,
|
||||
asset_id UUID NOT NULL REFERENCES assets(asset_id) ON DELETE RESTRICT,
|
||||
action_type TEXT NOT NULL CHECK (action_type = 'stock_consolidation'),
|
||||
effective_date DATE NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
actor_user_id TEXT,
|
||||
evidence_ref TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
quantity_before NUMERIC(20,8) NOT NULL,
|
||||
quantity_after NUMERIC(20,8) NOT NULL,
|
||||
average_price_before NUMERIC(20,8) NOT NULL,
|
||||
average_price_after NUMERIC(20,8) NOT NULL,
|
||||
total_cost_basis NUMERIC(20,8) NOT NULL,
|
||||
cash_balance NUMERIC(20,8) NOT NULL,
|
||||
profit_buffer NUMERIC(20,8) NOT NULL,
|
||||
ledger_entry_id BIGINT,
|
||||
UNIQUE (portfolio_id, idempotency_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_corporate_actions_portfolio ON portfolio_corporate_actions (portfolio_id, effective_date)")
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_ledger (
|
||||
@@ -394,6 +449,7 @@ class PostgresPortfolioRepository:
|
||||
ON portfolios (broker, status, updated_at DESC)
|
||||
"""
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_comments (
|
||||
@@ -484,6 +540,7 @@ class PostgresPortfolioRepository:
|
||||
SELECT create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)
|
||||
"""
|
||||
)
|
||||
cur.execute("SELECT pg_advisory_unlock(hashtext('portfoliohub.ensure_schema.v1'))")
|
||||
|
||||
@staticmethod
|
||||
def _row_to_user(row: Any) -> User:
|
||||
@@ -678,6 +735,7 @@ class PostgresPortfolioRepository:
|
||||
|
||||
@staticmethod
|
||||
def _row_to_ledger_entry_legacy(row: Any) -> LedgerEntry:
|
||||
raw_tax = row[6]
|
||||
return LedgerEntry(
|
||||
ledger_entry_id=0,
|
||||
portfolio_id=row[0],
|
||||
@@ -686,8 +744,10 @@ class PostgresPortfolioRepository:
|
||||
side=row[2],
|
||||
quantity=float(row[4]),
|
||||
price=float(row[3]),
|
||||
price_decimal=Decimal(str(row[3])) if row[3] is not None else None,
|
||||
fee_amount=float(row[5]),
|
||||
tax_amount=float(row[6]),
|
||||
tax_amount=float(raw_tax),
|
||||
tax_amount_decimal=Decimal(str(raw_tax)) if raw_tax is not None else None,
|
||||
executed_at=row[1],
|
||||
recorded_at=row[1],
|
||||
cash_balance_after=float(row[7]),
|
||||
@@ -1067,6 +1127,8 @@ class PostgresPortfolioRepository:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
self._attach_tags(cur, [portfolio])
|
||||
return portfolio
|
||||
@@ -1518,7 +1580,7 @@ class PostgresPortfolioRepository:
|
||||
note: str = "",
|
||||
) -> None:
|
||||
self.ensure_schema()
|
||||
trade_time = datetime.now(timezone.utc)
|
||||
trade_time = datetime.now(UTC)
|
||||
conn = self._connect()
|
||||
with conn.transaction(), conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -1570,6 +1632,242 @@ class PostgresPortfolioRepository:
|
||||
),
|
||||
)
|
||||
|
||||
def record_corporate_action(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: CorporateAction,
|
||||
idempotency_key: str,
|
||||
payload_hash: str,
|
||||
resume_partial: bool = False,
|
||||
) -> CorporateAction:
|
||||
"""Persist an immutable action and snapshot in one explicit transaction."""
|
||||
self.ensure_schema()
|
||||
conn = self._connect()
|
||||
|
||||
def hydrate_replay(prior: tuple) -> CorporateAction:
|
||||
if prior[8] != payload_hash:
|
||||
raise ValueError("IDEMPOTENCY_KEY_REUSE")
|
||||
(
|
||||
action.corporate_action_id,
|
||||
action.portfolio_id,
|
||||
action.asset_id,
|
||||
action.action_type,
|
||||
action.effective_date,
|
||||
action.actor_user_id,
|
||||
action.evidence_ref,
|
||||
action.idempotency_key,
|
||||
action.payload_hash,
|
||||
action.quantity_before,
|
||||
action.quantity_after,
|
||||
action.average_price_before,
|
||||
action.average_price_after,
|
||||
action.total_cost_basis,
|
||||
action.cash_balance,
|
||||
action.profit_buffer,
|
||||
ledger_id,
|
||||
action.recorded_at,
|
||||
) = prior
|
||||
action.corporate_action_id = str(action.corporate_action_id)
|
||||
action.ledger_entry_id = int(ledger_id)
|
||||
action.idempotent_replay = True
|
||||
return action
|
||||
|
||||
prior_sql = """
|
||||
SELECT corporate_action_id, portfolio_id, asset_id, action_type,
|
||||
effective_date, actor_user_id, evidence_ref, idempotency_key,
|
||||
payload_hash, quantity_before, quantity_after,
|
||||
average_price_before, average_price_after, total_cost_basis,
|
||||
cash_balance, profit_buffer, ledger_entry_id, recorded_at
|
||||
FROM portfolio_corporate_actions
|
||||
WHERE portfolio_id=%s AND idempotency_key=%s
|
||||
"""
|
||||
|
||||
# Repository connections are autocommit connections. A connection
|
||||
# context alone therefore cannot protect this multi-statement write.
|
||||
with conn.transaction(), conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT asset_quantity, cash_balance, profit_buffer, asset_id,
|
||||
status, asset_price
|
||||
FROM portfolios
|
||||
WHERE portfolio_id=%s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("portfolio not found")
|
||||
|
||||
cur.execute(prior_sql, (portfolio_id, idempotency_key))
|
||||
prior = cur.fetchone()
|
||||
if prior:
|
||||
return hydrate_replay(prior)
|
||||
|
||||
snapshot_quantity = Decimal(str(row[0]))
|
||||
cash_balance = Decimal(str(row[1]))
|
||||
profit_buffer = Decimal(str(row[2]))
|
||||
reference_price_before = quantize_stored_decimal(Decimal(str(row[5])))
|
||||
if row[4] != "enabled":
|
||||
raise ValueError("CORPORATE_ACTION_PORTFOLIO_DISABLED")
|
||||
if str(row[3]) != str(action.asset_id):
|
||||
raise ValueError("CORPORATE_ACTION_ASSET_MISMATCH")
|
||||
if cash_balance != action.cash_balance or profit_buffer != action.profit_buffer:
|
||||
raise ValueError("CORPORATE_ACTION_INVARIANT_MISMATCH")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id=%s
|
||||
AND side IN ('buy','sell')
|
||||
AND executed_at::date >= %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(portfolio_id, action.effective_date),
|
||||
)
|
||||
if cur.fetchone() is not None:
|
||||
raise ValueError("CORPORATE_ACTION_HISTORICAL_CONFLICT")
|
||||
|
||||
# Derive WAC from the authoritative trade ledger, never the
|
||||
# portfolio reference price or the already-mutated snapshot.
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT side, quantity, price, fee_amount, tax_amount, executed_at
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id=%s AND side IN ('buy','sell')
|
||||
ORDER BY executed_at ASC NULLS FIRST, ledger_entry_id ASC
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
history = [
|
||||
{
|
||||
"side": ledger_row[0],
|
||||
"quantity": ledger_row[1],
|
||||
"price": ledger_row[2],
|
||||
"fee_amount": ledger_row[3],
|
||||
"tax_amount": ledger_row[4],
|
||||
"executed_at": ledger_row[5],
|
||||
}
|
||||
for ledger_row in cur.fetchall()
|
||||
]
|
||||
from app.domain.pnl import calculate_current_wac
|
||||
|
||||
wac = calculate_current_wac(history)
|
||||
if Decimal(str(wac.current_quantity)) != action.quantity_before:
|
||||
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
|
||||
|
||||
if resume_partial:
|
||||
if snapshot_quantity != action.quantity_after:
|
||||
raise ValueError("CORPORATE_ACTION_RECOVERY_STATE_MISMATCH")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM portfolio_corporate_actions
|
||||
WHERE portfolio_id=%s
|
||||
),
|
||||
EXISTS (
|
||||
SELECT 1 FROM portfolio_ledger
|
||||
WHERE portfolio_id=%s AND source='corporate_action'
|
||||
)
|
||||
""",
|
||||
(portfolio_id, portfolio_id),
|
||||
)
|
||||
audit_exists, ledger_exists = cur.fetchone()
|
||||
if audit_exists or ledger_exists:
|
||||
raise ValueError("CORPORATE_ACTION_RECOVERY_ALREADY_RECORDED")
|
||||
elif snapshot_quantity != action.quantity_before:
|
||||
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
|
||||
|
||||
consolidation = calculate_stock_consolidation(
|
||||
action.quantity_before,
|
||||
action.quantity_after,
|
||||
Decimal(str(wac.current_wac or 0)),
|
||||
)
|
||||
action.average_price_before = consolidation.average_price_before
|
||||
action.average_price_after = consolidation.average_price_after
|
||||
action.total_cost_basis = consolidation.total_cost_basis
|
||||
action.cash_balance = cash_balance
|
||||
action.profit_buffer = profit_buffer
|
||||
|
||||
consolidation_ratio = action.quantity_before / action.quantity_after
|
||||
reference_price_after = quantize_stored_decimal(
|
||||
reference_price_before * consolidation_ratio
|
||||
)
|
||||
total_value_after = quantize_stored_decimal(
|
||||
cash_balance + action.quantity_after * reference_price_after
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET asset_quantity=%s, asset_price=%s, updated_at=NOW()
|
||||
WHERE portfolio_id=%s
|
||||
""",
|
||||
(action.quantity_after, reference_price_after, portfolio_id),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_ledger (
|
||||
portfolio_id, recorded_at, side, source, quantity, price,
|
||||
cash_balance_after, asset_quantity_after, total_value_after,
|
||||
profit_buffer_after, executed_at, note
|
||||
) VALUES (
|
||||
%s, NOW(), 'stock_consolidation', 'corporate_action', 0, %s,
|
||||
%s, %s, %s, %s, NOW(), %s
|
||||
)
|
||||
RETURNING ledger_entry_id, recorded_at
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
reference_price_after,
|
||||
cash_balance,
|
||||
action.quantity_after,
|
||||
total_value_after,
|
||||
profit_buffer,
|
||||
action.evidence_ref,
|
||||
),
|
||||
)
|
||||
lid, recorded = cur.fetchone()
|
||||
action.ledger_entry_id = int(lid)
|
||||
action.recorded_at = recorded
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_corporate_actions (
|
||||
corporate_action_id, portfolio_id, asset_id, action_type,
|
||||
effective_date, actor_user_id, evidence_ref, idempotency_key,
|
||||
payload_hash, quantity_before, quantity_after,
|
||||
average_price_before, average_price_after, total_cost_basis,
|
||||
cash_balance, profit_buffer, ledger_entry_id
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s
|
||||
)
|
||||
""",
|
||||
(
|
||||
action.corporate_action_id,
|
||||
portfolio_id,
|
||||
action.asset_id,
|
||||
action.action_type,
|
||||
action.effective_date,
|
||||
action.actor_user_id,
|
||||
action.evidence_ref,
|
||||
idempotency_key,
|
||||
payload_hash,
|
||||
action.quantity_before,
|
||||
action.quantity_after,
|
||||
action.average_price_before,
|
||||
action.average_price_after,
|
||||
action.total_cost_basis,
|
||||
cash_balance,
|
||||
profit_buffer,
|
||||
lid,
|
||||
),
|
||||
)
|
||||
return action
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
@@ -1584,7 +1882,7 @@ class PostgresPortfolioRepository:
|
||||
total_value_after::float8, profit_buffer_after::float8,
|
||||
strategy_set_id, note
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s
|
||||
WHERE portfolio_id = %s AND side IN ('buy', 'sell')
|
||||
ORDER BY recorded_at DESC, ledger_entry_id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
@@ -1597,9 +1895,9 @@ class PostgresPortfolioRepository:
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
date: "datetime | None" = None,
|
||||
from_: "datetime | None" = None,
|
||||
to_: "datetime | None" = None,
|
||||
date: datetime | None = None,
|
||||
from_: datetime | None = None,
|
||||
to_: datetime | None = None,
|
||||
) -> list[LedgerEntry]:
|
||||
"""Return ledger entries for portfolio_id filtered by date range (REQ-039).
|
||||
|
||||
@@ -1608,7 +1906,7 @@ class PostgresPortfolioRepository:
|
||||
boundaries (start of day / start of next day).
|
||||
"""
|
||||
self.ensure_schema()
|
||||
clauses = ["portfolio_id = %s"]
|
||||
clauses = ["portfolio_id = %s", "side IN ('buy','sell')"]
|
||||
params: list[object] = [portfolio_id]
|
||||
|
||||
if date is not None:
|
||||
@@ -1672,22 +1970,41 @@ 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
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s
|
||||
AND side IN ('buy', 'sell')
|
||||
ORDER BY executed_at ASC, ledger_entry_id ASC
|
||||
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,
|
||||
ca.profit_buffer, ca.evidence_ref, ca.actor_user_id,
|
||||
ca.idempotency_key, ca.payload_hash, ca.ledger_entry_id, ca.recorded_at
|
||||
FROM portfolio_ledger AS ledger
|
||||
LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = ledger.ledger_entry_id
|
||||
WHERE ledger.portfolio_id = %s
|
||||
ORDER BY ledger.executed_at ASC, ledger.ledger_entry_id ASC
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_ledger_entry(row) for row in rows]
|
||||
entries = []
|
||||
for row in rows:
|
||||
entry = self._row_to_ledger_entry(row)
|
||||
if len(row) > 18 and row[18] is not None:
|
||||
entry.corporate_action = {
|
||||
"corporate_action_id": str(row[18]), "action_type": row[19], "effective_date": row[20],
|
||||
"quantity_before": Decimal(str(row[21])), "quantity_after": Decimal(str(row[22])),
|
||||
"average_price_before": Decimal(str(row[23])), "average_price_after": Decimal(str(row[24])),
|
||||
"total_cost_basis": Decimal(str(row[25])), "cash_balance": Decimal(str(row[26])),
|
||||
"profit_buffer": Decimal(str(row[27])), "evidence_ref": row[28], "actor_user_id": row[29],
|
||||
"idempotency_key": row[30], "payload_hash": row[31], "ledger_entry_id": int(row[32]),
|
||||
"recorded_at": row[33],
|
||||
}
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
def insert_ledger_trade(
|
||||
self,
|
||||
@@ -1839,6 +2156,8 @@ class PostgresPortfolioRepository:
|
||||
def _row_to_ledger_entry(row: Any) -> LedgerEntry:
|
||||
if row is None:
|
||||
raise ValueError("원장 항목을 찾을 수 없습니다")
|
||||
raw_price = row[6]
|
||||
raw_tax = row[8]
|
||||
return LedgerEntry(
|
||||
ledger_entry_id=int(row[0]),
|
||||
portfolio_id=row[1],
|
||||
@@ -1846,9 +2165,11 @@ class PostgresPortfolioRepository:
|
||||
execution_id=row[3],
|
||||
side=row[4],
|
||||
quantity=float(row[5]),
|
||||
price=float(row[6]),
|
||||
price=float(raw_price),
|
||||
price_decimal=Decimal(str(raw_price)) if raw_price is not None else None,
|
||||
fee_amount=float(row[7]),
|
||||
tax_amount=float(row[8]),
|
||||
tax_amount=float(raw_tax),
|
||||
tax_amount_decimal=Decimal(str(raw_tax)) if raw_tax is not None else None,
|
||||
executed_at=row[9],
|
||||
recorded_at=row[10],
|
||||
cash_balance_after=float(row[11]),
|
||||
@@ -2007,24 +2328,78 @@ class PostgresPortfolioRepository:
|
||||
def _manual_total_value(self, cash: float, quantity: float, price: float, buffer: float) -> float:
|
||||
return float(cash) + float(quantity) * float(price) + float(buffer)
|
||||
|
||||
@staticmethod
|
||||
def _validate_ledger_decimal(value: Decimal, *, field_name: str, allow_zero: bool) -> Decimal:
|
||||
if value.is_nan() or value.is_infinite():
|
||||
raise ValueError(f"{field_name} must be a finite decimal")
|
||||
if allow_zero:
|
||||
if value < 0:
|
||||
raise ValueError(f"{field_name} must be greater than or equal to 0")
|
||||
elif value <= 0:
|
||||
raise ValueError(f"{field_name} must be greater than 0")
|
||||
scale = max(-value.as_tuple().exponent, 0)
|
||||
if scale > _LEDGER_NUMERIC_SCALE:
|
||||
raise ValueError(f"{field_name} exceeds NUMERIC(20,8) precision")
|
||||
digits = len(value.copy_abs().as_tuple().digits)
|
||||
integer_digits = max(digits - scale, 0)
|
||||
if value == 0:
|
||||
integer_digits = 0
|
||||
if integer_digits > _LEDGER_MAX_INTEGER_DIGITS:
|
||||
raise ValueError(f"{field_name} exceeds NUMERIC(20,8) precision")
|
||||
return value.quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
|
||||
@classmethod
|
||||
def _coerce_ledger_decimal(cls, value: Any, *, field_name: str, allow_zero: bool) -> Decimal:
|
||||
try:
|
||||
decimal_value = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
raise ValueError(f"{field_name} must be a decimal") from None
|
||||
return cls._validate_ledger_decimal(decimal_value, field_name=field_name, allow_zero=allow_zero)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_portfolio_snapshot_decimal(value: Any, *, field_name: str) -> Decimal:
|
||||
try:
|
||||
decimal_value = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
raise ValueError(f"{field_name} must be a decimal") from None
|
||||
if decimal_value.is_nan() or decimal_value.is_infinite():
|
||||
raise ValueError(f"{field_name} must be a finite decimal")
|
||||
return decimal_value
|
||||
|
||||
@staticmethod
|
||||
def _to_snapshot_float_and_ledger_decimal(value: Decimal, *, field_name: str) -> tuple[float, Decimal]:
|
||||
if value.is_nan() or value.is_infinite():
|
||||
raise ValueError(f"{field_name} must be a finite decimal")
|
||||
try:
|
||||
as_float = float(value)
|
||||
except (OverflowError, ValueError):
|
||||
raise ValueError(f"{field_name} cannot be represented in DOUBLE PRECISION snapshot") from None
|
||||
expected_ledger = value.quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
actual_ledger = Decimal(str(as_float)).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
if actual_ledger != expected_ledger:
|
||||
raise ValueError(f"{field_name} cannot be represented in DOUBLE PRECISION snapshot")
|
||||
return as_float, actual_ledger
|
||||
|
||||
def record_manual_ledger(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: str,
|
||||
amount: float | None = None,
|
||||
quantity: float | None = None,
|
||||
price: float | None = None,
|
||||
fee: float = 0.0,
|
||||
tax: float = 0.0,
|
||||
amount: Decimal | None = None,
|
||||
quantity: Decimal | None = None,
|
||||
price: Decimal | None = None,
|
||||
fee: Decimal = Decimal(0),
|
||||
tax: Decimal = Decimal(0),
|
||||
executed_at: datetime | None = None,
|
||||
target_ledger_entry_id: int | None = None,
|
||||
note: str = "",
|
||||
actor_user_id: str | None = None,
|
||||
confirmation_evidence: str | None = None,
|
||||
confirm_duplicate: bool = False,
|
||||
) -> LedgerEntry:
|
||||
self.ensure_schema()
|
||||
normalized = str(action).upper()
|
||||
if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "REVERSAL"}:
|
||||
if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "DIVIDEND", "REVERSAL"}:
|
||||
raise ValueError("unsupported manual ledger action")
|
||||
conn = self._connect()
|
||||
with conn.transaction():
|
||||
@@ -2034,16 +2409,18 @@ class PostgresPortfolioRepository:
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
cash = float(portfolio.cash_balance)
|
||||
qty = float(portfolio.asset_quantity)
|
||||
buffer = float(portfolio.profit_buffer)
|
||||
asset_price = float(portfolio.price)
|
||||
ledger_price = float(price or 0.0)
|
||||
ledger_quantity = float(quantity or 0.0)
|
||||
fee_f = float(fee or 0.0)
|
||||
tax_f = float(tax or 0.0)
|
||||
amount_f = float(amount or 0.0)
|
||||
reverse_target: LedgerEntry | None = None
|
||||
cash = self._coerce_portfolio_snapshot_decimal(portfolio.cash_balance, field_name="cash_balance")
|
||||
qty = self._coerce_portfolio_snapshot_decimal(portfolio.asset_quantity, field_name="asset_quantity")
|
||||
buffer = self._coerce_portfolio_snapshot_decimal(portfolio.profit_buffer, field_name="profit_buffer")
|
||||
asset_price = self._coerce_portfolio_snapshot_decimal(portfolio.price, field_name="asset_price")
|
||||
ledger_price = self._coerce_ledger_decimal(price or 0, field_name="price", allow_zero=True)
|
||||
ledger_quantity = self._coerce_ledger_decimal(quantity or 0, field_name="quantity", allow_zero=True)
|
||||
fee_f = self._coerce_ledger_decimal(fee or 0, field_name="fee", allow_zero=True)
|
||||
tax_f = self._coerce_ledger_decimal(tax or 0, field_name="tax", allow_zero=True)
|
||||
amount_f = self._coerce_ledger_decimal(amount or 0, field_name="amount", allow_zero=True)
|
||||
ledger_executed_at = executed_at or datetime.now(UTC)
|
||||
if ledger_executed_at.tzinfo is None:
|
||||
ledger_executed_at = ledger_executed_at.replace(tzinfo=UTC)
|
||||
|
||||
if normalized == "INVEST":
|
||||
buffer += amount_f
|
||||
@@ -2060,18 +2437,42 @@ class PostgresPortfolioRepository:
|
||||
cash -= amount_f
|
||||
buffer += amount_f
|
||||
ledger_price = amount_f
|
||||
elif normalized == "WITHDRAW":
|
||||
elif normalized == "WITHDRAW" or normalized == "PROFIT":
|
||||
if buffer < amount_f:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
buffer -= amount_f
|
||||
ledger_price = amount_f
|
||||
elif normalized == "PROFIT":
|
||||
if buffer < amount_f:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
buffer -= amount_f
|
||||
elif normalized == "DIVIDEND":
|
||||
if not confirm_duplicate:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT existing.ledger_entry_id
|
||||
FROM portfolio_ledger AS existing
|
||||
WHERE existing.portfolio_id = %s
|
||||
AND existing.source = 'manual'
|
||||
AND existing.side = 'dividend'
|
||||
AND existing.price = %s::numeric(20,8)
|
||||
AND existing.executed_at = %s
|
||||
AND existing.note = %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_ledger AS reversal
|
||||
WHERE reversal.portfolio_id = existing.portfolio_id
|
||||
AND reversal.side = 'reversal'
|
||||
AND reversal.target_ledger_entry_id = existing.ledger_entry_id
|
||||
)
|
||||
ORDER BY existing.ledger_entry_id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(portfolio_id, amount_f, ledger_executed_at, note),
|
||||
)
|
||||
duplicate_row = cur.fetchone()
|
||||
if duplicate_row is not None:
|
||||
raise ValueError(f"POTENTIAL_DUPLICATE_DIVIDEND:{int(duplicate_row[0])}")
|
||||
buffer += amount_f
|
||||
ledger_price = amount_f
|
||||
elif normalized == "BUY":
|
||||
required = ledger_quantity * ledger_price + fee_f + tax_f
|
||||
required = (ledger_quantity * ledger_price + fee_f + tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
if cash < required:
|
||||
raise ValueError("INSUFFICIENT_CASH")
|
||||
cash -= required
|
||||
@@ -2080,7 +2481,7 @@ class PostgresPortfolioRepository:
|
||||
elif normalized == "SELL":
|
||||
if qty < ledger_quantity:
|
||||
raise ValueError("INSUFFICIENT_QUANTITY")
|
||||
cash += ledger_quantity * ledger_price - fee_f - tax_f
|
||||
cash += (ledger_quantity * ledger_price - fee_f - tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
qty -= ledger_quantity
|
||||
asset_price = ledger_price
|
||||
elif normalized == "REVERSAL":
|
||||
@@ -2097,26 +2498,29 @@ class PostgresPortfolioRepository:
|
||||
""",
|
||||
(portfolio_id, target_ledger_entry_id),
|
||||
)
|
||||
reverse_target = self._row_to_ledger_entry(cur.fetchone())
|
||||
reverse_target_row = cur.fetchone()
|
||||
if reverse_target_row is None:
|
||||
raise ValueError("LEDGER_ENTRY_NOT_FOUND")
|
||||
cur.execute(
|
||||
"SELECT 1 FROM portfolio_ledger WHERE portfolio_id = %s AND side = 'reversal' AND target_ledger_entry_id = %s LIMIT 1",
|
||||
(portfolio_id, target_ledger_entry_id),
|
||||
)
|
||||
if cur.fetchone() is not None:
|
||||
raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED")
|
||||
target_action = (reverse_target.action or "").upper()
|
||||
amount_f = float(reverse_target.price)
|
||||
ledger_price = float(reverse_target.price)
|
||||
ledger_quantity = float(reverse_target.quantity)
|
||||
fee_f = float(reverse_target.fee_amount)
|
||||
tax_f = float(reverse_target.tax_amount)
|
||||
target_action = str(reverse_target_row[4] or "").upper()
|
||||
amount_f = self._coerce_ledger_decimal(reverse_target_row[6], field_name="amount", allow_zero=True)
|
||||
ledger_price = self._coerce_ledger_decimal(reverse_target_row[6], field_name="price", allow_zero=True)
|
||||
ledger_quantity = self._coerce_ledger_decimal(reverse_target_row[5], field_name="quantity", allow_zero=True)
|
||||
fee_f = self._coerce_ledger_decimal(reverse_target_row[7], field_name="fee", allow_zero=True)
|
||||
tax_f = self._coerce_ledger_decimal(reverse_target_row[8], field_name="tax", allow_zero=True)
|
||||
if target_action == "BUY":
|
||||
cash += ledger_quantity * ledger_price + fee_f + tax_f
|
||||
cash += (ledger_quantity * ledger_price + fee_f + tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
qty -= ledger_quantity
|
||||
elif target_action == "SELL":
|
||||
if cash < ledger_quantity * ledger_price - fee_f - tax_f:
|
||||
required_cash = (ledger_quantity * ledger_price - fee_f - tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
if cash < required_cash:
|
||||
raise ValueError("INSUFFICIENT_CASH")
|
||||
cash -= ledger_quantity * ledger_price - fee_f - tax_f
|
||||
cash -= required_cash
|
||||
qty += ledger_quantity
|
||||
elif target_action == "BUFFER_OUT":
|
||||
if cash < amount_f:
|
||||
@@ -2128,7 +2532,7 @@ class PostgresPortfolioRepository:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
cash += amount_f
|
||||
buffer -= amount_f
|
||||
elif target_action == "INVEST":
|
||||
elif target_action in {"INVEST", "DIVIDEND"}:
|
||||
if buffer < amount_f:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
buffer -= amount_f
|
||||
@@ -2144,7 +2548,25 @@ class PostgresPortfolioRepository:
|
||||
if buffer < 0:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
|
||||
total_value = self._manual_total_value(cash, qty, asset_price, buffer)
|
||||
snapshot_cash_float, snapshot_cash_ledger = self._to_snapshot_float_and_ledger_decimal(
|
||||
cash,
|
||||
field_name="cash_balance_after",
|
||||
)
|
||||
snapshot_qty_float, snapshot_qty_ledger = self._to_snapshot_float_and_ledger_decimal(
|
||||
qty,
|
||||
field_name="asset_quantity_after",
|
||||
)
|
||||
snapshot_price_float, _ = self._to_snapshot_float_and_ledger_decimal(
|
||||
asset_price,
|
||||
field_name="asset_price",
|
||||
)
|
||||
snapshot_buffer_float, snapshot_buffer_ledger = self._to_snapshot_float_and_ledger_decimal(
|
||||
buffer,
|
||||
field_name="profit_buffer_after",
|
||||
)
|
||||
total_value = (
|
||||
snapshot_cash_ledger + snapshot_qty_ledger * Decimal(str(snapshot_price_float)) + snapshot_buffer_ledger
|
||||
).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
@@ -2156,7 +2578,7 @@ class PostgresPortfolioRepository:
|
||||
last_trade_at = NOW()
|
||||
WHERE portfolio_id = %s
|
||||
""",
|
||||
(cash, qty, asset_price, buffer, portfolio_id),
|
||||
(snapshot_cash_float, snapshot_qty_float, snapshot_price_float, snapshot_buffer_float, portfolio_id),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -2165,7 +2587,7 @@ class PostgresPortfolioRepository:
|
||||
tax_amount, cash_balance_after, asset_quantity_after,
|
||||
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at,
|
||||
source, target_ledger_entry_id, confirmation_evidence, actor_user_id
|
||||
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), %s, %s, %s, %s)
|
||||
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING 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,
|
||||
@@ -2174,16 +2596,17 @@ class PostgresPortfolioRepository:
|
||||
(
|
||||
portfolio_id,
|
||||
normalized.lower(),
|
||||
float(ledger_price),
|
||||
float(ledger_quantity),
|
||||
float(fee_f),
|
||||
float(tax_f),
|
||||
float(cash),
|
||||
float(qty),
|
||||
float(total_value),
|
||||
float(buffer),
|
||||
ledger_price,
|
||||
ledger_quantity,
|
||||
fee_f,
|
||||
tax_f,
|
||||
snapshot_cash_ledger,
|
||||
snapshot_qty_ledger,
|
||||
total_value,
|
||||
snapshot_buffer_ledger,
|
||||
"manual",
|
||||
note,
|
||||
ledger_executed_at,
|
||||
"manual",
|
||||
target_ledger_entry_id,
|
||||
confirmation_evidence,
|
||||
@@ -2220,30 +2643,43 @@ 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
|
||||
FROM portfolio_ledger
|
||||
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 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,
|
||||
)
|
||||
return [self._row_to_ledger_entry(row) for row in cur.fetchall()]
|
||||
result = []
|
||||
for row in cur.fetchall():
|
||||
entry = self._row_to_ledger_entry(row)
|
||||
if row[21] is not None:
|
||||
entry.corporate_action = {"corporate_action_id": str(row[21]), "action_type": row[22], "effective_date": row[23], "quantity_before": Decimal(str(row[24])), "quantity_after": Decimal(str(row[25])), "average_price_before": Decimal(str(row[26])), "average_price_after": Decimal(str(row[27])), "total_cost_basis": Decimal(str(row[28])), "cash_balance": Decimal(str(row[29])), "profit_buffer": Decimal(str(row[30])), "evidence_ref": row[31], "actor_user_id": row[32]}
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]:
|
||||
if not portfolio_ids:
|
||||
@@ -2620,7 +3056,8 @@ class PostgresPortfolioRepository:
|
||||
raise ValueError(f"asset을 찾을 수 없습니다: {asset_id}")
|
||||
|
||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
|
||||
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix
|
||||
from app.core.security import generate_token, hash_token
|
||||
from app.core.security import token_prefix as _token_prefix
|
||||
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
@@ -2956,10 +3393,9 @@ def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgre
|
||||
connect_timeout=config.connect_timeout_sec,
|
||||
application_name=config.application_name,
|
||||
autocommit=True,
|
||||
) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
|
||||
return cur.fetchone() is not None
|
||||
) as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def create_database_if_missing(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
# Corporate actions (stock_consolidation-only, issue #34)
|
||||
class CorporateActionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
action_type: Literal["stock_consolidation"] = "stock_consolidation"
|
||||
effective_date: date
|
||||
quantity_before: Decimal = Field(..., gt=0, max_digits=20, decimal_places=8)
|
||||
quantity_after: Decimal = Field(..., gt=0, max_digits=20, decimal_places=8)
|
||||
evidence_ref: str = Field(..., min_length=1, max_length=500)
|
||||
confirm_daemon_managed: bool = False
|
||||
resume_partial: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_ratio(self):
|
||||
if self.quantity_after >= self.quantity_before:
|
||||
raise ValueError("quantity_after must be less than quantity_before")
|
||||
return self
|
||||
|
||||
class CorporateActionResponse(BaseModel):
|
||||
corporate_action_id: str
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
action_type: Literal["stock_consolidation"]
|
||||
effective_date: date
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
cash_balance: Decimal
|
||||
profit_buffer: Decimal
|
||||
evidence_ref: str
|
||||
actor_user_id: str | None = None
|
||||
recorded_at: datetime
|
||||
idempotent_replay: bool = False
|
||||
+106
-17
@@ -8,7 +8,6 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET/POST/PATCH /v1/tags (REQ-004)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -114,8 +113,8 @@ class LedgerTradeRequest(BaseModel):
|
||||
side: Literal["buy", "sell"]
|
||||
quantity: Decimal = Field(..., gt=0)
|
||||
price: Decimal = Field(..., gt=0)
|
||||
fee_amount: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
tax_amount: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
fee_amount: Decimal = Field(default=Decimal(0), ge=0)
|
||||
tax_amount: Decimal = Field(default=Decimal(0), ge=0)
|
||||
executed_at: datetime
|
||||
|
||||
|
||||
@@ -148,6 +147,7 @@ ManualLedgerAction = Literal[
|
||||
"SELL",
|
||||
"WITHDRAW",
|
||||
"PROFIT",
|
||||
"DIVIDEND",
|
||||
"REVERSAL",
|
||||
]
|
||||
|
||||
@@ -156,22 +156,27 @@ class ManualLedgerRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: ManualLedgerAction
|
||||
amount: Decimal | None = Field(default=None, gt=0)
|
||||
quantity: Decimal | None = Field(default=None, gt=0)
|
||||
price: Decimal | None = Field(default=None, gt=0)
|
||||
fee: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
tax: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
amount: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
|
||||
quantity: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
|
||||
price: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
|
||||
fee: Decimal = Field(default=Decimal(0), ge=0, max_digits=20, decimal_places=8)
|
||||
tax: Decimal = Field(default=Decimal(0), ge=0, max_digits=20, decimal_places=8)
|
||||
executed_at: date | None = None
|
||||
target_ledger_entry_id: int | None = None
|
||||
note: str = ""
|
||||
confirm_daemon_managed_manual_write: bool = False
|
||||
confirm_duplicate: bool = False
|
||||
confirmation_evidence: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_action_fields(self) -> "ManualLedgerRequest":
|
||||
def validate_action_fields(self) -> ManualLedgerRequest:
|
||||
amount_actions = {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"}
|
||||
trade_actions = {"BUY", "SELL"}
|
||||
provided_fields = self.model_fields_set
|
||||
fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields
|
||||
fee_provided = "fee" in provided_fields
|
||||
executed_at_provided = "executed_at" in provided_fields
|
||||
confirm_duplicate_requested = bool(self.confirm_duplicate)
|
||||
if self.action in amount_actions:
|
||||
if self.amount is None:
|
||||
raise ValueError("amount is required for this action")
|
||||
@@ -180,17 +185,33 @@ class ManualLedgerRequest(BaseModel):
|
||||
or self.price is not None
|
||||
or self.target_ledger_entry_id is not None
|
||||
or fee_or_tax_provided
|
||||
or executed_at_provided
|
||||
or confirm_duplicate_requested
|
||||
):
|
||||
raise ValueError("unexpected numeric/target field for amount action")
|
||||
elif self.action == "DIVIDEND":
|
||||
if self.amount is None:
|
||||
raise ValueError("amount is required for this action")
|
||||
if self.executed_at is None:
|
||||
raise ValueError("executed_at is required for DIVIDEND")
|
||||
if self.quantity is not None or self.price is not None or self.target_ledger_entry_id is not None or fee_provided:
|
||||
raise ValueError("unexpected numeric/target field for DIVIDEND")
|
||||
elif self.action in trade_actions:
|
||||
if self.quantity is None or self.price is None:
|
||||
raise ValueError("quantity and price are required for BUY/SELL")
|
||||
if self.amount is not None or self.target_ledger_entry_id is not None:
|
||||
if self.amount is not None or self.target_ledger_entry_id is not None or executed_at_provided or confirm_duplicate_requested:
|
||||
raise ValueError("unexpected amount/target field for BUY/SELL")
|
||||
elif self.action == "REVERSAL":
|
||||
if self.target_ledger_entry_id is None:
|
||||
raise ValueError("target_ledger_entry_id is required for REVERSAL")
|
||||
if self.amount is not None or self.quantity is not None or self.price is not None or fee_or_tax_provided:
|
||||
if (
|
||||
self.amount is not None
|
||||
or self.quantity is not None
|
||||
or self.price is not None
|
||||
or fee_or_tax_provided
|
||||
or executed_at_provided
|
||||
or confirm_duplicate_requested
|
||||
):
|
||||
raise ValueError("unexpected numeric field for REVERSAL")
|
||||
return self
|
||||
|
||||
@@ -198,12 +219,34 @@ class ManualLedgerRequest(BaseModel):
|
||||
class ManualLedgerResponse(BaseModel):
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
action: str
|
||||
action: Literal["INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "BUY", "SELL", "REVERSAL"]
|
||||
amount: float | None = None
|
||||
quantity: float = 0.0
|
||||
price: float = 0.0
|
||||
fee: float = 0.0
|
||||
tax: float = 0.0
|
||||
executed_at: datetime | None = None
|
||||
note: str = ""
|
||||
recorded_at: datetime | None = None
|
||||
cash_balance_after: float = 0.0
|
||||
asset_quantity_after: float = 0.0
|
||||
profit_buffer_after: float = 0.0
|
||||
total_value_after: float = 0.0
|
||||
source: str = "manual"
|
||||
target_ledger_entry_id: int | None = None
|
||||
confirmation_evidence: str | None = None
|
||||
|
||||
|
||||
class DividendManualLedgerResponse(BaseModel):
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
action: Literal["DIVIDEND"]
|
||||
amount: str
|
||||
quantity: float = 0.0
|
||||
price: float = 0.0
|
||||
fee: float = 0.0
|
||||
tax: str = "0"
|
||||
executed_at: datetime | None = None
|
||||
note: str = ""
|
||||
recorded_at: datetime | None = None
|
||||
cash_balance_after: float = 0.0
|
||||
@@ -216,7 +259,7 @@ class ManualLedgerResponse(BaseModel):
|
||||
|
||||
|
||||
class ManualLedgerHistoryResponse(BaseModel):
|
||||
items: list[ManualLedgerResponse]
|
||||
items: list[ManualLedgerResponse | DividendManualLedgerResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -226,12 +269,13 @@ class LedgerHistoryItem(BaseModel):
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
source: str
|
||||
action: str
|
||||
action: Literal["INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "BUY", "SELL", "REVERSAL"]
|
||||
recorded_at: datetime
|
||||
executed_at: datetime | None = None
|
||||
order_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
quantity: float | None = None
|
||||
amount: float | None = None
|
||||
price: float | None = None
|
||||
fee_amount: float | None = None
|
||||
tax_amount: float | None = None
|
||||
@@ -244,8 +288,53 @@ class LedgerHistoryItem(BaseModel):
|
||||
confirmation_evidence: str | None = None
|
||||
|
||||
|
||||
class DividendLedgerHistoryItem(BaseModel):
|
||||
"""Source-neutral, owner-visible ledger fact for GET /ledger."""
|
||||
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
source: str
|
||||
action: Literal["DIVIDEND"]
|
||||
recorded_at: datetime
|
||||
executed_at: datetime | None = None
|
||||
order_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
quantity: float | None = None
|
||||
amount: str
|
||||
price: float | None = None
|
||||
fee_amount: float | None = None
|
||||
tax_amount: str | None = None
|
||||
note: str | None = None
|
||||
cash_balance_after: float | None = None
|
||||
asset_quantity_after: float | None = None
|
||||
profit_buffer_after: float | None = None
|
||||
total_value_after: float | None = None
|
||||
target_ledger_entry_id: int | None = None
|
||||
confirmation_evidence: str | None = None
|
||||
|
||||
|
||||
class CorporateActionLedgerHistoryItem(BaseModel):
|
||||
"""Auditable corporate-action event in the unified ledger read model."""
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
source: Literal["corporate_action"]
|
||||
event_id: str
|
||||
event_type: Literal["stock_consolidation"]
|
||||
effective_date: date
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
cash_balance: Decimal
|
||||
profit_buffer: Decimal
|
||||
evidence_ref: str
|
||||
actor_user_id: str | None = None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class LedgerHistoryResponse(BaseModel):
|
||||
items: list[LedgerHistoryItem]
|
||||
items: list[LedgerHistoryItem | DividendLedgerHistoryItem | CorporateActionLedgerHistoryItem]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
@@ -283,7 +372,7 @@ class PortfolioCreateRequest(BaseModel):
|
||||
asset_quantity: float = Field(default=0.0, ge=0.0)
|
||||
asset_price: float = Field(default=0.0, ge=0.0)
|
||||
profit_buffer: float = Field(default=0.0, ge=0.0)
|
||||
strategy_id: str = Field(..., min_length=1)
|
||||
strategy_id: str | None = Field(default=None, min_length=1)
|
||||
managed_by_daemon_id: str | None = None
|
||||
user_id: str | None = None # admin-only: override owner
|
||||
|
||||
@@ -394,7 +483,7 @@ class StrategyUpdateRequest(BaseModel):
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_change(self) -> "StrategyUpdateRequest":
|
||||
def require_change(self) -> StrategyUpdateRequest:
|
||||
if not self.model_fields_set:
|
||||
raise ValueError("at least one field is required")
|
||||
return self
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# PortfolioHub v0.2.0 container contract
|
||||
# PortfolioHub v0.3.4 container contract
|
||||
|
||||
## Publication is not deployment
|
||||
|
||||
Only a requester-authorized push of the strict tag `v0.2.0` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors:
|
||||
Only a requester-authorized push of the strict tag `v0.3.4` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors:
|
||||
|
||||
- `ghcr.io/fourblocks2020/portfolio-hub:<source-commit-first-12>`
|
||||
- `ghcr.io/fourblocks2020/portfolio-hub:v0.2.0`
|
||||
- `ghcr.io/fourblocks2020/portfolio-hub:v0.3.4`
|
||||
|
||||
It never publishes `latest`. Both selectors must report the same registry digest; deployment and rollback must use the immutable identity:
|
||||
|
||||
@@ -13,9 +13,9 @@ It never publishes `latest`. Both selectors must report the same registry digest
|
||||
ghcr.io/fourblocks2020/portfolio-hub@sha256:<64-lowercase-hex-digest>
|
||||
```
|
||||
|
||||
OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.2.0`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration.
|
||||
OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.3.4`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration.
|
||||
|
||||
The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.2.0 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
|
||||
The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.3.4 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
|
||||
|
||||
## Image runtime
|
||||
|
||||
@@ -66,52 +66,54 @@ Values are read at process start; changing any value requires a container restar
|
||||
| `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` | optional; empty | **yes** | bootstrap password; inject temporarily and remove after first successful start |
|
||||
| `PORTFOLIOHUB_ENVIRONMENT` | optional; `development` | no | reported environment metadata |
|
||||
| `PORTFOLIOHUB_APP_NAME` | optional; `PortfolioHub` | no | application/OpenAPI display name |
|
||||
| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.2.0 | `0.2.0` | no | health/OpenAPI version metadata |
|
||||
| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.3.4 | `0.3.4` | no | health/OpenAPI version metadata |
|
||||
|
||||
`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.2.0 application does not open Redis or Loki connections. They are not production dependencies of this image.
|
||||
`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.3.4 application does not open Redis or Loki connections. They are not production dependencies of this image.
|
||||
|
||||
### Legacy and sibling mapping
|
||||
|
||||
The existing database variable names remain `TIMESCALEDB_*`; v0.2.0 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container.
|
||||
The existing database variable names remain `TIMESCALEDB_*`; v0.3.4 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container.
|
||||
|
||||
## Liveness and readiness
|
||||
|
||||
- **Liveness:** unauthenticated `GET /health` must return HTTP 200 with `status=ok`. It is intentionally process-only and does not query PostgreSQL. `db_enabled` only reports configuration; it is not readiness.
|
||||
- **Readiness:** run `pfhub --format json health-check` with the same runtime environment. With DB enabled it performs a repository ping, reads `alembic_version` without migration, compares it with the single packaged Alembic head, and exits nonzero on connectivity, missing schema, multiple heads, or revision mismatch.
|
||||
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.2.0`.
|
||||
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.3.4`.
|
||||
|
||||
Do not route traffic from a static `/health` success alone. Readiness checks never apply, stamp, or repair migrations.
|
||||
|
||||
## One-shot migration and 0029 -> 0030 policy
|
||||
## One-shot migration and 0029 -> 0031 policy
|
||||
|
||||
The installed image contains exactly **30** migration files and one Alembic head, `20260820_0030`. The application default command does not invoke Alembic. Migration is a separate pre-start operation using the exact same digest and database environment:
|
||||
The installed image contains exactly **31** migration files and one Alembic head, `20260913_0031`. The application default command does not invoke Alembic. Migration is a separate pre-start operation using the exact same digest and database environment:
|
||||
|
||||
1. back up the database and record the current immutable image digest and `alembic_version`;
|
||||
2. require the normal upgrade origin `20260814_0029`; for an older revision, review and apply the full ordered chain first;
|
||||
3. stop writes or otherwise establish an operator-approved maintenance boundary;
|
||||
4. execute `pfhub migrate-db` once with restart disabled;
|
||||
5. stop rollout on any nonzero result and preserve sanitized logs;
|
||||
6. run `pfhub --format json health-check` and require both `database_revision` and `expected_revision` to equal `20260820_0030`;
|
||||
6. run `pfhub --format json health-check` and require both `database_revision` and `expected_revision` to equal `20260913_0031`;
|
||||
7. start application replicas and verify `/health`, `/openapi.json`, authenticated caller paths, and strategy data;
|
||||
8. a repeated `pfhub migrate-db` is expected to be idempotent and remain at `0030`, but it is verification—not an application-start hook.
|
||||
8. a repeated `pfhub migrate-db` is expected to be idempotent and remain at `0031`, but it is verification—not an application-start hook.
|
||||
|
||||
Migration `0029` is additive/idempotent around the strategy catalog: it preserves existing strategy IDs and portfolio references, reuses the canonical `target_value`/`{}` strategy, and backfills catalog data. Before migration, inspect duplicate or invalid strategy references and take a backup. After migration, verify strategy listing and portfolio active-strategy fields before resuming writes.
|
||||
|
||||
v0.2.0 adds migration `0030`, an idempotent index on `(portfolio_id, recorded_at DESC, ledger_entry_id DESC)` for unified-ledger keyset reads. Operators at `0029` must run the same-image one-shot migration before rollout; it advances to `20260820_0030` without mutating ledger rows.
|
||||
v0.2.1 includes migration `0030`, an idempotent index on `(portfolio_id, recorded_at DESC, ledger_entry_id DESC)` for unified-ledger keyset reads. Operators at `0029` must run the same-image one-shot migration before rollout; it advances to `20260820_0030` without mutating ledger rows.
|
||||
|
||||
A pre-Alembic legacy schema is detected only when all three tables `strategy_sets`, `portfolios`, and `portfolio_ledger` exist; the CLI stamps `0027` and then runs real upgrades through `0030`. This compatibility path must be used only after backup and schema review. A partially matching schema is not auto-stamped and must fail for operator investigation.
|
||||
Migration `0031` is additive corporate-action storage. Operators must complete the same-image migration through `20260913_0031` before using the corporate-action endpoint; the endpoint is not available for traffic until readiness reports that head.
|
||||
|
||||
A pre-Alembic legacy schema is detected only when all three tables `strategy_sets`, `portfolios`, and `portfolio_ledger` exist; the CLI stamps `0027` and then runs real upgrades through `0031`. This compatibility path must be used only after backup and schema review. A partially matching schema is not auto-stamped and must fail for operator investigation.
|
||||
|
||||
Image rollback is **not** database rollback. Selecting older application bytes does not reverse schema/data changes, tokens, or side effects. Database restore, downgrade, or forward-fix requires separate review and authorization; no automatic downgrade is provided.
|
||||
|
||||
## API verification and known caller blockers
|
||||
|
||||
v0.2.0 preserves Bearer authentication and adds all-source unified-ledger reads. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 30-file migration inventory, isolated PostgreSQL migration twice, readiness at `0030`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it.
|
||||
v0.3.4 preserves Bearer authentication and all-source unified-ledger reads. Corporate-action quantity/reference-price updates, the complete ledger snapshot (including `total_value_after`), and the audit row execute in one explicit transaction. The guarded `resume_partial` path repairs only a verified quantity-only partial state without raw SQL. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 31-file migration inventory, isolated PostgreSQL migration twice, readiness at `0031`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it. The changed-scope Ruff gate uses the locked linter and the ACMR Python path selector, compares normalized findings against v0.2.1, v0.3.0, and v0.3.1, and rejects every newly introduced finding.
|
||||
|
||||
The strategy catalog API remains additive: strategy responses expose `nick`, admin create/update requests may set it, and portfolio responses expose the current strategy as `strategy_nick`. Existing clients may omit `nick`; supplied values are trimmed and blank-only values are rejected. trex-web v0.2.3 displays `strategy_nick`, but that dependency does not resolve or waive the separate caller blockers below.
|
||||
|
||||
Route presence is **not** a positive cross-repository caller-compatibility claim. Direct source comparison found the following unresolved consumers, which the requester explicitly separated as BLOCKED follow-up rather than adding legacy shims to portfolio-hub:
|
||||
|
||||
- trex-web omits required `strategy_id` when creating a portfolio, so that mutation currently returns `422`;
|
||||
- trex-web may create a portfolio without `strategy_id`; portfolio-hub must preserve that strategy-unassigned request as `active_strategy_id=null` rather than selecting a default strategy implicitly;
|
||||
- trex-web can omit required `account_ref` when creating or rotating credentials, so those requests currently return `422`;
|
||||
- trex-web sends daemon activation state through the general PATCH route, which can return `200` without changing activation; it must use the activate/deactivate routes;
|
||||
- portfolio-hub returns singular `broker` from `/v1/user_info`, while trex-web types `brokers[]`;
|
||||
|
||||
+4
-1
@@ -341,6 +341,7 @@
|
||||
- BUFFER_IN: cash_balance -> profit_buffer.
|
||||
- BUY: cash_balance -> asset_quantity.
|
||||
- SELL: asset_quantity -> cash_balance.
|
||||
- DIVIDEND: external net cash dividend -> profit_buffer only; `tax_amount` is reference metadata and does not affect snapshot math.
|
||||
- REVERSAL: append-only inverse effect of `target_ledger_entry_id`.
|
||||
|
||||
### Return Aggregate Semantics
|
||||
@@ -356,6 +357,8 @@
|
||||
|
||||
- Manual writes lock the portfolio row while calculating/appending/updating snapshots to avoid lost updates.
|
||||
- Manual writes reject insufficient cash/quantity/buffer with HTTP 409 rather than creating a new negative state.
|
||||
- DIVIDEND duplicate candidate (`portfolio_id`, `executed_at` UTC date, decimal `amount`, `note`) returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` unless request confirms with `confirm_duplicate=true`.
|
||||
- DIVIDEND uses date-only input stored as `executed_at` UTC midnight (`00:00:00Z`).
|
||||
- `total_value_after` for manual entries is the portfolio snapshot value after the mutation and includes cash, asset valuation, and profit buffer.
|
||||
|
||||
## Shared Consistency Rules
|
||||
@@ -369,7 +372,7 @@
|
||||
|
||||
- Alembic revisions in migrations/versions/ are the ordered schema history for persistent environments.
|
||||
- app/repositories/postgres.py includes ensure_schema() for bootstrap safety, but long-term schema evolution should run through Alembic.
|
||||
- Current Alembic head is `20260814_0029`; its strategy columns, default seed, and constraints are guarded for ensure_schema-first and repeatable execution.
|
||||
- Current Alembic head is `20260913_0031`; migration `0031` additively stores corporate actions for the corporate-action endpoint. Operators must migrate an environment to `0031` before using that endpoint. The preceding `0029` strategy columns, `0030` unified-ledger keyset index, and `0031` corporate-action storage are guarded for ensure_schema-first and repeatable execution.
|
||||
- Comments, tags, auth tokens, and ledger rows are normalized into dedicated tables keyed to users or portfolios.
|
||||
|
||||
## Current Gaps / Planned Changes
|
||||
|
||||
@@ -302,11 +302,11 @@
|
||||
### Endpoint: `POST /v1/portfolios`
|
||||
|
||||
- Auth: any valid token; service/client → HTTP 403
|
||||
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_id": "<required strategy ID>", "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
|
||||
- Response: HTTP 201 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str>", "strategy_name": "<str>", "strategy_params": {<dict>}, "strategy_revision": <int>, ...}`
|
||||
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_id": "<active strategy ID|null (optional)>", "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
|
||||
- Response: HTTP 201 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str|null>", "strategy_name": "<str|null>", "strategy_params": {<dict>}, "strategy_revision": <int|null>, ...}`
|
||||
- Note: `symbol` is populated from the server-side joined asset row (`portfolios.asset_id -> assets.asset_id -> assets.symbol`), not from client-side catalog reconstruction.
|
||||
- Note: a non-null `managed_by_daemon_id` must be a daemon server-generated UUID that exists, is active, and is owned by the target portfolio owner (admin `user_id` override uses the final target owner, not the calling admin). Daemon validation and the portfolio write share one atomic transaction (`FOR SHARE` daemon lock); a selected daemon that is deactivated concurrently is rejected rather than committed.
|
||||
- Error: HTTP 403 if service/client token; HTTP 404 if `strategy_id` is unknown or the daemon does not exist/is not owned by the portfolio owner; HTTP 409 if the strategy is inactive or the daemon is inactive; HTTP 422 for a missing/invalid `strategy_id`, an unknown/inactive asset, or a non-UUID `managed_by_daemon_id`
|
||||
- Error: HTTP 403 if service/client token; HTTP 404 if a supplied `strategy_id` is unknown or the daemon does not exist/is not owned by the portfolio owner; HTTP 409 if a supplied strategy is inactive or the daemon is inactive; HTTP 422 for an invalid `strategy_id`, an unknown/inactive asset, or a non-UUID `managed_by_daemon_id`
|
||||
|
||||
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}`
|
||||
|
||||
@@ -418,12 +418,14 @@
|
||||
|
||||
- Auth: DB user/session token with portfolio ownership. Service/client tokens and env tokens are HTTP 403 fail-closed.
|
||||
- Purpose: trex-web/user 수동 장부 append. Existing `/ledger/trades` remains daemon/service-token broker execution write only.
|
||||
- Body: `ManualLedgerRequest` with `action` enum `INVEST|BUFFER_OUT|BUFFER_IN|BUY|SELL|WITHDRAW|PROFIT|REVERSAL`.
|
||||
- Body: `ManualLedgerRequest` with `action` enum `INVEST|BUFFER_OUT|BUFFER_IN|BUY|SELL|WITHDRAW|PROFIT|DIVIDEND|REVERSAL`.
|
||||
- `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `WITHDRAW`, `PROFIT` require `amount`.
|
||||
- `DIVIDEND` requires `amount` and date-only `executed_at` (`YYYY-MM-DD`); `executed_at` is stored as UTC midnight (`00:00:00Z`). Optional non-negative `tax` is metadata only.
|
||||
- `BUY`, `SELL` require `quantity` and `price`; optional `fee`, `tax` default 0.
|
||||
- `REVERSAL` requires `target_ledger_entry_id`; direct mutation/delete of prior rows is not supported.
|
||||
- Duplicate candidate `DIVIDEND` (same portfolio, same date, same decimal `amount`, same `note`, and original not already reversed): first POST returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` with `matched_ledger_entry_id`; retry with `confirm_duplicate=true` records the entry.
|
||||
- `REVERSAL` requires `target_ledger_entry_id`; direct mutation/delete of prior rows is not supported. Reversing `DIVIDEND` reduces `profit_buffer` by the original net amount under existing reversal conflict/insufficient-balance policy.
|
||||
- `QUANTITY_ADJUSTMENT` is not part of this API.
|
||||
- Response: HTTP 201 `ManualLedgerResponse` with `ledger_entry_id`, `portfolio_id`, `action`, `source=manual`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`, `target_ledger_entry_id`, `confirmation_evidence`.
|
||||
- Response: HTTP 201 `ManualLedgerResponse` with `ledger_entry_id`, `portfolio_id`, `action`, `source=manual`, `executed_at`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`, `target_ledger_entry_id`, `confirmation_evidence`.
|
||||
- Validation/conflict policy:
|
||||
- disabled portfolio → HTTP 409 `PORTFOLIO_DISABLED`.
|
||||
- daemon-managed portfolio without `confirm_daemon_managed_manual_write=true` → HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`.
|
||||
@@ -435,7 +437,14 @@
|
||||
|
||||
- Auth: same owner-scoped user/session token policy as manual write.
|
||||
- Query: `limit` 1..200, default 50.
|
||||
- Response: `ManualLedgerHistoryResponse {"items": [ManualLedgerResponse], "total": <int>}` ordered newest first.
|
||||
- `GET /v1/portfolios/{id}/ledger` returns corporate-action events as a distinct `CorporateActionLedgerHistoryItem` with event ID/type, effective date, before/after quantity and WAC, total cost basis, cash/profit buffer, evidence, actor, and recorded time. These events are never represented as BUY/SELL trades.
|
||||
- For stock consolidations, WAC fields are rounded half-up to the persisted 8-decimal scale. `total_cost_basis` is preserved independently as the authoritative pre-action acquisition cost; a sub-cent residue when `average_price_after * quantity_after` is recomputed does not make an integral-share consolidation invalid.
|
||||
- `POST /v1/portfolios/{id}/corporate-actions` updates quantity and the post-action reference price, appends the `portfolio_ledger` snapshot (including non-null `total_value_after`), and appends the corporate-action audit row in one explicit database transaction. A failure in any statement rolls back every statement.
|
||||
- The post-action reference price is `asset_price_before * quantity_before / quantity_after`; ledger `total_value_after` is `cash_balance_after + asset_quantity_after * post_action_reference_price`. Cash and profit buffer are unchanged.
|
||||
- `resume_partial=false` is the compatible default. `resume_partial=true` is accepted only when the locked portfolio quantity already equals `quantity_after`, trade-ledger replay still proves `quantity_before`, and both corporate-action audit and corporate-action ledger rows are absent. Same-key replay is idempotent; a different-key recovery after repair is rejected.
|
||||
- `GET /v1/portfolios/{id}/trades` remains restricted to BUY/SELL history.
|
||||
- `portfolio corporate-action` is the operator command; it requires an idempotency key and confirmation unless `--force` is supplied. `--resume-partial` enables only the guarded recovery mode above.
|
||||
|
||||
|
||||
### Endpoint: `GET /v1/portfolios/{portfolio_id}/returns/summary` (REQ-056, BAT-024)
|
||||
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
|-----------|-----------|------|
|
||||
| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index, BAT-006/REQ-017: `AND revoked_at IS NULL` 조건 추가) | Alembic revision 20260520_0022에도 CONCURRENTLY 버전 존재 |
|
||||
| Alembic 전용 | `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades` | ensure_schema()에 없음 — v1 API 전제 조건 |
|
||||
| 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets (`status`, `revision`, `updated_at` 포함), portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic `20260814_0029` 실행 시 충돌 없음; 기본 전략 seed/constraints도 존재 여부 guard 적용 |
|
||||
| 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets (`status`, `revision`, `updated_at` 포함), portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic `20260913_0031`까지 실행 시 충돌 없음; 기본 전략 seed/constraints, unified-ledger keyset index, additive corporate-action storage도 존재 여부 guard 적용 |
|
||||
|
||||
### 허용 실행 순서
|
||||
|
||||
1. `init-db` 선행 → App 시작 → 정상 운영 (권장)
|
||||
2. App 시작 (ensure_schema() 선행) → `migrate-db` 후속 실행 (legacy stamp 경로)
|
||||
3. 이미 Alembic 관리 DB → `migrate-db` 증분 upgrade
|
||||
3. 이미 Alembic 관리 DB → `migrate-db` 증분 upgrade through `20260913_0031` before using the corporate-action endpoint
|
||||
|
||||
### 운영 제약
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Requirements
|
||||
|
||||
각 requirement는 `req-NNN/` 폴더에 정본과 관련 검증 자료를 함께 둔다. 정본 requirement 문서만 Markdown body 앞에 JSON front matter를 둔다.
|
||||
|
||||
## Directory layout and file naming
|
||||
|
||||
```text
|
||||
docs/requirements/
|
||||
README.md
|
||||
TEMPLATE.md
|
||||
req-NNN/
|
||||
req-NNN_<slug>.md
|
||||
test-catalog.md
|
||||
test-evidence.md
|
||||
history/
|
||||
provenance-reapproval-request.md
|
||||
README.md
|
||||
```
|
||||
|
||||
- `NNN`: requirement ID의 세 자리 decimal number. 폴더명과 정본 파일의 번호는 일치해야 한다.
|
||||
- `<slug>`: requirement title을 나타내는 lowercase kebab-case ASCII slug
|
||||
- `req-NNN/`는 하나의 requirement 묶음이며, 그 안의 `req-NNN_<slug>.md`만 정본이다.
|
||||
- `test-catalog.md`는 AC와 실행 가능한 테스트의 연결을, `test-evidence.md`는 특정 commit·환경에서의 실행 결과를 기록한다. 이들은 보조 문서이며 별도 REQ ID, revision 또는 approval status를 부여하지 않는다.
|
||||
- 과거 승인 요청·정정 절차 기록은 필요할 때 `history/`에 보존한다. 당시 요청서와 현재 완료 상태를 구분하고, 완료 상태 및 승인 근거는 `history/README.md`에 기록한다.
|
||||
- `docs/requirements/` 바로 아래에는 공통 README/TEMPLATE와 requirement별 폴더를 둔다. 개별 REQ나 보조 문서를 이 위치에 혼재시키지 않는다.
|
||||
- 문서 검색·수집은 하위 폴더까지 탐색하고, 파일 접두사만이 아니라 JSON front matter의 `schema`와 `id`로 정본을 식별한다.
|
||||
- 배치 변경만으로 승인된 정본의 내용·ID·revision·status를 변경하지 않는다.
|
||||
|
||||
## Metadata schema
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "proofline.requirement/v1",
|
||||
"id": "REQ-NNN",
|
||||
"title": "사람이 읽는 제목",
|
||||
"revision": "major.minor",
|
||||
"status": "draft | approved | retired",
|
||||
"depends_on": ["REQ-NNN"],
|
||||
"applies_to": ["algorithm-name"],
|
||||
"language": "ko"
|
||||
}
|
||||
```
|
||||
|
||||
- `id`: repository 안에서 고유한 requirement identifier
|
||||
- `revision`: 사람이 읽는 requirement revision. `draft` 상태에서 본문을 수정해도 올리지 않으며, `approved` 상태로 전환할 때 또는 승인된 requirement의 의미를 변경하기 위해 재승인할 때만 올린다.
|
||||
- `status`: requirement lifecycle 상태. `approved`는 명시적인 사용자 승인을 의미한다.
|
||||
- `depends_on`: 선행 requirement identifier 목록
|
||||
- `applies_to`: requirement가 적용되는 algorithm 또는 product 영역
|
||||
|
||||
## Python parsing
|
||||
|
||||
front matter body는 JSON이므로 별도 dependency 없이 Python 표준 라이브러리로 읽을 수 있다.
|
||||
|
||||
```python
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
text = Path("docs/requirements/req-001/req-001_manual-cash-dividend.md").read_text(encoding="utf-8")
|
||||
_, metadata_json, _ = text.split("---", 2)
|
||||
metadata = json.loads(metadata_json)
|
||||
```
|
||||
|
||||
JSON front matter의 `revision`과 `status`가 문서의 단일 source of truth다.
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
{
|
||||
"schema": "proofline.requirement/v1",
|
||||
"id": "REQ-NNN",
|
||||
"title": "요구사항 제목",
|
||||
"revision": "0.1",
|
||||
"status": "draft",
|
||||
"depends_on": [],
|
||||
"applies_to": ["product-area"],
|
||||
"language": "ko"
|
||||
}
|
||||
---
|
||||
|
||||
# REQ-NNN — 요구사항 제목
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 요구사항으로 달성해야 하는 사용자 또는 시스템의 결과를 기술한다.
|
||||
|
||||
## 2. 배경
|
||||
|
||||
현재 문제, 요구사항이 필요한 이유, 관련된 운영·도메인 맥락을 기술한다. 구현 방법보다 해결해야 할 문제에 집중한다.
|
||||
|
||||
## 3. 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 이 요구사항이 제공하거나 변경해야 하는 동작
|
||||
- 영향을 받는 API, 데이터, 제품 영역 또는 운영 흐름
|
||||
|
||||
### 제외
|
||||
|
||||
- 이번 요구사항에서 다루지 않는 기능
|
||||
- 별도 requirement 또는 후속 작업으로 분리할 항목
|
||||
|
||||
## 4. 용어와 기준
|
||||
|
||||
- **용어**: 이 문서에서 사용하는 의미
|
||||
- **정본 기준**: 값이나 상태의 canonical source
|
||||
|
||||
## 5. 요구사항
|
||||
|
||||
`REQ-NNN`은 문서와 기능 묶음의 identifier다. 고객에게 전달할 요구사항명세리스트와 구현 추적에서는 독립적으로 구현·검증할 수 있는 각 요구사항을 `REQ-NNN.01`, `REQ-NNN.02` 형식으로 식별한다.
|
||||
|
||||
각 요구사항에는 하네스가 변경 영향과 필요한 회귀 범위를 판정할 수 있도록 `Impact`를 기록한다.
|
||||
|
||||
### REQ-NNN.01 — 요구사항 이름
|
||||
|
||||
시스템은 관찰 가능한 결과를 명확하게 기술한 조건을 충족해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: `<영향받는 API, event 또는 없음>`
|
||||
- Persistence: `<영향받는 table, state 또는 없음>`
|
||||
- Consumers: `<영향받는 downstream consumer 또는 없음>`
|
||||
- Migration: `<필요 여부와 범위>`
|
||||
- Compatibility: `<유지해야 하는 기존 동작>`
|
||||
- Deployment prerequisites: `<배포 전제조건 또는 없음>`
|
||||
|
||||
### REQ-NNN.02 — 요구사항 이름
|
||||
|
||||
시스템은 정상 동작뿐 아니라 실패·경계·변경 없음 조건도 명확히 충족해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: `<영향받는 API, event 또는 없음>`
|
||||
- Persistence: `<영향받는 table, state 또는 없음>`
|
||||
- Consumers: `<영향받는 downstream consumer 또는 없음>`
|
||||
- Migration: `<필요 여부와 범위>`
|
||||
- Compatibility: `<유지해야 하는 기존 동작>`
|
||||
- Deployment prerequisites: `<배포 전제조건 또는 없음>`
|
||||
|
||||
## 6. 제약과 불변조건
|
||||
|
||||
- 구현 방식과 무관하게 항상 유지해야 하는 데이터·상태 불변조건
|
||||
- 기존 호출자와 저장 데이터에 대해 유지해야 하는 하위호환 조건
|
||||
- 인증·권한·민감정보 처리 조건
|
||||
- 원자성, 정밀도, 동시성 또는 순서 보장 조건
|
||||
|
||||
## 7. Acceptance Criteria
|
||||
|
||||
Acceptance Criterion은 소속 요구사항을 포함한 `REQ-NNN.01-AC01` 형식으로 식별한다. 문서 전체에서만 고유한 `AC-001` 형식은 사용하지 않는다.
|
||||
|
||||
### REQ-NNN.01-AC01 — 판정 가능한 결과
|
||||
|
||||
**Criterion**
|
||||
|
||||
- 하나의 지속적인 요구사항을 PASS 또는 FAIL로 판정할 수 있게 기술한다.
|
||||
- 특정 함수명이나 내부 구현보다 외부에서 관찰 가능한 결과를 명시한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 정상 경로 검증 방법
|
||||
- 실패 및 경계 조건 검증 방법
|
||||
- 변경되면 안 되는 상태의 검증 방법
|
||||
- 필요한 경우 실제 persistence backend 또는 production entry point 검증 방법
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `unit | API | integration | end-to-end`
|
||||
- Backend: `<검증 대상 backend 또는 없음>`
|
||||
- Cases: `positive | negative | boundary | unchanged-state | concurrency`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 성공 시 반드시 변경되어야 하는 상태
|
||||
- 성공하더라도 변경되면 안 되는 상태
|
||||
- 실패 시 부분 기록 없이 유지되어야 하는 상태
|
||||
|
||||
### REQ-NNN.02-AC01 — 판정 가능한 결과
|
||||
|
||||
**Criterion**
|
||||
|
||||
- 두 번째 독립 요구사항을 기술한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- Criterion 전체를 입증할 검증 절차와 기대 결과를 기술한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `unit | API | integration | end-to-end`
|
||||
- Backend: `<검증 대상 backend 또는 없음>`
|
||||
- Cases: `positive | negative | boundary | unchanged-state | concurrency`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 성공 시 반드시 변경되어야 하는 상태
|
||||
- 성공하더라도 변경되면 안 되는 상태
|
||||
- 실패 시 부분 기록 없이 유지되어야 하는 상태
|
||||
|
||||
## 8. 오류 및 충돌 계약
|
||||
|
||||
- 입력 오류: 상태 코드 또는 오류 분류와 무변경 조건
|
||||
- 상태 충돌: 재시도·확인·중복 처리 정책
|
||||
- 저장 실패: transaction rollback과 부분 기록 방지 조건
|
||||
|
||||
## 9. 데이터 및 인터페이스 계약
|
||||
|
||||
- 입력 field와 필수·선택 여부
|
||||
- 출력 field와 의미
|
||||
- 저장해야 하는 canonical 값과 metadata
|
||||
- 시간대, 단위, 정밀도 및 식별자 규칙
|
||||
|
||||
## 10. 하위호환성
|
||||
|
||||
- 유지해야 하는 기존 동작과 호출 방식
|
||||
- 기존 데이터에 적용할 규칙
|
||||
- 의도적으로 변경되는 동작이 있다면 그 경계
|
||||
|
||||
## 11. 검증 계획
|
||||
|
||||
- Unit/API contract 검증
|
||||
- Persistence migration 및 round-trip 검증
|
||||
- 동시성·중복·재시도 검증
|
||||
- 기존 기능 회귀 검증
|
||||
- OpenAPI 또는 사용자-facing 문서 정합성 검증
|
||||
|
||||
### Test Traceability Contract
|
||||
|
||||
- Requirement 문서에는 검증 의도와 판정 조건만 기록한다.
|
||||
- 실행 가능한 테스트 정의는 별도 test catalog artifact에서 관리한다.
|
||||
- 각 테스트 케이스는 안정적인 identifier를 사용하고 `verifies`로 Acceptance Criterion을 참조한다.
|
||||
- 하나의 테스트가 여러 Criterion을 검증할 수 있으며, 각 연결을 빠짐없이 기록한다.
|
||||
- 실행 결과는 requirement 또는 test definition에 덮어쓰지 않고 별도 evidence artifact에 기록한다.
|
||||
- Evidence는 requirement revision, 검증한 commit, 환경과 실행 시각에 결합한다.
|
||||
- 테스트 정의의 존재, 자동화 여부, 특정 commit에서의 실행 여부와 PASS 결과를 서로 다른 상태로 취급한다.
|
||||
|
||||
Test definition 예시:
|
||||
|
||||
```text
|
||||
Test ID: TC-NNN-001
|
||||
Verifies:
|
||||
- REQ-NNN.01-AC01
|
||||
Test reference: <test file, case 또는 harness selector>
|
||||
Automated: true | false
|
||||
```
|
||||
|
||||
Execution evidence 예시:
|
||||
|
||||
```text
|
||||
Test ID: TC-NNN-001
|
||||
Requirement revision: REQ-NNN@major.minor
|
||||
Commit: <commit SHA>
|
||||
Environment: <runtime 및 backend>
|
||||
Result: PASS | FAIL | BLOCKED
|
||||
Executed at: <timestamp>
|
||||
```
|
||||
|
||||
## 12. 추적성
|
||||
|
||||
- 근거 이슈: `<issue URL 또는 ID>`
|
||||
- 구현 PR: `<PR URL 또는 ID>`
|
||||
- 관련 설계 문서: `<repository-relative path>`
|
||||
- 관련 requirement: `<REQ-NNN 또는 없음>`
|
||||
|
||||
## 13. 확정된 결정
|
||||
|
||||
- 사용자와 확정한 제품·정책 결정을 기록한다.
|
||||
- 확정된 결정은 Open Question으로 다시 남기지 않는다.
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
`approved` 전 반드시 결정해야 하며, 각 항목에는 선택지, 권장안, 결정 주체와 종료 조건을 둔다. 미결정 항목이 없으면 `없음`으로 기록한다.
|
||||
|
||||
- 없음
|
||||
|
||||
## 15. 승인 기록
|
||||
|
||||
- 상태: `draft`
|
||||
- 승인 주체: 미승인
|
||||
- 승인 근거: 없음
|
||||
@@ -0,0 +1,11 @@
|
||||
# REQ-001 Approval History
|
||||
|
||||
`provenance-reapproval-request.md`는 승인 기록 정정을 요청했던 당시 문서이며, 현재 승인 대기 요청이 아니다. 역사적 기록 보존을 위해 당시 본문·경로·digest 표기를 수정하지 않았다.
|
||||
|
||||
- 현재 정본: [REQ-001 — 사용자 입력 현금 배당 원장 기록](../req-001_manual-cash-dividend.md)
|
||||
- 정정 범위: 승인 기록에서 혼동한 OQ 선택지 문구만 정정. 요구사항 계약 변경 없음.
|
||||
- 최종 재승인: 사용자가 변경된 exact diff와 digest를 최종 재승인함.
|
||||
- 승인 근거: https://github.com/fourblocks2020/portfolio-hub/pull/33#issuecomment-5602775793
|
||||
- 최종 재승인된 정본 SHA-256: `1319d8f008f81d125610d49591dde8ee43d3294e0fdb01a5e69fb01306009b47`
|
||||
|
||||
이번 폴더 재배치는 정본 내용·ID·revision·status를 변경하지 않는다. 요청서 안의 기존 경로는 당시 위치이며, 현재 위치는 위 정본 링크를 따른다.
|
||||
@@ -0,0 +1,25 @@
|
||||
# REQ-001 Provenance Reapproval Request
|
||||
|
||||
- 대상 requirement: `REQ-001` (`docs/requirements/req-001_manual-cash-dividend.md`)
|
||||
- 현재 canonical SHA-256: `b9498b5d9b6dd4638d701f68273dbbde1a37dbbaa74d1d3c9923fb1702b6f076`
|
||||
- 상태: 승인된 canonical 본문은 변경하지 않음
|
||||
|
||||
## 요청 배경
|
||||
|
||||
`REQ-001` 승인 기록의 문구(`OQ-001 선택지 A`)가 본문 계약(`tax` 허용) 및 이슈 #31의 확정 OQ-001(B)와 provenance 상 충돌한다는 리뷰 코멘트가 확인되었다.
|
||||
|
||||
## 요청 사항
|
||||
|
||||
승인된 canonical 파일 자체를 구현 PR에서 직접 수정하지 않고, 아래 절차 중 하나를 요청한다.
|
||||
|
||||
1. `REQ-001` 한정 1회 예외를 승인하여 provenance 문구를 정정하고 재승인한다.
|
||||
2. 또는 별도 canonical artifact(새 revision)로 provenance 정정본을 생성하고 재승인한다.
|
||||
|
||||
## 정정 대상 문구(제안)
|
||||
|
||||
- 기존: `이 대화에서 REQ-001 승인과 OQ-001 선택지 A를 명시적으로 확정함`
|
||||
- 제안: `이 대화에서 REQ-001 승인과 비-DIVIDEND confirm_duplicate=false 허용/true HTTP 422 결정을 명시적으로 확정함`
|
||||
|
||||
## 비고
|
||||
|
||||
본 문서는 절차 요청 artifact이며, `REQ-001` canonical bytes/revision/status를 직접 변경하지 않는다.
|
||||
@@ -0,0 +1,591 @@
|
||||
---
|
||||
{
|
||||
"schema": "proofline.requirement/v1",
|
||||
"id": "REQ-001",
|
||||
"title": "사용자 입력 현금 배당 원장 기록",
|
||||
"revision": "1.0",
|
||||
"status": "approved",
|
||||
"depends_on": [],
|
||||
"applies_to": ["portfolio-hub", "manual-ledger"],
|
||||
"language": "ko"
|
||||
}
|
||||
---
|
||||
|
||||
# REQ-001 — 사용자 입력 현금 배당 원장 기록
|
||||
|
||||
## 1. 목적
|
||||
|
||||
사용자는 Broker에서 자동으로 수집하기 어려운 현금 배당의 실제 실수령액을 portfolio-hub의 manual ledger에 직접 기록할 수 있어야 한다. 기록된 배당은 전략 주문 가능 현금과 분리하여 `profit_buffer`에 반영하고, 입력 사실과 변경 후 portfolio 상태를 원장에서 조회하고 정정할 수 있어야 한다.
|
||||
|
||||
## 2. 배경
|
||||
|
||||
초기 범위에서는 Broker별 배당 조회 기능의 가용성과 형식 차이에 의존하지 않고 사용자가 실제 입금 내역을 직접 기록한다. 배당 실수령액은 투자 원금이나 주문 가능 현금이 아니므로 `cash_balance`에 합산하지 않는다. 잘못 입력한 기록은 기존 append-only `REVERSAL` 흐름으로 상쇄하며, 원본 원장을 수정하거나 삭제하지 않는다.
|
||||
|
||||
이 문서는 GitHub 이슈 #31의 확정 계약과 PR #33의 구현 증거를 요구사항으로 정리한 초안이다. 구현 코드와 테스트는 의도의 근거이지만, 이 문서의 `approved` 전환은 별도의 명시적인 사용자 승인을 필요로 한다.
|
||||
|
||||
## 3. 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- manual ledger action `DIVIDEND`
|
||||
- 배당 실수령액, 선택적 세금 metadata, 입금일과 메모 입력
|
||||
- 입력값 검증과 OpenAPI 계약
|
||||
- PostgreSQL 원장 저장과 portfolio snapshot 갱신
|
||||
- 중복 후보 경고와 사용자 확인 후 재제출
|
||||
- manual ledger 및 unified ledger 조회
|
||||
- 기존 `REVERSAL`을 이용한 배당 정정
|
||||
- Decimal 정밀도와 transaction 원자성
|
||||
- 기존 manual ledger action 하위호환
|
||||
- API·저장소·실제 PostgreSQL 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- Broker 배당 자동 수집 또는 동기화
|
||||
- 외화 환산과 다통화 손익
|
||||
- 배당 총액(gross amount) 입력
|
||||
- 세금 자동 계산·역산과 세율 정책
|
||||
- 배당수익 별도 집계
|
||||
- 실현손익 또는 기간 수익률 breakdown 변경
|
||||
- 배당금을 `cash_balance`나 전략 주문 가능 현금으로 이동하는 기능
|
||||
- portfolio-hub CLI의 배당 입력·표시 UX
|
||||
- trex-web의 `현금 배당` label, 입력 form과 중복 확인 UX
|
||||
- reversal UI
|
||||
|
||||
## 4. 용어와 기준
|
||||
|
||||
- **실수령액(net cash dividend)**: 세금 등을 제외하고 사용자 계좌에 실제 입금된 배당금. API에서는 `amount`로 표현한다.
|
||||
- **세금 metadata**: 사용자가 참고 목적으로 입력하는 `tax`. 자동 계산하지 않으며 portfolio 상태에 영향을 주지 않는다.
|
||||
- **입금일**: 사용자가 date-only 형식으로 입력하는 배당 입금 날짜. 기존 `executed_at`에 UTC 자정으로 저장한다.
|
||||
- **기록 시각**: 서버가 원장 항목을 append한 시각인 `recorded_at`.
|
||||
- **중복 후보**: 동일 portfolio, 동일 UTC 입금일, 동일 Decimal `amount`, 동일 `note`를 가진 reversal되지 않은 기존 `DIVIDEND`.
|
||||
- **정본 기준**: 배당 사실과 변경 후 상태는 PostgreSQL `portfolio_ledger`의 append-only 기록 및 그 기록과 같은 transaction에서 갱신된 portfolio snapshot을 기준으로 한다.
|
||||
|
||||
## 5. 요구사항
|
||||
|
||||
`REQ-001`은 사용자 입력 현금 배당 기능 묶음의 identifier다. 고객 요구사항명세리스트와 구현 추적에서는 독립적으로 구현·검증할 수 있는 다음 항목을 사용한다.
|
||||
|
||||
### REQ-001.01 — 현금 배당 입력 계약
|
||||
|
||||
시스템은 portfolio 소유 사용자가 manual ledger API를 통해 실제 배당 실수령액, 입금일, 선택적 세금 metadata와 메모를 입력할 수 있게 해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: `POST /v1/portfolios/{portfolio_id}/ledger/manual`, OpenAPI
|
||||
- Persistence: `portfolio_ledger`
|
||||
- Consumers: portfolio-hub API client, 후속 trex-web 연계
|
||||
- Migration: 신규 입금일 column 없이 기존 원장 metadata 구조 사용
|
||||
- Compatibility: 기존 manual action의 유효 입력 계약 유지
|
||||
- Deployment prerequisites: 기존 portfolio-hub PostgreSQL migration head 적용
|
||||
|
||||
### REQ-001.02 — 배당에 따른 portfolio 상태 변경
|
||||
|
||||
시스템은 유효한 `DIVIDEND`의 `amount`만큼 `profit_buffer`를 증가시키고, `cash_balance`와 `asset_quantity`는 변경하지 않아야 한다. `total_value_after`는 증가한 buffer를 반영해야 하며 `tax`는 모든 상태 계산에서 제외해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: manual ledger 생성 응답, ledger 조회 응답
|
||||
- Persistence: portfolio snapshot, `portfolio_ledger` after-snapshot
|
||||
- Consumers: portfolio valuation과 unified ledger 소비자
|
||||
- Migration: 없음
|
||||
- Compatibility: 기존 action의 상태 계산식 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.03 — 입금일과 기록 시각 분리
|
||||
|
||||
시스템은 date-only 입금일을 기존 `executed_at`에 UTC 자정(`00:00:00Z`)으로 저장하고, `recorded_at`은 서버 append 시각으로 독립적으로 유지해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: manual ledger 요청·응답, unified ledger 응답
|
||||
- Persistence: 기존 `portfolio_ledger.executed_at`, `recorded_at`
|
||||
- Consumers: 입금일 표시와 중복 판정 소비자
|
||||
- Migration: 입금일 전용 DB column 추가 없음
|
||||
- Compatibility: 기존 원장의 `recorded_at` 의미 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.04 — 배당 원장 저장과 조회
|
||||
|
||||
시스템은 배당을 `source=manual`, canonical `action=DIVIDEND`로 append하고, unified ledger에서 배당 금액과 metadata 및 변경 후 snapshot을 직접 조회할 수 있게 해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: manual ledger 응답, `GET /v1/portfolios/{portfolio_id}/ledger`, OpenAPI
|
||||
- Persistence: `portfolio_ledger`
|
||||
- Consumers: portfolio-hub API client, trex-web 원장 화면
|
||||
- Migration: 기존 manual ledger metadata column 사용
|
||||
- Compatibility: broker 원장과 기존 manual action의 unified ledger 표현 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.05 — 중복 후보 경고와 확인 재제출
|
||||
|
||||
시스템은 중복 후보를 자동 기록하지 않고 HTTP 409 경고와 일치한 원장 ID를 반환해야 한다. 사용자가 `confirm_duplicate=true`로 확인한 재요청은 실제 예외 기록으로 허용해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: manual ledger 요청의 `confirm_duplicate`, HTTP 409 오류 응답
|
||||
- Persistence: 중복 후보 조회와 조건부 append
|
||||
- Consumers: 중복 확인 UX를 제공하는 후속 trex-web
|
||||
- Migration: 없음
|
||||
- Compatibility: 기존 호출자가 새 배당 전용 field를 생략한 경우의 기존 계약 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.06 — DIVIDEND reversal
|
||||
|
||||
시스템은 기존 append-only `REVERSAL`로 원본 배당의 실수령액만큼 `profit_buffer`를 감소시켜 배당 효과를 상쇄해야 한다. 원본 원장을 수정하거나 삭제해서는 안 된다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: 기존 manual `REVERSAL` 요청·응답
|
||||
- Persistence: 원본 `DIVIDEND`, 새 `REVERSAL`, portfolio snapshot
|
||||
- Consumers: manual ledger와 unified ledger 소비자
|
||||
- Migration: 없음
|
||||
- Compatibility: 기존 잔액 부족·대상 없음·중복 reversal 정책 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.07 — Decimal 정밀도와 원자적 정합성
|
||||
|
||||
시스템은 수용한 Decimal 금액을 입력, 중복 비교, 원장 저장, snapshot 계산과 reversal에서 같은 의미로 유지해야 한다. 원장과 portfolio snapshot이 같은 값을 표현할 수 없는 요청은 어떤 상태도 기록하기 전에 원자적으로 거부해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: Decimal 입력 검증과 충돌 응답
|
||||
- Persistence: Decimal 원장 값과 portfolio snapshot
|
||||
- Consumers: 잔액·원장·reversal 정합성에 의존하는 모든 소비자
|
||||
- Migration: column type 변경 없음
|
||||
- Compatibility: 수용된 값의 정합성을 보장하면서 기존 manual action 계약 유지
|
||||
- Deployment prerequisites: 실제 PostgreSQL 경계값 검증
|
||||
|
||||
### REQ-001.08 — 기존 manual ledger 하위호환
|
||||
|
||||
시스템은 `DIVIDEND` 도입으로 기존 manual ledger action의 입력, 상태 변경, 충돌과 reversal 계약을 변경해서는 안 된다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: 기존 manual ledger API 전체
|
||||
- Persistence: 기존 action의 원장 및 snapshot 갱신
|
||||
- Consumers: 기존 portfolio-hub API client
|
||||
- Migration: 없음
|
||||
- Compatibility: `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `BUY`, `SELL`, `WITHDRAW`, `PROFIT`, `REVERSAL` 유지
|
||||
- Deployment prerequisites: 기존 action 회귀 검증
|
||||
|
||||
### REQ-001.09 — 권한과 민감정보 비노출
|
||||
|
||||
시스템은 기존 portfolio 소유권과 manual-write 권한 정책을 유지하고, 배당 기능으로 인해 API·로그·오류 응답에 secret 또는 account 식별정보를 추가로 노출해서는 안 된다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: 인증된 manual ledger API와 오류 응답
|
||||
- Persistence: actor와 기존 감사 metadata
|
||||
- Consumers: 사용자 및 운영 로그 소비자
|
||||
- Migration: 없음
|
||||
- Compatibility: 기존 인증·권한·daemon-managed portfolio 확인 정책 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
## 6. 제약과 불변조건
|
||||
|
||||
- `amount`는 0보다 큰 Decimal이어야 한다.
|
||||
- `tax`는 선택 입력이며 기본값은 `0`이고 음수일 수 없다.
|
||||
- `amount`, `tax`와 관련 수치는 원장의 `NUMERIC(20,8)` 범위와 scale을 준수해야 한다.
|
||||
- `tax`는 metadata이며 `profit_buffer`, `cash_balance`, `asset_quantity`, `total_value_after` 계산에 영향을 주지 않는다.
|
||||
- `quantity`, 거래 `price`, `fee`, 주문·체결 ID와 reversal 대상 ID는 `DIVIDEND` 입력 대상이 아니다.
|
||||
- date-only 입금일은 서버 timezone과 무관하게 UTC 자정으로 변환한다.
|
||||
- 원장 append와 portfolio snapshot 갱신은 원자적으로 함께 성공하거나 실패해야 한다.
|
||||
- 실패하거나 거부된 요청은 원장과 portfolio snapshot을 모두 변경하지 않는다.
|
||||
- 원장은 append-only이며 원본 배당을 직접 수정하거나 삭제하지 않는다.
|
||||
- 중복 비교는 binary float 근사가 아니라 저장 계약과 동일한 Decimal 의미를 사용한다.
|
||||
- `profit_buffer`가 부족한 `DIVIDEND` reversal은 기존 잔액 부족 정책에 따라 거부한다.
|
||||
|
||||
|
||||
## 7. Acceptance Criteria
|
||||
|
||||
### REQ-001.01-AC01 — 유효한 배당 입력 수용
|
||||
|
||||
**Criterion**
|
||||
|
||||
portfolio 소유 사용자가 `action=DIVIDEND`, 유효한 `amount`, date-only `executed_at`, 선택적 `tax`와 `note`를 제출하면 시스템은 manual ledger 항목을 생성하고 HTTP 201을 반환한다. `tax` 생략 시 저장 의미는 `0`이다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- `amount>0`, `tax>=0`, `executed_at=YYYY-MM-DD`인 정상 요청이 HTTP 201인지 확인한다.
|
||||
- `tax`를 생략한 요청의 저장·응답 값이 `0`인지 확인한다.
|
||||
- 생성 응답의 `action`, `source`, `amount`, `tax`, `executed_at`과 after-snapshot을 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 성공 시 원장 항목이 정확히 하나 추가되어야 한다.
|
||||
- 성공 시 `tax`는 portfolio 상태를 변경하지 않아야 한다.
|
||||
- 실패한 입력은 원장과 snapshot을 변경하지 않아야 한다.
|
||||
|
||||
### REQ-001.01-AC02 — 잘못된 배당 입력 거부
|
||||
|
||||
**Criterion**
|
||||
|
||||
시스템은 `amount` 또는 입금일이 없거나, 허용 범위를 벗어난 수치 또는 `DIVIDEND`와 무관한 field가 포함된 요청을 원장 기록 전에 거부한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 누락·0·음수·8자리 초과 소수·저장 범위 초과 `amount`를 검증한다.
|
||||
- 누락되거나 잘못된 date-only `executed_at`을 검증한다.
|
||||
- 음수·8자리 초과 소수·저장 범위를 벗어난 `tax`를 검증한다.
|
||||
- `quantity`, `price`, `fee`, `target_ledger_entry_id` 입력을 검증한다.
|
||||
- 각 거부가 HTTP 422이며 상태가 변하지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `unit | API`
|
||||
- Backend: 없음
|
||||
- Cases: `negative | boundary | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 거부된 요청은 원장 항목을 만들지 않아야 한다.
|
||||
- 거부된 요청은 모든 portfolio snapshot을 유지해야 한다.
|
||||
|
||||
### REQ-001.02-AC01 — 배당 상태 효과
|
||||
|
||||
**Criterion**
|
||||
|
||||
유효한 배당 기록 후 `profit_buffer_after`는 직전 값에 `amount`를 더한 값이어야 한다. `cash_balance_after`와 `asset_quantity_after`는 직전 값과 같아야 하며, `total_value_after`는 증가한 buffer를 반영해야 한다. `tax` 값은 이 계산에 영향을 주지 않는다.
|
||||
|
||||
**Verification**
|
||||
|
||||
서로 다른 `amount`와 `tax` 조합으로 기록 전후 portfolio와 ledger after-snapshot을 비교하고 각 계산식을 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- `profit_buffer` 외의 현금·수량 상태는 변경되지 않아야 한다.
|
||||
- 동일 `amount`에 대해 `tax` 값이 달라도 상태 효과는 같아야 한다.
|
||||
- ledger after-snapshot과 portfolio snapshot은 같아야 한다.
|
||||
|
||||
### REQ-001.03-AC01 — UTC 입금일 저장
|
||||
|
||||
**Criterion**
|
||||
|
||||
`YYYY-MM-DD` 입금일은 `executed_at`의 해당 UTC 날짜 `00:00:00Z`로 저장·반환되어야 한다. `recorded_at`은 서버 append 시각이어야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 서로 다른 날짜를 입력해 UTC 자정 저장과 조회를 확인한다.
|
||||
- 서버 환경 timezone과 무관한 결과를 확인한다.
|
||||
- `recorded_at`이 입력한 입금일로 대체되지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- date-only 입력은 시간대 변환으로 전날 또는 다음 날이 되어서는 안 된다.
|
||||
- `recorded_at`의 감사 의미는 유지되어야 한다.
|
||||
|
||||
### REQ-001.04-AC01 — Unified ledger 배당 조회
|
||||
|
||||
**Criterion**
|
||||
|
||||
기록된 배당은 manual ledger와 unified ledger에서 `source=manual`, `action=DIVIDEND`, `executed_at`, canonical `amount`, `tax_amount`, `note` 및 변경 후 snapshot으로 조회되어야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 배당 생성 후 `source=manual` filter를 사용해 unified ledger를 조회한다.
|
||||
- canonical `amount`가 별도 field로 존재하며 거래 `price` 해석에만 의존하지 않는지 확인한다.
|
||||
- OpenAPI의 request·response schema에 같은 field와 nullability가 나타나는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 조회는 원장과 portfolio 상태를 변경하지 않아야 한다.
|
||||
- broker entry와 기존 manual entry의 기존 표현을 변경하지 않아야 한다.
|
||||
|
||||
### REQ-001.05-AC01 — 최초 중복 후보 경고
|
||||
|
||||
**Criterion**
|
||||
|
||||
동일 portfolio, UTC 입금일, Decimal `amount`, `note`의 reversal되지 않은 기존 배당이 있고 `confirm_duplicate`가 없거나 `false`이면 시스템은 새 항목을 기록하지 않고 HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND`와 `matched_ledger_entry_id`를 반환한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 첫 요청을 기록한 뒤 동일 요청을 재제출한다.
|
||||
- 409 code와 원본 entry ID를 확인한다.
|
||||
- 원장 수와 portfolio snapshot이 변하지 않았는지 확인한다.
|
||||
- Decimal 값이 다른 요청을 같은 값으로 오판하지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `negative | boundary | unchanged-state | concurrency`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 경고 응답은 새 원장을 만들지 않아야 한다.
|
||||
- 경고 응답은 `profit_buffer`를 변경하지 않아야 한다.
|
||||
- 일치 ID는 실제 중복 후보를 정확히 가리켜야 한다.
|
||||
|
||||
### REQ-001.05-AC02 — 확인된 중복과 reversal된 원본 처리
|
||||
|
||||
**Criterion**
|
||||
|
||||
중복 후보 요청에 `confirm_duplicate=true`가 있으면 시스템은 별도 배당 항목을 기록해야 한다. 이미 reversal된 배당 원본은 새로운 중복 후보에서 제외해야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 409 이후 동일 요청에 `confirm_duplicate=true`를 보내 HTTP 201과 별도 entry ID를 확인한다.
|
||||
- 원본을 reversal한 뒤 동일 배당을 다시 제출해 경고 없이 기록되는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 확인된 중복은 각각 독립적인 append-only 원장이어야 한다.
|
||||
- reversal된 원본은 중복 억제 상태를 영구히 남기지 않아야 한다.
|
||||
|
||||
### REQ-001.05-AC03 — 동시 중복 요청의 일관성
|
||||
|
||||
**Criterion**
|
||||
|
||||
동일 portfolio에 확인되지 않은 같은 배당 요청이 동시에 도착하더라도 새 배당 항목은 최대 하나만 기록되어야 하며 최종 원장과 portfolio snapshot은 일치해야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
실제 PostgreSQL에서 같은 요청을 동시에 제출해 확인되지 않은 요청 둘이 모두 자동 기록되지 않으며, 거부된 요청은 상태를 변경하지 않고 최종 원장 수와 `profit_buffer`가 허용된 기록 수와 일치하는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `concurrency | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- lost update가 발생하지 않아야 한다.
|
||||
- 원장 수와 최종 snapshot 사이에 불일치가 없어야 한다.
|
||||
- 경고로 거부된 transaction은 부분 상태를 남기지 않아야 한다.
|
||||
|
||||
### REQ-001.06-AC01 — 배당 reversal
|
||||
|
||||
**Criterion**
|
||||
|
||||
유효한 `DIVIDEND`를 reversal하면 원본 `amount`만큼 `profit_buffer`가 감소하고 원본 이전 값으로 복원되어야 한다. 원본과 reversal 원장은 모두 남아야 하며 현금과 수량은 변경되지 않아야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 정상 금액과 최소 단위 `0.00000001`을 각각 기록하고 reversal한다.
|
||||
- 원본 이전 buffer로 정확히 복원되는지 확인한다.
|
||||
- 원본 보존, reversal 대상 ID, 중복 reversal 거부와 buffer 부족 거부를 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | negative | boundary | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 원본 원장은 수정·삭제되지 않아야 한다.
|
||||
- reversal은 `cash_balance`와 `asset_quantity`를 변경하지 않아야 한다.
|
||||
- 실패한 reversal은 새 원장이나 부분 snapshot을 남기지 않아야 한다.
|
||||
|
||||
### REQ-001.07-AC01 — Decimal round-trip
|
||||
|
||||
**Criterion**
|
||||
|
||||
API가 수용한 최대 8자리 소수의 `amount`와 `tax`는 PostgreSQL 저장, 조회, 중복 비교와 reversal에서 같은 Decimal 의미를 유지해야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- `0.00000001` 등 경계값을 기록하고 DB text 표현·API 응답·reversal 결과를 비교한다.
|
||||
- float로 변환하면 같아질 수 있는 서로 다른 Decimal 입력이 동일한 중복으로 판정되지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 수용된 원장 값과 snapshot 효과가 달라져서는 안 된다.
|
||||
- reversal 기준은 원본의 정확한 Decimal 금액이어야 한다.
|
||||
|
||||
### REQ-001.08-AC01 — 기존 action 회귀 없음
|
||||
|
||||
**Criterion**
|
||||
|
||||
기존 manual action은 `DIVIDEND` 도입 전과 같은 유효 입력, 상태 효과와 충돌 정책을 유지해야 한다. 새 배당 전용 field를 사용하지 않는 기존 호출자는 변경 없이 동작해야 한다. 비-`DIVIDEND` action에 `confirm_duplicate=false`를 명시한 요청은 허용하고, `confirm_duplicate=true`를 명시한 요청은 원장 기록 전에 HTTP 422로 거부해야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 기존 action 전체의 정상·잔액 부족·reversal 경로를 실행한다.
|
||||
- 새 배당 전용 field를 생략한 기존 요청과 저장 데이터의 회귀를 확인한다.
|
||||
- 비-`DIVIDEND` action에 `confirm_duplicate=false`를 명시한 요청이 기존 action과 동일하게 처리되는지 확인한다.
|
||||
- 비-`DIVIDEND` action에 `confirm_duplicate=true`를 명시한 요청이 HTTP 422이며 상태가 변하지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | negative | boundary | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 기존 action의 계산식과 원장 표현이 변경되지 않아야 한다.
|
||||
- 새 배당 전용 field의 기본값은 기존 호출자를 깨뜨리지 않아야 한다.
|
||||
- 비-`DIVIDEND` action의 `confirm_duplicate=true` 거부는 원장과 portfolio snapshot을 변경하지 않아야 한다.
|
||||
|
||||
### REQ-001.09-AC01 — 권한 유지와 민감정보 비노출
|
||||
|
||||
**Criterion**
|
||||
|
||||
배당 기록은 기존 portfolio 소유권과 manual-write 확인 정책을 통과한 사용자에게만 허용되어야 한다. 응답·오류·로그는 이 기능으로 인해 secret, credential 또는 불필요한 account 식별정보를 추가로 노출하지 않아야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 기존 인증·소유권·daemon-managed portfolio 확인 경로를 실행한다.
|
||||
- 정상 응답, 422, 409와 persistence 오류의 body 및 rendered log를 민감정보 sentinel로 검사한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL, application log
|
||||
- Cases: `positive | negative | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 권한이 없는 요청은 원장과 snapshot을 변경하지 않아야 한다.
|
||||
- 오류 경로에서도 credential과 connection 정보가 노출되지 않아야 한다.
|
||||
|
||||
## 8. 오류 및 충돌 계약
|
||||
|
||||
- 유효하지 않은 schema 입력은 HTTP 422로 거부하고 상태를 변경하지 않는다.
|
||||
- 중복 후보의 최초 요청은 HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND`와 `matched_ledger_entry_id`를 반환한다.
|
||||
- 사용자 확인이 필요한 daemon-managed portfolio 정책과 disabled portfolio 정책은 기존 HTTP 409 계약을 유지한다.
|
||||
- reversal 대상 없음, 중복 reversal과 잔액 부족은 기존 manual ledger 충돌 계약을 유지한다.
|
||||
- 입력 검증 또는 저장 실패로 요청을 기록할 수 없으면 부분 기록 없이 거부한다.
|
||||
- DB 오류 또는 transaction 실패 시 원장 append와 portfolio snapshot update를 모두 rollback한다.
|
||||
|
||||
## 9. 데이터 및 인터페이스 계약
|
||||
|
||||
### 요청
|
||||
|
||||
- `action`: `DIVIDEND`
|
||||
- `amount`: 필수, `>0`, Decimal, 최대 20자리·소수점 이하 최대 8자리
|
||||
- `tax`: 선택, 기본값 `0`, `>=0`, Decimal, 최대 20자리·소수점 이하 최대 8자리
|
||||
- `executed_at`: 필수 date-only `YYYY-MM-DD`
|
||||
- `note`: 선택 문자열
|
||||
- `confirm_duplicate`: 선택 boolean, 기본값 `false`
|
||||
|
||||
`confirm_duplicate`는 `DIVIDEND`의 중복 확인 계약에 사용한다. 비-`DIVIDEND` action에서는 명시적 `false`를 허용하고 명시적 `true`를 HTTP 422로 거부한다.
|
||||
|
||||
### 허용하지 않는 DIVIDEND 입력
|
||||
|
||||
- `quantity`
|
||||
- 거래 `price`
|
||||
- `fee`
|
||||
- `target_ledger_entry_id`
|
||||
- 주문 또는 체결 식별자
|
||||
|
||||
### 생성 응답과 unified ledger
|
||||
|
||||
- `ledger_entry_id`
|
||||
- `portfolio_id`
|
||||
- `source=manual`
|
||||
- `action=DIVIDEND`
|
||||
- canonical `amount`
|
||||
- `tax` 또는 unified ledger의 `tax_amount`
|
||||
- UTC 자정의 `executed_at`
|
||||
- 서버 append 시각 `recorded_at`
|
||||
- `note`
|
||||
- `cash_balance_after`
|
||||
- `asset_quantity_after`
|
||||
- `profit_buffer_after`
|
||||
- `total_value_after`
|
||||
|
||||
### 저장 계약
|
||||
|
||||
- 입금일 전용 DB column을 추가하지 않고 기존 `executed_at`을 사용한다.
|
||||
- `tax`는 참고 metadata로 저장한다.
|
||||
- 배당 사실과 after-snapshot은 `source=manual`인 append-only 원장으로 보존한다.
|
||||
|
||||
## 10. 하위호환성
|
||||
|
||||
- 기존 manual ledger action의 필수·선택 field와 상태 계산을 유지한다.
|
||||
- 기존 broker ledger write와 unified ledger 조회 계약을 유지한다.
|
||||
- 새 배당 전용 field를 사용하지 않는 기존 action 요청을 변경 없이 허용한다.
|
||||
- `REVERSAL`의 대상 없음, 잔액 부족과 중복 reversal 정책을 변경하지 않는다.
|
||||
|
||||
## 11. 검증 계획
|
||||
|
||||
- API schema와 OpenAPI에서 `DIVIDEND` action 및 입력·응답 field 검증
|
||||
- 배당 기록 전후 portfolio와 ledger after-snapshot 비교
|
||||
- UTC 입금일과 서버 `recorded_at` 분리 검증
|
||||
- 최초 중복 409, 확인 재제출, reversal된 원본 제외 검증
|
||||
- 동시 중복 요청의 최대 1건 기록과 최종 원장·snapshot 일치 검증
|
||||
- 최소 단위와 저장 범위 경계값의 실제 PostgreSQL Decimal round-trip 검증
|
||||
- 입력 또는 저장 실패의 pre-write rejection과 rollback 검증
|
||||
- 기존 action과 기존 저장 데이터의 하위호환 회귀
|
||||
- 응답·오류·로그의 민감정보 비노출 검증
|
||||
- 문서와 OpenAPI 계약 정합성 검증
|
||||
|
||||
### Test Traceability Contract
|
||||
|
||||
- 이 requirement 문서에는 검증 의도와 판정 조건만 기록한다.
|
||||
- 실행 가능한 테스트 정의는 별도 test catalog artifact에서 관리한다.
|
||||
- 각 테스트는 안정적인 identifier와 `verifies` 목록으로 하나 이상의 `REQ-001.XX-ACYY`를 참조한다.
|
||||
- 테스트 정의의 존재, 자동화 여부, 특정 commit에서의 실행 여부와 PASS 결과를 별도 상태로 관리한다.
|
||||
- 실행 결과는 requirement나 test definition을 수정하지 않고 별도 evidence에 기록한다.
|
||||
- Evidence는 최소한 `REQ-001` revision, 검증한 commit SHA, runtime/backend, 결과와 실행 시각을 포함한다.
|
||||
|
||||
## 12. 추적성
|
||||
|
||||
- 근거 이슈: `https://github.com/fourblocks2020/portfolio-hub/issues/31`
|
||||
- 구현 PR: `https://github.com/fourblocks2020/portfolio-hub/pull/33`
|
||||
- 검토한 PR head: `6df3e4ba029cc465bf28030767190d6af55c5402`
|
||||
- 관련 설계 문서:
|
||||
- `docs/interface-contract.md`
|
||||
- `docs/data-model.md`
|
||||
- `docs/runtime-flows.md`
|
||||
- 관련 requirement: 없음
|
||||
|
||||
## 13. 확정된 결정
|
||||
|
||||
- 사용자는 실수령액 `amount`를 입력한다.
|
||||
- `tax`는 기본값 `0`인 선택적 non-negative metadata이며 자동 계산하지 않는다.
|
||||
- 입금일은 새 DB column 없이 기존 `executed_at`에 저장한다.
|
||||
- date-only 입금일은 UTC 자정으로 변환한다.
|
||||
- 최초 중복 후보 요청은 409로 거부하고 `confirm_duplicate=true`인 재요청만 기록한다.
|
||||
- 구현 범위는 portfolio-hub API·DB·OpenAPI·테스트이며 UI는 별도 연계 범위다.
|
||||
- 잘못 입력한 배당은 기존 append-only `REVERSAL`로 정정한다.
|
||||
- 비-`DIVIDEND` action에서는 `confirm_duplicate=false`를 허용하고 `confirm_duplicate=true`를 HTTP 422로 거부한다.
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
- 없음
|
||||
|
||||
## 15. 승인 기록
|
||||
|
||||
- 상태: `approved`
|
||||
- 승인 주체: `genichin`
|
||||
- 승인 근거: 이 대화에서 REQ-001 승인과 비-DIVIDEND confirm_duplicate=false 허용/true HTTP 422 결정을 명시적으로 확정함
|
||||
@@ -0,0 +1,112 @@
|
||||
# REQ-001 Test Catalog
|
||||
|
||||
Test ID: TC-REQ-001-001
|
||||
Verifies:
|
||||
- REQ-001.01-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_tax_defaults_to_zero_when_omitted
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_tax_omitted_persists_zero_on_real_postgres
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-002
|
||||
Verifies:
|
||||
- REQ-001.01-AC02
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_invalid_amount_tax_and_unrelated_fields_without_state_change
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_sub_minimum_numeric_precision
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_amount_exceeding_numeric_20_8
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-003
|
||||
Verifies:
|
||||
- REQ-001.02-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_tax_defaults_to_zero_when_omitted
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-004
|
||||
Verifies:
|
||||
- REQ-001.03-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_executed_at_uses_utc_midnight_independent_of_server_timezone
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-005
|
||||
Verifies:
|
||||
- REQ-001.04-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_includes_dividend_fields
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_openapi_contains_nullable_amount
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_history_item_rejects_dividend_numeric_branch_payload
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-006
|
||||
Verifies:
|
||||
- REQ-001.05-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_requires_confirmation
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-007
|
||||
Verifies:
|
||||
- REQ-001.05-AC02
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_requires_confirmation
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_reversed_dividend_is_excluded_from_future_duplicate_candidate_matching
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-008
|
||||
Verifies:
|
||||
- REQ-001.05-AC03
|
||||
Test reference:
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_cold_repositories_concurrent_dividend_requests_follow_duplicate_contract
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-009
|
||||
Verifies:
|
||||
- REQ-001.06-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_reversal_decreases_profit_buffer
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_duplicate_reversal_is_rejected_without_state_change
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_reversal_insufficient_buffer_is_rejected_without_state_change
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-010
|
||||
Verifies:
|
||||
- REQ-001.07-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_check_does_not_collapse_decimals_that_can_alias_in_float
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_tax_does_not_collapse_decimals_that_can_alias_in_float
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_amount_alias_prone_decimals_are_not_misdetected_as_duplicate_in_api_on_real_postgres
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_existing_buffer_plus_min_dividend_is_rejected_before_snapshot_divergence
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_tax_alias_prone_decimals_round_trip_distinctly_on_real_postgres
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-011
|
||||
Verifies:
|
||||
- REQ-001.08-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_false_is_accepted_for_non_dividend_actions
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_true_is_rejected_for_non_dividend_actions
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_keeps_non_dividend_amount_as_number
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_existing_high_precision_snapshot_still_allows_legacy_manual_actions
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_invest_and_large_dividend_tax_metadata_are_accepted_when_snapshot_is_safe
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-012
|
||||
Verifies:
|
||||
- REQ-001.09-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_manual_write_rejects_service_token
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_owner_mismatch_returns_403
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_daemon_managed_requires_confirmation_then_records_evidence
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_manual_ledger_conflict_message_is_sanitized
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_manual_ledger_conflict_secret_is_not_rendered_in_logs
|
||||
Automated: true
|
||||
@@ -0,0 +1,78 @@
|
||||
# REQ-001 Test Evidence
|
||||
|
||||
## Evidence Batch: local API regression run
|
||||
|
||||
Requirement revision: `REQ-001@1.0`
|
||||
Commit: `8368e7ba165b560af6ae813fa2dce715657515f5`
|
||||
Environment: `local sandbox / pytest / fake repository`
|
||||
Executed at: `2026-09-09T13:41:00Z`
|
||||
Run selector: `pytest -q -o addopts='' tests/test_v1_portfolios_manual_ledger.py`
|
||||
Run result: `38 passed`
|
||||
|
||||
Test ID: TC-REQ-001-002
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-003
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-004
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-005
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-006
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-007
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-009
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-010
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-012
|
||||
Result: PASS
|
||||
|
||||
## Evidence Batch: local real PostgreSQL selector availability
|
||||
|
||||
Requirement revision: `REQ-001@1.0`
|
||||
Commit: `8368e7ba165b560af6ae813fa2dce715657515f5`
|
||||
Environment: `local sandbox / PostgreSQL DSN not configured`
|
||||
Executed at: `2026-09-09T14:19:00Z`
|
||||
Run selector: `pytest -q -o addopts='' tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_amount_alias_prone_decimals_are_not_misdetected_as_duplicate_in_api_on_real_postgres`
|
||||
Run result: `1 skipped`
|
||||
|
||||
## Evidence Batch: independent real PostgreSQL re-review run
|
||||
|
||||
Requirement revision: `REQ-001@1.0`
|
||||
Commit: `db35d083dec75bc79b5a97b015b5c40056c12772`
|
||||
Environment: `disposable PostgreSQL / independent re-review (review 5155656169)`
|
||||
Executed at: `2026-09-09T14:25:22Z`
|
||||
Run selector: `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py (module)`
|
||||
Run result: `10 passed, 2 failed` (`2 failed` are pre-existing stale migration-head baseline)
|
||||
|
||||
Test ID: TC-REQ-001-001
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-003
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-008
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-009
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-010
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-011
|
||||
Result: PASS
|
||||
|
||||
## Evidence-only follow-up integrity note
|
||||
|
||||
- Tested code/test commit for the latest PostgreSQL batch: `db35d083dec75bc79b5a97b015b5c40056c12772`
|
||||
- This follow-up updates evidence text only; no application/test source files were changed after the tested commit.
|
||||
@@ -280,11 +280,12 @@
|
||||
3. Disabled portfolio receives HTTP 409 `PORTFOLIO_DISABLED`.
|
||||
4. If `managed_by_daemon_id IS NOT NULL` and request lacks `confirm_daemon_managed_manual_write=true`, the API returns HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION` with daemon id and required confirmation marker.
|
||||
5. Valid actions dispatch to a single repository transaction that locks the portfolio row (`FOR UPDATE OF p`), calculates the action effect, appends a `portfolio_ledger` row with `source='manual'`, and updates the portfolio snapshot.
|
||||
6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. Insufficient state returns HTTP 409 with structured code.
|
||||
6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. DIVIDEND requires `amount` and date-only `executed_at`, stores `executed_at` at UTC midnight, and increases only `profit_buffer` (`tax` metadata only). Insufficient state returns HTTP 409 with structured code.
|
||||
7. REVERSAL requires `target_ledger_entry_id`, rejects duplicate reversal targets, and appends a reversing row instead of mutating/deleting the target.
|
||||
8. Response includes after-snapshot: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`.
|
||||
9. `GET /v1/portfolios/{portfolio_id}/ledger/manual` returns newest manual entries only (`source='manual'`).
|
||||
10. `GET /v1/portfolios/{portfolio_id}/returns/summary` returns `net_contributed_capital=sum(INVEST)-sum(WITHDRAW)` and `cumulative_profit_withdrawn=sum(PROFIT)` separately.
|
||||
8. Duplicate DIVIDEND candidate (same portfolio/date/amount/note and original not reversed) returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` with matched entry id; `confirm_duplicate=true` re-submit records it.
|
||||
9. Response includes after-snapshot: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`.
|
||||
10. `GET /v1/portfolios/{portfolio_id}/ledger/manual` returns newest manual entries only (`source='manual'`).
|
||||
11. `GET /v1/portfolios/{portfolio_id}/returns/summary` returns `net_contributed_capital=sum(INVEST)-sum(WITHDRAW)` and `cumulative_profit_withdrawn=sum(PROFIT)` separately.
|
||||
|
||||
## Flow: `v1 API — POST /auth/service-tokens`
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portfoliohub"
|
||||
version = "0.2.0"
|
||||
version = "0.3.4"
|
||||
description = "PortfolioHub API for portfolio management and ledger access"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -25,8 +25,8 @@ COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
VERSION_REF_RE = re.compile(r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
|
||||
IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)")
|
||||
EXPECTED_MIGRATION_COUNT = 29
|
||||
EXPECTED_ALEMBIC_HEAD = "20260814_0029"
|
||||
EXPECTED_MIGRATION_COUNT = 31
|
||||
EXPECTED_ALEMBIC_HEAD = "20260913_0031"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Reject Ruff findings introduced since a stable release tag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _run(command: list[str], *, cwd: Path, accepted: tuple[int, ...] = (0,)) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False)
|
||||
if result.returncode not in accepted:
|
||||
raise RuntimeError(
|
||||
f"command failed ({result.returncode}): {' '.join(command)}\n{result.stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def changed_python_paths(repo: Path, base: str) -> list[str]:
|
||||
result = _run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--diff-filter=ACMR",
|
||||
f"{base}...HEAD",
|
||||
"--",
|
||||
"*.py",
|
||||
],
|
||||
cwd=repo,
|
||||
)
|
||||
return sorted(path for path in result.stdout.split("\0") if path)
|
||||
|
||||
|
||||
def _source_line(root: Path, relative_path: str, row: int) -> str:
|
||||
lines = (root / relative_path).read_text(encoding="utf-8").splitlines()
|
||||
return lines[row - 1].strip() if 0 < row <= len(lines) else ""
|
||||
|
||||
|
||||
def normalized_findings(
|
||||
diagnostics: list[dict[str, object]], root: Path
|
||||
) -> Counter[tuple[str, str, str, str]]:
|
||||
findings: Counter[tuple[str, str, str, str]] = Counter()
|
||||
for diagnostic in diagnostics:
|
||||
filename = Path(str(diagnostic["filename"])).resolve()
|
||||
relative_path = filename.relative_to(root.resolve()).as_posix()
|
||||
location = diagnostic["location"]
|
||||
if not isinstance(location, dict):
|
||||
raise TypeError("Ruff diagnostic location must be an object")
|
||||
row = int(location["row"])
|
||||
finding = (
|
||||
relative_path,
|
||||
str(diagnostic["code"]),
|
||||
str(diagnostic["message"]),
|
||||
_source_line(root, relative_path, row),
|
||||
)
|
||||
findings[finding] += 1
|
||||
return findings
|
||||
|
||||
|
||||
def _ruff_diagnostics(root: Path, paths: list[str]) -> list[dict[str, object]]:
|
||||
if not paths:
|
||||
return []
|
||||
ruff = shutil.which("ruff")
|
||||
if ruff is None:
|
||||
raise RuntimeError("ruff executable is not available")
|
||||
result = _run(
|
||||
[ruff, "check", *paths, "--output-format=json"],
|
||||
cwd=root,
|
||||
accepted=(0, 1),
|
||||
)
|
||||
payload = json.loads(result.stdout)
|
||||
if not isinstance(payload, list):
|
||||
raise TypeError("Ruff JSON output must be a list")
|
||||
return payload
|
||||
|
||||
|
||||
def introduced_findings(repo: Path, base: str) -> tuple[list[str], Counter[tuple[str, str, str, str]]]:
|
||||
paths = changed_python_paths(repo, base)
|
||||
if not paths:
|
||||
return paths, Counter()
|
||||
|
||||
current = normalized_findings(_ruff_diagnostics(repo, paths), repo)
|
||||
with tempfile.TemporaryDirectory(prefix="portfoliohub-ruff-base-") as parent:
|
||||
baseline_root = Path(parent) / "tree"
|
||||
_run(["git", "worktree", "add", "--detach", str(baseline_root), base], cwd=repo)
|
||||
try:
|
||||
baseline_paths = [path for path in paths if (baseline_root / path).is_file()]
|
||||
baseline = normalized_findings(
|
||||
_ruff_diagnostics(baseline_root, baseline_paths), baseline_root
|
||||
)
|
||||
finally:
|
||||
_run(
|
||||
["git", "worktree", "remove", "--force", str(baseline_root)],
|
||||
cwd=repo,
|
||||
)
|
||||
return paths, current - baseline
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", required=True, help="stable comparison tag")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = Path(
|
||||
_run(["git", "rev-parse", "--show-toplevel"], cwd=Path.cwd()).stdout.strip()
|
||||
)
|
||||
ruff_version = _run([shutil.which("ruff") or "ruff", "--version"], cwd=repo).stdout.strip()
|
||||
paths, introduced = introduced_findings(repo, args.base)
|
||||
print(f"base={args.base} head=HEAD selector=ACMR-python files={len(paths)} tool={ruff_version}")
|
||||
for path in paths:
|
||||
print(f"selected={path}")
|
||||
if introduced:
|
||||
for (path, code, message, source), count in sorted(introduced.items()):
|
||||
print(f"NEW {count}x {path}: {code} {message} | {source}")
|
||||
print(f"introduced_findings={sum(introduced.values())}")
|
||||
return 1
|
||||
print("introduced_findings=0")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -33,8 +33,8 @@ class RecordingRunner:
|
||||
if command[:3] == ["docker", "run", "--rm"] and "urllib.request" in command[-1]:
|
||||
enabled = "TARGET:8000" not in command[-1] and "plain" not in command[-1]
|
||||
return image_release.Result('{"status":"ok","db_enabled":' + str(enabled).lower() + '}')
|
||||
if command[:3] == ["docker", "run", "--rm"] and "alembic_version" in command[-1]: return image_release.Result("20260814_0029\n")
|
||||
if command[:3] == ["docker", "run", "--rm"] and "health-check" in command: return image_release.Result('{"database_revision":"20260814_0029"}\n')
|
||||
if command[:3] == ["docker", "run", "--rm"] and "alembic_version" in command[-1]: return image_release.Result("20260913_0031\n")
|
||||
if command[:3] == ["docker", "run", "--rm"] and "health-check" in command: return image_release.Result('{"database_revision":"20260913_0031"}\n')
|
||||
return image_release.Result()
|
||||
|
||||
def artifacts(tmp_path: Path):
|
||||
@@ -73,13 +73,13 @@ def test_project_version_must_match_release_tag(tmp_path):
|
||||
|
||||
def test_installed_smoke_has_import_cli_exact_inventory_and_cleanup():
|
||||
inventory = tuple(
|
||||
[f"202601{i:02d}_{i:04d}_migration.py" for i in range(1, 29)]
|
||||
+ ["20260814_0029_strategy_catalog_v1.py"]
|
||||
[f"202601{i:02d}_{i:04d}_migration.py" for i in range(1, 30)]
|
||||
+ ["20260820_0030_portfolio_ledger_keyset_index.py", "20260913_0031_corporate_actions.py"]
|
||||
)
|
||||
runner = RecordingRunner(); image_release.smoke_installed_image("candidate", inventory, runner, 17)
|
||||
runs = [x[0] for x in runner.commands if x[0][:2] == ["docker","run"]]
|
||||
assert len(runs) == 3 and all("--read-only" in x and "none" in x for x in runs)
|
||||
assert "migration_inventory" in runs[-1][-1] and "20260814_0029" in runs[-1][-1]
|
||||
assert "migration_inventory" in runs[-1][-1] and "20260913_0031" in runs[-1][-1]
|
||||
assert len([x for x in runner.commands if x[0][:3] == ["docker","rm","--force"]]) == 3
|
||||
|
||||
def _assert_exact_startup_cleanup(commands: list[list[str]]) -> None:
|
||||
|
||||
@@ -16,7 +16,11 @@ def test_branch_verification_has_no_registry_write_authority_or_path():
|
||||
text=VERIFY.read_text(); assert "branches: [main]" in text and "pull_request:" in text
|
||||
for forbidden in ("packages: write","docker login","docker push","REGISTRY_TOKEN","image_release.py publish"):
|
||||
assert forbidden not in text
|
||||
assert "uv sync --extra dev --locked" in text and "uv run pytest -q" in text
|
||||
assert "uv sync --extra dev --locked" in text and "uv run python -m pytest -q" in text
|
||||
assert "Verify corporate-action atomicity and guarded recovery in PostgreSQL" in text
|
||||
assert "tests/test_corporate_action_atomic_recovery_postgres.py" in text
|
||||
assert "${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres" in text
|
||||
assert "***@" not in text
|
||||
|
||||
def test_tag_publication_is_strict_single_build_handoff_without_latest():
|
||||
text=PUBLISH.read_text(); verify,publish=text.split("\n publish:",1)
|
||||
@@ -50,7 +54,7 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive
|
||||
assert 'RELEASE_VERSION=v0.1.3' not in verify
|
||||
|
||||
|
||||
def test_release_version_and_migration_contract_are_v020_head_0030():
|
||||
def test_release_version_and_migration_contract_has_current_head_0031():
|
||||
project = (REPO / "pyproject.toml").read_text()
|
||||
config = (REPO / "app/core/config.py").read_text()
|
||||
migrations = sorted(
|
||||
@@ -58,10 +62,10 @@ def test_release_version_and_migration_contract_are_v020_head_0030():
|
||||
for path in (REPO / "app/db/alembic/versions").glob("*.py")
|
||||
if path.name != "__init__.py"
|
||||
)
|
||||
assert 'version = "0.2.0"' in project
|
||||
assert 'app_version: str = "0.2.0"' in config
|
||||
assert len(migrations) == 30
|
||||
assert migrations[-1].startswith("20260820_0030")
|
||||
assert 'version = "0.3.4"' in project
|
||||
assert 'app_version: str = "0.3.4"' in config
|
||||
assert len(migrations) == 31
|
||||
assert migrations[-1].startswith("20260913_0031_")
|
||||
|
||||
def test_dockerignore_is_deny_by_default_required_inputs_only():
|
||||
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"
|
||||
@@ -85,8 +89,8 @@ def test_operations_separate_publication_deployment_migration_and_db_rollback():
|
||||
"Image rollback is **not** database rollback",
|
||||
"read-only root filesystem",
|
||||
"10001:10001",
|
||||
"PortfolioHub v0.2.0 container contract",
|
||||
"v0.2.0 adds migration `0030`",
|
||||
"PortfolioHub v0.3.4 container contract",
|
||||
"v0.2.1 includes migration `0030`",
|
||||
"trex-web v0.2.3 displays `strategy_nick`",
|
||||
):
|
||||
assert value in text
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from collections import Counter
|
||||
|
||||
from scripts.ruff_ratchet import normalized_findings
|
||||
|
||||
|
||||
def test_normalized_findings_ignore_line_number_changes(tmp_path):
|
||||
source = tmp_path / "sample.py"
|
||||
source.write_text("\nvalue = missing\n", encoding="utf-8")
|
||||
diagnostics = [
|
||||
{
|
||||
"filename": str(source),
|
||||
"code": "F821",
|
||||
"message": "Undefined name `missing`",
|
||||
"location": {"row": 2, "column": 9},
|
||||
}
|
||||
]
|
||||
|
||||
assert normalized_findings(diagnostics, tmp_path) == Counter(
|
||||
{("sample.py", "F821", "Undefined name `missing`", "value = missing"): 1}
|
||||
)
|
||||
|
||||
|
||||
def test_normalized_findings_preserve_duplicate_count(tmp_path):
|
||||
source = tmp_path / "sample.py"
|
||||
source.write_text("missing\n", encoding="utf-8")
|
||||
diagnostic = {
|
||||
"filename": str(source),
|
||||
"code": "F821",
|
||||
"message": "Undefined name `missing`",
|
||||
"location": {"row": 1, "column": 1},
|
||||
}
|
||||
|
||||
assert normalized_findings([diagnostic, diagnostic], tmp_path).total() == 2
|
||||
@@ -9,20 +9,27 @@ or production application database.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
import threading
|
||||
from contextlib import ExitStack
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from urllib.parse import unquote, urlparse
|
||||
from uuid import uuid4
|
||||
from unittest.mock import patch
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from psycopg import sql
|
||||
|
||||
from app.core.auth import AuthContext, Permission, require_read
|
||||
from app.core.config import Settings
|
||||
from app.db.migrations import (
|
||||
bootstrap_or_upgrade_to_head,
|
||||
get_alembic_config,
|
||||
upgrade_to_head,
|
||||
)
|
||||
from app.main import create_app
|
||||
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
|
||||
|
||||
_LOCAL_DSN_ENV = "BAT025_LOCAL_POSTGRES_DSN"
|
||||
@@ -105,10 +112,9 @@ def _current_revision(settings: Settings) -> str:
|
||||
user=settings.active_db_user,
|
||||
password=settings.active_db_password,
|
||||
sslmode=settings.active_db_sslmode,
|
||||
) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT version_num FROM alembic_version")
|
||||
return str(cur.fetchone()[0])
|
||||
) as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT version_num FROM alembic_version")
|
||||
return str(cur.fetchone()[0])
|
||||
|
||||
|
||||
def _metadata_columns(settings: Settings) -> dict[str, tuple[str, str, str | None]]:
|
||||
@@ -119,18 +125,17 @@ def _metadata_columns(settings: Settings) -> dict[str, tuple[str, str, str | Non
|
||||
user=settings.active_db_user,
|
||||
password=settings.active_db_password,
|
||||
sslmode=settings.active_db_sslmode,
|
||||
) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'portfolio_ledger'
|
||||
AND column_name IN ('source', 'target_ledger_entry_id',
|
||||
'confirmation_evidence', 'actor_user_id')
|
||||
"""
|
||||
)
|
||||
return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()}
|
||||
)
|
||||
return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()}
|
||||
|
||||
|
||||
def _seed_portfolio(repo: PostgresPortfolioRepository, suffix: str) -> str:
|
||||
@@ -159,6 +164,23 @@ def _seed_portfolio(repo: PostgresPortfolioRepository, suffix: str) -> str:
|
||||
return portfolio_id
|
||||
|
||||
|
||||
def _api_client(settings: Settings, repo: PostgresPortfolioRepository, *, user_id: str) -> tuple[TestClient, ExitStack]:
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[require_read] = lambda: AuthContext(
|
||||
token="bat025-test-token",
|
||||
permission=Permission.READ,
|
||||
user_id=user_id,
|
||||
token_type="session",
|
||||
token_source="db",
|
||||
)
|
||||
stack = ExitStack()
|
||||
stack.enter_context(patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo))
|
||||
stack.enter_context(patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo))
|
||||
stack.enter_context(patch("app.api.v1_portfolios.get_settings", return_value=settings))
|
||||
client = TestClient(app)
|
||||
return client, stack
|
||||
|
||||
|
||||
def test_upgrade_from_pre_0028_preserves_business_data_and_backfills_source(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
@@ -168,7 +190,7 @@ def test_upgrade_from_pre_0028_preserves_business_data_and_backfills_source(
|
||||
command.upgrade(get_alembic_config(isolated_settings), _PRE_BAT025)
|
||||
repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(repo, "preserve")
|
||||
recorded_at = datetime(2026, 7, 15, tzinfo=timezone.utc)
|
||||
recorded_at = datetime(2026, 7, 15, tzinfo=UTC)
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -248,7 +270,7 @@ def test_broker_and_manual_ledger_writes_regress_after_real_upgrade(
|
||||
side="buy",
|
||||
quantity=2,
|
||||
price=100,
|
||||
executed_at=datetime.now(timezone.utc),
|
||||
executed_at=datetime.now(UTC),
|
||||
)
|
||||
manual = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
@@ -271,3 +293,314 @@ def test_broker_and_manual_ledger_writes_regress_after_real_upgrade(
|
||||
)
|
||||
assert cur.fetchone() == ("manual", "bat025-user", "synthetic-local-fixture")
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_dividend_precision_and_reversal_round_trip_on_real_postgres(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
"""DIVIDEND min unit persists and REVERSAL removes the same amount exactly."""
|
||||
upgrade_to_head(isolated_settings)
|
||||
repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(repo, "dividend-precision")
|
||||
|
||||
dividend = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="DIVIDEND",
|
||||
amount=Decimal("0.00000001"),
|
||||
tax=Decimal("0.00000001"),
|
||||
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
|
||||
note="precision-test",
|
||||
)
|
||||
|
||||
assert dividend.price == 0.00000001
|
||||
assert dividend.tax_amount == 0.00000001
|
||||
assert dividend.tax_amount_decimal == Decimal("0.00000001")
|
||||
assert dividend.profit_buffer_after == 0.00000001
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT price::text, tax_amount::text, profit_buffer_after::text FROM portfolio_ledger WHERE ledger_entry_id = %s",
|
||||
(dividend.ledger_entry_id,),
|
||||
)
|
||||
assert cur.fetchone() == ("0.00000001", "0.00000001", "0.00000001")
|
||||
|
||||
reversal = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="REVERSAL",
|
||||
target_ledger_entry_id=dividend.ledger_entry_id,
|
||||
)
|
||||
assert reversal.profit_buffer_after == 0.0
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute("SELECT profit_buffer::text FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
assert cur.fetchone()[0] == "0"
|
||||
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_dividend_tax_omitted_persists_zero_on_real_postgres(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
"""Omitted tax should persist as zero in PostgreSQL."""
|
||||
upgrade_to_head(isolated_settings)
|
||||
repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(repo, "dividend-tax-omitted")
|
||||
|
||||
dividend = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="DIVIDEND",
|
||||
amount=Decimal("1.00000000"),
|
||||
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
|
||||
note="tax-omitted",
|
||||
)
|
||||
assert dividend.tax_amount == 0.0
|
||||
assert dividend.tax_amount_decimal == Decimal("0.00000000")
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute("SELECT tax_amount::text FROM portfolio_ledger WHERE ledger_entry_id = %s", (dividend.ledger_entry_id,))
|
||||
assert cur.fetchone()[0] == "0.00000000"
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_existing_high_precision_snapshot_still_allows_legacy_manual_actions(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
"""Existing DOUBLE snapshot precision should not block unrelated manual actions."""
|
||||
upgrade_to_head(isolated_settings)
|
||||
repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(repo, "legacy-snapshot-precision")
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE portfolios SET cash_balance = %s WHERE portfolio_id = %s",
|
||||
(0.123456789, portfolio_id),
|
||||
)
|
||||
|
||||
entry = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="INVEST",
|
||||
amount=Decimal("1.00000000"),
|
||||
note="compat",
|
||||
)
|
||||
assert entry.action == "INVEST"
|
||||
assert entry.price == 1.0
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_dividend_large_integer_with_8dp_is_rejected_to_protect_double_snapshot(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
"""Inputs valid for NUMERIC(20,8) but unsafe for DOUBLE snapshot are rejected."""
|
||||
upgrade_to_head(isolated_settings)
|
||||
repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(repo, "double-snapshot-guard")
|
||||
|
||||
with pytest.raises(ValueError, match="DOUBLE PRECISION snapshot"):
|
||||
repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="DIVIDEND",
|
||||
amount=Decimal("1000000000.00000001"),
|
||||
tax=Decimal("0.00000001"),
|
||||
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
|
||||
note="too-large-for-double",
|
||||
)
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_large_existing_buffer_plus_min_dividend_is_rejected_before_snapshot_divergence(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
"""Reject writes that would diverge between NUMERIC ledger and DOUBLE snapshot."""
|
||||
upgrade_to_head(isolated_settings)
|
||||
repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(repo, "large-buffer-min-dividend")
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute("UPDATE portfolios SET profit_buffer = %s WHERE portfolio_id = %s", (1_000_000_000.0, portfolio_id))
|
||||
|
||||
with pytest.raises(ValueError, match="DOUBLE PRECISION snapshot"):
|
||||
repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="DIVIDEND",
|
||||
amount=Decimal("0.00000001"),
|
||||
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
|
||||
note="should-reject",
|
||||
)
|
||||
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) FROM portfolio_ledger WHERE portfolio_id = %s AND side = 'dividend'", (portfolio_id,))
|
||||
assert cur.fetchone()[0] == 0
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_large_invest_and_large_dividend_tax_metadata_are_accepted_when_snapshot_is_safe(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
"""No hidden per-field cap for values that do not cause snapshot divergence."""
|
||||
upgrade_to_head(isolated_settings)
|
||||
repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(repo, "large-invest-large-tax")
|
||||
|
||||
invest = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="INVEST",
|
||||
amount=Decimal("50000000.00000000"),
|
||||
note="large-invest",
|
||||
)
|
||||
assert invest.price == 50000000.0
|
||||
|
||||
dividend = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="DIVIDEND",
|
||||
amount=Decimal("1.00000000"),
|
||||
tax=Decimal("50000000.00000000"),
|
||||
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
|
||||
note="large-tax-metadata",
|
||||
)
|
||||
assert dividend.tax_amount == 50000000.0
|
||||
assert dividend.tax_amount_decimal == Decimal("50000000.00000000")
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_dividend_tax_alias_prone_decimals_round_trip_distinctly_on_real_postgres(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
upgrade_to_head(isolated_settings)
|
||||
repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(repo, "tax-alias-roundtrip")
|
||||
|
||||
first = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="DIVIDEND",
|
||||
amount=Decimal("1.00000000"),
|
||||
tax=Decimal("90000000.00000001"),
|
||||
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
|
||||
note="alias-tax-1",
|
||||
)
|
||||
second = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="DIVIDEND",
|
||||
amount=Decimal("1.00000000"),
|
||||
tax=Decimal("90000000.00000002"),
|
||||
executed_at=datetime(2026, 9, 2, 0, 0, 0, tzinfo=UTC),
|
||||
note="alias-tax-2",
|
||||
)
|
||||
assert first.tax_amount_decimal == Decimal("90000000.00000001")
|
||||
assert second.tax_amount_decimal == Decimal("90000000.00000002")
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_dividend_amount_alias_prone_decimals_are_not_misdetected_as_duplicate_in_api_on_real_postgres(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
upgrade_to_head(isolated_settings)
|
||||
repo = _repo(isolated_settings)
|
||||
suffix = "amount-alias-api"
|
||||
portfolio_id = _seed_portfolio(repo, suffix)
|
||||
user_id = f"bat025-user-{suffix}"
|
||||
settings = isolated_settings.model_copy(update={"credential_encryption_key": "bat025-test-key"})
|
||||
|
||||
client, stack = _api_client(settings, repo, user_id=user_id)
|
||||
auth_header = {"Authorization": "Bea" + "rer bat025-test-token"}
|
||||
try:
|
||||
first = client.post(
|
||||
f"/v1/portfolios/{portfolio_id}/ledger/manual",
|
||||
json={
|
||||
"action": "DIVIDEND",
|
||||
"amount": "90000000.00000001",
|
||||
"tax": "0.00000000",
|
||||
"executed_at": "2026-09-01",
|
||||
"note": "amount-alias",
|
||||
},
|
||||
headers=auth_header,
|
||||
)
|
||||
second = client.post(
|
||||
f"/v1/portfolios/{portfolio_id}/ledger/manual",
|
||||
json={
|
||||
"action": "DIVIDEND",
|
||||
"amount": "90000000.00000002",
|
||||
"tax": "0.00000000",
|
||||
"executed_at": "2026-09-01",
|
||||
"note": "amount-alias",
|
||||
},
|
||||
headers=auth_header,
|
||||
)
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 201
|
||||
assert first.json()["amount"] == "90000000.00000001"
|
||||
assert second.json()["amount"] == "90000000.00000002"
|
||||
|
||||
history = client.get(
|
||||
f"/v1/portfolios/{portfolio_id}/ledger?source=manual",
|
||||
headers=auth_header,
|
||||
)
|
||||
assert history.status_code == 200
|
||||
amounts = [item["amount"] for item in history.json()["items"] if item["action"] == "DIVIDEND"]
|
||||
assert "90000000.00000001" in amounts
|
||||
assert "90000000.00000002" in amounts
|
||||
finally:
|
||||
client.close()
|
||||
stack.close()
|
||||
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT price::text
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND side = 'dividend' AND note = 'amount-alias'
|
||||
ORDER BY ledger_entry_id ASC
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
assert [row[0] for row in cur.fetchall()] == ["90000000.00000001", "90000000.00000002"]
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_cold_repositories_concurrent_dividend_requests_follow_duplicate_contract(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
"""Cold concurrent writes should not fail on ensure_schema DDL race."""
|
||||
upgrade_to_head(isolated_settings)
|
||||
seed_repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(seed_repo, "cold-concurrent-dividend")
|
||||
seed_repo.close()
|
||||
barrier = threading.Barrier(2)
|
||||
results: list[tuple[str, object]] = []
|
||||
|
||||
def _worker(note: str) -> None:
|
||||
repo = _repo(isolated_settings)
|
||||
try:
|
||||
barrier.wait()
|
||||
entry = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action="DIVIDEND",
|
||||
amount=Decimal("1.00000000"),
|
||||
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
|
||||
note=note,
|
||||
)
|
||||
results.append(("ok", entry))
|
||||
except ValueError as exc:
|
||||
results.append(("error", str(exc)))
|
||||
finally:
|
||||
repo.close()
|
||||
|
||||
t1 = threading.Thread(target=_worker, args=("same-note",))
|
||||
t2 = threading.Thread(target=_worker, args=("same-note",))
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
assert len(results) == 2
|
||||
successes = [value for kind, value in results if kind == "ok"]
|
||||
errors = [value for kind, value in results if kind == "error"]
|
||||
assert len(successes) == 1
|
||||
assert len(errors) == 1
|
||||
assert "POTENTIAL_DUPLICATE_DIVIDEND:" in str(errors[0])
|
||||
assert "duplicate key value" not in str(errors[0]).lower()
|
||||
|
||||
verify_repo = _repo(isolated_settings)
|
||||
with verify_repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM portfolio_ledger WHERE portfolio_id = %s AND side = 'dividend'",
|
||||
(portfolio_id,),
|
||||
)
|
||||
assert cur.fetchone()[0] == 1
|
||||
cur.execute("SELECT profit_buffer::text FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
assert cur.fetchone()[0] == "1"
|
||||
verify_repo.close()
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Real PostgreSQL regressions for atomic corporate-action writes and recovery."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from urllib.parse import unquote, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from psycopg import sql
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.db.migrations import upgrade_to_head
|
||||
from app.domain.corporate_actions import canonical_payload_hash
|
||||
from app.domain.models import CorporateAction
|
||||
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
|
||||
from app.schemas.corporate_actions import CorporateActionRequest
|
||||
|
||||
_LOCAL_DSN_ENV = "PORTFOLIOHUB_TEST_POSTGRES_DSN"
|
||||
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
||||
_PORTFOLIO_ID = "3573f4d6-cc27-4f91-82d9-6330e5250d20"
|
||||
_CASH = Decimal("400060.62490000")
|
||||
_TOTAL_COST = Decimal("1577211.97990000")
|
||||
|
||||
|
||||
def _maintenance_dsn() -> str:
|
||||
dsn = os.environ.get(_LOCAL_DSN_ENV)
|
||||
if not dsn:
|
||||
pytest.skip(f"{_LOCAL_DSN_ENV} is required for real PostgreSQL integration tests")
|
||||
parsed = urlparse(dsn)
|
||||
if parsed.scheme not in {"postgres", "postgresql"}:
|
||||
pytest.fail("test DSN must use postgres/postgresql")
|
||||
if parsed.hostname not in _LOOPBACK_HOSTS or parsed.path.lstrip("/") != "postgres":
|
||||
pytest.fail("tests require a loopback PostgreSQL maintenance database named postgres")
|
||||
return dsn
|
||||
|
||||
|
||||
def _settings(dsn: str, database: str) -> Settings:
|
||||
parsed = urlparse(dsn)
|
||||
return Settings(
|
||||
db_enabled=True,
|
||||
timescaledb_host=parsed.hostname,
|
||||
timescaledb_port=parsed.port or 5432,
|
||||
timescaledb_name=database,
|
||||
timescaledb_user=unquote(parsed.username or ""),
|
||||
timescaledb_password=unquote(parsed.password or ""),
|
||||
timescaledb_sslmode="disable",
|
||||
timescaledb_connect_timeout_sec=3,
|
||||
timescaledb_application_name="corporate-action-atomic-test",
|
||||
)
|
||||
|
||||
|
||||
def _repo(settings: Settings) -> PostgresPortfolioRepository:
|
||||
return PostgresPortfolioRepository(
|
||||
PortfolioDBConfig(
|
||||
host=settings.active_db_host,
|
||||
port=settings.active_db_port,
|
||||
dbname=settings.active_db_name,
|
||||
user=settings.active_db_user,
|
||||
password=settings.active_db_password,
|
||||
sslmode=settings.active_db_sslmode,
|
||||
connect_timeout_sec=settings.active_db_connect_timeout_sec,
|
||||
application_name=settings.active_db_application_name,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repository() -> PostgresPortfolioRepository:
|
||||
maintenance_dsn = _maintenance_dsn()
|
||||
database = f"portfolio_hub_ca_{uuid4().hex}"
|
||||
with psycopg.connect(maintenance_dsn, autocommit=True) as conn, conn.cursor() as cur:
|
||||
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database)))
|
||||
settings = _settings(maintenance_dsn, database)
|
||||
upgrade_to_head(settings)
|
||||
repo = _repo(settings)
|
||||
try:
|
||||
yield repo
|
||||
finally:
|
||||
repo.close()
|
||||
with psycopg.connect(maintenance_dsn, autocommit=True) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
||||
"WHERE datname = %s AND pid <> pg_backend_pid()",
|
||||
(database,),
|
||||
)
|
||||
cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(database)))
|
||||
|
||||
|
||||
def _seed_daekyo(repo: PostgresPortfolioRepository, *, quantity: Decimal) -> str:
|
||||
user_id = "ca-recovery-user"
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute("INSERT INTO users (id, username) VALUES (%s, %s)", (user_id, user_id))
|
||||
cur.execute(
|
||||
"INSERT INTO assets (symbol, markets) VALUES ('019685', ARRAY['KRX']) RETURNING asset_id"
|
||||
)
|
||||
asset_id = str(cur.fetchone()[0])
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolios (
|
||||
portfolio_id, name, owner_user_id, cash_balance, asset_quantity,
|
||||
asset_price, profit_buffer, asset_id, broker, status
|
||||
) VALUES (%s, '대교우B', %s, %s, %s, 643, 0, %s, 'kis', 'enabled')
|
||||
""",
|
||||
(_PORTFOLIO_ID, user_id, _CASH, quantity, asset_id),
|
||||
)
|
||||
# The fee makes the authoritative BUY-ledger cost exactly 1,577,211.9799.
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_ledger (
|
||||
portfolio_id, recorded_at, side, source, quantity, price, fee_amount, tax_amount,
|
||||
cash_balance_after, asset_quantity_after, total_value_after,
|
||||
profit_buffer_after, strategy_set_id, note, executed_at
|
||||
) VALUES (%s, '2026-09-09T00:00:00Z', 'buy', 'broker', 1452, 1086, 339.9799, 0,
|
||||
%s, 1452, %s, 0, 'runtime', 'seed', '2026-09-09T00:00:00Z')
|
||||
""",
|
||||
(_PORTFOLIO_ID, _CASH, _CASH + Decimal(1452) * Decimal(643)),
|
||||
)
|
||||
return asset_id
|
||||
|
||||
|
||||
def _request(*, resume_partial: bool = False) -> CorporateActionRequest:
|
||||
return CorporateActionRequest(
|
||||
effective_date=date(2026, 9, 10),
|
||||
quantity_before=Decimal(1452),
|
||||
quantity_after=Decimal(726),
|
||||
evidence_ref="KIS 019685 2026-09-10",
|
||||
resume_partial=resume_partial,
|
||||
)
|
||||
|
||||
|
||||
def _action(asset_id: str, request: CorporateActionRequest, key: str) -> CorporateAction:
|
||||
digest = canonical_payload_hash(request)
|
||||
return CorporateAction(
|
||||
str(uuid4()),
|
||||
_PORTFOLIO_ID,
|
||||
asset_id,
|
||||
request.action_type,
|
||||
request.effective_date,
|
||||
request.quantity_before,
|
||||
request.quantity_after,
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
_CASH,
|
||||
Decimal(0),
|
||||
request.evidence_ref,
|
||||
key,
|
||||
digest,
|
||||
0,
|
||||
"ca-recovery-user",
|
||||
)
|
||||
|
||||
|
||||
def _record(repo: PostgresPortfolioRepository, asset_id: str, request: CorporateActionRequest, key: str):
|
||||
action = _action(asset_id, request, key)
|
||||
return repo.record_corporate_action(
|
||||
_PORTFOLIO_ID,
|
||||
action=action,
|
||||
idempotency_key=key,
|
||||
payload_hash=action.payload_hash,
|
||||
resume_partial=request.resume_partial,
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
"SELECT asset_quantity, asset_price, cash_balance, profit_buffer "
|
||||
"FROM portfolios WHERE portfolio_id=%s",
|
||||
(_PORTFOLIO_ID,),
|
||||
)
|
||||
portfolio_row = cur.fetchone()
|
||||
assert portfolio_row is not None
|
||||
portfolio = tuple(Decimal(str(value)) for value in portfolio_row)
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM portfolio_ledger "
|
||||
"WHERE portfolio_id=%s AND source='corporate_action'",
|
||||
(_PORTFOLIO_ID,),
|
||||
)
|
||||
ledger_count = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM portfolio_corporate_actions WHERE portfolio_id=%s",
|
||||
(_PORTFOLIO_ID,),
|
||||
)
|
||||
action_count = cur.fetchone()[0]
|
||||
return (*portfolio, ledger_count, action_count)
|
||||
|
||||
|
||||
def test_stock_consolidation_persists_reference_price_total_value_and_audit(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(1452))
|
||||
|
||||
saved = _record(repository, asset_id, _request(), "ca-normal")
|
||||
|
||||
assert saved.average_price_after == Decimal("2172.46829187")
|
||||
assert saved.total_cost_basis == _TOTAL_COST
|
||||
assert _state(repository) == (
|
||||
Decimal("726.00000000"),
|
||||
Decimal("1286.00000000"),
|
||||
_CASH,
|
||||
Decimal("0E-8"),
|
||||
1,
|
||||
1,
|
||||
)
|
||||
with repository._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT price, total_value_after FROM portfolio_ledger "
|
||||
"WHERE portfolio_id=%s AND source='corporate_action'",
|
||||
(_PORTFOLIO_ID,),
|
||||
)
|
||||
assert cur.fetchone() == (
|
||||
Decimal("1286.00000000"),
|
||||
Decimal("1333696.62490000"),
|
||||
)
|
||||
|
||||
|
||||
def test_ledger_failure_rolls_back_portfolio_quantity_and_reference_price(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(1452))
|
||||
with repository._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE FUNCTION reject_corporate_action_ledger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.source = 'corporate_action' THEN
|
||||
RAISE EXCEPTION 'injected ledger failure';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE TRIGGER reject_ca_ledger BEFORE INSERT ON portfolio_ledger "
|
||||
"FOR EACH ROW EXECUTE FUNCTION reject_corporate_action_ledger()"
|
||||
)
|
||||
|
||||
with pytest.raises(psycopg.Error, match="injected ledger failure"):
|
||||
_record(repository, asset_id, _request(), "ca-failure")
|
||||
|
||||
assert _state(repository) == (
|
||||
Decimal("1452.00000000"),
|
||||
Decimal("643.00000000"),
|
||||
_CASH,
|
||||
Decimal("0E-8"),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
def test_audit_failure_rolls_back_portfolio_and_inserted_ledger(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(1452))
|
||||
before = _state(repository)
|
||||
with repository._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE FUNCTION reject_corporate_action_audit() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'injected corporate-action audit failure';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE TRIGGER reject_ca_audit BEFORE INSERT ON portfolio_corporate_actions "
|
||||
"FOR EACH ROW EXECUTE FUNCTION reject_corporate_action_audit()"
|
||||
)
|
||||
|
||||
with pytest.raises(psycopg.Error, match="injected corporate-action audit failure"):
|
||||
_record(repository, asset_id, _request(), "ca-audit-failure")
|
||||
|
||||
assert _state(repository) == before
|
||||
|
||||
|
||||
def test_explicit_resume_repairs_only_verified_quantity_only_partial_state(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(726))
|
||||
request = _request(resume_partial=True)
|
||||
|
||||
first = _record(repository, asset_id, request, "ca-recovery")
|
||||
replay = _record(repository, asset_id, request, "ca-recovery")
|
||||
|
||||
assert first.idempotent_replay is False
|
||||
assert replay.idempotent_replay is True
|
||||
assert replay.ledger_entry_id == first.ledger_entry_id
|
||||
assert _state(repository) == (
|
||||
Decimal("726.00000000"),
|
||||
Decimal("1286.00000000"),
|
||||
_CASH,
|
||||
Decimal("0E-8"),
|
||||
1,
|
||||
1,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="CORPORATE_ACTION_RECOVERY_ALREADY_RECORDED"):
|
||||
_record(repository, asset_id, request, "ca-recovery-different-key")
|
||||
assert _state(repository)[-2:] == (1, 1)
|
||||
|
||||
|
||||
def test_resume_rejects_a_normal_unapplied_snapshot(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(1452))
|
||||
|
||||
with pytest.raises(ValueError, match="CORPORATE_ACTION_RECOVERY_STATE_MISMATCH"):
|
||||
_record(repository, asset_id, _request(resume_partial=True), "ca-invalid-recovery")
|
||||
|
||||
assert _state(repository)[-2:] == (0, 0)
|
||||
@@ -0,0 +1,31 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domain.corporate_actions import calculate_stock_consolidation
|
||||
|
||||
|
||||
def test_stock_consolidation_preserves_cost_and_scales_wac():
|
||||
result = calculate_stock_consolidation(Decimal(1452), Decimal(726), Decimal("1086.23"))
|
||||
assert result.average_price_after == Decimal("2172.46")
|
||||
assert result.total_cost_basis == Decimal("1577205.96000000")
|
||||
assert result.ratio == Decimal("0.5")
|
||||
|
||||
def test_stock_consolidation_rejects_fractional_result():
|
||||
import pytest
|
||||
|
||||
from app.domain.corporate_actions import CorporateActionError
|
||||
with pytest.raises(CorporateActionError) as exc:
|
||||
calculate_stock_consolidation(Decimal(3), Decimal("1.5"), Decimal(10))
|
||||
assert exc.value.code == "FRACTIONAL_RESULT_UNSUPPORTED"
|
||||
|
||||
|
||||
def test_stock_consolidation_allows_stored_wac_rounding_residue():
|
||||
result = calculate_stock_consolidation(
|
||||
Decimal(1452),
|
||||
Decimal(726),
|
||||
Decimal("1086.234145936639256198347107438016528926"),
|
||||
)
|
||||
|
||||
assert result.average_price_before == Decimal("1086.23414594")
|
||||
assert result.average_price_after == Decimal("2172.46829187")
|
||||
assert result.total_cost_basis == Decimal("1577211.97990000")
|
||||
assert result.total_cost_basis - result.average_price_after * result.quantity_after == Decimal("0.00000238")
|
||||
@@ -0,0 +1,75 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from app.domain.corporate_actions import canonical_payload_hash
|
||||
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
|
||||
from app.schemas.corporate_actions import CorporateActionRequest
|
||||
|
||||
|
||||
def test_buy_consolidation_sell_replays_wac_and_realized_pnl():
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 10, "price": 100, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 1, tzinfo=UTC)},
|
||||
{"side": "stock_consolidation", "quantity": 0, "price": 0, "quantity_before": 10, "quantity_after": 5, "average_price_after": 200, "executed_at": datetime(2026, 1, 2, tzinfo=UTC)},
|
||||
{"side": "sell", "quantity": 2, "price": 250, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 3, tzinfo=UTC)},
|
||||
]
|
||||
assert calculate_current_wac(rows).current_wac == 200
|
||||
result = calculate_realized_pnl(rows)
|
||||
assert result.items[0].cost_basis_per_unit == 200
|
||||
assert result.total_realized_pnl == 100
|
||||
|
||||
|
||||
def test_consolidation_replay_uses_preserved_total_cost_basis():
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 1452, "price": 1086.2341459366393, "fee_amount": 0, "executed_at": datetime(2026, 9, 9, tzinfo=UTC)},
|
||||
{
|
||||
"side": "stock_consolidation",
|
||||
"quantity_before": 1452,
|
||||
"quantity_after": 726,
|
||||
"average_price_after": Decimal("2172.46829187"),
|
||||
"total_cost_basis": Decimal("1577211.97990000"),
|
||||
"executed_at": datetime(2026, 9, 10, tzinfo=UTC),
|
||||
},
|
||||
]
|
||||
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_quantity == 726
|
||||
assert result.current_wac == float(Decimal("1577211.97990000") / Decimal(726))
|
||||
|
||||
|
||||
def test_canonical_hash_same_payload_and_changed_payload_differ():
|
||||
a = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal(10), quantity_after=Decimal(5), evidence_ref="ev")
|
||||
b = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal(10), quantity_after=Decimal(4), evidence_ref="ev")
|
||||
assert canonical_payload_hash(a) == canonical_payload_hash(a.model_copy())
|
||||
assert canonical_payload_hash(a) != canonical_payload_hash(b)
|
||||
|
||||
|
||||
def test_default_resume_flag_preserves_pre_recovery_idempotency_hash():
|
||||
request = CorporateActionRequest(
|
||||
effective_date=date(2026, 1, 2),
|
||||
quantity_before=Decimal(10),
|
||||
quantity_after=Decimal(5),
|
||||
evidence_ref="ev",
|
||||
)
|
||||
legacy_payload = request.model_dump(mode="json", exclude={"resume_partial"})
|
||||
explicit_default_payload = {**legacy_payload, "resume_partial": False}
|
||||
|
||||
assert canonical_payload_hash(request) == canonical_payload_hash(legacy_payload)
|
||||
assert canonical_payload_hash(explicit_default_payload) == canonical_payload_hash(legacy_payload)
|
||||
assert canonical_payload_hash(
|
||||
request.model_copy(update={"resume_partial": True})
|
||||
) != canonical_payload_hash(legacy_payload)
|
||||
|
||||
|
||||
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 "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
|
||||
|
||||
|
||||
def test_joined_corporate_action_metadata_is_complete_in_mapping_contract():
|
||||
source = Path("app/repositories/postgres.py").read_text()
|
||||
for field in ("action_type", "quantity_before", "quantity_after", "average_price_before", "average_price_after", "total_cost_basis", "cash_balance", "profit_buffer", "evidence_ref", "actor_user_id", "idempotency_key", "payload_hash", "recorded_at"):
|
||||
assert f'"{field}"' in source
|
||||
@@ -0,0 +1,191 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import v1_portfolios
|
||||
from app.domain.models import CorporateAction, Portfolio
|
||||
from app.main import create_app
|
||||
from app.repositories.postgres import PostgresPortfolioRepository
|
||||
|
||||
|
||||
class MissingPortfolioRepo:
|
||||
def get(self, portfolio_id):
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class ResumeCaptureRepo:
|
||||
def __init__(self):
|
||||
self.resume_partial = None
|
||||
|
||||
def get(self, portfolio_id):
|
||||
return Portfolio(
|
||||
portfolio_id=portfolio_id,
|
||||
broker="kis",
|
||||
status="enabled",
|
||||
name="대교우B",
|
||||
symbol="019685",
|
||||
asset_id="asset-1",
|
||||
owner_user_id="u1",
|
||||
cash_balance=400060.6249,
|
||||
asset_quantity=726,
|
||||
price=643,
|
||||
profit_buffer=0,
|
||||
)
|
||||
|
||||
def record_corporate_action(
|
||||
self,
|
||||
portfolio_id,
|
||||
*,
|
||||
action,
|
||||
idempotency_key,
|
||||
payload_hash,
|
||||
resume_partial=False,
|
||||
):
|
||||
self.resume_partial = resume_partial
|
||||
action.average_price_before = Decimal("1086.23414594")
|
||||
action.average_price_after = Decimal("2172.46829187")
|
||||
action.total_cost_basis = Decimal("1577211.97990000")
|
||||
action.ledger_entry_id = 7
|
||||
action.recorded_at = datetime(2026, 9, 13, tzinfo=UTC)
|
||||
return action
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_create_corporate_action_maps_postgres_missing_portfolio_to_404(monkeypatch):
|
||||
app = create_app()
|
||||
repo = MissingPortfolioRepo()
|
||||
monkeypatch.setattr(v1_portfolios, "_get_repo", lambda: repo)
|
||||
app.dependency_overrides[v1_portfolios.require_trade_write] = lambda: type("Auth", (), {"user_id": "u1", "permission": "trade_write"})()
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/v1/portfolios/missing/corporate-actions",
|
||||
headers={"Idempotency-Key": "idem-1"},
|
||||
json={"effective_date": "2026-01-01", "quantity_before": "10", "quantity_after": "5", "evidence_ref": "ev"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Portfolio not found"
|
||||
|
||||
|
||||
def test_corporate_action_api_passes_explicit_resume_mode(monkeypatch):
|
||||
app = create_app()
|
||||
repo = ResumeCaptureRepo()
|
||||
monkeypatch.setattr(v1_portfolios, "_get_repo", lambda: repo)
|
||||
app.dependency_overrides[v1_portfolios.require_trade_write] = lambda: type(
|
||||
"Auth", (), {"user_id": "u1", "permission": "trade_write"}
|
||||
)()
|
||||
|
||||
response = TestClient(app).post(
|
||||
"/v1/portfolios/pf-1/corporate-actions",
|
||||
headers={"Idempotency-Key": "resume-1"},
|
||||
json={
|
||||
"effective_date": "2026-09-10",
|
||||
"quantity_before": "1452",
|
||||
"quantity_after": "726",
|
||||
"evidence_ref": "KIS 019685 2026-09-10",
|
||||
"resume_partial": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["quantity_after"] == "726"
|
||||
assert repo.resume_partial is True
|
||||
|
||||
|
||||
def _action(asset_id="asset-1"):
|
||||
return CorporateAction("ca-1", "pf-1", asset_id, "stock_consolidation", date(2026, 1, 1), Decimal(2), Decimal(1), Decimal(0), Decimal(0), Decimal(0), Decimal(10), Decimal(0), "ev", "idem", "hash", 0)
|
||||
|
||||
|
||||
def _repo_for_row(asset_id):
|
||||
repo = object.__new__(PostgresPortfolioRepository)
|
||||
repo.ensure_schema = MagicMock()
|
||||
conn = MagicMock()
|
||||
cur = MagicMock()
|
||||
conn.__enter__.return_value = conn
|
||||
conn.cursor.return_value.__enter__.return_value = cur
|
||||
repo._connect = MagicMock(return_value=conn)
|
||||
cur.fetchone.side_effect = [
|
||||
(Decimal(2), Decimal(10), Decimal(0), asset_id, "enabled", Decimal(643)),
|
||||
None,
|
||||
None,
|
||||
(7, date(2026, 1, 1)),
|
||||
]
|
||||
cur.fetchall.return_value = [("buy", Decimal(2), Decimal(0), Decimal(0), Decimal(0), datetime(2025, 12, 31, tzinfo=UTC))]
|
||||
return repo
|
||||
|
||||
|
||||
def test_record_corporate_action_accepts_uuid_row_against_string_domain_id():
|
||||
asset = UUID("12345678-1234-5678-1234-567812345678")
|
||||
action = _action(str(asset))
|
||||
saved = _repo_for_row(asset).record_corporate_action("pf-1", action=action, idempotency_key="idem", payload_hash="hash")
|
||||
assert saved.ledger_entry_id == 7
|
||||
|
||||
|
||||
def test_record_corporate_action_rejects_different_asset_id():
|
||||
repo = _repo_for_row(UUID("12345678-1234-5678-1234-567812345678"))
|
||||
with pytest.raises(ValueError, match="CORPORATE_ACTION_ASSET_MISMATCH"):
|
||||
repo.record_corporate_action("pf-1", action=_action("87654321-4321-8765-4321-876543218765"), idempotency_key="idem", payload_hash="hash")
|
||||
|
||||
|
||||
def test_record_corporate_action_accepts_daekyo_rounding_residue():
|
||||
repo = object.__new__(PostgresPortfolioRepository)
|
||||
repo.ensure_schema = MagicMock()
|
||||
conn = MagicMock()
|
||||
cur = MagicMock()
|
||||
conn.__enter__.return_value = conn
|
||||
conn.cursor.return_value.__enter__.return_value = cur
|
||||
repo._connect = MagicMock(return_value=conn)
|
||||
cur.fetchone.side_effect = [
|
||||
(Decimal(1452), Decimal(10), Decimal(0), "asset-1", "enabled", Decimal(643)),
|
||||
None,
|
||||
None,
|
||||
(7, datetime(2026, 9, 13, tzinfo=UTC)),
|
||||
]
|
||||
cur.fetchall.return_value = [
|
||||
(
|
||||
"buy",
|
||||
Decimal(1452),
|
||||
Decimal("1086.234145936639256198347107438016528926"),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
datetime(2026, 9, 9, tzinfo=UTC),
|
||||
)
|
||||
]
|
||||
action = CorporateAction(
|
||||
"ca-1",
|
||||
"pf-1",
|
||||
"asset-1",
|
||||
"stock_consolidation",
|
||||
date(2026, 9, 10),
|
||||
Decimal(1452),
|
||||
Decimal(726),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
Decimal(10),
|
||||
Decimal(0),
|
||||
"KIS 019685 2026-09-10",
|
||||
"idem-daekyo",
|
||||
"hash-daekyo",
|
||||
0,
|
||||
)
|
||||
|
||||
saved = repo.record_corporate_action(
|
||||
"pf-1",
|
||||
action=action,
|
||||
idempotency_key="idem-daekyo",
|
||||
payload_hash="hash-daekyo",
|
||||
)
|
||||
|
||||
assert saved.quantity_after == Decimal(726)
|
||||
assert saved.average_price_after == Decimal("2172.46829187")
|
||||
assert saved.total_cost_basis == Decimal("1577211.97990000")
|
||||
assert saved.ledger_entry_id == 7
|
||||
@@ -507,7 +507,7 @@ def test_delete_already_disabled_is_idempotent() -> None:
|
||||
|
||||
|
||||
# Issue #26: strategy catalog references on portfolio mutations.
|
||||
def test_post_strategy_reference_errors_and_required_contract() -> None:
|
||||
def test_post_strategy_reference_errors_and_optional_contract() -> None:
|
||||
repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active"))
|
||||
client = _make_client()
|
||||
with _with_repo(repo):
|
||||
@@ -516,7 +516,7 @@ def test_post_strategy_reference_errors_and_required_contract() -> None:
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "missing"},
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
invalid = client.post(
|
||||
without_strategy = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom"},
|
||||
headers=_auth("admin-tok"),
|
||||
@@ -528,7 +528,8 @@ def test_post_strategy_reference_errors_and_required_contract() -> None:
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
assert invalid.status_code == 422
|
||||
assert without_strategy.status_code == 201
|
||||
assert without_strategy.json()["active_strategy_id"] is None
|
||||
assert inactive.status_code == 409
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, TokenPrincipal
|
||||
from app.main import create_app
|
||||
from app.schemas.v1 import LedgerHistoryItem
|
||||
|
||||
_PORTFOLIO_ID = "pf-001"
|
||||
_USER_ID = "user-001"
|
||||
@@ -68,6 +74,8 @@ class _FakeRepo:
|
||||
self.principal = principal or _token_principal()
|
||||
self.entries: list[LedgerEntry] = []
|
||||
self._next_id = 1
|
||||
self._dividend_amounts: dict[int, Decimal] = {}
|
||||
self._dividend_tax_amounts: dict[int, Decimal] = {}
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
@@ -83,11 +91,17 @@ class _FakeRepo:
|
||||
def record_manual_ledger(self, portfolio_id: str, **kwargs: Any) -> LedgerEntry:
|
||||
self.get(portfolio_id)
|
||||
action = kwargs["action"]
|
||||
amount = float(kwargs.get("amount") or 0.0)
|
||||
amount_raw = kwargs.get("amount")
|
||||
amount_decimal = Decimal(str(amount_raw if amount_raw is not None else 0))
|
||||
amount = float(amount_decimal)
|
||||
quantity = float(kwargs.get("quantity") or 0.0)
|
||||
price = float(kwargs.get("price") or 0.0)
|
||||
fee = float(kwargs.get("fee") or 0.0)
|
||||
tax = float(kwargs.get("tax") or 0.0)
|
||||
tax_decimal = Decimal(str(kwargs.get("tax") if kwargs.get("tax") is not None else 0))
|
||||
executed_at = kwargs.get("executed_at")
|
||||
confirm_duplicate = bool(kwargs.get("confirm_duplicate") or False)
|
||||
note = kwargs.get("note") or ""
|
||||
target = kwargs.get("target_ledger_entry_id")
|
||||
if action == "BUY":
|
||||
required = quantity * price + fee + tax
|
||||
@@ -122,12 +136,64 @@ class _FakeRepo:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
self.portfolio.profit_buffer -= amount
|
||||
price = amount
|
||||
elif action == "DIVIDEND":
|
||||
reversed_ids = {
|
||||
e.target_ledger_entry_id
|
||||
for e in self.entries
|
||||
if e.action == "REVERSAL" and e.target_ledger_entry_id is not None
|
||||
}
|
||||
duplicate = next(
|
||||
(
|
||||
e
|
||||
for e in self.entries
|
||||
if e.action == "DIVIDEND"
|
||||
and e.ledger_entry_id not in reversed_ids
|
||||
and e.executed_at == executed_at
|
||||
and self._dividend_amounts.get(e.ledger_entry_id, Decimal(str(e.price))) == amount_decimal
|
||||
and e.note == note
|
||||
),
|
||||
None,
|
||||
)
|
||||
if duplicate is not None and not confirm_duplicate:
|
||||
raise ValueError(f"POTENTIAL_DUPLICATE_DIVIDEND:{duplicate.ledger_entry_id}")
|
||||
self.portfolio.profit_buffer += amount
|
||||
price = amount
|
||||
elif action == "REVERSAL":
|
||||
target_entry = next((e for e in self.entries if e.ledger_entry_id == target), None)
|
||||
if target_entry is None:
|
||||
raise ValueError("LEDGER_ENTRY_NOT_FOUND")
|
||||
if any(e.target_ledger_entry_id == target and e.action == "REVERSAL" for e in self.entries):
|
||||
raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED")
|
||||
target_action = target_entry.action or ""
|
||||
target_amount = float(target_entry.price)
|
||||
target_quantity = float(target_entry.quantity)
|
||||
target_fee = float(target_entry.fee_amount)
|
||||
target_tax = float(target_entry.tax_amount)
|
||||
if target_action == "BUY":
|
||||
self.portfolio.cash_balance += target_quantity * target_amount + target_fee + target_tax
|
||||
self.portfolio.asset_quantity -= target_quantity
|
||||
elif target_action == "SELL":
|
||||
required_cash = target_quantity * target_amount - target_fee - target_tax
|
||||
if self.portfolio.cash_balance < required_cash:
|
||||
raise ValueError("INSUFFICIENT_CASH")
|
||||
self.portfolio.cash_balance -= required_cash
|
||||
self.portfolio.asset_quantity += target_quantity
|
||||
elif target_action == "BUFFER_OUT":
|
||||
if self.portfolio.cash_balance < target_amount:
|
||||
raise ValueError("INSUFFICIENT_CASH")
|
||||
self.portfolio.cash_balance -= target_amount
|
||||
self.portfolio.profit_buffer += target_amount
|
||||
elif target_action == "BUFFER_IN":
|
||||
if self.portfolio.profit_buffer < target_amount:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
self.portfolio.cash_balance += target_amount
|
||||
self.portfolio.profit_buffer -= target_amount
|
||||
elif target_action in {"INVEST", "DIVIDEND"}:
|
||||
if self.portfolio.profit_buffer < target_amount:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
self.portfolio.profit_buffer -= target_amount
|
||||
elif target_action in {"WITHDRAW", "PROFIT"}:
|
||||
self.portfolio.profit_buffer += target_amount
|
||||
price = 0.0
|
||||
total = self.portfolio.cash_balance + self.portfolio.asset_quantity * self.portfolio.price + self.portfolio.profit_buffer
|
||||
entry = LedgerEntry(
|
||||
@@ -137,14 +203,17 @@ class _FakeRepo:
|
||||
side=action.lower(),
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
price_decimal=amount_decimal if action == "DIVIDEND" else Decimal(str(price)),
|
||||
fee_amount=fee,
|
||||
tax_amount=tax,
|
||||
recorded_at=datetime.now(timezone.utc),
|
||||
tax_amount_decimal=tax_decimal if action == "DIVIDEND" else Decimal(str(tax)),
|
||||
executed_at=executed_at,
|
||||
recorded_at=datetime.now(UTC),
|
||||
cash_balance_after=self.portfolio.cash_balance,
|
||||
asset_quantity_after=self.portfolio.asset_quantity,
|
||||
profit_buffer_after=self.portfolio.profit_buffer,
|
||||
total_value_after=total,
|
||||
note=kwargs.get("note") or "",
|
||||
note=note,
|
||||
source="manual",
|
||||
target_ledger_entry_id=target,
|
||||
confirmation_evidence=kwargs.get("confirmation_evidence"),
|
||||
@@ -152,6 +221,9 @@ class _FakeRepo:
|
||||
)
|
||||
self._next_id += 1
|
||||
self.entries.append(entry)
|
||||
if action == "DIVIDEND":
|
||||
self._dividend_amounts[entry.ledger_entry_id] = amount_decimal
|
||||
self._dividend_tax_amounts[entry.ledger_entry_id] = tax_decimal
|
||||
return entry
|
||||
|
||||
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]:
|
||||
@@ -330,6 +402,31 @@ def test_insufficient_cash_rejected_as_409() -> None:
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["code"] == "INSUFFICIENT_CASH"
|
||||
assert resp.json()["detail"]["message"] == "Insufficient cash balance"
|
||||
|
||||
|
||||
def test_manual_ledger_conflict_message_is_sanitized() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
secret = "SENTINEL_SECRET_TOKEN_12345"
|
||||
|
||||
def _raise_secret(*args: Any, **kwargs: Any) -> LedgerEntry:
|
||||
raise ValueError(f"synthetic backend failure: {secret}")
|
||||
|
||||
repo.record_manual_ledger = _raise_secret # type: ignore[method-assign]
|
||||
|
||||
resp = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01"},
|
||||
headers=_auth(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["code"] == "MANUAL_LEDGER_CONFLICT"
|
||||
assert detail["message"] == "Manual ledger request could not be processed"
|
||||
assert secret not in resp.text
|
||||
|
||||
|
||||
def test_manual_history_returns_total_value_after() -> None:
|
||||
@@ -402,6 +499,570 @@ def test_amount_actions_reject_fee_and_tax_fields() -> None:
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax() -> None:
|
||||
repo = _FakeRepo(_portfolio(cash=1000, quantity=10, price=10, buffer=500))
|
||||
client = _make_client(repo)
|
||||
|
||||
resp = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 120, "tax": 10, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["action"] == "DIVIDEND"
|
||||
assert body["amount"] == "120"
|
||||
assert body["tax"] == "10"
|
||||
assert body["executed_at"] == "2026-09-01T00:00:00Z"
|
||||
assert body["cash_balance_after"] == 1000.0
|
||||
assert body["asset_quantity_after"] == 10.0
|
||||
assert body["profit_buffer_after"] == 620.0
|
||||
assert body["total_value_after"] == 1720.0
|
||||
assert body["source"] == "manual"
|
||||
|
||||
|
||||
def test_dividend_tax_defaults_to_zero_when_omitted() -> None:
|
||||
repo = _FakeRepo(_portfolio(cash=1000, quantity=10, price=10, buffer=500))
|
||||
client = _make_client(repo)
|
||||
|
||||
resp = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 120, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["tax"] == "0"
|
||||
assert body["cash_balance_after"] == 1000.0
|
||||
assert body["asset_quantity_after"] == 10.0
|
||||
assert body["profit_buffer_after"] == 620.0
|
||||
|
||||
|
||||
def test_dividend_executed_at_uses_utc_midnight_independent_of_server_timezone(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
original_tz = None
|
||||
if hasattr(time, "tzset"):
|
||||
original_tz = os.environ.get("TZ")
|
||||
monkeypatch.setenv("TZ", "Asia/Seoul")
|
||||
time.tzset()
|
||||
|
||||
try:
|
||||
resp = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01"},
|
||||
headers=_auth(),
|
||||
)
|
||||
finally:
|
||||
if hasattr(time, "tzset"):
|
||||
if original_tz is None:
|
||||
monkeypatch.delenv("TZ", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("TZ", original_tz)
|
||||
time.tzset()
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["executed_at"] == "2026-09-01T00:00:00Z"
|
||||
assert body["recorded_at"] != body["executed_at"]
|
||||
|
||||
|
||||
def test_dividend_requires_executed_at_and_rejects_fee_field() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
missing_date = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 50},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert missing_date.status_code == 422
|
||||
|
||||
invalid_fee = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "fee": 0},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert invalid_fee.status_code == 422
|
||||
|
||||
|
||||
def test_dividend_rejects_invalid_amount_tax_and_unrelated_fields_without_state_change() -> None:
|
||||
repo = _FakeRepo(_portfolio(cash=1000, quantity=10, price=10, buffer=500))
|
||||
client = _make_client(repo)
|
||||
baseline = (
|
||||
repo.portfolio.cash_balance,
|
||||
repo.portfolio.asset_quantity,
|
||||
repo.portfolio.profit_buffer,
|
||||
len(repo.entries),
|
||||
)
|
||||
|
||||
invalid_payloads = [
|
||||
{"action": "DIVIDEND", "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": 1},
|
||||
{"action": "DIVIDEND", "amount": 0, "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": -1, "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-13-40"},
|
||||
{"action": "DIVIDEND", "amount": 1, "tax": -1, "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": 1, "tax": "0.000000001", "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": 1, "tax": "1000000000000.00000000", "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "fee": 0},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "quantity": 1},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "price": 1},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "target_ledger_entry_id": 1},
|
||||
]
|
||||
|
||||
for payload in invalid_payloads:
|
||||
resp = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json=payload,
|
||||
headers=_auth(),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert (
|
||||
repo.portfolio.cash_balance,
|
||||
repo.portfolio.asset_quantity,
|
||||
repo.portfolio.profit_buffer,
|
||||
len(repo.entries),
|
||||
) == baseline
|
||||
|
||||
|
||||
def test_dividend_rejects_sub_minimum_numeric_precision() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
too_precise = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": "0.000000004", "executed_at": "2026-09-01"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert too_precise.status_code == 422
|
||||
|
||||
|
||||
def test_dividend_rejects_amount_exceeding_numeric_20_8() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
too_large = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": "1000000000000.00000000", "executed_at": "2026-09-01"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert too_large.status_code == 422
|
||||
|
||||
|
||||
def test_dividend_duplicate_requires_confirmation() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
first = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
duplicate = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert duplicate.status_code == 409
|
||||
assert duplicate.json()["detail"]["code"] == "POTENTIAL_DUPLICATE_DIVIDEND"
|
||||
assert duplicate.json()["detail"]["matched_ledger_entry_id"] == first.json()["ledger_entry_id"]
|
||||
|
||||
confirmed = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={
|
||||
"action": "DIVIDEND",
|
||||
"amount": 50,
|
||||
"executed_at": "2026-09-01",
|
||||
"note": "Q3",
|
||||
"confirm_duplicate": True,
|
||||
},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert confirmed.status_code == 201
|
||||
|
||||
|
||||
def test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
first = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": "1.00000000", "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": "1.00000001", "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert second.status_code == 201
|
||||
|
||||
|
||||
def test_dividend_duplicate_check_does_not_collapse_decimals_that_can_alias_in_float() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
first = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": "90000000.00000001", "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": "90000000.00000002", "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert second.status_code == 201
|
||||
assert first.json()["amount"] == "90000000.00000001"
|
||||
assert second.json()["amount"] == "90000000.00000002"
|
||||
|
||||
history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth())
|
||||
assert history.status_code == 200
|
||||
amounts = [item["amount"] for item in history.json()["items"] if item["action"] == "DIVIDEND"]
|
||||
assert "90000000.00000001" in amounts
|
||||
assert "90000000.00000002" in amounts
|
||||
|
||||
|
||||
def test_dividend_tax_does_not_collapse_decimals_that_can_alias_in_float() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
first = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={
|
||||
"action": "DIVIDEND",
|
||||
"amount": "1.00000000",
|
||||
"tax": "90000000.00000001",
|
||||
"executed_at": "2026-09-01",
|
||||
"note": "Q3-1",
|
||||
},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={
|
||||
"action": "DIVIDEND",
|
||||
"amount": "1.00000000",
|
||||
"tax": "90000000.00000002",
|
||||
"executed_at": "2026-09-02",
|
||||
"note": "Q3-2",
|
||||
},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert second.status_code == 201
|
||||
assert first.json()["tax"] == "90000000.00000001"
|
||||
assert second.json()["tax"] == "90000000.00000002"
|
||||
|
||||
history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth())
|
||||
assert history.status_code == 200
|
||||
taxes = [item["tax_amount"] for item in history.json()["items"] if item["action"] == "DIVIDEND"]
|
||||
assert "90000000.00000001" in taxes
|
||||
assert "90000000.00000002" in taxes
|
||||
|
||||
|
||||
def test_reversed_dividend_is_excluded_from_future_duplicate_candidate_matching() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
first = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
reversal = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "REVERSAL", "target_ledger_entry_id": first.json()["ledger_entry_id"]},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert reversal.status_code == 201
|
||||
|
||||
replay = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert replay.status_code == 201
|
||||
|
||||
|
||||
def test_confirm_duplicate_false_is_accepted_for_non_dividend_actions() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
invest = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "INVEST", "amount": 100, "confirm_duplicate": False},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert invest.status_code == 201
|
||||
assert isinstance(invest.json()["amount"], float)
|
||||
|
||||
buy = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "BUY", "quantity": 1, "price": 10, "confirm_duplicate": False},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert buy.status_code == 201
|
||||
|
||||
|
||||
def test_unified_ledger_keeps_non_dividend_amount_as_number() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
created = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "INVEST", "amount": 100},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["amount"] == 100.0
|
||||
|
||||
history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth())
|
||||
assert history.status_code == 200
|
||||
item = history.json()["items"][0]
|
||||
assert item["action"] == "INVEST"
|
||||
assert item["amount"] == 100.0
|
||||
|
||||
|
||||
def test_confirm_duplicate_true_is_rejected_for_non_dividend_actions() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
invest = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "INVEST", "amount": 100, "confirm_duplicate": True},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert invest.status_code == 422
|
||||
|
||||
|
||||
def test_dividend_reversal_decreases_profit_buffer() -> None:
|
||||
repo = _FakeRepo(_portfolio(buffer=200))
|
||||
client = _make_client(repo)
|
||||
|
||||
dividend = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 80, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert dividend.status_code == 201
|
||||
assert dividend.json()["profit_buffer_after"] == 280.0
|
||||
|
||||
reversal = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert reversal.status_code == 201
|
||||
assert reversal.json()["profit_buffer_after"] == 200.0
|
||||
|
||||
|
||||
def test_duplicate_reversal_is_rejected_without_state_change() -> None:
|
||||
repo = _FakeRepo(_portfolio(buffer=200))
|
||||
client = _make_client(repo)
|
||||
|
||||
dividend = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 80, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert dividend.status_code == 201
|
||||
|
||||
first_reversal = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert first_reversal.status_code == 201
|
||||
before_count = len(repo.entries)
|
||||
|
||||
second_reversal = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert second_reversal.status_code == 409
|
||||
assert second_reversal.json()["detail"]["code"] == "LEDGER_ENTRY_ALREADY_REVERSED"
|
||||
assert len(repo.entries) == before_count
|
||||
assert repo.portfolio.profit_buffer == 200.0
|
||||
|
||||
|
||||
def test_dividend_reversal_insufficient_buffer_is_rejected_without_state_change() -> None:
|
||||
repo = _FakeRepo(_portfolio(buffer=0, cash=1000, quantity=10, price=10))
|
||||
client = _make_client(repo)
|
||||
|
||||
dividend = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 100, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert dividend.status_code == 201
|
||||
|
||||
withdraw = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "WITHDRAW", "amount": 100},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert withdraw.status_code == 201
|
||||
assert repo.portfolio.profit_buffer == 0.0
|
||||
before_count = len(repo.entries)
|
||||
|
||||
reversal = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert reversal.status_code == 409
|
||||
assert reversal.json()["detail"]["code"] == "INSUFFICIENT_BUFFER"
|
||||
assert len(repo.entries) == before_count
|
||||
assert repo.portfolio.profit_buffer == 0.0
|
||||
|
||||
|
||||
def test_manual_ledger_conflict_secret_is_not_rendered_in_logs(caplog: pytest.LogCaptureFixture) -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
secret = "SENTINEL_SECRET_TOKEN_67890"
|
||||
|
||||
def _raise_secret(*args: Any, **kwargs: Any) -> LedgerEntry:
|
||||
raise ValueError(f"synthetic persistence {secret}")
|
||||
|
||||
repo.record_manual_ledger = _raise_secret # type: ignore[method-assign]
|
||||
caplog.clear()
|
||||
caplog.set_level("INFO")
|
||||
|
||||
resp = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
rendered = "\n".join(f"{record.name}:{record.getMessage()}" for record in caplog.records)
|
||||
assert secret not in rendered
|
||||
|
||||
|
||||
def test_unified_ledger_includes_dividend_fields() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
created = client.post(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||
json={"action": "DIVIDEND", "amount": 33, "tax": 3, "executed_at": "2026-09-01", "note": "Q3"},
|
||||
headers=_auth(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth())
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
assert item["source"] == "manual"
|
||||
assert item["action"] == "DIVIDEND"
|
||||
assert item["amount"] == "33"
|
||||
assert item["price"] == 33.0
|
||||
assert item["executed_at"] == "2026-09-01T00:00:00Z"
|
||||
assert item["tax_amount"] == "3"
|
||||
assert item["note"] == "Q3"
|
||||
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)
|
||||
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schemas = response.json()["components"]["schemas"]
|
||||
manual_amount_types = {option.get("type") for option in schemas["ManualLedgerResponse"]["properties"]["amount"]["anyOf"]}
|
||||
assert manual_amount_types == {"number", "null"}
|
||||
assert schemas["DividendManualLedgerResponse"]["properties"]["amount"]["type"] == "string"
|
||||
history_amount_types = {option.get("type") for option in schemas["LedgerHistoryItem"]["properties"]["amount"]["anyOf"]}
|
||||
assert history_amount_types == {"number", "null"}
|
||||
assert schemas["DividendLedgerHistoryItem"]["properties"]["amount"]["type"] == "string"
|
||||
assert "DIVIDEND" not in schemas["LedgerHistoryItem"]["properties"]["action"]["enum"]
|
||||
assert schemas["DividendLedgerHistoryItem"]["properties"]["action"]["const"] == "DIVIDEND"
|
||||
|
||||
|
||||
def test_unified_ledger_history_item_rejects_dividend_numeric_branch_payload() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
LedgerHistoryItem(
|
||||
ledger_entry_id=1,
|
||||
portfolio_id=_PORTFOLIO_ID,
|
||||
source="manual",
|
||||
action="DIVIDEND",
|
||||
recorded_at=datetime.now(UTC),
|
||||
amount=1.0,
|
||||
tax_amount=0.0,
|
||||
)
|
||||
|
||||
|
||||
def test_reversal_rejects_fee_tax_and_missing_target() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
@@ -432,7 +1093,7 @@ def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None:
|
||||
repo.entries.append(
|
||||
LedgerEntry(
|
||||
ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker",
|
||||
recorded_at=datetime.now(timezone.utc), cash_balance_after=900, asset_quantity_after=12,
|
||||
recorded_at=datetime.now(UTC), cash_balance_after=900, asset_quantity_after=12,
|
||||
)
|
||||
)
|
||||
first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth())
|
||||
|
||||
Reference in New Issue
Block a user