Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f268ac53 | ||
|
|
deaaa96c46 | ||
|
|
996127f215 | ||
|
|
567d662913 | ||
|
|
f79f3c79b5 | ||
|
|
8841bdd441 | ||
|
|
c7d8235bde |
@@ -39,7 +39,7 @@ 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 scripts/ruff_ratchet.py tests/release
|
||||
- name: Reject Ruff findings introduced by v0.3.0 or later patches
|
||||
|
||||
@@ -11,6 +11,20 @@ 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:
|
||||
@@ -22,7 +36,13 @@ 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: 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 tests/test_service_token_rename_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 scripts/ruff_ratchet.py tests/release
|
||||
- name: Reject Ruff findings introduced by v0.3.0 or later patches
|
||||
@@ -31,7 +51,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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을 검토한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0038"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 기업행동을 반영한 holdings 원가의 정본성과 불완전 metadata 차단
|
||||
|
||||
## Criterion
|
||||
|
||||
Portfolio owner 또는 허가된 daemon이 holdings cost-basis를 조회할 때, 시간순 ledger replay에 `stock_consolidation`이 있으면 응답은 기업행동 감사 기록의 양수 `quantity_after`를 현재 수량으로 적용하고 `total_cost_basis`를 authoritative 총원가로 보존하며 주당 원가를 `total_cost_basis / quantity_after`로 산출해야 한다. 필수값인 양수 `quantity_after` 또는 `total_cost_basis`가 없으면 기업행동 전 WAC나 snapshot 값으로 원가를 추정하지 않고 두 원가 필드를 `null`로 반환하며 기업행동 metadata 부족을 기존 warning 필드에서 구분해야 한다. 기업행동 이후 거래는 같은 replay에서 반영되고, 기업행동이 없는 기존 BUY/SELL 원가·warning·권한·응답 구조는 유지되어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: BUY/SELL 뒤 `1,452 → 726` consolidation을 구성하면 `current_quantity=726`, `total_cost_basis=1,577,211.97990000`, `cost_basis_per_unit=total_cost_basis/726`과 null warning을 반환한다. 저장된 8자리 `average_price_after`를 곱하여 총원가를 재구성하지 않으며, 후속 BUY/SELL 또는 기업행동이 있으면 정렬된 replay 결과가 현재 원가에 반영된다.
|
||||
- 실패·경계 조건: `quantity_after` 누락·0 이하 또는 `total_cost_basis` 누락에서는 `cost_basis_per_unit`과 `total_cost_basis`가 모두 null이고 기업행동 metadata 부족 warning이 non-null이어야 한다. 기업행동 전 WAC를 정상 원가로 노출하거나 BUY/SELL-only 결과, 기존 불완전 거래 warning, response field/nullability, authorization, cash·profit buffer를 변경하면 FAIL이다.
|
||||
- 검사 방법: domain unit test, holdings API fixture와 실제 PostgreSQL joined-ledger integration으로 metadata 전달·순서·정상/실패 결과를 검사한다. 기존 BUY/SELL·authorization·corporate-action atomicity 회귀와 전체 test suite, 정적 검사를 실행하고 기존 trex-core consumer가 정상 응답의 수량·단가·총원가 일관성을 통과하는지 별도 호환성 probe로 확인한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0039"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Broker ledger venue API and persistence contract
|
||||
|
||||
## Criterion
|
||||
|
||||
Broker ledger write는 optional nullable `execution_venue`와 `order_venue`를 수용해야 한다. `execution_venue`는 broker가 확인한 actual venue인 `KRX`, `NXT` 또는 NULL이고, `order_venue`는 original routing venue인 `KRX`, `NXT`, `SOR` 또는 NULL이다. Portfolio-hub는 두 값을 추정·상호 변환하지 않고 broker ledger row에 저장하며 POST의 new/duplicate 응답과 unified `GET /v1/portfolios/{portfolio_id}/ledger` broker item에서 그대로 반환해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: `(execution_venue=NULL, order_venue=SOR)`, KRX/NXT actual과 KRX/NXT/SOR order 조합이 POST→repository→실제 PostgreSQL→POST response→unified GET에서 동일하게 round-trip되고 OpenAPI가 두 nullable enum을 노출한다.
|
||||
- 실패·경계 조건: `execution_venue=SOR`, AUTO 또는 미지원 값과 `order_venue=AUTO` 또는 미지원 값은 `422`나 DB constraint로 거부되며, order venue·시간·default로 actual venue를 채우지 않는다. 필드 생략 request는 계속 유효하고 manual/corporate-action/historical item은 NULL을 반환할 수 있다.
|
||||
- 검사 방법: schema/OpenAPI·API·repository tests와 실제 PostgreSQL round-trip/check-constraint integration을 실행하고 `LedgerEntry`·request/response·unified mapper의 end-to-end field path를 검토한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0040"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Idempotent venue provenance enrichment
|
||||
|
||||
## Criterion
|
||||
|
||||
동일 `(portfolio_id, order_id, execution_id)`의 broker ledger duplicate는 기존 portfolio row lock을 유지하면서 기존 ledger row를 `FOR UPDATE`로 선택해야 한다. 같은 transaction에서 기존 economic payload(`side`, `quantity`, `price`, `fee_amount`, `tax_amount`)를 먼저 비교하고 불일치면 metadata 변경 없이 `409`로 중단해야 한다. Economic payload가 같을 때 모든 incoming venue를 검사하여 저장값과 서로 다른 non-null이 하나라도 있으면 어느 field도 갱신하지 않고 `409`로 중단하며, 충돌이 없을 때만 저장값 NULL·incoming non-null인 venue들을 하나의 metadata-only update로 보강해야 한다. Incoming NULL은 기존 non-null을 유지해야 한다. 이 경로는 기존 ledger row를 재생성하거나 portfolio cash·asset quantity·snapshot과 WAC/PnL를 다시 변경하지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: execution/order venue 각각과 두 필드 동시 NULL→non-null duplicate가 같은 ledger row를 갱신하고 `200 duplicate` 최종 저장값을 반환하며 ledger row 수·cash·quantity·snapshot이 최초 `201 new` 이후 변하지 않는다. Incoming NULL replay는 기존 값을 보존한다.
|
||||
- 실패·경계 조건: economic payload 불일치는 venue 검사·update 전에 `409`이고, execution 또는 order venue의 non-null 불일치는 모든 venue 검사를 완료한 뒤 update 전에 `409`이므로 어느 venue도 부분 갱신되지 않으며 회계가 변하지 않는다. 서로 다른 concurrent backfill은 portfolio·ledger row lock 아래 하나만 성공하거나 같은 값으로 수렴하고 상충 값의 last-write-wins를 허용하지 않는다.
|
||||
- 검사 방법: API/repository idempotency·conflict tests와 실제 PostgreSQL row-lock/concurrency integration에서 final row·row count·portfolio balances·quantity·snapshot을 함께 검사한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0041"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Venue migration and mixed-version compatibility
|
||||
|
||||
## Criterion
|
||||
|
||||
Venue provenance schema 변경은 `portfolio_ledger`에 nullable `execution_venue`와 `order_venue` 및 값 constraint만 additive하게 추가하고 기존 row를 backfill하거나 재해석하지 않아야 한다. 빈 DB bootstrap, 기존 Alembic head에서의 upgrade와 migration replay가 모두 같은 head schema에 수렴해야 하며, Hub가 새 필드를 optional로 먼저 수용한 뒤 core adapter가 전송을 시작하는 혼합 버전 순서에서 구·신 payload와 기존 ledger consumer가 함께 동작해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: disposable PostgreSQL에서 cold bootstrap, pre-migration legacy fixture upgrade와 replay가 성공하고 기존 row의 두 venue가 NULL인 채 다른 값이 보존된다. 새 core payload와 필드를 생략한 구 payload가 모두 수용되며 manual/corporate-action, authorization, ordering/filter/cursor, joined corporate-action, WAC/PnL 회귀가 통과한다.
|
||||
- 실패·경계 조건: migration이 historical venue를 KRX/NXT/SOR로 채우거나 기존 row를 삭제·변경하거나 invalid venue를 허용하면 실패한다. Hub보다 core adapter를 먼저 배포하는 순서는 지원된다고 주장하지 않는다. Downgrade는 신규 constraint와 column만 제거하고 기존 schema를 복원한다.
|
||||
- 검사 방법: Alembic upgrade/downgrade/replay·`app/db/migrations.py` cold/legacy integration, PostgreSQL schema inventory와 기존 ledger API/repository 회귀를 실행하고 interface contract의 Hub-first rollout 문구를 검사한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0042"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 활성 service token name-only rename와 identity 보존
|
||||
|
||||
## Criterion
|
||||
|
||||
존재하는 활성 service token을 기존 이름에서 새 이름으로 rename하면 동일한 durable token identity의 `name`만 변경되어야 한다. 성공 직후 기존 이름의 active lookup은 더 이상 해당 token을 반환하지 않고 새 이름의 lookup은 동일한 token을 반환해야 하며, rename 전에 유효했던 bearer token은 같은 principal·permission·runtime/daemon scope로 계속 인증되어야 한다. Rename transaction 자체는 `id`, token secret/hash/prefix, owner, permission, token type, expiry, `last_used_at`/revocation timestamps, runtime/daemon binding을 포함한 `name` 외 모든 저장 값을 변경하지 않아야 한다. 후속 인증이 기존 인증 계약에 따라 `last_used_at`을 갱신하는 것은 rename mutation이 아니다. Revoked token은 rename 대상이 아니다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 실제 PostgreSQL에서 활성 token을 rename한 직후, 후속 인증 전에 before/after row를 비교하여 `name` 외 모든 column이 동일하고 old-name lookup이 비어 있으며 new-name lookup이 같은 token identity를 반환한다. 그 다음 기존 bearer token으로 인증하여 같은 principal을 확인하며, 이 인증으로 인한 기존 계약상의 `last_used_at` 갱신만 허용한다.
|
||||
- 실패·경계 조건: old name의 활성 token이 없으면 `서비스 토큰을 찾을 수 없습니다: NAME`으로 실패하고 active/revoked row가 모두 변경되지 않는다. 같은 이름의 revoked row만 존재해도 그 row를 rename 대상으로 선택하지 않는다.
|
||||
- 검사 방법: repository/service unit test와 loopback real-PostgreSQL integration test로 persisted row, lookup, authentication principal 및 protected-field 불변성을 검증한다. Rename 경로가 token 생성·rotation·revoke 함수를 호출하면 test가 실패해야 한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0043"
|
||||
status: active
|
||||
---
|
||||
|
||||
# rename 충돌·동시성·same-name 원자성
|
||||
|
||||
## Criterion
|
||||
|
||||
Service-token rename은 active service-token name uniqueness를 원자적으로 유지해야 한다. 새 이름을 다른 active service token이 사용 중이면 `이미 존재하는 서비스 토큰 이름입니다: NEW_NAME`으로 실패하며 어느 token도 일부 변경되지 않아야 한다. 같은 target name을 두 rename 또는 rename과 issue가 동시에 획득하려 할 때 database uniqueness가 순서를 결정하여 최대 한 작업만 성공하고 나머지는 같은 domain conflict로 귀결되어야 한다. 새 이름과 기존 이름이 같으면 활성 token의 존재를 확인한 뒤 성공 no-op(`changed=false`)로 처리하고 durable write를 수행하지 않아야 한다. 동일 이름의 revoked token만 존재하는 경우에는 활성 rename을 막지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: unused target과 revoked-only target rename이 성공하고, same-name request는 동일 token 정보를 `changed=false`로 반환하며 repository write count가 0이다.
|
||||
- 실패·경계 조건: active target collision 및 concurrent target collision은 명시된 duplicate-name error를 반환하고 source/target row의 `name`과 보호 필드를 rollback 전후 동일하게 유지한다. Missing same-name request는 no-op 성공이 아니라 missing-token error다.
|
||||
- 검사 방법: service fake/spy test로 no-op write absence를 검증하고, loopback real-PostgreSQL test에서 active/revoked collision과 독립 connection을 사용한 rename-vs-rename 및 rename-vs-issue 경쟁을 실행하여 성공 수, conflict message, persisted final rows를 검증한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0044"
|
||||
status: active
|
||||
---
|
||||
|
||||
# service-token rename CLI 확인·출력·회귀 계약
|
||||
|
||||
## Criterion
|
||||
|
||||
CLI는 `pfhub service-token rename NAME --new-name NEW_NAME [--force/-f]`를 제공해야 한다. 기본 실행은 old/new name을 식별하는 확인 prompt를 표시하고 거부 시 성공 exit로 종료하면서 어떠한 rename도 수행하지 않아야 하며, `--force/-f`는 prompt 없이 같은 동작을 수행해야 한다. 성공한 table 및 JSON 출력은 `token_id`, `old_name`, `new_name`, `changed`만 표현하고 raw/masked token, token hash/prefix나 다른 secret-derived 값을 포함하지 않아야 한다. Missing/duplicate domain error는 기존 CLI error contract에 따라 non-zero로 반환되어야 하며 기존 `list`, `issue`, `rotate`, `rebind`, `revoke`, `migrate-null-daemon`의 동작은 유지되어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: command help와 required `--new-name`가 노출되고, 승인 prompt와 `--force` 실행의 table/JSON이 정확한 네 필드 및 `changed` 상태를 반환한다. Same-name 결과도 `changed=false`로 직렬화된다.
|
||||
- 실패·경계 조건: prompt 거부는 exit code 0과 zero service/repository call을 보장한다. Missing/duplicate는 exit code 1과 지정된 message를 반환한다. 모든 success/error 출력에 raw token, hash, prefix와 credential이 없어야 한다.
|
||||
- 검사 방법: Typer CLI runner test로 help, missing option, prompt accept/reject, `--force`와 `-f`, table/JSON, same-name, missing/duplicate를 검증하고 기존 service-token CLI regression suite 및 full test suite를 실행한다.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_line_number": 7,
|
||||
"next_ac_number": 37
|
||||
"next_line_number": 11,
|
||||
"next_ac_number": 45
|
||||
}
|
||||
|
||||
@@ -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 데이터 보정.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: "dcy-0008"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# 기업행동 원가를 WAC replay에 반영 (#37)
|
||||
|
||||
## Problem
|
||||
|
||||
기업행동이 기록된 portfolio에서 `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis`가 기업행동 후 수량과 기업행동 전 WAC를 섞어 반환한다. `019685` 사례에서 portfolio snapshot 수량은 `726`이지만 endpoint는 병합 전 단가 `1,086.2341459366392`를 사용하여 총원가를 `788,605.9899500001`로 계산한다. 기업행동 감사 기록은 병합 후 수량 `726`과 authoritative `total_cost_basis=1,577,211.97990000`을 이미 보유한다. 이 불완전 응답은 downstream의 안전 gate를 활성화하여 자동 reconcile을 중단시킨다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `E-001` Source issue `https://github.com/fourblocks2020/portfolio-hub/issues/37`은 운영 사례, 확인된 원인, 수정 사양과 acceptance criteria를 기록한다. 확인 시점의 issue body SHA-256은 `79acbcd09e22c309a8438efde32ff12b9414815557e28ad36802a71c9200bdec`, `updated_at`은 `2026-09-19T02:20:12Z`다.
|
||||
- `E-002` 기준 source는 `portfolio-hub` `f79f3c79b5e9c7e13fb28260af71e7e56cf575f5`다. `app/repositories/postgres.py::get_all_trades_for_pnl()`은 기업행동 metadata를 `LedgerEntry.corporate_action`에 보존하며 `executed_at ASC, ledger_entry_id ASC` 순서로 반환한다.
|
||||
- `E-003` `app/api/v1_portfolios.py::get_holdings_cost_basis()`는 ledger entry를 domain replay row로 변환할 때 `side`, `quantity`, `price`, `fee_amount`, `executed_at`만 전달하므로 `corporate_action` metadata를 버린다. 이후 portfolio snapshot 수량과 `calculate_current_wac()`의 unit WAC를 곱하여 총원가를 다시 만든다.
|
||||
- `E-004` `app/domain/pnl.py::calculate_current_wac()`에는 `stock_consolidation` 분기가 이미 있으나 metadata가 없으면 running state에서 값을 추정하는 fallback이 있고, 반환형 `CurrentWacResult`는 authoritative total을 보존하지 않는다.
|
||||
- `E-005` `app/schemas/v1.py::HoldingsCostBasisResponse`는 `cost_basis_per_unit`과 `total_cost_basis`를 이미 nullable로 정의하므로 신규 wire field나 DB migration 없이 fail-closed 응답을 표현할 수 있다.
|
||||
- `E-006` `docs/interface-contract.md`는 stock consolidation의 `total_cost_basis`를 authoritative pre-action acquisition cost로 정의하고, persisted 8-decimal WAC의 반올림 residue가 정상임을 명시한다.
|
||||
- `E-007` active `AC-0037`은 joined repository read와 corporate-action metadata mapping 보존을 소유하지만, `req-0007`은 WAC·원가 회계 규칙 변경을 명시적으로 제외한다. Authoritative total을 holdings 응답까지 보존하는 동작은 별도의 active AC가 필요하다.
|
||||
|
||||
## Scope
|
||||
|
||||
- `S-001` holdings endpoint가 `LedgerEntry.corporate_action`의 `quantity_before`, `quantity_after`, `total_cost_basis`와 식별·순서 metadata를 domain replay row에 전달하도록 한다. 이는 existing `AC-0037`을 `satisfy`한다.
|
||||
- `S-002` 유효한 `stock_consolidation`을 replay할 때 running quantity를 `quantity_after`로 바꾸고, authoritative `total_cost_basis`를 보존하며, `cost_basis_per_unit = total_cost_basis / quantity_after`로 계산한다. 이 endpoint 원가 계약은 지속적인 독립 검증을 위해 새 AC를 `create`한다.
|
||||
- `S-003` `quantity_after`가 없거나 0 이하이거나 `total_cost_basis`가 없으면 기업행동 전 WAC로 추정하지 않는다. holdings 응답의 두 원가 필드를 `null`로 반환하고 기존 `data_completeness_warning` 필드에 기업행동 metadata 부족을 구분하는 안정적인 문자열을 반환한다. 신규 response field는 만들지 않는다.
|
||||
- `S-004` 정상 기업행동, metadata 부족, 기업행동 이후 BUY/SELL, BUY/SELL-only 회귀와 API metadata 전달을 domain·API·PostgreSQL integration 수준에서 검증한다. 기존 trex-core source를 변경하지 않고 consumer compatibility를 검증한다.
|
||||
- `S-005` interface contract에 holdings cost-basis의 기업행동 원가 출처와 계산식을 기록한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `OS-001` trex-core source·허용오차·slot 상태 변경, broker 주문, B warning 삭제 또는 production data 보정.
|
||||
- `OS-002` `0.1%`, `1,000원 상한`, 소수점 8자리 일치 같은 추가 tolerance 정책.
|
||||
- `OS-003` corporate-action write transaction, idempotency, recovery, cash, profit buffer, portfolio reference price 또는 DB schema 변경.
|
||||
- `OS-004` holdings 응답에 신규 reason field를 추가하거나 기존 BUY/SELL-only WAC 계약을 변경하는 일.
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- `R-001` 현재 WAC domain은 `float`를 사용한다. 저장된 8자리 `average_price_after`를 authoritative total 재구성에 사용하면 반올림 residue가 생기므로, total은 별도 replay state로 보존하고 endpoint가 그 값을 그대로 반환해야 한다.
|
||||
- `R-002` 기업행동 metadata 부족 상태에서 기존 WAC를 남기면 warning과 함께 잘못된 원가가 다시 노출될 수 있다. unusable 상태를 결과형에서 명시적으로 표현하고 두 원가 필드를 함께 `null`로 만드는 회귀가 필요하다.
|
||||
- `R-003` 기업행동 이후 BUY/SELL이 있으면 authoritative total도 거래와 함께 증감해야 한다. unit WAC와 quantity만 갱신하고 total을 고정하면 후속 거래가 있는 portfolio에서 잘못된 값을 반환하므로 running total과 unit WAC의 관계를 함께 검증해야 한다.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- `A-001` Issue #37에 기록된 정상 동작과 비범위가 이번 delivery의 제품 결정이다. Discovery confirmation에서 이 scope를 canonical하게 확정한다.
|
||||
- `A-002` repository가 제공하는 `executed_at ASC, ledger_entry_id ASC` 순서를 endpoint에서 재정렬하지 않고 그대로 replay한다.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Activity Log
|
||||
|
||||
- 2026-09-19T11:36:22+09:00 — 사용자가 `dcy-0008`을 confirmed로 확정하고 exact `req-0008`·`ac-0038`을 승인했다. `req-0008`은 approved, `ac-0038`은 active로 전환했고 `proofline validate`가 통과했다(legacy line status warning만 존재).
|
||||
- 2026-09-19T11:36:22+09:00 — 구현 계획: API replay row에 기업행동 metadata를 보존하고, WAC replay에서 authoritative total과 post-action quantity를 적용하며, 필수 metadata 누락을 fail-closed로 처리한다. Domain/API/PostgreSQL/consumer 회귀와 기존 BUY/SELL 동작을 검증한다.
|
||||
- 2026-09-19T11:54:52+09:00 — 구현 및 검증: 기업행동 metadata 전달, `total_cost_basis / replay quantity` 계산, 누락 metadata의 원가 `null`/warning 처리를 구현했다. 표적 테스트 66건, 전체 테스트 631건(25 skip), PostgreSQL 기업행동 모듈 6건, Ruff ratchet, compileall, dependency check, ProofLine validation이 통과했다. metadata 전달을 제거한 sabotage에서 신규 API 회귀가 실패하고 복원 후 통과했다. `trex-core` 실제 reconciliation 경로는 `auto_synced`로 호환됨을 확인했다. 다음 단계는 독립 구현 리뷰와 PR delivery다.
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
id: "line-0008"
|
||||
status: discovery
|
||||
---
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: "req-0008"
|
||||
status: approved
|
||||
discovery: "dcy-0008"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0038"
|
||||
update: []
|
||||
retire: []
|
||||
satisfy:
|
||||
- "ac-0037"
|
||||
---
|
||||
|
||||
# 기업행동 원가를 WAC replay에 반영 (#37)
|
||||
|
||||
## Objective
|
||||
|
||||
기업행동이 포함된 portfolio의 holdings cost-basis가 기업행동 감사 기록의 authoritative 총원가와 기업행동 후 수량을 사용하여 현재 원가를 반환하고, 계산에 필요한 metadata가 없을 때 병합 전 원가를 정상값처럼 노출하지 않도록 한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` replay에 repository가 결합한 `stock_consolidation` metadata를 전달한다.
|
||||
- 정상 기업행동은 `quantity_after`를 현재 replay 수량으로 적용하고 감사 기록의 `total_cost_basis`를 authoritative 총원가로 보존하며, 주당 원가는 `total_cost_basis / quantity_after`로 계산한다.
|
||||
- 기업행동 이후 BUY/SELL 또는 후속 기업행동이 있으면 같은 시간순 ledger replay 안에서 현재 수량·주당 원가·총원가를 계속 계산한다.
|
||||
- 필수 기업행동 metadata인 양수 `quantity_after` 또는 `total_cost_basis`가 없으면 기업행동 전 WAC로 추정하지 않고 holdings 응답의 `cost_basis_per_unit`과 `total_cost_basis`를 `null`로 반환하며, 기존 warning 필드로 기업행동 metadata 부족을 구분한다.
|
||||
- 정상·불완전 기업행동, 기업행동 이후 거래, BUY/SELL-only, authorization, ordering과 consumer compatibility를 회귀 검증하고 interface contract를 동기화한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- 기업행동 감사 기록의 `total_cost_basis`가 원가 총액의 정본이다. Persisted `average_price_after`를 곱하여 총원가를 재구성하거나 별도의 허용오차 판정에 사용하지 않는다.
|
||||
- `HoldingsCostBasisResponse`의 기존 필드와 nullability를 유지하며 신규 reason field 또는 DB migration을 추가하지 않는다.
|
||||
- Repository의 `executed_at ASC, ledger_entry_id ASC` 순서, daemon scope와 portfolio ownership 검증을 유지한다.
|
||||
- 기업행동이 없는 BUY/SELL-only portfolio의 WAC 및 기존 불완전 거래 이력 warning 의미를 변경하지 않는다.
|
||||
- trex-core source와 검증 tolerance를 변경하지 않는다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- `0.1%`, `1,000원 상한`, 소수점 8자리 일치 등 기업행동 원가의 추가 tolerance 정책.
|
||||
- Corporate-action write transaction, idempotency, recovery, cash, profit buffer, portfolio reference price 또는 기존 ledger row의 수정·backfill.
|
||||
- Broker 주문, trex-core slot 변경, B warning 삭제, production rollout 또는 특정 production portfolio 보정.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
id: "dcy-0009"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# Broker ledger venue provenance preservation
|
||||
|
||||
## Problem
|
||||
|
||||
`trex-core`는 broker가 확인한 실제 체결 거래소를 `LedgerTrade.venue`에, 최초 주문 라우팅 거래소를 `LedgerTrade.order_venue`에 구분하여 durable ledger outbox까지 보존한다. 그러나 `PortfolioHubLedger._payload()`가 두 값을 HTTP payload에서 누락하고, `portfolio-hub`의 broker ledger request·저장소·응답 모델에도 대응 필드가 없다. 이 때문에 core 내부에서 보존된 venue provenance가 portfolio-hub 전달 경계에서 소실된다.
|
||||
|
||||
기대 상태는 core의 nullable actual execution venue와 original order venue를 추정·변환 없이 portfolio-hub에 전달하고, 기존 broker ledger 행·manual ledger·corporate action·구버전 client의 동작을 깨뜨리지 않으면서 저장·조회하는 것이다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `E-001` GitHub Issue #32 `https://github.com/fourblocks2020/portfolio-hub/issues/32`는 `LedgerTrade.venue -> execution_venue`, `LedgerTrade.order_venue -> order_venue` mapping, nullable historical compatibility, duplicate metadata 보강, Hub-first rollout과 venue-only 범위를 구현 계획으로 정한다.
|
||||
- `E-002` `trex-core` revision `bbdacb121c25a7539f6f9f7fe0559fc03d4542eb`의 `trex_core/application/ledger_port.py`는 `LedgerTrade.venue: Optional[str]`와 `LedgerTrade.order_venue: str`를 별도 필드로 정의한다.
|
||||
- `E-003` 같은 revision의 `trex_core/adapters/persistence/postgresql.py`와 migration `024_add_ledger_order_venue.sql`은 durable outbox에 `venue`와 `order_venue`를 별도 저장·복원하지만, `trex_core/adapters/ledger/portfolio_hub.py::PortfolioHubLedger._payload()`는 두 값을 HTTP payload에 포함하지 않는다.
|
||||
- `E-004` `portfolio-hub` revision `567d662913335a90d0de51b8e0e961b6b166d543`의 `app/schemas/v1.py::LedgerTradeRequest`·`LedgerTradeResponse`, `app/domain/models.py::LedgerEntry`, `app/repositories/portfolio_repository.py`와 `app/repositories/postgres.py`에는 execution/order venue 계약이 없다.
|
||||
- `E-005` 현재 Alembic head의 `portfolio_ledger`에는 두 venue column이 없으며 `app/db/migrations.py`와 Alembic migration이 빈 DB와 legacy DB bootstrap을 함께 소유한다.
|
||||
- `E-006` portfolio-hub active AC-0032·AC-0034·AC-0035·AC-0037은 unified ledger read, nullable source-specific facts, authorization·manual compatibility와 joined ledger 안정성을 소유하지만 broker venue의 POST validation·persistence·duplicate 보강을 명시하지 않는다. 따라서 새 observable contract는 `create` 대상이고 기존 AC는 의미를 바꾸지 않는 `satisfy` 대상이다.
|
||||
- `E-007` trex-core active AC-0019는 original `order_venue`와 nullable actual execution venue의 durable 분리, `(order_venue=SOR, venue=NULL)` 보존, 무추론 원칙과 기존 `LedgerWriter` 전달 경로를 이미 소유한다. Core 변경은 기존 AC의 의미를 바꾸지 않는 satisfy 구현이다.
|
||||
|
||||
## Scope
|
||||
|
||||
- `S-001` portfolio-hub에 새 active AC를 `create`하여 broker ledger request가 nullable `execution_venue` (`KRX`, `NXT`)와 nullable `order_venue` (`KRX`, `NXT`, `SOR`)를 수용하고 다른 값과 `execution_venue=SOR`를 거부하는 계약을 소유한다.
|
||||
- `S-002` `portfolio_ledger`에 nullable venue column과 allowlist constraint를 additive migration으로 추가하고 historical row를 NULL로 유지한다. 빈 DB·legacy DB migration apply/replay와 실제 PostgreSQL round-trip을 검증한다.
|
||||
- `S-003` 새 active AC를 `create`하여 broker ledger insert, duplicate lookup, domain mapping, POST 응답과 unified GET 응답이 두 venue를 추정·변환 없이 저장·반환하는 계약을 소유한다.
|
||||
- `S-004` 동일 `(portfolio_id, order_id, execution_id)` duplicate는 portfolio row lock으로 기존 동시성 경계를 유지하면서 기존 ledger row도 `FOR UPDATE`로 선택한다. 같은 transaction에서 기존 economic payload 비교를 먼저 수행해 불일치면 아무 metadata도 갱신하지 않고 `409`로 중단한다. Economic payload가 같을 때만 모든 venue의 non-null 충돌을 먼저 검사하고, 충돌이 없으면 NULL column만 incoming non-null 값으로 한 번에 보강한다. Incoming NULL은 기존 값을 지우지 않으며 이 보강은 ledger row를 재생성하거나 cash·quantity·snapshot 회계를 다시 적용하지 않는다.
|
||||
- `S-005` 기존 active AC-0032·AC-0034·AC-0035·AC-0037을 `satisfy`하여 source-neutral ordering/filter/cursor, nullable source-specific representation, owner-only authorization, manual/corporate-action 및 WAC/PnL compatibility를 유지한다.
|
||||
- `S-006` trex-core는 active AC-0019 아래 `PortfolioHubLedger._payload()`가 기존 `LedgerTrade.venue`를 `execution_venue`, 기존 `LedgerTrade.order_venue`를 `order_venue`로 전송하도록 변경한다. producer, `LedgerTrade`, outbox schema와 broker routing은 변경하지 않는다.
|
||||
- `S-007` 배포 호환 경계는 portfolio-hub 선배포 후 trex-core adapter 배포이며, 구버전 core payload와 historical/manual/corporate-action row는 venue가 NULL인 상태로 계속 유효하다.
|
||||
- `S-008` 검증은 portfolio-hub schema/API/repository/PostgreSQL migration과 concurrency/idempotency, trex-core exact adapter payload·outbox reload/retry, 양 저장소 교차 contract `201 new -> 200 duplicate -> GET /ledger`를 포함한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `OS-001` `broker_ordered_at`, `core_observed_filled_at` 또는 다른 timestamp provenance.
|
||||
- `OS-002` trex-core `LedgerTrade`, producer, **broker execution adapter**, durable outbox schema 또는 주문 routing 변경. `PortfolioHubLedger` HTTP ledger transport adapter의 두 기존 field 직렬화는 `S-006` 범위다.
|
||||
- `OS-003` historical venue 추정·backfill, 특히 `order_venue`나 시간대로 actual execution venue를 추론하는 동작.
|
||||
- `OS-004` ledger detail endpoint, idempotency key, broker 주문 실행·라우팅 정책 변경.
|
||||
- `OS-005` production migration·deployment·runtime restart·실제 broker 주문.
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- `R-001` mixed-version rollout에서 새 core가 구 Hub에 먼저 배포되면 추가 payload field가 거부되거나 유실될 수 있다. Hub를 먼저 배포하고 기존 payload omission을 허용한다.
|
||||
- `R-002` duplicate metadata 보강이 기존 회계 transaction path를 재실행하면 잔액·수량·snapshot이 중복 반영될 수 있다. row lock과 단일 transaction에서 provenance-only update를 분리하고 회계 불변 회귀를 실행한다.
|
||||
- `R-003` repository query의 positional mapping과 joined corporate-action projection에 column을 추가할 때 기존 mapping이 어긋날 수 있다. 명시 projection·mapping 검토와 실제 PostgreSQL mixed-row 회귀로 방지한다.
|
||||
- `R-004` concurrent duplicates가 서로 다른 non-null venue를 보강하면 last-write-wins로 provenance가 변조될 수 있다. 동일 row를 잠그고 NULL-only update와 non-null conflict를 transaction 안에서 판정한다.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- `A-001` Issue #32에 적힌 venue-only 구현 계획과 사용자의 현재 구현 지시가 제품 범위의 근거이며, 추가 timestamp나 broker lifecycle 변경은 승인되지 않았다.
|
||||
- `A-002` trex-core active AC-0019의 기존 producer/outbox 계약은 변경 없이 유지되고, 이번 core 변경은 transport 누락을 복구하는 satisfy 작업이다.
|
||||
|
||||
### Open Questions
|
||||
|
||||
없음. Issue #32와 현재 source에서 API naming, 값 allowlist, NULL semantics, duplicate policy, migration policy, rollout 순서와 제외 범위가 모두 결정되어 있다.
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
id: "line-0009"
|
||||
status: discovery
|
||||
---
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: "req-0009"
|
||||
status: approved
|
||||
discovery: "dcy-0009"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0039"
|
||||
- "ac-0040"
|
||||
- "ac-0041"
|
||||
update: []
|
||||
retire: []
|
||||
satisfy:
|
||||
- "ac-0032"
|
||||
- "ac-0034"
|
||||
- "ac-0035"
|
||||
- "ac-0037"
|
||||
---
|
||||
|
||||
# Broker ledger venue provenance preservation
|
||||
|
||||
## Objective
|
||||
|
||||
`trex-core`가 이미 durable하게 구분하는 actual execution venue와 original order-routing venue를 portfolio-hub의 broker ledger write·storage·read 경계까지 손실 없이 보존한다. Unknown actual venue와 SOR routing을 정상 조합으로 유지하고, 기존 client·historical/manual/corporate-action row·회계·조회 계약과 혼합 버전 배포의 하위호환성을 보장한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- Broker ledger request에 optional nullable `execution_venue`와 `order_venue`를 추가한다.
|
||||
- `execution_venue`는 `KRX`, `NXT`, NULL만 허용하고 `order_venue`는 `KRX`, `NXT`, `SOR`, NULL만 허용한다.
|
||||
- `portfolio_ledger`에 두 nullable column과 같은 allowlist constraint를 additive migration으로 추가한다. 기존 row는 NULL로 유지한다.
|
||||
- Broker ledger insert·domain mapping·POST response와 unified GET broker item이 저장값을 추정·변환 없이 반환한다.
|
||||
- 동일 `(portfolio_id, order_id, execution_id)` duplicate는 portfolio row lock에 더해 기존 ledger row를 `FOR UPDATE`로 선택한다. 같은 transaction에서 기존 economic payload를 먼저 비교해 불일치면 metadata 변경 없이 `409`로 중단한다. Economic payload가 같을 때 모든 venue의 non-null 충돌을 먼저 검사하고, 충돌이 없을 때만 NULL→non-null venue를 하나의 metadata-only update로 보강한다. Incoming NULL은 기존 non-null을 지우지 않는다.
|
||||
- Metadata-only duplicate 보강은 ledger row, portfolio cash·asset quantity·snapshot, WAC/PnL 또는 corporate-action state를 다시 적용하지 않는다.
|
||||
- 기존 active AC-0032·AC-0034·AC-0035·AC-0037을 의미 변경 없이 satisfy하여 unified ordering/filter/cursor, canonical nullable representation, owner authorization, manual/corporate-action 및 joined ledger compatibility를 유지한다.
|
||||
- trex-core는 active AC-0019 아래 기존 `LedgerTrade.venue`를 `execution_venue`, `LedgerTrade.order_venue`를 `order_venue`로 HTTP payload에 전달한다. Core producer·domain·outbox schema는 변경하지 않는다.
|
||||
- 실제 PostgreSQL migration·round-trip·concurrent duplicate와 core adapter payload/reload, 교차 저장소 `201 new -> 200 duplicate -> GET /ledger` contract를 검증한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- `SOR`는 order routing provenance이며 actual execution venue로 저장하거나 추론하지 않는다.
|
||||
- Unknown actual venue는 NULL로 보존하고 `order_venue`, 시간 또는 historical default에서 KRX/NXT를 추론하지 않는다.
|
||||
- 기존 idempotency key와 기존 경제적 payload conflict 판정은 유지한다.
|
||||
- 추가 request field는 optional이어야 하며 구버전 core/client payload가 계속 유효해야 한다.
|
||||
- Historical, manual, corporate-action row는 두 venue가 NULL이어도 유효해야 한다.
|
||||
- Hub-first, core-adapter-second 배포 순서를 문서화하되 production rollout은 수행하지 않는다.
|
||||
- Normative Design Documents: 없음. 이 REQ와 create AC-0039~AC-0041 및 satisfy 대상 active AC가 normative specification을 소유한다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- `broker_ordered_at`, `core_observed_filled_at` 또는 다른 timestamp provenance.
|
||||
- trex-core `LedgerTrade`, producer, **broker execution adapter**, outbox table/schema 또는 routing policy 변경. `PortfolioHubLedger` HTTP ledger transport adapter의 기존 venue 직렬화는 Scope에 포함한다.
|
||||
- Historical venue 추정·backfill.
|
||||
- 새로운 ledger detail endpoint 또는 idempotency key 변경.
|
||||
- Production migration·deployment·restart·실제 broker 주문.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: "dcy-0010"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# Service token rename without secret rotation
|
||||
|
||||
## Problem
|
||||
|
||||
`pfhub service-token`은 활성 service token을 이름으로 조회해 rotate, rebind, revoke할 수 있지만 이름만 바꾸는 명령은 제공하지 않는다. 이름 오타나 명명 규칙을 교정하려면 새 token을 발급하고 기존 token을 revoke해야 하므로, label 변경만 필요한 경우에도 raw bearer token과 client 배포를 교체해야 한다.
|
||||
|
||||
기대 상태는 활성 service token의 동일한 durable row에서 `name`만 원자적으로 변경하고 기존 bearer token, identity, owner, permission, expiry, runtime/daemon binding을 유지하는 것이다. CLI는 확인 prompt와 non-interactive `--force`를 제공하고 table/JSON에서 secret 없이 변경 결과를 반환해야 한다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `E-001` GitHub Issue #5 (`https://github.com/fourblocks2020/portfolio-hub/issues/5`)는 `pfhub service-token rename NAME --new-name NEW_NAME [--force]`와 name-only 변경, active-name uniqueness, missing/duplicate/no-op 오류, table/JSON 출력을 요구한다. 원문은 commit `17d6cbe3ba4ec10842bafb4af90709ef83fe721f`의 `docs/issues/issue-0005-original.md`에 byte-for-byte 보존되어 있다.
|
||||
- `E-002` `app/cli/commands/service_tokens.py`의 current command set은 `list`, `issue`, `rotate`, `rebind`, `revoke`, `migrate-null-daemon`이며 rename path가 없다. `revoke_service_token()`은 `--force/-f`와 `typer.confirm()`의 기존 UX를 제공한다.
|
||||
- `E-003` `app/services/auth_service.py::AuthService`는 active service token lookup, issue/rotate/rebind/revoke lifecycle을 소유한다. `issue_service_token()`은 PostgreSQL `UniqueViolation`을 `이미 존재하는 서비스 토큰 이름입니다: {name}`으로 변환한다.
|
||||
- `E-004` `app/repositories/postgres.py::get_service_token_by_name()`은 `name`, `token_type='service'`, `revoked_at IS NULL`로 active row만 조회한다. 같은 파일의 cold-start schema와 migration `app/db/alembic/versions/20260520_0022_service_token_unique_index_fix.py`는 `idx_auth_tokens_service_name_unique` partial unique index로 active service-token name uniqueness를 이미 강제한다.
|
||||
- `E-005` `app/repositories/portfolio_repository.py::PortfolioRepository`에는 rename contract가 없으며 `app/schemas/auth.py`에도 rename CLI response가 없다.
|
||||
- `E-006` `AuthToken`/PostgreSQL row는 token identity와 secret-derived 값, permission, owner, expiry, runtime/daemon binding을 name과 별도 column으로 보존한다. raw token은 저장하지 않으므로 name-only invariant는 row before/after 비교와 기존 bearer token 재인증으로 검증해야 한다.
|
||||
- `E-007` current `main` baseline `996127f2155a71c1085c4fbf35b504cc3c43439c`에서 관련 구현이 없고, Issue #5 및 `service token rename` keyword의 기존 구현 PR도 없다.
|
||||
- `E-008` 현재 ProofLine active AC 검색 결과 service-token label rename lifecycle을 소유하는 criterion은 없다. service-token authorization 및 daemon-scope criteria는 회귀 대상일 뿐 이 신규 capability를 satisfy하지 않는다.
|
||||
|
||||
## Scope
|
||||
|
||||
- `S-001` AC admission은 durable CLI behavior를 소유하는 신규 REQ/AC `create`로 분류한다. Approved REQ 원문과 기존 active AC 의미는 변경하지 않는다.
|
||||
- `S-002` `PortfolioRepository`와 PostgreSQL adapter에 active service token의 `name`만 바꾸는 단일 `UPDATE ... RETURNING` contract를 추가한다. 새 이름 사전 조회를 uniqueness 권위로 사용하지 않고 기존 partial unique index가 concurrent conflict를 결정한다.
|
||||
- `S-003` `AuthService`에 missing active token, duplicate active name, same-name success no-op를 구분하는 rename contract를 추가한다. `new_name == name`도 active row 존재를 확인하며 repository write는 하지 않는다.
|
||||
- `S-004` `pfhub service-token rename NAME --new-name NEW_NAME [--force/-f]`를 추가한다. prompt 취소는 success exit와 zero write이고, 결과는 `token_id`, `old_name`, `new_name`, `changed`만 table/JSON으로 출력한다.
|
||||
- `S-005` 성공 rename은 동일 row의 `name` 외 모든 column과 기존 bearer-token authentication principal을 보존한다. raw/masked token을 출력하거나 token generation/hash/revoke 경로를 호출하지 않는다.
|
||||
- `S-006` unit/service/CLI와 loopback real-PostgreSQL integration으로 success, missing, active collision, revoked-name reuse, same-name no-op, prompt/force, secret non-disclosure, protected-field invariants와 concurrent conflict를 검증한다.
|
||||
- `S-007` existing interface documentation과 CLI examples를 additive contract에 맞춰 갱신하며 기존 service-token lifecycle/auth/daemon-scope 회귀를 유지한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `OS-001` raw token rotation, reissue, revoke 또는 secret 표시
|
||||
- `OS-002` token owner, permission, type, expiry, runtime/daemon binding 변경
|
||||
- `OS-003` revoked token row 자체의 rename
|
||||
- `OS-004` service-token name 문자셋·길이·정규화에 대한 신규 정책
|
||||
- `OS-005` HTTP API endpoint, database schema migration, historical backfill
|
||||
- `OS-006` 다른 repository 변경, production rollout 또는 production token mutation
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- `R-001` 애플리케이션의 “이름 사용 가능” 사전 조회만으로 충돌을 판단하면 concurrent rename/issue가 race한다. single-statement update와 partial unique index를 authority로 사용하고 실제 PostgreSQL 경쟁 테스트로 완화한다.
|
||||
- `R-002` update projection이나 CLI response에 secret-derived/internal field가 포함되면 정보 노출 또는 unintended mutation이 생긴다. canonical auth-token projection을 반환하되 CLI response를 네 개의 비밀이 아닌 필드로 제한하고 negative test를 둔다.
|
||||
- `R-003` same-name no-op가 존재 확인 없이 성공하면 missing token을 정상 처리하거나 revoked row를 오인할 수 있다. active lookup 성공을 no-op의 precondition으로 고정한다.
|
||||
- `R-004` application rollback은 이미 완료된 metadata rename을 자동으로 되돌리지 않는다. 필요한 경우 동일 command로 이전 이름에 다시 rename하며 production 실행은 별도 운영 승인으로 둔다.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Activity Log
|
||||
|
||||
## 2026-09-19 — implementation start
|
||||
|
||||
- 사용자 승인: `req-0010` approved, `ac-0042`~`ac-0044` active.
|
||||
- 계획: repository protocol/PostgreSQL atomic rename, service same-name no-op, CLI response/command, docs와 unit/real-PostgreSQL tests를 구현한다.
|
||||
- 범위 제외: schema migration, HTTP API, production token mutation/rollout.
|
||||
- 다음 행동: 실패하는 focused tests를 먼저 추가하고 최소 구현 후 전체 검증한다.
|
||||
|
||||
## 2026-09-19T21:00:42+09:00 — implementation verified
|
||||
|
||||
- 구현: active service token을 단일 PostgreSQL `UPDATE ... RETURNING`으로 rename하고 service same-name no-op, duplicate domain error, Typer confirmation/`--force/-f`, secret-free table/JSON response, README 예시를 추가했다.
|
||||
- focused test: rename service/CLI selector `9 passed`; real-PostgreSQL rename suite `5 passed`.
|
||||
- 실제 backend: current migrations 적용 후 보호 필드·bearer principal 보존, active/revoked/missing 경계, duplicate rollback, rename-vs-rename 및 rename-vs-issue independent-connection 경쟁을 검증했다.
|
||||
- CI selector: 기존 PostgreSQL workflow 단계에 `tests/test_service_token_rename_postgres.py`를 등록하고 release contract test로 runtime password-variable 조합과 selector 존재를 검증했다. Corporate-action 및 rename PostgreSQL selector 합계 `11 passed`.
|
||||
- sabotage: SQL rename parameter를 일시적으로 old name으로 대체했을 때 persisted-name assertion이 의도대로 실패했고 복원 후 동일 selector `1 passed`였다.
|
||||
- 전체 회귀: `742 passed, 26 skipped`; `proofline validate`, `git diff --check`, compileall 및 신규 PostgreSQL 파일 Ruff F/I 검사가 통과했다. Ruff ratchet은 commit 전 working tree를 포함하지 않으므로 commit 후 canonical selectors를 다시 실행한다.
|
||||
- 독립 리뷰: runtime/test/security 두 검토의 구현 의미·coverage 평가는 적합했다. DSN 표시 관련 blocker는 tool credential-redaction을 literal로 오인한 false positive였으며, byte-level non-secret re-review에서 `${POSTGRES_PASSWORD}` 사용과 literal placeholder 부재를 확인하고 최종 `PASS`했다.
|
||||
- lifecycle: approved `req-0010`과 active `ac-0042`~`ac-0044`는 기존 ProofLine 관례대로 유지한다. production token mutation 및 rollout은 수행하지 않았다.
|
||||
|
||||
## 2026-09-19T21:13:04+09:00 — CI portability correction
|
||||
|
||||
- PR #40 최초 CI는 Python 3.11/Rich의 ANSI escape가 required-option stderr의 `--new-name` 내부에 삽입되어 literal substring assertion 한 건이 실패했다. CLI exit code와 required-option 동작은 정상이며 production implementation 결함은 아니었다.
|
||||
- 테스트를 `click.unstyle()`로 stderr 정규화 후 option contract를 확인하도록 수정했다.
|
||||
- CI와 같은 Python 3.11 및 `uv sync --extra dev --locked` 환경에서 실패 selector `1 passed`, 전체 suite `731 passed, 37 skipped`, 실제 PostgreSQL selector `11 passed`, Ruff ratchet 3종 `introduced_findings=0`, ProofLine validation과 diff check 통과를 확인했다.
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
id: "line-0010"
|
||||
status: discovery
|
||||
---
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: "req-0010"
|
||||
status: approved
|
||||
discovery: "dcy-0010"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0042"
|
||||
- "ac-0043"
|
||||
- "ac-0044"
|
||||
update: []
|
||||
retire: []
|
||||
satisfy: []
|
||||
---
|
||||
|
||||
# Service token rename without secret rotation
|
||||
|
||||
## Objective
|
||||
|
||||
운영자가 활성 service token의 bearer token과 권한·binding을 교체하지 않고 식별용 이름만 안전하게 변경할 수 있게 한다. 변경은 concurrent issue/rename과 경쟁하더라도 기존 active-name uniqueness를 지키며, CLI는 interactive와 automation 환경 모두에서 secret을 노출하지 않는 명확한 결과를 제공해야 한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- 활성 service token 한 건의 `name`만 동일 durable identity에서 변경한다.
|
||||
- 기존 active service-token name uniqueness를 concurrent conflict의 최종 권위로 사용하고 충돌은 전체 rename을 실패시킨다.
|
||||
- 존재하지 않는 활성 이름, 같은 이름, revoked-name reuse를 서로 구분해 안정적으로 처리한다.
|
||||
- `pfhub service-token rename NAME --new-name NEW_NAME [--force/-f]`와 table/JSON 결과를 제공한다.
|
||||
- repository, service, CLI, 문서와 unit/real-PostgreSQL 검증을 함께 갱신한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Rename transaction 자체는 `id`, token secret/hash/prefix, owner, permission, token type, expiry, `last_used_at`/revocation timestamps, runtime/daemon binding 등 `name` 외 저장 필드를 변경하지 않는다.
|
||||
- rename은 token rotation, issue, revoke 경로를 호출하지 않고 기존 bearer token의 authentication principal을 보존한다. Rename 이후의 인증은 기존 인증 계약에 따라 `last_used_at`을 갱신할 수 있으며 이는 rename mutation에 포함되지 않는다.
|
||||
- duplicate-name 판단은 애플리케이션의 사전 조회가 아니라 PostgreSQL partial unique index를 최종 권위로 삼으며, 실패 시 partial mutation이 없어야 한다.
|
||||
- raw token, token hash 또는 connection credential은 CLI table/JSON, error, log, test evidence에 출력하지 않는다.
|
||||
- 기존 migration `20260520_0022`의 partial unique index를 사용하며 신규 schema migration이나 backfill을 추가하지 않는다.
|
||||
- 현재 `service-token` command와 HTTP API의 기존 의미 및 응답 계약을 변경하지 않는다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- revoked token row rename
|
||||
- service-token name의 신규 문자셋·길이·정규화 정책
|
||||
- owner, permission, expiry, runtime/daemon binding 변경
|
||||
- HTTP rename endpoint 또는 다른 repository의 producer/consumer 변경
|
||||
- production token rename 또는 production rollout
|
||||
@@ -109,6 +109,7 @@ pfhub service-token list --user alice
|
||||
pfhub service-token issue nightly --user alice --ttl-days 30
|
||||
pfhub service-token issue permanent --user alice --no-expiry
|
||||
pfhub service-token issue my-daemon-token --user alice --daemon-id <daemon_id>
|
||||
pfhub service-token rename nightly --new-name nightly-kis
|
||||
pfhub service-token revoke nightly --force
|
||||
pfhub service-token rotate permanent --force
|
||||
pfhub daemon create alpha-bot --user alice --display-name "Alpha Trading Bot"
|
||||
@@ -137,6 +138,7 @@ Supported commands:
|
||||
- `user delete`
|
||||
- `service-token list`
|
||||
- `service-token issue`
|
||||
- `service-token rename`
|
||||
- `service-token revoke`
|
||||
- `service-token rotate`
|
||||
- `daemon create`
|
||||
|
||||
+61
-13
@@ -27,6 +27,7 @@ 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.repositories.portfolio_repository import LedgerTradeConflictError
|
||||
from app.schemas.corporate_actions import (
|
||||
CorporateActionRequest,
|
||||
CorporateActionResponse,
|
||||
@@ -604,7 +605,13 @@ def _require_service_token(auth: AuthContext = Depends(require_trade_write)) ->
|
||||
return auth
|
||||
|
||||
|
||||
def _payloads_match(existing, body: LedgerTradeRequest) -> bool:
|
||||
def _economic_payloads_match(existing, body: LedgerTradeRequest) -> bool:
|
||||
"""Defence in depth for non-Postgres repository implementations.
|
||||
|
||||
The Postgres repository performs this comparison under the portfolio and
|
||||
ledger row locks; this check preserves the public contract for test or
|
||||
alternate repository implementations.
|
||||
"""
|
||||
return (
|
||||
existing.side == body.side
|
||||
and abs(existing.quantity - float(body.quantity)) < 1e-9
|
||||
@@ -659,14 +666,21 @@ def record_ledger_trade_v1(
|
||||
executed_at=body.executed_at,
|
||||
fee_amount=float(body.fee_amount),
|
||||
tax_amount=float(body.tax_amount),
|
||||
execution_venue=body.execution_venue,
|
||||
order_venue=body.order_venue,
|
||||
)
|
||||
except LedgerTradeConflictError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Duplicate key with different payload",
|
||||
) from None
|
||||
except HTTPException:
|
||||
raise
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
|
||||
if not created:
|
||||
if not _payloads_match(trade, body):
|
||||
if not _economic_payloads_match(trade, body):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Duplicate key with different payload",
|
||||
@@ -687,6 +701,8 @@ def record_ledger_trade_v1(
|
||||
fee_amount=trade.fee_amount,
|
||||
tax_amount=trade.tax_amount,
|
||||
executed_at=trade.executed_at,
|
||||
execution_venue=trade.execution_venue,
|
||||
order_venue=trade.order_venue,
|
||||
recorded_at=trade.recorded_at,
|
||||
cash_balance_after=trade.cash_balance_after,
|
||||
asset_quantity_after=trade.asset_quantity_after,
|
||||
@@ -827,6 +843,7 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem | DividendLedgerHi
|
||||
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,
|
||||
execution_venue=entry.execution_venue, order_venue=entry.order_venue,
|
||||
)
|
||||
action = (entry.action or entry.side or "").upper()
|
||||
if action == "DIVIDEND":
|
||||
@@ -853,6 +870,8 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem | DividendLedgerHi
|
||||
total_value_after=entry.total_value_after,
|
||||
target_ledger_entry_id=entry.target_ledger_entry_id,
|
||||
confirmation_evidence=entry.confirmation_evidence,
|
||||
execution_venue=entry.execution_venue,
|
||||
order_venue=entry.order_venue,
|
||||
)
|
||||
return LedgerHistoryItem(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
@@ -875,6 +894,8 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem | DividendLedgerHi
|
||||
total_value_after=entry.total_value_after,
|
||||
target_ledger_entry_id=entry.target_ledger_entry_id,
|
||||
confirmation_evidence=entry.confirmation_evidence,
|
||||
execution_venue=entry.execution_venue,
|
||||
order_venue=entry.order_venue,
|
||||
)
|
||||
|
||||
|
||||
@@ -1282,21 +1303,42 @@ def get_holdings_cost_basis(
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
|
||||
# Build rows for domain function
|
||||
rows = [
|
||||
{
|
||||
"side": e.side,
|
||||
"quantity": e.quantity,
|
||||
"price": e.price,
|
||||
"fee_amount": e.fee_amount,
|
||||
"executed_at": e.executed_at,
|
||||
# Build rows for domain function, preserving joined corporate-action metadata.
|
||||
rows = []
|
||||
corporate_action_fields = (
|
||||
"corporate_action_id",
|
||||
"action_type",
|
||||
"quantity_before",
|
||||
"quantity_after",
|
||||
"average_price_after",
|
||||
"total_cost_basis",
|
||||
)
|
||||
for entry in ledger_entries:
|
||||
row = {
|
||||
"side": entry.side,
|
||||
"quantity": entry.quantity,
|
||||
"price": entry.price,
|
||||
"fee_amount": entry.fee_amount,
|
||||
"executed_at": entry.executed_at,
|
||||
"ledger_entry_id": entry.ledger_entry_id,
|
||||
}
|
||||
for e in ledger_entries
|
||||
]
|
||||
if entry.corporate_action:
|
||||
row.update(
|
||||
{
|
||||
field: entry.corporate_action.get(field)
|
||||
for field in corporate_action_fields
|
||||
}
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
wac_result = calculate_current_wac(rows)
|
||||
|
||||
current_quantity = portfolio.asset_quantity
|
||||
if wac_result.total_cost_basis is not None and wac_result.current_quantity > 0:
|
||||
current_quantity = wac_result.current_quantity
|
||||
total_cost_basis = wac_result.total_cost_basis
|
||||
cost_basis_per_unit = total_cost_basis / current_quantity
|
||||
else:
|
||||
cost_basis_per_unit = wac_result.current_wac if current_quantity > 0 else None
|
||||
total_cost_basis = (
|
||||
cost_basis_per_unit * current_quantity
|
||||
@@ -1338,7 +1380,13 @@ def create_corporate_action(
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -285,6 +285,11 @@ def corporate_action_portfolio(
|
||||
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."""
|
||||
@@ -314,11 +319,17 @@ def corporate_action_portfolio(
|
||||
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}
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ import typer
|
||||
|
||||
from app.cli.core.output import mask_token_text, render_service_token_result
|
||||
from app.cli.core.utils import render_output
|
||||
from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRebindCliResponse, ServiceTokenRevokeCliResponse
|
||||
from app.schemas.auth import (
|
||||
AuthTokenResponse,
|
||||
ServiceTokenIssueCliResponse,
|
||||
ServiceTokenRebindCliResponse,
|
||||
ServiceTokenRenameCliResponse,
|
||||
ServiceTokenRevokeCliResponse,
|
||||
)
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
token_app = typer.Typer(help="Service token lifecycle commands.")
|
||||
@@ -92,6 +98,32 @@ def issue_service_token(
|
||||
ctx.obj.console.print("[yellow]⚠ 이 토큰은 다시 표시되지 않습니다. 지금 복사해 안전한 곳에 저장하세요.[/yellow]")
|
||||
|
||||
|
||||
@token_app.command("rename")
|
||||
def rename_service_token(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Active service token name to rename."),
|
||||
new_name: str = typer.Option(..., "--new-name", help="New unique service token name."),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
"""Rename an active service token without rotating its bearer token."""
|
||||
if not force:
|
||||
confirmed = typer.confirm(f"서비스 토큰 이름을 '{name}'에서 '{new_name}'(으)로 변경하시겠습니까?")
|
||||
if not confirmed:
|
||||
raise typer.Exit(code=0)
|
||||
|
||||
def _rename(service: AuthService, app_context):
|
||||
return service.rename_service_token(name, new_name)
|
||||
|
||||
renamed = _with_service(ctx, _rename)
|
||||
response = ServiceTokenRenameCliResponse(
|
||||
token_id=renamed.auth_token.id,
|
||||
old_name=name,
|
||||
new_name=renamed.auth_token.name,
|
||||
changed=renamed.changed,
|
||||
)
|
||||
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||
|
||||
|
||||
@token_app.command("rotate")
|
||||
def rotate_service_token(
|
||||
ctx: typer.Context,
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ if env_file.exists():
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "PortfolioHub"
|
||||
app_version: str = "0.3.2"
|
||||
app_version: str = "0.4.0"
|
||||
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,54 @@
|
||||
"""Add nullable execution and order venue provenance to portfolio ledger.
|
||||
|
||||
Revision ID: 20260919_0032
|
||||
Revises: 20260913_0031
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "20260919_0032"
|
||||
down_revision = "20260913_0031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_venue TEXT")
|
||||
op.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS order_venue TEXT")
|
||||
op.execute(
|
||||
"""
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_portfolio_ledger_execution_venue'
|
||||
AND conrelid = 'portfolio_ledger'::regclass
|
||||
) THEN
|
||||
ALTER TABLE portfolio_ledger
|
||||
ADD CONSTRAINT ck_portfolio_ledger_execution_venue
|
||||
CHECK (execution_venue IN ('KRX', 'NXT'));
|
||||
END IF;
|
||||
END $$
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_portfolio_ledger_order_venue'
|
||||
AND conrelid = 'portfolio_ledger'::regclass
|
||||
) THEN
|
||||
ALTER TABLE portfolio_ledger
|
||||
ADD CONSTRAINT ck_portfolio_ledger_order_venue
|
||||
CHECK (order_venue IN ('KRX', 'NXT', 'SOR'));
|
||||
END IF;
|
||||
END $$
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_portfolio_ledger_order_venue", "portfolio_ledger", type_="check")
|
||||
op.drop_constraint("ck_portfolio_ledger_execution_venue", "portfolio_ledger", type_="check")
|
||||
op.drop_column("portfolio_ledger", "order_venue")
|
||||
op.drop_column("portfolio_ledger", "execution_venue")
|
||||
@@ -16,7 +16,12 @@ def quantize_stored_decimal(value: Decimal) -> Decimal:
|
||||
|
||||
def canonical_payload_hash(payload: object) -> str:
|
||||
if hasattr(payload, "model_dump"):
|
||||
payload = payload.model_dump(mode="json", exclude_none=True)
|
||||
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()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class MarketCode(str, Enum):
|
||||
@@ -181,6 +181,8 @@ class LedgerEntry:
|
||||
target_ledger_entry_id: int | None = None
|
||||
confirmation_evidence: str | None = None
|
||||
actor_user_id: str | None = None
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
corporate_action: dict[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
|
||||
+59
-33
@@ -57,6 +57,9 @@ class CurrentWacResult:
|
||||
data_completeness_warning: str | None
|
||||
"""Non-None when ledger history is incomplete (sell before buy, oversell, etc.)."""
|
||||
|
||||
total_cost_basis: float | None = None
|
||||
"""Authoritative current total after a corporate action, when available."""
|
||||
|
||||
|
||||
def calculate_realized_pnl(
|
||||
rows: list[dict[str, Any]],
|
||||
@@ -204,29 +207,32 @@ def calculate_realized_pnl(
|
||||
def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
"""Calculate the current holding WAC (weighted average cost) from a time-ordered ledger.
|
||||
|
||||
Replays the full ledger history (buy/sell) and returns the running WAC state,
|
||||
representing the cost basis of currently held (unsold) shares.
|
||||
Replays the full ledger history (buy/sell/corporate action) and returns the
|
||||
running WAC state representing the cost basis of currently held shares.
|
||||
|
||||
Args:
|
||||
rows: List of portfolio_ledger row dicts, each containing:
|
||||
side (str), quantity (float), price (float),
|
||||
executed_at (datetime).
|
||||
Must be sorted by executed_at ASC.
|
||||
rows: Time-ordered portfolio-ledger dictionaries. BUY/SELL rows contain
|
||||
side, quantity, price, fee_amount and executed_at. A
|
||||
stock_consolidation row must also contain a positive
|
||||
quantity_after and authoritative total_cost_basis.
|
||||
Rows must be sorted by executed_at ASC (and ledger_entry_id ASC
|
||||
for ties) by the repository.
|
||||
|
||||
Returns:
|
||||
CurrentWacResult with:
|
||||
- current_wac: WAC per unit (None when current_quantity == 0)
|
||||
- current_quantity: remaining quantity after all trades
|
||||
- current_wac: WAC per unit (None for no holding or unusable
|
||||
corporate-action metadata)
|
||||
- current_quantity: remaining replay quantity
|
||||
- cost_basis_from: earliest buy date used
|
||||
- data_completeness_warning: non-None when history is incomplete
|
||||
- total_cost_basis: authoritative current total after a corporate
|
||||
action, otherwise None for the legacy BUY/SELL path
|
||||
|
||||
WAC Rules (same as calculate_realized_pnl, REQ-041, buy-fee-in-cost-basis):
|
||||
buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty)
|
||||
running_qty += qty
|
||||
(buy_fee = fee_amount of the buy row, incorporated into cost basis)
|
||||
sell: running_qty -= qty (WAC per unit unchanged on sell)
|
||||
Incomplete history (sell with running_qty=0, or sell qty > running_qty):
|
||||
data_completeness_warning is set; calculation continues with best estimate.
|
||||
WAC rules preserve the existing BUY/SELL behavior. A stock consolidation
|
||||
replaces the replay quantity with quantity_after, preserves
|
||||
total_cost_basis as authoritative, and derives unit cost by division.
|
||||
Missing required corporate-action metadata fails closed instead of falling
|
||||
back to the pre-action WAC.
|
||||
"""
|
||||
if not rows:
|
||||
return CurrentWacResult(
|
||||
@@ -240,6 +246,8 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
running_qty: float = 0.0
|
||||
cost_basis_from: datetime | None = None
|
||||
warning_triggered: bool = False
|
||||
corporate_action_metadata_missing: bool = False
|
||||
authoritative_total_cost_basis: float | None = None
|
||||
|
||||
for row in rows:
|
||||
side = str(row.get("side", "")).lower()
|
||||
@@ -252,38 +260,47 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
# Include buy fee in cost basis (매수 수수료 편입)
|
||||
new_total_qty = running_qty + qty
|
||||
if new_total_qty > 0:
|
||||
running_wac = (running_wac * running_qty + price * qty + fee) / new_total_qty
|
||||
if authoritative_total_cost_basis is not None:
|
||||
authoritative_total_cost_basis += price * qty + fee
|
||||
running_wac = authoritative_total_cost_basis / new_total_qty
|
||||
else:
|
||||
running_wac = (
|
||||
running_wac * running_qty + price * qty + fee
|
||||
) / new_total_qty
|
||||
running_qty = new_total_qty
|
||||
if cost_basis_from is None and executed_at is not None:
|
||||
cost_basis_from = executed_at
|
||||
|
||||
elif side == "sell":
|
||||
if running_qty <= 0:
|
||||
warning_triggered = True
|
||||
elif qty > running_qty + 1e-9:
|
||||
if running_qty <= 0 or qty > running_qty + 1e-9:
|
||||
warning_triggered = True
|
||||
|
||||
running_qty -= qty
|
||||
if running_qty < 0:
|
||||
running_qty = 0.0
|
||||
if authoritative_total_cost_basis is not None:
|
||||
authoritative_total_cost_basis = running_wac * running_qty
|
||||
|
||||
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
|
||||
quantity_after = row.get("quantity_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))
|
||||
if quantity_after is None or total_cost_basis is None:
|
||||
corporate_action_metadata_missing = True
|
||||
continue
|
||||
after = float(quantity_after)
|
||||
if after <= 0:
|
||||
corporate_action_metadata_missing = True
|
||||
continue
|
||||
running_qty = after
|
||||
authoritative_total_cost_basis = float(total_cost_basis)
|
||||
running_wac = authoritative_total_cost_basis / after
|
||||
|
||||
warning: str | None = None
|
||||
if warning_triggered:
|
||||
if corporate_action_metadata_missing:
|
||||
warning = (
|
||||
"기업행동 metadata 불완전: 양수 quantity_after와 total_cost_basis가 필요합니다."
|
||||
)
|
||||
elif warning_triggered:
|
||||
warning = (
|
||||
"이력 불완전: 보유 수량보다 많은 매도 또는 buy 이력 없는 sell이 감지되어 "
|
||||
"cost_basis 산출이 부정확할 수 있습니다. "
|
||||
@@ -291,10 +308,19 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
)
|
||||
|
||||
return CurrentWacResult(
|
||||
current_wac=running_wac if running_qty > 0 else None,
|
||||
current_wac=(
|
||||
running_wac
|
||||
if running_qty > 0 and not corporate_action_metadata_missing
|
||||
else None
|
||||
),
|
||||
current_quantity=running_qty,
|
||||
cost_basis_from=cost_basis_from,
|
||||
data_completeness_warning=warning,
|
||||
total_cost_basis=(
|
||||
authoritative_total_cost_basis
|
||||
if running_qty > 0 and not corporate_action_metadata_missing
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ from typing import Protocol
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, CorporateAction, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
||||
|
||||
|
||||
class LedgerTradeConflictError(ValueError):
|
||||
"""A duplicate broker-ledger key disagrees with its persisted payload."""
|
||||
|
||||
|
||||
class PortfolioRepository(Protocol):
|
||||
def ping(self) -> None: ...
|
||||
|
||||
@@ -83,6 +87,8 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None: ...
|
||||
|
||||
def rename_service_token(self, name: str, new_name: str) -> AuthToken: ...
|
||||
|
||||
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
|
||||
|
||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ...
|
||||
@@ -284,7 +290,15 @@ class PortfolioRepository(Protocol):
|
||||
note: str = "",
|
||||
) -> None: ...
|
||||
|
||||
def record_corporate_action(self, portfolio_id: str, *, action: CorporateAction, idempotency_key: str, payload_hash: str) -> CorporateAction: ...
|
||||
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]: ...
|
||||
|
||||
@@ -316,6 +330,8 @@ class PortfolioRepository(Protocol):
|
||||
executed_at: datetime,
|
||||
fee_amount: float = 0.0,
|
||||
tax_amount: float = 0.0,
|
||||
execution_venue: str | None = None,
|
||||
order_venue: str | None = None,
|
||||
) -> tuple[LedgerEntry, bool]: ...
|
||||
|
||||
def record_manual_ledger(
|
||||
|
||||
+378
-72
@@ -14,7 +14,10 @@ from typing import Any
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.corporate_actions import calculate_stock_consolidation
|
||||
from app.domain.corporate_actions import (
|
||||
calculate_stock_consolidation,
|
||||
quantize_stored_decimal,
|
||||
)
|
||||
from app.domain.errors import (
|
||||
DaemonInactiveError,
|
||||
DaemonInUseError,
|
||||
@@ -37,6 +40,7 @@ from app.domain.models import (
|
||||
TokenPrincipal,
|
||||
User,
|
||||
)
|
||||
from app.repositories.portfolio_repository import LedgerTradeConflictError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_LEDGER_NUMERIC_SCALE = 8
|
||||
@@ -379,7 +383,11 @@ class PostgresPortfolioRepository:
|
||||
profit_buffer_after NUMERIC(20,8) NOT NULL DEFAULT 0,
|
||||
strategy_set_id TEXT NOT NULL DEFAULT 'legacy',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
execution_venue TEXT NULL CONSTRAINT ck_portfolio_ledger_execution_venue
|
||||
CHECK (execution_venue IN ('KRX', 'NXT')),
|
||||
order_venue TEXT NULL CONSTRAINT ck_portfolio_ledger_order_venue
|
||||
CHECK (order_venue IN ('KRX', 'NXT', 'SOR'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -392,6 +400,33 @@ class PostgresPortfolioRepository:
|
||||
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS target_ledger_entry_id BIGINT")
|
||||
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS confirmation_evidence TEXT")
|
||||
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS actor_user_id TEXT")
|
||||
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_venue TEXT")
|
||||
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS order_venue TEXT")
|
||||
cur.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_portfolio_ledger_execution_venue'
|
||||
AND conrelid = 'portfolio_ledger'::regclass
|
||||
) THEN
|
||||
ALTER TABLE portfolio_ledger
|
||||
ADD CONSTRAINT ck_portfolio_ledger_execution_venue
|
||||
CHECK (execution_venue IS NULL OR execution_venue IN ('KRX', 'NXT'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_portfolio_ledger_order_venue'
|
||||
AND conrelid = 'portfolio_ledger'::regclass
|
||||
) THEN
|
||||
ALTER TABLE portfolio_ledger
|
||||
ADD CONSTRAINT ck_portfolio_ledger_order_venue
|
||||
CHECK (order_venue IS NULL OR order_venue IN ('KRX', 'NXT', 'SOR'));
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DO $$
|
||||
@@ -1093,6 +1128,37 @@ class PostgresPortfolioRepository:
|
||||
return None
|
||||
return self._row_to_auth_token(row)
|
||||
|
||||
def rename_service_token(self, name: str, new_name: str) -> AuthToken:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth_tokens
|
||||
SET name = %s
|
||||
WHERE name = %s
|
||||
AND token_type = 'service'
|
||||
AND revoked_at IS NULL
|
||||
RETURNING
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
token_prefix,
|
||||
permission,
|
||||
token_type,
|
||||
expires_at,
|
||||
last_used_at,
|
||||
revoked_at,
|
||||
created_at,
|
||||
runtime_id,
|
||||
daemon_id
|
||||
""",
|
||||
(new_name, name),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||
return self._row_to_auth_token(row)
|
||||
|
||||
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
@@ -1629,46 +1695,155 @@ class PostgresPortfolioRepository:
|
||||
),
|
||||
)
|
||||
|
||||
def record_corporate_action(self, portfolio_id: str, *, action: CorporateAction, idempotency_key: str, payload_hash: str) -> CorporateAction:
|
||||
"""Persist an immutable action and snapshot atomically."""
|
||||
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()
|
||||
with self._connect() as conn: # noqa: SIM117 - keep transaction and cursor lifetimes explicit
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT asset_quantity, cash_balance, profit_buffer, asset_id, status FROM portfolios WHERE portfolio_id=%s FOR UPDATE", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("portfolio not found")
|
||||
cur.execute("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", (portfolio_id, idempotency_key))
|
||||
prior = cur.fetchone()
|
||||
if prior:
|
||||
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,
|
||||
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 Decimal(str(row[1])) != action.cash_balance or Decimal(str(row[2])) != action.profit_buffer:
|
||||
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))
|
||||
|
||||
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 locked ledger, never portfolios.price.
|
||||
cur.execute("SELECT side, quantity, price, fee_amount, tax_amount, executed_at FROM portfolio_ledger WHERE portfolio_id=%s ORDER BY executed_at ASC NULLS FIRST, ledger_entry_id ASC", (portfolio_id,))
|
||||
history = [{"side": r[0], "quantity": r[1], "price": r[2], "fee_amount": r[3], "tax_amount": r[4], "executed_at": r[5]} for r in cur.fetchall()]
|
||||
|
||||
# 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(row[0])) != action.quantity_before or Decimal(str(wac.current_quantity)) != action.quantity_before:
|
||||
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,
|
||||
@@ -1677,28 +1852,83 @@ class PostgresPortfolioRepository:
|
||||
action.average_price_before = consolidation.average_price_before
|
||||
action.average_price_after = consolidation.average_price_after
|
||||
action.total_cost_basis = consolidation.total_cost_basis
|
||||
cur.execute("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", (portfolio_id, idempotency_key))
|
||||
prior = cur.fetchone()
|
||||
if prior:
|
||||
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
|
||||
if Decimal(str(row[0])) != action.quantity_before:
|
||||
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
|
||||
cur.execute("UPDATE portfolios SET asset_quantity=%s, updated_at=NOW() WHERE portfolio_id=%s", (float(action.quantity_after), portfolio_id))
|
||||
cur.execute("INSERT INTO portfolio_ledger (portfolio_id, recorded_at, side, source, quantity, price, cash_balance_after, asset_quantity_after, profit_buffer_after, executed_at, note) VALUES (%s,NOW(),'stock_consolidation','corporate_action',0,0,%s,%s,%s,NOW(),%s) RETURNING ledger_entry_id, recorded_at", (portfolio_id, float(action.cash_balance), float(action.quantity_after), float(action.profit_buffer), action.evidence_ref))
|
||||
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, action.recorded_at = int(lid), 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,action.cash_balance,action.profit_buffer,lid))
|
||||
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]:
|
||||
@@ -1803,13 +2033,13 @@ class PostgresPortfolioRepository:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ledger_entry_id, portfolio_id, order_id, execution_id, side,
|
||||
quantity::float8, price::float8,
|
||||
fee_amount::float8, tax_amount::float8,
|
||||
executed_at, recorded_at,
|
||||
cash_balance_after::float8, asset_quantity_after::float8,
|
||||
total_value_after::float8, profit_buffer_after::float8,
|
||||
strategy_set_id, note, source,
|
||||
ledger.ledger_entry_id, ledger.portfolio_id, ledger.order_id, ledger.execution_id, ledger.side,
|
||||
ledger.quantity::float8, ledger.price::float8,
|
||||
ledger.fee_amount::float8, ledger.tax_amount::float8,
|
||||
ledger.executed_at, ledger.recorded_at,
|
||||
ledger.cash_balance_after::float8, ledger.asset_quantity_after::float8,
|
||||
ledger.total_value_after::float8, ledger.profit_buffer_after::float8,
|
||||
ledger.strategy_set_id, ledger.note, ledger.source,
|
||||
ca.corporate_action_id, ca.action_type, ca.effective_date,
|
||||
ca.quantity_before, ca.quantity_after, ca.average_price_before,
|
||||
ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
|
||||
@@ -1851,6 +2081,8 @@ class PostgresPortfolioRepository:
|
||||
executed_at,
|
||||
fee_amount: float = 0.0,
|
||||
tax_amount: float = 0.0,
|
||||
execution_venue: str | None = None,
|
||||
order_venue: str | None = None,
|
||||
) -> tuple[LedgerEntry, bool]:
|
||||
self.ensure_schema()
|
||||
side_normalized = str(side).lower()
|
||||
@@ -1911,16 +2143,70 @@ class PostgresPortfolioRepository:
|
||||
executed_at, recorded_at,
|
||||
cash_balance_after::float8, asset_quantity_after::float8,
|
||||
total_value_after::float8, profit_buffer_after::float8,
|
||||
strategy_set_id, note
|
||||
strategy_set_id, note, source, target_ledger_entry_id,
|
||||
confirmation_evidence, actor_user_id, execution_venue, order_venue
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
|
||||
ORDER BY recorded_at DESC, ledger_entry_id DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
""",
|
||||
(portfolio_id, order_id, execution_id),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing is not None:
|
||||
economic_payload_matches = (
|
||||
existing[4] == side_normalized
|
||||
and abs(float(existing[5]) - qty_float) < 1e-9
|
||||
and abs(float(existing[6]) - price_float) < 1e-9
|
||||
and abs(float(existing[7]) - fee_float) < 1e-9
|
||||
and abs(float(existing[8]) - tax_float) < 1e-9
|
||||
)
|
||||
if not economic_payload_matches:
|
||||
raise LedgerTradeConflictError("duplicate economic payload conflict")
|
||||
|
||||
existing_execution_venue = existing[21]
|
||||
existing_order_venue = existing[22]
|
||||
if (
|
||||
existing_execution_venue is not None
|
||||
and execution_venue is not None
|
||||
and existing_execution_venue != execution_venue
|
||||
) or (
|
||||
existing_order_venue is not None
|
||||
and order_venue is not None
|
||||
and existing_order_venue != order_venue
|
||||
):
|
||||
raise LedgerTradeConflictError("duplicate venue payload conflict")
|
||||
|
||||
enriched_execution_venue = existing_execution_venue or execution_venue
|
||||
enriched_order_venue = existing_order_venue or order_venue
|
||||
if (
|
||||
enriched_execution_venue != existing_execution_venue
|
||||
or enriched_order_venue != existing_order_venue
|
||||
):
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolio_ledger
|
||||
SET execution_venue = %s, order_venue = %s
|
||||
WHERE ledger_entry_id = %s AND recorded_at = %s
|
||||
RETURNING
|
||||
ledger_entry_id, portfolio_id, order_id, execution_id, side,
|
||||
quantity::float8, price::float8,
|
||||
fee_amount::float8, tax_amount::float8,
|
||||
executed_at, recorded_at,
|
||||
cash_balance_after::float8, asset_quantity_after::float8,
|
||||
total_value_after::float8, profit_buffer_after::float8,
|
||||
strategy_set_id, note, source, target_ledger_entry_id,
|
||||
confirmation_evidence, actor_user_id, execution_venue, order_venue
|
||||
""",
|
||||
(
|
||||
enriched_execution_venue,
|
||||
enriched_order_venue,
|
||||
existing[0],
|
||||
existing[10],
|
||||
),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
# Duplicate: do not insert a new row, do not mutate the portfolio.
|
||||
# OQ-4 (b): return the CURRENT authoritative portfolio state so the
|
||||
# caller always syncs to the latest balances, not the first-call snapshot.
|
||||
@@ -1935,12 +2221,13 @@ class PostgresPortfolioRepository:
|
||||
portfolio_id, recorded_at, ledger_entry_id, order_id, execution_id,
|
||||
side, price, quantity, fee_amount, tax_amount,
|
||||
cash_balance_after, asset_quantity_after, total_value_after,
|
||||
profit_buffer_after, strategy_set_id, note, executed_at
|
||||
profit_buffer_after, strategy_set_id, note, executed_at,
|
||||
execution_venue, order_venue
|
||||
) VALUES (
|
||||
%s, NOW(), DEFAULT, %s, %s,
|
||||
%s, %s, %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,
|
||||
@@ -1949,7 +2236,8 @@ class PostgresPortfolioRepository:
|
||||
executed_at, recorded_at,
|
||||
cash_balance_after::float8, asset_quantity_after::float8,
|
||||
total_value_after::float8, profit_buffer_after::float8,
|
||||
strategy_set_id, note
|
||||
strategy_set_id, note, source, target_ledger_entry_id,
|
||||
confirmation_evidence, actor_user_id, execution_venue, order_venue
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
@@ -1967,6 +2255,8 @@ class PostgresPortfolioRepository:
|
||||
strategy_set_id,
|
||||
"",
|
||||
executed_at,
|
||||
execution_venue,
|
||||
order_venue,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@@ -2015,6 +2305,16 @@ class PostgresPortfolioRepository:
|
||||
target_ledger_entry_id=int(row[18]) if len(row) > 18 and row[18] is not None else None,
|
||||
confirmation_evidence=row[19] if len(row) > 19 else None,
|
||||
actor_user_id=row[20] if len(row) > 20 else None,
|
||||
execution_venue=(
|
||||
row[21]
|
||||
if len(row) > 21 and row[21] in {None, "KRX", "NXT"}
|
||||
else None
|
||||
),
|
||||
order_venue=(
|
||||
row[22]
|
||||
if len(row) > 22 and row[22] in {None, "KRX", "NXT", "SOR"}
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def reset(self, portfolio_id: str) -> Portfolio:
|
||||
@@ -2325,7 +2625,8 @@ class PostgresPortfolioRepository:
|
||||
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
|
||||
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
|
||||
execution_venue, order_venue
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND ledger_entry_id = %s
|
||||
""",
|
||||
@@ -2424,7 +2725,8 @@ class PostgresPortfolioRepository:
|
||||
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,
|
||||
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
|
||||
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
|
||||
execution_venue, order_venue
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
@@ -2456,7 +2758,8 @@ class PostgresPortfolioRepository:
|
||||
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
|
||||
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
|
||||
execution_venue, order_venue
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND source = 'manual'
|
||||
ORDER BY recorded_at DESC, ledger_entry_id DESC
|
||||
@@ -2476,30 +2779,33 @@ class PostgresPortfolioRepository:
|
||||
limit: int = 50,
|
||||
) -> list[LedgerEntry]:
|
||||
self.ensure_schema()
|
||||
clauses = ["portfolio_id = %s"]
|
||||
clauses = ["ledger.portfolio_id = %s"]
|
||||
params: list[Any] = [portfolio_id]
|
||||
if source is not None:
|
||||
clauses.append("source = %s")
|
||||
clauses.append("ledger.source = %s")
|
||||
params.append(source)
|
||||
if before_recorded_at is not None and before_ledger_entry_id is not None:
|
||||
clauses.append("(recorded_at, ledger_entry_id) < (%s, %s)")
|
||||
clauses.append("(ledger.recorded_at, ledger.ledger_entry_id) < (%s, %s)")
|
||||
params.extend([before_recorded_at, before_ledger_entry_id])
|
||||
params.append(int(limit))
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
|
||||
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
|
||||
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
|
||||
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
|
||||
SELECT ledger.ledger_entry_id, ledger.portfolio_id, ledger.order_id, ledger.execution_id, ledger.side,
|
||||
ledger.quantity, ledger.price, ledger.fee_amount, ledger.tax_amount,
|
||||
ledger.executed_at, ledger.recorded_at, ledger.cash_balance_after,
|
||||
ledger.asset_quantity_after, ledger.total_value_after, ledger.profit_buffer_after,
|
||||
ledger.strategy_set_id, ledger.note, ledger.source, ledger.target_ledger_entry_id,
|
||||
ledger.confirmation_evidence, ledger.actor_user_id,
|
||||
ledger.execution_venue, ledger.order_venue,
|
||||
ca.corporate_action_id, ca.action_type, ca.effective_date,
|
||||
ca.quantity_before, ca.quantity_after, ca.average_price_before,
|
||||
ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
|
||||
ca.profit_buffer, ca.evidence_ref, ca.actor_user_id
|
||||
FROM portfolio_ledger
|
||||
LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = portfolio_ledger.ledger_entry_id
|
||||
FROM portfolio_ledger AS ledger
|
||||
LEFT JOIN portfolio_corporate_actions AS ca ON ca.ledger_entry_id = ledger.ledger_entry_id
|
||||
WHERE """ + " AND ".join(clauses) + """
|
||||
ORDER BY recorded_at DESC, ledger_entry_id DESC
|
||||
ORDER BY ledger.recorded_at DESC, ledger.ledger_entry_id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
params,
|
||||
@@ -2507,8 +2813,8 @@ class PostgresPortfolioRepository:
|
||||
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]}
|
||||
if row[23] is not None:
|
||||
entry.corporate_action = {"corporate_action_id": str(row[23]), "action_type": row[24], "effective_date": row[25], "quantity_before": Decimal(str(row[26])), "quantity_after": Decimal(str(row[27])), "average_price_before": Decimal(str(row[28])), "average_price_after": Decimal(str(row[29])), "total_cost_basis": Decimal(str(row[30])), "cash_balance": Decimal(str(row[31])), "profit_buffer": Decimal(str(row[32])), "evidence_ref": row[33], "actor_user_id": row[34]}
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
@@ -90,6 +90,13 @@ class ServiceTokenRebindCliResponse(BaseModel):
|
||||
previous_daemon_id: str | None = None
|
||||
|
||||
|
||||
class ServiceTokenRenameCliResponse(BaseModel):
|
||||
token_id: str
|
||||
old_name: str
|
||||
new_name: str
|
||||
changed: bool
|
||||
|
||||
|
||||
class ServiceTokenRevokeCliResponse(BaseModel):
|
||||
token_id: str
|
||||
name: str
|
||||
|
||||
@@ -14,6 +14,7 @@ class CorporateActionRequest(BaseModel):
|
||||
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):
|
||||
|
||||
@@ -37,6 +37,8 @@ class LedgerEntryResponse(BaseModel):
|
||||
profit_buffer_after: float = 0.0
|
||||
strategy_set_id: str = "manual"
|
||||
note: str = ""
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
|
||||
|
||||
class PortfolioBase(BaseModel):
|
||||
|
||||
@@ -108,6 +108,8 @@ class UserInfoResponse(BaseModel):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LedgerTradeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
order_id: str = Field(..., min_length=1)
|
||||
execution_id: str = Field(..., min_length=1)
|
||||
side: Literal["buy", "sell"]
|
||||
@@ -116,6 +118,8 @@ class LedgerTradeRequest(BaseModel):
|
||||
fee_amount: Decimal = Field(default=Decimal(0), ge=0)
|
||||
tax_amount: Decimal = Field(default=Decimal(0), ge=0)
|
||||
executed_at: datetime
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
|
||||
|
||||
class LedgerTradeResponse(BaseModel):
|
||||
@@ -129,6 +133,8 @@ class LedgerTradeResponse(BaseModel):
|
||||
fee_amount: float
|
||||
tax_amount: float
|
||||
executed_at: datetime
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
recorded_at: datetime | None = None
|
||||
cash_balance_after: float = 0.0
|
||||
asset_quantity_after: float = 0.0
|
||||
@@ -286,6 +292,8 @@ class LedgerHistoryItem(BaseModel):
|
||||
total_value_after: float | None = None
|
||||
target_ledger_entry_id: int | None = None
|
||||
confirmation_evidence: str | None = None
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
|
||||
|
||||
class DividendLedgerHistoryItem(BaseModel):
|
||||
@@ -311,6 +319,8 @@ class DividendLedgerHistoryItem(BaseModel):
|
||||
total_value_after: float | None = None
|
||||
target_ledger_entry_id: int | None = None
|
||||
confirmation_evidence: str | None = None
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
|
||||
|
||||
class CorporateActionLedgerHistoryItem(BaseModel):
|
||||
@@ -331,6 +341,8 @@ class CorporateActionLedgerHistoryItem(BaseModel):
|
||||
evidence_ref: str
|
||||
actor_user_id: str | None = None
|
||||
recorded_at: datetime
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
|
||||
|
||||
class LedgerHistoryResponse(BaseModel):
|
||||
|
||||
@@ -35,6 +35,12 @@ class IssuedServiceToken:
|
||||
auth_token: AuthToken
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenamedServiceToken:
|
||||
auth_token: AuthToken
|
||||
changed: bool
|
||||
|
||||
|
||||
def get_auth_service_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
|
||||
return get_portfolio_repository_from_settings(settings)
|
||||
|
||||
@@ -130,6 +136,19 @@ class AuthService:
|
||||
raw_token, auth_token = self._repository.rotate_service_token(name)
|
||||
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
|
||||
|
||||
def rename_service_token(self, name: str, new_name: str) -> RenamedServiceToken:
|
||||
if name == new_name:
|
||||
existing = self._repository.get_service_token_by_name(name)
|
||||
if existing is None:
|
||||
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||
return RenamedServiceToken(auth_token=existing, changed=False)
|
||||
|
||||
try:
|
||||
renamed = self._repository.rename_service_token(name, new_name)
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {new_name}") from exc
|
||||
return RenamedServiceToken(auth_token=renamed, changed=True)
|
||||
|
||||
def rebind_service_token(self, name: str, daemon_id: str) -> tuple[str | None, AuthToken]:
|
||||
existing = self._repository.get_service_token_by_name(name)
|
||||
if existing is None:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# PortfolioHub v0.3.2 container contract
|
||||
# PortfolioHub v0.4.0 container contract
|
||||
|
||||
## Publication is not deployment
|
||||
|
||||
Only a requester-authorized push of the strict tag `v0.3.2` 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.4.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:
|
||||
|
||||
- `ghcr.io/fourblocks2020/portfolio-hub:<source-commit-first-12>`
|
||||
- `ghcr.io/fourblocks2020/portfolio-hub:v0.3.2`
|
||||
- `ghcr.io/fourblocks2020/portfolio-hub:v0.4.0`
|
||||
|
||||
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.3.2`. 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.4.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.
|
||||
|
||||
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.2 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.4.0 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
|
||||
|
||||
## Image runtime
|
||||
|
||||
@@ -66,34 +66,34 @@ 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.3.2 | `0.3.2` | no | health/OpenAPI version metadata |
|
||||
| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.4.0 | `0.4.0` | 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.3.2 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.4.0 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.3.2 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.4.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.
|
||||
|
||||
## 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.3.2`.
|
||||
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.4.0`.
|
||||
|
||||
Do not route traffic from a static `/health` success alone. Readiness checks never apply, stamp, or repair migrations.
|
||||
|
||||
## One-shot migration and 0029 -> 0031 policy
|
||||
## One-shot migration and 0029 -> 0032 policy
|
||||
|
||||
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:
|
||||
The installed image contains exactly **32** migration files and one Alembic head, `20260919_0032`. 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 `20260913_0031`;
|
||||
6. run `pfhub --format json health-check` and require both `database_revision` and `expected_revision` to equal `20260919_0032`;
|
||||
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 `0031`, but it is verification—not an application-start hook.
|
||||
8. a repeated `pfhub migrate-db` is expected to be idempotent and remain at `0032`, 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.
|
||||
|
||||
@@ -101,13 +101,15 @@ v0.2.1 includes migration `0030`, an idempotent index on `(portfolio_id, recorde
|
||||
|
||||
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.
|
||||
Migration `0032` adds nullable `execution_venue` and `order_venue` columns to `portfolio_ledger`, with constrained values for KRX/NXT and KRX/NXT/SOR respectively. Existing rows remain valid with NULL venue provenance. Back up the database before migration, stop writes at the maintenance boundary, and require readiness at `20260919_0032` before allowing callers to rely on the new fields.
|
||||
|
||||
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 `0032`. 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.3.2 preserves Bearer authentication and all-source unified-ledger reads. It records integral-share stock consolidations even when 8-decimal WAC storage creates a harmless recomputation residue: WAC is rounded half-up to 8 decimals, while the authoritative total cost basis remains independently stored and drives replay. 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.
|
||||
v0.4.0 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. Ledger trade records may additionally carry nullable `execution_venue` and `order_venue` provenance, and active service tokens may be renamed without rotating their secret. 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 32-file migration inventory, isolated PostgreSQL migration twice, readiness at `0032`, 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.
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
|
||||
- Status: confirmed
|
||||
- Owner: repository-maintainers
|
||||
- Last Updated (KST): 2026-05-21 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004)
|
||||
- Last Updated (KST): 2026-09-19
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004), ProofLine req-0009
|
||||
- Primary Runtime: mixed
|
||||
|
||||
## Project Summary
|
||||
@@ -70,7 +70,7 @@
|
||||
### Inputs
|
||||
|
||||
- pfhub subcommands :: include user, portfolio, strategy, tag, service-token, daemon, credential, health-check, init-db, and migrate-db command families
|
||||
- `service-token` subcommands: `issue`, `rotate`, `rebind` (BAT-007: `--daemon-id` 변경), `revoke`, `list`, `migrate-null-daemon --dry-run` (BAT-007: NULL daemon_id 진단 CLI)
|
||||
- `service-token` subcommands: `issue`, `rename --new-name NEW_NAME [--force/-f]`, `rotate`, `rebind` (BAT-007: `--daemon-id` 변경), `revoke`, `list`, `migrate-null-daemon --dry-run` (BAT-007: NULL daemon_id 진단 CLI)
|
||||
- `daemon` subcommands: `create`, `list`, `update` (BAT-007: `--deactivate --force` 시 바인딩 service token 자동 revoke)
|
||||
- Flags and options :: include --format table|json, --force confirmations, and operation-specific mutation arguments
|
||||
- Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution
|
||||
@@ -407,11 +407,11 @@
|
||||
### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011; REQ-033 column rename BAT-015)
|
||||
|
||||
- Auth: DB service token with `trade-write` permission; `daemon_id IS NULL` → HTTP 403 fail-closed
|
||||
- Request: `LedgerTradeRequest` — `order_id`, `execution_id`, `side` (buy|sell), `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`
|
||||
- Response: HTTP 201 + `LedgerTradeResponse` with `status="new"`; HTTP 200 + `status="duplicate"` for idempotent replay; HTTP 409 for conflicting payload
|
||||
- `LedgerTradeResponse` fields: `ledger_entry_id`, `portfolio_id` (renamed from `asset_id` by REQ-033 / dcy-005 — schema and DB column now match in meaning), `order_id`, `execution_id`, `side`, `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`, `recorded_at`, `status`
|
||||
- Error: HTTP 403 (no daemon_id on token, portfolio unassigned, daemon mismatch); HTTP 404 (non-existent portfolio); HTTP 409 (disabled portfolio or payload conflict)
|
||||
- Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint
|
||||
- Request: `LedgerTradeRequest` — `order_id`, `execution_id`, `side` (buy|sell), `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`, optional `execution_venue` (`KRX|NXT|null`), optional `order_venue` (`KRX|NXT|SOR|null`). `SOR` is routing provenance only and is rejected as `execution_venue`; the service never infers actual execution venue from routing venue or time.
|
||||
- Response: HTTP 201 + `LedgerTradeResponse` with `status="new"`; HTTP 200 + `status="duplicate"` for idempotent replay; both responses and broker items from `GET /v1/portfolios/{portfolio_id}/ledger` return persisted `execution_venue` and `order_venue`; HTTP 409 for conflicting payload
|
||||
- `LedgerTradeResponse` fields: `ledger_entry_id`, `portfolio_id` (renamed from `asset_id` by REQ-033 / dcy-005 — schema and DB column now match in meaning), `order_id`, `execution_id`, `side`, `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`, `execution_venue`, `order_venue`, `recorded_at`, `status`
|
||||
- Error: HTTP 403 (no daemon_id on token, portfolio unassigned, daemon mismatch); HTTP 404 (non-existent portfolio); HTTP 409 (disabled portfolio, economic-payload conflict, or conflicting non-null venue); HTTP 422 (unsupported venue or unknown request field)
|
||||
- Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint. Equal economic payloads may atomically enrich only NULL venue metadata; incoming NULL never erases persisted metadata, conflicting non-null values return HTTP 409, and metadata enrichment never reapplies accounting.
|
||||
- **구 path `POST /v1/assets/{asset_id}/ledger/trades`**: BAT-011에서 제거됨. 호출 시 HTTP 404.
|
||||
|
||||
### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024)
|
||||
@@ -439,8 +439,19 @@
|
||||
- Query: `limit` 1..200, default 50.
|
||||
- `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.
|
||||
- `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}/holdings/cost-basis` (REQ-043, req-0008)
|
||||
|
||||
- BUY/SELL과 `stock_consolidation` ledger를 repository가 제공한 `executed_at ASC, ledger_entry_id ASC` 순서대로 replay한다.
|
||||
- 정상 `stock_consolidation`은 감사 metadata의 양수 `quantity_after`를 replay 수량으로 적용하고 `total_cost_basis`를 authoritative 총원가로 보존한다. 응답의 `cost_basis_per_unit`는 authoritative 총원가를 현재 수량으로 나눈 값이다.
|
||||
- Persisted `average_price_after`는 감사·표시용 반올림 값이다. Endpoint는 이를 수량과 곱하여 총원가를 재구성하거나 별도 허용오차 판정에 사용하지 않는다.
|
||||
- `quantity_after`가 없거나 0 이하이거나 `total_cost_basis`가 없으면 기업행동 전 WAC로 추정하지 않는다. `cost_basis_per_unit`과 `total_cost_basis`는 모두 `null`이고 `data_completeness_warning`은 기업행동 metadata 부족을 나타낸다.
|
||||
- 기업행동이 없는 BUY/SELL-only 원가와 기존 불완전 거래 이력 warning, response field/nullability, daemon scope와 portfolio ownership 계약은 유지한다.
|
||||
|
||||
|
||||
### Endpoint: `GET /v1/portfolios/{portfolio_id}/returns/summary` (REQ-056, BAT-024)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
- **유형**: Feature / Enhancement
|
||||
- **우선순위**: Medium
|
||||
- **영역**: CLI / 인증 (`pfhub service-token`)
|
||||
- **대상 버전**: portfoliohub 0.1.0 (commit `58899b1`)
|
||||
|
||||
## 배경 / 문제
|
||||
|
||||
현재 `pfhub service-token` 하위 명령은 `list` / `issue` / `rotate` / `rebind` /
|
||||
`revoke` / `migrate-null-daemon` 6개뿐이며, **기존 서비스 토큰의 `name` 을 변경하는 명령이 없다.**
|
||||
|
||||
- `name` 은 `rotate` / `rebind` / `revoke` 의 **조회 키**로 쓰인다
|
||||
(`app/cli/commands/service_tokens.py` — 각 명령이 `name` 인자로 토큰을 찾음).
|
||||
- 이름을 바꾸려면 현재로선 `issue`(새 이름으로 신규 발급) → `revoke`(기존 폐기)가 유일한 우회 경로다.
|
||||
- 이 우회는 **토큰 값(평문)까지 새로 바뀌므로**, 단순히 라벨만 정리하려는 경우에도
|
||||
모든 클라이언트·데몬의 토큰을 재배포해야 한다. 이름 오타 수정, 명명 규칙 정비 등에 과도한 비용이 든다.
|
||||
|
||||
## 요구사항
|
||||
|
||||
기존 서비스 토큰의 `name` 만 변경하고 **토큰 값·바인딩은 그대로 유지**하는 CLI 명령을 추가한다.
|
||||
|
||||
### 제안 인터페이스
|
||||
|
||||
```
|
||||
pfhub service-token rename NAME --new-name NEW_NAME [--force]
|
||||
```
|
||||
|
||||
- `NAME` (인자): 현재 서비스 토큰 이름 (조회 키)
|
||||
- `--new-name` (필수 옵션): 변경할 새 이름
|
||||
- `--force` (옵션): 확인 프롬프트 생략 (`revoke` 명령과 동일 패턴)
|
||||
|
||||
## 수용 기준 (Acceptance Criteria)
|
||||
|
||||
1. `NAME` 으로 활성(비폐기) 서비스 토큰을 찾아 `name` 을 `NEW_NAME` 으로 변경한다.
|
||||
2. 다음 값은 **변경되지 않는다**: 토큰 값(평문)·`token_hash`·`token_prefix`·
|
||||
`permission`·`token_type`·`daemon_id`·`runtime_id`·`expires_at`·`token_id`.
|
||||
3. 이름 유일성 검증: `NEW_NAME` 이 다른 활성 서비스 토큰과 충돌하면 명확한 에러로 거부한다.
|
||||
- DB에는 이미 부분 유니크 인덱스가 존재한다:
|
||||
`idx_auth_tokens_service_name_unique ON auth_tokens(name) WHERE token_type='service' AND revoked_at IS NULL`
|
||||
(`app/repositories/postgres.py:190-193`) — `UniqueViolation` 을 사용자 메시지로 변환할 것.
|
||||
4. 에러 처리:
|
||||
- `NAME` 에 해당하는 활성 서비스 토큰이 없으면 → "서비스 토큰을 찾을 수 없습니다: NAME"
|
||||
- `NEW_NAME` 이 이미 사용 중이면 → "이미 존재하는 서비스 토큰 이름입니다: NEW_NAME"
|
||||
- `NEW_NAME == NAME` 이면 → 변경 없음 안내 후 정상 종료
|
||||
5. 출력: `token_id`, 기존 `name`, 변경된 `name` 을 표시 (table / json 양쪽 지원).
|
||||
6. `--force` 미지정 시 변경 전 확인 프롬프트를 띄운다.
|
||||
|
||||
## 구현 범위 (참고)
|
||||
|
||||
- CLI: `app/cli/commands/service_tokens.py` 에 `rename` 명령 추가.
|
||||
- 서비스: `app/services/auth_service.py` 에 `rename_service_token(name, new_name)` 추가
|
||||
— `issue_service_token` 과 동일하게 `psycopg.errors.UniqueViolation` 을 `ValueError` 로 변환.
|
||||
- 리포지토리: `app/repositories/postgres.py` 에 `UPDATE auth_tokens SET name=%s WHERE ...`
|
||||
메서드 추가 및 `app/repositories/portfolio_repository.py` 프로토콜에 시그니처 추가.
|
||||
- 스키마: `app/schemas/auth.py` 에 rename 응답용 CLI 모델 추가 (기존 `ServiceToken*CliResponse` 패턴 따름).
|
||||
- 테스트: 정상 변경 / 미존재 토큰 / 이름 충돌 / 동일 이름 케이스.
|
||||
|
||||
## 범위 외 (Out of Scope)
|
||||
|
||||
- 토큰 값 회전(rotation)은 본 기능에 포함하지 않는다. 값 변경이 필요하면 기존 `rotate` 를 사용한다.
|
||||
- 폐기된(revoked) 토큰의 이름 변경은 지원하지 않는다 (유일성 인덱스가 활성 토큰만 대상으로 함).
|
||||
- 서비스 토큰 이름 형식(문자셋) 검증 규칙은 현재 `issue` 에도 없으므로 본 이슈에서 신규 도입하지 않는다.
|
||||
(별도 정비가 필요하면 분리 이슈로 다룬다.)
|
||||
|
||||
## 비고
|
||||
|
||||
- 관련: service-token 마스킹 이슈(해결 완료). 본 기능은 토큰 값을 노출/변경하지 않으므로 마스킹과 무관하다.
|
||||
- governance: 신규 CLI 명령 추가이므로 `draft-req` → batch delivery 흐름 대상으로 검토.
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portfoliohub"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
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 = 31
|
||||
EXPECTED_ALEMBIC_HEAD = "20260913_0031"
|
||||
EXPECTED_MIGRATION_COUNT = 32
|
||||
EXPECTED_ALEMBIC_HEAD = "20260919_0032"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
|
||||
@@ -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("20260913_0031\n")
|
||||
if command[:3] == ["docker", "run", "--rm"] and "health-check" in command: return image_release.Result('{"database_revision":"20260913_0031"}\n')
|
||||
if command[:3] == ["docker", "run", "--rm"] and "alembic_version" in command[-1]: return image_release.Result("20260919_0032\n")
|
||||
if command[:3] == ["docker", "run", "--rm"] and "health-check" in command: return image_release.Result('{"database_revision":"20260919_0032"}\n')
|
||||
return image_release.Result()
|
||||
|
||||
def artifacts(tmp_path: Path):
|
||||
@@ -74,12 +74,16 @@ 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, 30)]
|
||||
+ ["20260820_0030_portfolio_ledger_keyset_index.py", "20260913_0031_corporate_actions.py"]
|
||||
+ [
|
||||
"20260820_0030_portfolio_ledger_keyset_index.py",
|
||||
"20260913_0031_corporate_actions.py",
|
||||
"20260919_0032_ledger_venue_provenance.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 "20260913_0031" in runs[-1][-1]
|
||||
assert "migration_inventory" in runs[-1][-1] and "20260919_0032" 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,12 @@ 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 "tests/test_service_token_rename_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 +55,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_has_current_head_0031():
|
||||
def test_release_version_and_migration_contract_has_current_head_0032():
|
||||
project = (REPO / "pyproject.toml").read_text()
|
||||
config = (REPO / "app/core/config.py").read_text()
|
||||
migrations = sorted(
|
||||
@@ -58,10 +63,10 @@ def test_release_version_and_migration_contract_has_current_head_0031():
|
||||
for path in (REPO / "app/db/alembic/versions").glob("*.py")
|
||||
if path.name != "__init__.py"
|
||||
)
|
||||
assert 'version = "0.3.2"' in project
|
||||
assert 'app_version: str = "0.3.2"' in config
|
||||
assert len(migrations) == 31
|
||||
assert migrations[-1].startswith("20260913_0031_")
|
||||
assert 'version = "0.4.0"' in project
|
||||
assert 'app_version: str = "0.4.0"' in config
|
||||
assert len(migrations) == 32
|
||||
assert migrations[-1].startswith("20260919_0032_")
|
||||
|
||||
def test_dockerignore_is_deny_by_default_required_inputs_only():
|
||||
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"
|
||||
@@ -85,7 +90,7 @@ def test_operations_separate_publication_deployment_migration_and_db_rollback():
|
||||
"Image rollback is **not** database rollback",
|
||||
"read-only root filesystem",
|
||||
"10001:10001",
|
||||
"PortfolioHub v0.3.2 container contract",
|
||||
"PortfolioHub v0.4.0 container contract",
|
||||
"v0.2.1 includes migration `0030`",
|
||||
"trex-web v0.2.3 displays `strategy_nick`",
|
||||
):
|
||||
|
||||
+91
-1
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import psycopg.errors
|
||||
|
||||
from app.core.auth import AuthContext, Permission
|
||||
from app.core.config import Settings
|
||||
@@ -96,6 +97,7 @@ class _AuthRepo:
|
||||
self.tokens: dict[str, AuthToken] = {}
|
||||
self._daemons: dict[str, object] = {}
|
||||
self._seq = 1
|
||||
self.rename_calls = 0
|
||||
|
||||
def get_user_by_username(self, username: str):
|
||||
return self.users.get(username)
|
||||
@@ -105,10 +107,20 @@ class _AuthRepo:
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||
for token in self.tokens.values():
|
||||
if token.name == name and token.token_type == "service":
|
||||
if token.name == name and token.token_type == "service" and token.revoked_at is None:
|
||||
return token
|
||||
return None
|
||||
|
||||
def rename_service_token(self, name: str, new_name: str) -> AuthToken:
|
||||
self.rename_calls += 1
|
||||
token = self.get_service_token_by_name(name)
|
||||
if token is None:
|
||||
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||
if self.get_service_token_by_name(new_name) is not None:
|
||||
raise psycopg.errors.UniqueViolation("duplicate service-token name")
|
||||
token.name = new_name
|
||||
return token
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -231,6 +243,84 @@ def test_revoke_service_token_not_found_raises() -> None:
|
||||
raise AssertionError("Expected ValueError for missing service token")
|
||||
|
||||
|
||||
def test_rename_service_token_changes_only_name() -> None:
|
||||
from app.domain.models import User
|
||||
|
||||
repo = _AuthRepo()
|
||||
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
|
||||
issued = _service(repo).issue_service_token("old-name", "alice")
|
||||
before = issued.auth_token
|
||||
protected = (
|
||||
before.id,
|
||||
before.user_id,
|
||||
before.token_prefix,
|
||||
before.permission,
|
||||
before.token_type,
|
||||
before.expires_at,
|
||||
before.last_used_at,
|
||||
before.revoked_at,
|
||||
before.created_at,
|
||||
before.runtime_id,
|
||||
before.daemon_id,
|
||||
)
|
||||
|
||||
result = _service(repo).rename_service_token("old-name", "new-name")
|
||||
|
||||
assert result.changed is True
|
||||
assert result.auth_token.name == "new-name"
|
||||
assert repo.get_service_token_by_name("old-name") is None
|
||||
assert repo.get_service_token_by_name("new-name") is result.auth_token
|
||||
assert repo.rename_calls == 1
|
||||
assert protected == (
|
||||
result.auth_token.id,
|
||||
result.auth_token.user_id,
|
||||
result.auth_token.token_prefix,
|
||||
result.auth_token.permission,
|
||||
result.auth_token.token_type,
|
||||
result.auth_token.expires_at,
|
||||
result.auth_token.last_used_at,
|
||||
result.auth_token.revoked_at,
|
||||
result.auth_token.created_at,
|
||||
result.auth_token.runtime_id,
|
||||
result.auth_token.daemon_id,
|
||||
)
|
||||
|
||||
|
||||
def test_rename_service_token_same_name_is_noop_without_write() -> None:
|
||||
from app.domain.models import User
|
||||
|
||||
repo = _AuthRepo()
|
||||
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
|
||||
issued = _service(repo).issue_service_token("unchanged", "alice")
|
||||
|
||||
result = _service(repo).rename_service_token("unchanged", "unchanged")
|
||||
|
||||
assert result.changed is False
|
||||
assert result.auth_token is issued.auth_token
|
||||
assert repo.rename_calls == 0
|
||||
|
||||
|
||||
def test_rename_service_token_missing_and_duplicate_errors() -> None:
|
||||
from app.domain.models import User
|
||||
|
||||
repo = _AuthRepo()
|
||||
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
|
||||
service = _service(repo)
|
||||
service.issue_service_token("source", "alice")
|
||||
service.issue_service_token("target", "alice")
|
||||
|
||||
for old_name, new_name, message in (
|
||||
("missing", "new", "서비스 토큰을 찾을 수 없습니다: missing"),
|
||||
("source", "target", "이미 존재하는 서비스 토큰 이름입니다: target"),
|
||||
):
|
||||
try:
|
||||
service.rename_service_token(old_name, new_name)
|
||||
except ValueError as exc:
|
||||
assert str(exc) == message
|
||||
else:
|
||||
raise AssertionError("Expected rename failure")
|
||||
|
||||
|
||||
# REQ-018: daemon_id forward 보정 (service layer)
|
||||
|
||||
def test_issue_service_token_with_daemon_id_stores_it() -> None:
|
||||
|
||||
@@ -34,7 +34,7 @@ from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioReposi
|
||||
|
||||
_LOCAL_DSN_ENV = "BAT025_LOCAL_POSTGRES_DSN"
|
||||
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
||||
_HEAD = "20260715_0028"
|
||||
_HEAD = "20260919_0032"
|
||||
_PRE_BAT025 = "20260628_0027"
|
||||
|
||||
|
||||
@@ -138,6 +138,26 @@ def _metadata_columns(settings: Settings) -> dict[str, tuple[str, str, str | Non
|
||||
return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()}
|
||||
|
||||
|
||||
def _venue_columns(settings: Settings) -> dict[str, tuple[str, str, str | None]]:
|
||||
with psycopg.connect(
|
||||
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,
|
||||
) 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 ('execution_venue', 'order_venue')
|
||||
"""
|
||||
)
|
||||
return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()}
|
||||
|
||||
|
||||
def _seed_portfolio(repo: PostgresPortfolioRepository, suffix: str) -> str:
|
||||
portfolio_id = f"bat025-{suffix}"
|
||||
user_id = f"bat025-user-{suffix}"
|
||||
@@ -215,14 +235,79 @@ def test_upgrade_from_pre_0028_preserves_business_data_and_backfills_source(
|
||||
"confirmation_evidence": ("text", "YES", None),
|
||||
"actor_user_id": ("text", "YES", None),
|
||||
}
|
||||
assert _venue_columns(isolated_settings) == {
|
||||
"execution_venue": ("text", "YES", None),
|
||||
"order_venue": ("text", "YES", None),
|
||||
}
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*), side, price::text, quantity::text, note, source "
|
||||
"FROM portfolio_ledger GROUP BY side, price, quantity, note, source"
|
||||
"SELECT COUNT(*), side, price::text, quantity::text, note, source, "
|
||||
"execution_venue, order_venue FROM portfolio_ledger "
|
||||
"GROUP BY side, price, quantity, note, source, execution_venue, order_venue"
|
||||
)
|
||||
count, side, price, quantity, note, source = cur.fetchone()
|
||||
row = cur.fetchone()
|
||||
assert row is not None
|
||||
count, side, price, quantity, note, source, execution_venue, order_venue = row
|
||||
assert count == before_count
|
||||
assert (side, price, quantity, note, source) == ("buy", "123.45000000", "7.00000000", "preserve-me", "broker")
|
||||
assert (execution_venue, order_venue) == (None, None)
|
||||
|
||||
with pytest.raises(psycopg.errors.CheckViolation), repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE portfolio_ledger SET execution_venue = 'SOR' WHERE portfolio_id = %s",
|
||||
(portfolio_id,),
|
||||
)
|
||||
|
||||
upgrade_to_head(isolated_settings)
|
||||
assert _current_revision(isolated_settings) == _HEAD
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_venue_migration_downgrade_removes_only_new_columns(
|
||||
isolated_settings: Settings,
|
||||
) -> None:
|
||||
from alembic import command
|
||||
|
||||
upgrade_to_head(isolated_settings)
|
||||
repo = _repo(isolated_settings)
|
||||
portfolio_id = _seed_portfolio(repo, "venue-downgrade")
|
||||
recorded_at = datetime(2026, 9, 19, tzinfo=UTC)
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_ledger (
|
||||
portfolio_id, recorded_at, side, price, quantity,
|
||||
cash_balance_after, asset_quantity_after, total_value_after,
|
||||
strategy_set_id, note, executed_at,
|
||||
execution_venue, order_venue
|
||||
) VALUES (%s, %s, 'buy', 100, 1, 900, 1, 1000,
|
||||
'downgrade', 'preserve-me', %s, 'NXT', 'SOR')
|
||||
""",
|
||||
(portfolio_id, recorded_at, recorded_at),
|
||||
)
|
||||
|
||||
command.downgrade(get_alembic_config(isolated_settings), "20260913_0031")
|
||||
|
||||
assert _current_revision(isolated_settings) == "20260913_0031"
|
||||
assert _venue_columns(isolated_settings) == {}
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*), MIN(note) FROM portfolio_ledger WHERE portfolio_id = %s",
|
||||
(portfolio_id,),
|
||||
)
|
||||
assert cur.fetchone() == (1, "preserve-me")
|
||||
|
||||
upgrade_to_head(isolated_settings)
|
||||
assert _venue_columns(isolated_settings) == {
|
||||
"execution_venue": ("text", "YES", None),
|
||||
"order_venue": ("text", "YES", None),
|
||||
}
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT execution_venue, order_venue FROM portfolio_ledger WHERE portfolio_id = %s",
|
||||
(portfolio_id,),
|
||||
)
|
||||
assert cur.fetchone() == (None, None)
|
||||
repo.close()
|
||||
|
||||
|
||||
|
||||
+168
-2
@@ -4,13 +4,25 @@ import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import app.cli.commands.health as health_commands
|
||||
from click import unstyle
|
||||
from typer.testing import CliRunner
|
||||
|
||||
import app.cli.commands.health as health_commands
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.core.security import hash_token, token_prefix
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
from app.domain.models import (
|
||||
Asset,
|
||||
AuthToken,
|
||||
BrokerCredential,
|
||||
Daemon,
|
||||
LedgerEntry,
|
||||
Portfolio,
|
||||
PortfolioComment,
|
||||
PortfolioTag,
|
||||
StrategySet,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -126,6 +138,15 @@ class FakeRepo:
|
||||
None,
|
||||
)
|
||||
|
||||
def rename_service_token(self, name: str, new_name: str) -> AuthToken:
|
||||
token = self.get_service_token_by_name(name)
|
||||
if token is None:
|
||||
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||
if self.get_service_token_by_name(new_name) is not None:
|
||||
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {new_name}")
|
||||
token.name = new_name
|
||||
return token
|
||||
|
||||
def create_daemon(
|
||||
self,
|
||||
owner_user_id: str,
|
||||
@@ -1055,6 +1076,7 @@ def test_cli_help_exposes_bot_token_commands() -> None:
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "issue" in result.stdout
|
||||
assert "rename" in result.stdout
|
||||
assert "rebind" in result.stdout
|
||||
assert "revoke" in result.stdout
|
||||
|
||||
@@ -1258,6 +1280,150 @@ def test_cli_service_token_revoke_can_abort() -> None:
|
||||
assert repo._auth_tokens[token.id].revoked_at is None
|
||||
|
||||
|
||||
def test_cli_service_token_rename_can_abort_without_mutation() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
token = repo.create_auth_token(
|
||||
user.id,
|
||||
name="old-name",
|
||||
token_hash=hash_token("rename-token"),
|
||||
token_prefix=token_prefix("rename-token"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["service-token", "rename", "old-name", "--new-name", "new-name"],
|
||||
input="n\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert repo._auth_tokens[token.id].name == "old-name"
|
||||
|
||||
|
||||
def test_cli_service_token_rename_force_json_has_only_safe_fields() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
token = repo.create_auth_token(
|
||||
user.id,
|
||||
name="old-name",
|
||||
token_hash=hash_token("rename-token"),
|
||||
token_prefix=token_prefix("rename-token"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "service-token", "rename", "old-name", "--new-name", "new-name", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.stdout) == {
|
||||
"token_id": token.id,
|
||||
"old_name": "old-name",
|
||||
"new_name": "new-name",
|
||||
"changed": True,
|
||||
}
|
||||
assert repo._auth_tokens[token.id].name == "new-name"
|
||||
|
||||
|
||||
def test_cli_service_token_rename_prompt_accepts_and_table_is_secret_free() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
token = repo.create_auth_token(
|
||||
user.id,
|
||||
name="old-name",
|
||||
token_hash=hash_token("rename-secret-value"),
|
||||
token_prefix=token_prefix("rename-secret-value"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["service-token", "rename", "old-name", "--new-name", "new-name"],
|
||||
input="y\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
for field in ("token_id", "old_name", "new_name", "changed"):
|
||||
assert field in result.stdout
|
||||
assert "rename-secret-value" not in result.stdout
|
||||
assert token.token_prefix not in result.stdout
|
||||
assert repo._auth_tokens[token.id].name == "new-name"
|
||||
|
||||
|
||||
def test_cli_service_token_rename_requires_new_name_and_reports_domain_errors() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
repo.create_auth_token(
|
||||
user.id,
|
||||
name="source",
|
||||
token_hash=hash_token("rename-source"),
|
||||
token_prefix=token_prefix("rename-source"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
)
|
||||
repo.create_auth_token(
|
||||
user.id,
|
||||
name="target",
|
||||
token_hash=hash_token("rename-target"),
|
||||
token_prefix=token_prefix("rename-target"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
missing_option = runner.invoke(runner.app, ["service-token", "rename", "source", "--force"])
|
||||
missing_token = runner.invoke(
|
||||
runner.app,
|
||||
["service-token", "rename", "missing", "--new-name", "new", "--force"],
|
||||
)
|
||||
duplicate = runner.invoke(
|
||||
runner.app,
|
||||
["service-token", "rename", "source", "--new-name", "target", "--force"],
|
||||
)
|
||||
|
||||
assert missing_option.exit_code == 2
|
||||
assert "--new-name" in unstyle(missing_option.stderr)
|
||||
assert missing_token.exit_code == 1
|
||||
assert "서비스 토큰을 찾을 수 없습니다: missing" in missing_token.stdout
|
||||
assert duplicate.exit_code == 1
|
||||
assert "이미 존재하는 서비스 토큰 이름입니다: target" in duplicate.stdout
|
||||
|
||||
|
||||
def test_cli_service_token_rename_same_name_short_force_alias() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
token = repo.create_auth_token(
|
||||
user.id,
|
||||
name="same-name",
|
||||
token_hash=hash_token("rename-token"),
|
||||
token_prefix=token_prefix("rename-token"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "service-token", "rename", "same-name", "--new-name", "same-name", "-f"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.stdout) == {
|
||||
"token_id": token.id,
|
||||
"old_name": "same-name",
|
||||
"new_name": "same-name",
|
||||
"changed": False,
|
||||
}
|
||||
|
||||
|
||||
def test_cli_service_token_revoke_force_succeeds() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""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 [entry.side for entry in pnl_entries] == ["buy", "stock_consolidation"]
|
||||
corporate_entry = pnl_entries[1]
|
||||
assert corporate_entry.corporate_action is not None
|
||||
assert corporate_entry.corporate_action["quantity_before"] == Decimal("1452.00000000")
|
||||
assert corporate_entry.corporate_action["quantity_after"] == Decimal("726.00000000")
|
||||
assert corporate_entry.corporate_action["total_cost_basis"] == _TOTAL_COST
|
||||
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)
|
||||
@@ -230,6 +230,8 @@ def test_ledger_trade_201_when_daemon_matches(monkeypatch) -> None:
|
||||
trade.price = 50000.0
|
||||
trade.fee_amount = 0.0
|
||||
trade.tax_amount = 0.0
|
||||
trade.execution_venue = None
|
||||
trade.order_venue = None
|
||||
trade.executed_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc)
|
||||
trade.recorded_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc)
|
||||
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
|
||||
|
||||
@@ -2,6 +2,8 @@ from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
@@ -10,7 +12,7 @@ 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": "stock_consolidation", "quantity": 0, "price": 0, "quantity_before": 10, "quantity_after": 5, "average_price_after": 200, "total_cost_basis": 1000, "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
|
||||
@@ -35,6 +37,59 @@ def test_consolidation_replay_uses_preserved_total_cost_basis():
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_quantity == 726
|
||||
assert result.current_wac == float(Decimal("1577211.97990000") / Decimal(726))
|
||||
assert result.total_cost_basis == float(Decimal("1577211.97990000"))
|
||||
assert result.data_completeness_warning is None
|
||||
|
||||
|
||||
def test_consolidation_replay_ignores_rounded_average_price_after():
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 1452, "price": 1086.2341459366393},
|
||||
{
|
||||
"side": "stock_consolidation",
|
||||
"quantity_after": 726,
|
||||
"average_price_after": Decimal("1.00"),
|
||||
"total_cost_basis": Decimal("1577211.97990000"),
|
||||
},
|
||||
]
|
||||
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_wac == float(Decimal("1577211.97990000") / Decimal(726))
|
||||
assert result.total_cost_basis == float(Decimal("1577211.97990000"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata",
|
||||
[
|
||||
{"total_cost_basis": Decimal(1000)},
|
||||
{"quantity_after": 0, "total_cost_basis": Decimal(1000)},
|
||||
{"quantity_after": 5},
|
||||
],
|
||||
)
|
||||
def test_consolidation_replay_fails_closed_when_required_metadata_is_missing(metadata):
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 10, "price": 100},
|
||||
{"side": "stock_consolidation", **metadata},
|
||||
]
|
||||
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_wac is None
|
||||
assert result.total_cost_basis is None
|
||||
assert result.data_completeness_warning is not None
|
||||
assert "기업행동 metadata" in result.data_completeness_warning
|
||||
|
||||
|
||||
def test_consolidation_authoritative_total_continues_through_later_trades():
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 10, "price": 100},
|
||||
{"side": "stock_consolidation", "quantity_after": 5, "total_cost_basis": Decimal(1000)},
|
||||
{"side": "buy", "quantity": 1, "price": 300, "fee_amount": 2},
|
||||
{"side": "sell", "quantity": 2, "price": 400},
|
||||
]
|
||||
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_quantity == 4
|
||||
assert result.current_wac == pytest.approx(1302 / 6)
|
||||
assert result.total_cost_basis == pytest.approx((1302 / 6) * 4)
|
||||
|
||||
|
||||
def test_canonical_hash_same_payload_and_changed_payload_differ():
|
||||
@@ -44,10 +99,27 @@ def test_canonical_hash_same_payload_and_changed_payload_differ():
|
||||
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 "strategy_set_id, note, source," in source
|
||||
assert "ledger.strategy_set_id, ledger.note, ledger.source," in source
|
||||
assert "ca.corporate_action_id, ca.action_type" in source
|
||||
assert 'row[18] is not None' in source
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import v1_portfolios
|
||||
from app.domain.models import CorporateAction
|
||||
from app.domain.models import CorporateAction, Portfolio
|
||||
from app.main import create_app
|
||||
from app.repositories.postgres import PostgresPortfolioRepository
|
||||
|
||||
@@ -20,6 +20,46 @@ class MissingPortfolioRepo:
|
||||
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()
|
||||
@@ -35,6 +75,31 @@ def test_create_corporate_action_maps_postgres_missing_portfolio_to_404(monkeypa
|
||||
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)
|
||||
|
||||
@@ -47,7 +112,12 @@ def _repo_for_row(asset_id):
|
||||
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"), None, None, None, (7, date(2026, 1, 1))]
|
||||
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
|
||||
|
||||
@@ -74,8 +144,7 @@ def test_record_corporate_action_accepts_daekyo_rounding_residue():
|
||||
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"),
|
||||
None,
|
||||
(Decimal(1452), Decimal(10), Decimal(0), "asset-1", "enabled", Decimal(643)),
|
||||
None,
|
||||
None,
|
||||
(7, datetime(2026, 9, 13, tzinfo=UTC)),
|
||||
|
||||
@@ -12,10 +12,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
|
||||
from app.repositories.portfolio_repository import LedgerTradeConflictError
|
||||
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
|
||||
|
||||
|
||||
@@ -133,6 +136,182 @@ def test_duplicate_broker_write_is_idempotent(repo, seeded_portfolio):
|
||||
assert cur.fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_duplicate_venue_enrichment_is_metadata_only(repo, seeded_portfolio):
|
||||
args = {
|
||||
"portfolio_id": seeded_portfolio,
|
||||
"order_id": "ord-venue-enrich",
|
||||
"execution_id": "exec-venue-enrich",
|
||||
"side": "buy",
|
||||
"quantity": 5.0,
|
||||
"price": 10_000.0,
|
||||
"executed_at": _now(),
|
||||
}
|
||||
_first, created = repo.insert_ledger_trade(**args)
|
||||
assert created is True
|
||||
before = repo.get(seeded_portfolio)
|
||||
|
||||
enriched, created = repo.insert_ledger_trade(
|
||||
**args,
|
||||
execution_venue="NXT",
|
||||
order_venue="SOR",
|
||||
)
|
||||
|
||||
assert created is False
|
||||
assert enriched.execution_venue == "NXT"
|
||||
assert enriched.order_venue == "SOR"
|
||||
after = repo.get(seeded_portfolio)
|
||||
assert after.cash_balance == before.cash_balance
|
||||
assert after.asset_quantity == before.asset_quantity
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*), MIN(execution_venue), MIN(order_venue)
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
|
||||
""",
|
||||
(seeded_portfolio, args["order_id"], args["execution_id"]),
|
||||
)
|
||||
assert cur.fetchone() == (1, "NXT", "SOR")
|
||||
|
||||
|
||||
def test_duplicate_venue_conflict_is_atomic(repo, seeded_portfolio):
|
||||
args = {
|
||||
"portfolio_id": seeded_portfolio,
|
||||
"order_id": "ord-venue-conflict",
|
||||
"execution_id": "exec-venue-conflict",
|
||||
"side": "buy",
|
||||
"quantity": 1.0,
|
||||
"price": 10_000.0,
|
||||
"executed_at": _now(),
|
||||
}
|
||||
repo.insert_ledger_trade(**args, execution_venue="KRX")
|
||||
before = repo.get(seeded_portfolio)
|
||||
|
||||
with pytest.raises(LedgerTradeConflictError):
|
||||
repo.insert_ledger_trade(
|
||||
**args,
|
||||
execution_venue="NXT",
|
||||
order_venue="SOR",
|
||||
)
|
||||
|
||||
after = repo.get(seeded_portfolio)
|
||||
assert after.cash_balance == before.cash_balance
|
||||
assert after.asset_quantity == before.asset_quantity
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT execution_venue, order_venue
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
|
||||
""",
|
||||
(seeded_portfolio, args["order_id"], args["execution_id"]),
|
||||
)
|
||||
assert cur.fetchone() == ("KRX", None)
|
||||
|
||||
|
||||
def test_duplicate_null_venues_do_not_erase_existing_values(repo, seeded_portfolio):
|
||||
args = {
|
||||
"portfolio_id": seeded_portfolio,
|
||||
"order_id": "ord-venue-preserve",
|
||||
"execution_id": "exec-venue-preserve",
|
||||
"side": "buy",
|
||||
"quantity": 1.0,
|
||||
"price": 10_000.0,
|
||||
"executed_at": _now(),
|
||||
}
|
||||
repo.insert_ledger_trade(
|
||||
**args,
|
||||
execution_venue="KRX",
|
||||
order_venue="SOR",
|
||||
)
|
||||
|
||||
duplicate, created = repo.insert_ledger_trade(**args)
|
||||
|
||||
assert created is False
|
||||
assert duplicate.execution_venue == "KRX"
|
||||
assert duplicate.order_venue == "SOR"
|
||||
|
||||
|
||||
def test_duplicate_economic_conflict_is_rejected(repo, seeded_portfolio):
|
||||
args = {
|
||||
"portfolio_id": seeded_portfolio,
|
||||
"order_id": "ord-economic-conflict",
|
||||
"execution_id": "exec-economic-conflict",
|
||||
"side": "buy",
|
||||
"quantity": 1.0,
|
||||
"price": 10_000.0,
|
||||
"executed_at": _now(),
|
||||
}
|
||||
repo.insert_ledger_trade(**args)
|
||||
|
||||
with pytest.raises(LedgerTradeConflictError):
|
||||
repo.insert_ledger_trade(**{**args, "quantity": 2.0}, order_venue="SOR")
|
||||
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT quantity::float8, order_venue
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
|
||||
""",
|
||||
(seeded_portfolio, args["order_id"], args["execution_id"]),
|
||||
)
|
||||
assert cur.fetchone() == (1.0, None)
|
||||
|
||||
|
||||
def test_concurrent_conflicting_venue_enrichment_has_no_last_write_wins(
|
||||
repo, seeded_portfolio
|
||||
):
|
||||
args = {
|
||||
"portfolio_id": seeded_portfolio,
|
||||
"order_id": "ord-venue-concurrent",
|
||||
"execution_id": "exec-venue-concurrent",
|
||||
"side": "buy",
|
||||
"quantity": 1.0,
|
||||
"price": 10_000.0,
|
||||
"executed_at": _now(),
|
||||
}
|
||||
repo.insert_ledger_trade(**args)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def enrich(execution_venue: str):
|
||||
worker_repo = PostgresPortfolioRepository(repo._config)
|
||||
barrier.wait()
|
||||
try:
|
||||
entry, created = worker_repo.insert_ledger_trade(
|
||||
portfolio_id=seeded_portfolio,
|
||||
order_id="ord-venue-concurrent",
|
||||
execution_id="exec-venue-concurrent",
|
||||
side="buy",
|
||||
quantity=1.0,
|
||||
price=10_000.0,
|
||||
executed_at=args["executed_at"],
|
||||
execution_venue=execution_venue,
|
||||
)
|
||||
return ("ok", created, entry.execution_venue)
|
||||
except LedgerTradeConflictError:
|
||||
return ("conflict", False, None)
|
||||
finally:
|
||||
worker_repo.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(enrich, ("KRX", "NXT")))
|
||||
|
||||
assert sorted(result[0] for result in results) == ["conflict", "ok"]
|
||||
winner = next(result[2] for result in results if result[0] == "ok")
|
||||
assert winner in {"KRX", "NXT"}
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*), MIN(execution_venue), MIN(order_venue)
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
|
||||
""",
|
||||
(seeded_portfolio, args["order_id"], args["execution_id"]),
|
||||
)
|
||||
assert cur.fetchone() == (1, winner, None)
|
||||
|
||||
|
||||
def test_negative_balance_allowed(repo, seeded_portfolio):
|
||||
"""REQ-037 AC-3 + OQ-3: oversell produces negative asset_quantity without rejection."""
|
||||
entry, created = repo.insert_ledger_trade(
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
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.core.security import hash_token
|
||||
from app.db.migrations import upgrade_to_head
|
||||
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
_LOCAL_DSN_ENV = "PORTFOLIOHUB_TEST_POSTGRES_DSN"
|
||||
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
|
||||
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)
|
||||
assert parsed.hostname is not None
|
||||
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="service-token-rename-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 postgres_context() -> Iterator[tuple[Settings, PostgresPortfolioRepository]]:
|
||||
maintenance_dsn = _maintenance_dsn()
|
||||
database = f"portfolio_hub_token_rename_{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)
|
||||
repo.create_user("alice", permission="read")
|
||||
try:
|
||||
yield settings, 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 _stored_row(repo: PostgresPortfolioRepository, token_id: str) -> tuple:
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, name, token_prefix, token_hash, permission, token_type,
|
||||
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
|
||||
FROM auth_tokens
|
||||
WHERE id = %s
|
||||
""",
|
||||
(token_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
def test_rename_persists_only_name_and_preserves_bearer_authentication(
|
||||
postgres_context,
|
||||
) -> None:
|
||||
settings, repo = postgres_context
|
||||
service = AuthService(repo, settings)
|
||||
issued = service.issue_service_token("old-name", "alice")
|
||||
before = _stored_row(repo, issued.auth_token.id)
|
||||
|
||||
renamed = service.rename_service_token("old-name", "new-name")
|
||||
after = _stored_row(repo, issued.auth_token.id)
|
||||
|
||||
assert renamed.changed is True
|
||||
assert renamed.auth_token.id == issued.auth_token.id
|
||||
assert after[2] == "new-name"
|
||||
assert before[:2] + before[3:] == after[:2] + after[3:]
|
||||
assert repo.get_service_token_by_name("old-name") is None
|
||||
assert repo.get_service_token_by_name("new-name").id == issued.auth_token.id
|
||||
|
||||
principal = repo.get_token_principal(hash_token(issued.token))
|
||||
assert principal is not None
|
||||
assert principal.user_id == issued.auth_token.user_id
|
||||
assert principal.permission == issued.auth_token.permission
|
||||
assert principal.runtime_id == issued.auth_token.runtime_id
|
||||
assert principal.daemon_id == issued.auth_token.daemon_id
|
||||
|
||||
|
||||
def test_rename_collision_rolls_back_and_revoked_name_is_reusable(
|
||||
postgres_context,
|
||||
) -> None:
|
||||
settings, repo = postgres_context
|
||||
service = AuthService(repo, settings)
|
||||
source = service.issue_service_token("source", "alice")
|
||||
target = service.issue_service_token("target", "alice")
|
||||
source_before = _stored_row(repo, source.auth_token.id)
|
||||
target_before = _stored_row(repo, target.auth_token.id)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="이미 존재하는 서비스 토큰 이름입니다: target"
|
||||
):
|
||||
service.rename_service_token("source", "target")
|
||||
|
||||
assert _stored_row(repo, source.auth_token.id) == source_before
|
||||
assert _stored_row(repo, target.auth_token.id) == target_before
|
||||
|
||||
repo.revoke_auth_token(target.auth_token.user_id, target.auth_token.id)
|
||||
renamed = service.rename_service_token("source", "target")
|
||||
assert renamed.changed is True
|
||||
assert renamed.auth_token.id == source.auth_token.id
|
||||
|
||||
|
||||
def test_same_name_is_noop_and_missing_same_name_fails(postgres_context) -> None:
|
||||
settings, repo = postgres_context
|
||||
service = AuthService(repo, settings)
|
||||
issued = service.issue_service_token("same-name", "alice")
|
||||
before = _stored_row(repo, issued.auth_token.id)
|
||||
|
||||
result = service.rename_service_token("same-name", "same-name")
|
||||
|
||||
assert result.changed is False
|
||||
assert _stored_row(repo, issued.auth_token.id) == before
|
||||
with pytest.raises(ValueError, match="서비스 토큰을 찾을 수 없습니다: missing"):
|
||||
service.rename_service_token("missing", "missing")
|
||||
|
||||
revoked = service.issue_service_token("revoked-source", "alice")
|
||||
repo.revoke_auth_token(revoked.auth_token.user_id, revoked.auth_token.id)
|
||||
revoked_before = _stored_row(repo, revoked.auth_token.id)
|
||||
with pytest.raises(
|
||||
ValueError, match="서비스 토큰을 찾을 수 없습니다: revoked-source"
|
||||
):
|
||||
service.rename_service_token("revoked-source", "new-name")
|
||||
assert _stored_row(repo, revoked.auth_token.id) == revoked_before
|
||||
|
||||
|
||||
def test_concurrent_renames_to_same_target_have_one_winner(postgres_context) -> None:
|
||||
settings, repo = postgres_context
|
||||
service = AuthService(repo, settings)
|
||||
first = service.issue_service_token("race-first", "alice")
|
||||
second = service.issue_service_token("race-second", "alice")
|
||||
barrier = Barrier(2)
|
||||
|
||||
def worker(source: str) -> str:
|
||||
worker_repo = _repo(settings)
|
||||
try:
|
||||
barrier.wait()
|
||||
AuthService(worker_repo, settings).rename_service_token(
|
||||
source, "race-target"
|
||||
)
|
||||
return source
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
finally:
|
||||
worker_repo.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(worker, "race-first"),
|
||||
executor.submit(worker, "race-second"),
|
||||
]
|
||||
results = [future.result(timeout=15) for future in futures]
|
||||
|
||||
winners = [result for result in results if result in {"race-first", "race-second"}]
|
||||
conflicts = [
|
||||
result
|
||||
for result in results
|
||||
if result == "이미 존재하는 서비스 토큰 이름입니다: race-target"
|
||||
]
|
||||
assert len(winners) == 1
|
||||
assert len(conflicts) == 1
|
||||
active_target = repo.get_service_token_by_name("race-target")
|
||||
assert active_target is not None
|
||||
expected_id = (
|
||||
first.auth_token.id if winners == ["race-first"] else second.auth_token.id
|
||||
)
|
||||
assert active_target.id == expected_id
|
||||
|
||||
|
||||
def test_concurrent_rename_and_issue_have_one_winner(postgres_context) -> None:
|
||||
settings, repo = postgres_context
|
||||
service = AuthService(repo, settings)
|
||||
source = service.issue_service_token("race-source", "alice")
|
||||
barrier = Barrier(2)
|
||||
|
||||
def rename_worker() -> str:
|
||||
worker_repo = _repo(settings)
|
||||
try:
|
||||
barrier.wait()
|
||||
AuthService(worker_repo, settings).rename_service_token(
|
||||
"race-source", "race-target"
|
||||
)
|
||||
return "renamed"
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
finally:
|
||||
worker_repo.close()
|
||||
|
||||
def issue_worker() -> str:
|
||||
worker_repo = _repo(settings)
|
||||
try:
|
||||
barrier.wait()
|
||||
AuthService(worker_repo, settings).issue_service_token(
|
||||
"race-target", "alice"
|
||||
)
|
||||
return "issued"
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
finally:
|
||||
worker_repo.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = [executor.submit(rename_worker), executor.submit(issue_worker)]
|
||||
results = [future.result(timeout=15) for future in outcomes]
|
||||
|
||||
winners = [result for result in results if result in {"renamed", "issued"}]
|
||||
conflicts = [
|
||||
result
|
||||
for result in results
|
||||
if result == "이미 존재하는 서비스 토큰 이름입니다: race-target"
|
||||
]
|
||||
assert len(winners) == 1
|
||||
assert len(conflicts) == 1
|
||||
active_target = repo.get_service_token_by_name("race-target")
|
||||
assert active_target is not None
|
||||
if winners == ["renamed"]:
|
||||
assert active_target.id == source.auth_token.id
|
||||
assert repo.get_service_token_by_name("race-source") is None
|
||||
else:
|
||||
assert active_target.id != source.auth_token.id
|
||||
assert repo.get_service_token_by_name("race-source").id == source.auth_token.id
|
||||
@@ -7,7 +7,8 @@ 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 decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -286,6 +287,101 @@ class TestAC2PartialSellWacUnchanged:
|
||||
assert resp.json()["total_cost_basis"] == pytest.approx(500_000.0)
|
||||
|
||||
|
||||
def test_corporate_action_row_returns_authoritative_holdings_cost_basis() -> None:
|
||||
buy = _make_entry(
|
||||
ledger_entry_id=1,
|
||||
side="buy",
|
||||
quantity=1452,
|
||||
price=1086.2341459366393,
|
||||
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=726,
|
||||
price=2172.46829187,
|
||||
recorded_at=datetime(2026, 9, 2, tzinfo=UTC),
|
||||
corporate_action={
|
||||
"corporate_action_id": "ca-019685",
|
||||
"action_type": "stock_consolidation",
|
||||
"quantity_before": Decimal(1452),
|
||||
"quantity_after": Decimal(726),
|
||||
"average_price_after": Decimal("2172.46829187"),
|
||||
"total_cost_basis": Decimal("1577211.97990000"),
|
||||
},
|
||||
)
|
||||
repo = _make_service_repo(
|
||||
# Corporate-action replay is authoritative for the returned quantity.
|
||||
portfolio=_make_portfolio(symbol="019685", asset_quantity=999),
|
||||
trades=[buy, corporate_action],
|
||||
)
|
||||
|
||||
with _with_repo(repo):
|
||||
response = _make_client(_make_settings()).get(
|
||||
_PATH,
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["current_quantity"] == pytest.approx(726)
|
||||
assert body["cost_basis_per_unit"] == 1577211.9799 / 726
|
||||
assert body["total_cost_basis"] == 1577211.9799
|
||||
assert body["data_completeness_warning"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"corporate_action",
|
||||
[
|
||||
{"action_type": "stock_consolidation", "total_cost_basis": Decimal(1000)},
|
||||
{
|
||||
"action_type": "stock_consolidation",
|
||||
"quantity_after": Decimal(0),
|
||||
"total_cost_basis": Decimal(1000),
|
||||
},
|
||||
{"action_type": "stock_consolidation", "quantity_after": Decimal(10)},
|
||||
],
|
||||
)
|
||||
def test_corporate_action_missing_required_metadata_returns_null_costs(
|
||||
corporate_action: dict[str, object],
|
||||
) -> None:
|
||||
buy = _make_entry(
|
||||
ledger_entry_id=1,
|
||||
side="buy",
|
||||
quantity=20,
|
||||
price=100,
|
||||
executed_at=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
)
|
||||
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=corporate_action,
|
||||
)
|
||||
repo = _make_service_repo(
|
||||
portfolio=_make_portfolio(asset_quantity=10),
|
||||
trades=[buy, action],
|
||||
)
|
||||
|
||||
with _with_repo(repo):
|
||||
response = _make_client(_make_settings()).get(
|
||||
_PATH,
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["cost_basis_per_unit"] is None
|
||||
assert body["total_cost_basis"] is None
|
||||
assert "기업행동 metadata" in body["data_completeness_warning"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: user read_token (owner) → 200 + normal response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -147,6 +147,97 @@ def test_ledger_trade_duplicate_same_payload() -> None:
|
||||
assert resp.json()["status"] == "duplicate"
|
||||
|
||||
|
||||
def test_ledger_trade_round_trips_and_forwards_venue_provenance() -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
trade=_make_trade(execution_venue="NXT", order_venue="SOR")
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.post(
|
||||
_NEW_PATH,
|
||||
json=_trade_payload(execution_venue="NXT", order_venue="SOR"),
|
||||
headers={"Authorization": "Bearer svc-token"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["execution_venue"] == "NXT"
|
||||
assert resp.json()["order_venue"] == "SOR"
|
||||
assert repo.insert_ledger_trade.call_args.kwargs["execution_venue"] == "NXT"
|
||||
assert repo.insert_ledger_trade.call_args.kwargs["order_venue"] == "SOR"
|
||||
|
||||
|
||||
def test_ledger_trade_allows_unknown_actual_venue_with_sor_route() -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(trade=_make_trade(execution_venue=None, order_venue="SOR"))
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.post(
|
||||
_NEW_PATH,
|
||||
json=_trade_payload(execution_venue=None, order_venue="SOR"),
|
||||
headers={"Authorization": "Bearer svc-token"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["execution_venue"] is None
|
||||
assert resp.json()["order_venue"] == "SOR"
|
||||
|
||||
|
||||
def test_ledger_trade_rejects_sor_as_execution_venue() -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo()
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.post(
|
||||
_NEW_PATH,
|
||||
json=_trade_payload(execution_venue="SOR", order_venue="SOR"),
|
||||
headers={"Authorization": "Bearer svc-token"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
repo.insert_ledger_trade.assert_not_called()
|
||||
|
||||
|
||||
def test_ledger_trade_rejects_unknown_request_fields() -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo()
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.post(
|
||||
_NEW_PATH,
|
||||
json=_trade_payload(broker_ordered_at="2026-05-01T09:59:59+09:00"),
|
||||
headers={"Authorization": "Bearer svc-token"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
repo.insert_ledger_trade.assert_not_called()
|
||||
|
||||
|
||||
def test_ledger_trade_openapi_exposes_nullable_venue_enums() -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo()
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
response = client.get("/openapi.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
properties = response.json()["components"]["schemas"]["LedgerTradeRequest"][
|
||||
"properties"
|
||||
]
|
||||
execution_options = properties["execution_venue"]["anyOf"]
|
||||
order_options = properties["order_venue"]["anyOf"]
|
||||
assert {tuple(option.get("enum", ())) for option in execution_options} == {
|
||||
(),
|
||||
("KRX", "NXT"),
|
||||
}
|
||||
assert {tuple(option.get("enum", ())) for option in order_options} == {
|
||||
(),
|
||||
("KRX", "NXT", "SOR"),
|
||||
}
|
||||
assert {option.get("type") for option in execution_options} == {"string", "null"}
|
||||
assert {option.get("type") for option in order_options} == {"string", "null"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: same key + different payload → 409
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import time
|
||||
from contextlib import ExitStack
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
@@ -977,6 +977,64 @@ def test_unified_ledger_includes_dividend_fields() -> None:
|
||||
assert item["profit_buffer_after"] == created.json()["profit_buffer_after"]
|
||||
|
||||
|
||||
def test_unified_ledger_includes_corporate_action_fields() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
repo.entries.append(
|
||||
LedgerEntry(
|
||||
ledger_entry_id=7,
|
||||
portfolio_id=_PORTFOLIO_ID,
|
||||
source="corporate_action",
|
||||
side="stock_consolidation",
|
||||
quantity=10,
|
||||
price=200,
|
||||
recorded_at=datetime(2026, 9, 2, tzinfo=UTC),
|
||||
corporate_action={
|
||||
"corporate_action_id": "ca-7",
|
||||
"action_type": "stock_consolidation",
|
||||
"effective_date": date(2026, 9, 2),
|
||||
"quantity_before": Decimal(20),
|
||||
"quantity_after": Decimal(10),
|
||||
"average_price_before": Decimal(100),
|
||||
"average_price_after": Decimal(200),
|
||||
"total_cost_basis": Decimal(2000),
|
||||
"cash_balance": Decimal(1000),
|
||||
"profit_buffer": Decimal(500),
|
||||
"evidence_ref": "notice://ca-7",
|
||||
"actor_user_id": _USER_ID,
|
||||
},
|
||||
)
|
||||
)
|
||||
client = _make_client(repo)
|
||||
|
||||
response = client.get(
|
||||
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=corporate_action",
|
||||
headers=_auth(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
item = response.json()["items"][0]
|
||||
assert item == {
|
||||
"ledger_entry_id": 7,
|
||||
"portfolio_id": _PORTFOLIO_ID,
|
||||
"source": "corporate_action",
|
||||
"event_id": "ca-7",
|
||||
"event_type": "stock_consolidation",
|
||||
"effective_date": "2026-09-02",
|
||||
"quantity_before": "20",
|
||||
"quantity_after": "10",
|
||||
"average_price_before": "100",
|
||||
"average_price_after": "200",
|
||||
"total_cost_basis": "2000",
|
||||
"cash_balance": "1000",
|
||||
"profit_buffer": "500",
|
||||
"evidence_ref": "notice://ca-7",
|
||||
"actor_user_id": _USER_ID,
|
||||
"recorded_at": "2026-09-02T00:00:00Z",
|
||||
"execution_venue": None,
|
||||
"order_venue": None,
|
||||
}
|
||||
|
||||
|
||||
def test_unified_ledger_openapi_contains_nullable_amount() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
@@ -1038,6 +1096,7 @@ def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None:
|
||||
LedgerEntry(
|
||||
ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker",
|
||||
recorded_at=datetime.now(UTC), cash_balance_after=900, asset_quantity_after=12,
|
||||
execution_venue="NXT", order_venue="SOR",
|
||||
)
|
||||
)
|
||||
first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth())
|
||||
@@ -1045,6 +1104,8 @@ def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None:
|
||||
first_item = first.json()["items"][0]
|
||||
assert first_item["source"] == "broker"
|
||||
assert first_item["action"] == "BUY"
|
||||
assert first_item["execution_venue"] == "NXT"
|
||||
assert first_item["order_venue"] == "SOR"
|
||||
assert "side" not in first_item
|
||||
assert first.json()["next_cursor"]
|
||||
second = client.get(
|
||||
|
||||
Reference in New Issue
Block a user