Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f268ac53 | ||
|
|
deaaa96c46 | ||
|
|
996127f215 | ||
|
|
567d662913 | ||
|
|
f79f3c79b5 | ||
|
|
8841bdd441 | ||
|
|
c7d8235bde | ||
|
|
2b470c735f | ||
|
|
4520fbbfbd | ||
|
|
1010d257ad | ||
|
|
5b443f718e | ||
|
|
a4e4951ed6 | ||
|
|
54e4fe4213 | ||
|
|
fa824dfd36 | ||
|
|
925c6b4fa4 | ||
|
|
cb19c35d23 | ||
|
|
14c118f503 | ||
|
|
48735bbb4a | ||
|
|
a4664f5991 | ||
|
|
77468631b1 | ||
|
|
7a45bec3cb | ||
|
|
dc755889b3 | ||
|
|
dfcafade94 | ||
|
|
caaf7ce68c | ||
|
|
044688b485 | ||
|
|
308a1ea59e | ||
|
|
db35d083de | ||
|
|
8368e7ba16 | ||
|
|
42297030ca | ||
|
|
e261a41b7f | ||
|
|
d3601d32dc | ||
|
|
fde5b33f05 | ||
|
|
7fc3386f3a | ||
|
|
7aa3de524a | ||
|
|
64975033e6 | ||
|
|
1015b465bb | ||
|
|
f193aa6da3 | ||
|
|
a36b451a3d | ||
|
|
78e347703e | ||
|
|
6df3e4ba02 | ||
|
|
b74284e626 | ||
|
|
aa40c7fdff | ||
|
|
a71c91f6ef | ||
|
|
ccde81b7a0 | ||
|
|
9d2c2aea5f | ||
|
|
5d6a0ad013 | ||
|
|
6e17a53b17 | ||
|
|
f48a7c5cd7 | ||
|
|
ac742b1150 | ||
|
|
fe22be0b66 | ||
|
|
5eb8c04266 | ||
|
|
02bfa60299 | ||
|
|
ec912fcde8 | ||
|
|
75e55fde9c | ||
|
|
ee1af6ef49 | ||
|
|
fe2dd3d15e | ||
|
|
c292df0a4b | ||
|
|
e6a90800ec | ||
|
|
acfaab7fd4 |
@@ -3,6 +3,7 @@ PORTFOLIOHUB_ALLOWED_ORIGINS=http://localhost:3000
|
||||
PORTFOLIOHUB_READ_TOKENS=read-token-123
|
||||
PORTFOLIOHUB_TRADE_WRITE_TOKENS=trade-token-456
|
||||
PORTFOLIOHUB_ADMIN_TOKENS=admin-token-789
|
||||
PORTFOLIOHUB_LEDGER_CURSOR_SECRET=replace-with-a-long-random-secret
|
||||
PORTFOLIOHUB_ENVIRONMENT=development
|
||||
|
||||
# Enable DB-backed repository (uses TIMESCALEDB_* below)
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
- run: python scripts/image_release.py validate-checkout --actual "$(git rev-parse HEAD)" --expected "${GITHUB_SHA}"
|
||||
- run: python scripts/image_release.py validate-event --event "${GITHUB_EVENT_NAME}" --ref "${GITHUB_REF}" --commit "${GITHUB_SHA}" --project pyproject.toml
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
@@ -39,9 +39,14 @@ jobs:
|
||||
python-version: "3.11"
|
||||
- run: python -m pip install "uv==0.11.14"
|
||||
- run: uv sync --extra dev --locked
|
||||
- run: uv run pytest -q
|
||||
- run: uv run python -m pytest -q
|
||||
- name: Honest scoped lint
|
||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py tests/release
|
||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
|
||||
- name: Reject Ruff findings introduced by v0.3.0 or later patches
|
||||
run: |
|
||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.1
|
||||
- name: Record exact source migration inventory
|
||||
run: uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
|
||||
- name: Create excluded synthetic canary
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Verify published PortfolioHub image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Strict release tag (for example v0.1.1)"
|
||||
required: true
|
||||
type: string
|
||||
commit:
|
||||
description: "Exact 40-character lowercase source commit"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Validate immutable selectors and image contract
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
COMMIT: ${{ inputs.commit }}
|
||||
IMAGE: ghcr.io/fourblocks2020/portfolio-hub
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
||||
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]]
|
||||
short_sha="${COMMIT:0:12}"
|
||||
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io --username "$ACTOR" --password-stdin >/dev/null
|
||||
version_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$VERSION")"
|
||||
source_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$short_sha")"
|
||||
version_digest="sha256:$(printf '%s' "$version_raw" | sha256sum | cut -d' ' -f1)"
|
||||
source_digest="sha256:$(printf '%s' "$source_raw" | sha256sum | cut -d' ' -f1)"
|
||||
test "$version_digest" = "$source_digest"
|
||||
|
||||
docker pull "$IMAGE@$version_digest" >/dev/null
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Architecture}}')" = amd64
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Config.User}}')" = 10001:10001
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$COMMIT"
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = "$VERSION"
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.source"}}')" = https://github.com/fourblocks2020/portfolio-hub
|
||||
|
||||
if docker buildx imagetools inspect "$IMAGE:latest" >/dev/null 2>&1; then
|
||||
echo "mutable latest selector exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "digest=$version_digest"
|
||||
echo "version_selector=$IMAGE:$VERSION"
|
||||
echo "source_selector=$IMAGE:$short_sha"
|
||||
echo "immutable=$IMAGE@$version_digest"
|
||||
{
|
||||
echo "## Verified published PortfolioHub image"
|
||||
echo
|
||||
echo "- digest: \`$version_digest\`"
|
||||
echo "- version selector: \`$IMAGE:$VERSION\`"
|
||||
echo "- source selector: \`$IMAGE:$short_sha\`"
|
||||
echo "- source commit: \`$COMMIT\`"
|
||||
echo "- architecture: \`linux/amd64\`"
|
||||
echo "- runtime user: \`10001:10001\`"
|
||||
echo "- latest: absent"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -11,20 +11,47 @@ permissions:
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
timescaledb:
|
||||
image: timescale/timescaledb@sha256:252a443e2936039b83dd8da1373d01e59e932d1054fa6adf1bc061f1d56ae60a
|
||||
env:
|
||||
POSTGRES_DB: portfolio_hub
|
||||
POSTGRES_USER: portfolio_hub_user
|
||||
POSTGRES_PASSWORD: change-me
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U portfolio_hub_user -d portfolio_hub"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: python -m pip install "uv==0.11.14"
|
||||
- run: uv sync --extra dev --locked
|
||||
- run: uv run pytest -q
|
||||
- run: uv run python -m pytest -q
|
||||
- name: Verify corporate-action atomicity and guarded recovery in PostgreSQL
|
||||
env:
|
||||
POSTGRES_PASSWORD: change-me
|
||||
run: |
|
||||
export PORTFOLIOHUB_TEST_POSTGRES_DSN="postgresql://portfolio_hub_user:${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres"
|
||||
uv run python -m pytest -q tests/test_corporate_action_atomic_recovery_postgres.py 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 tests/release
|
||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
|
||||
- name: Reject Ruff findings introduced by v0.3.0 or later patches
|
||||
run: |
|
||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.1
|
||||
- name: Validate container contracts without registry writes
|
||||
run: uv run pytest -q tests/release
|
||||
run: uv run python -m pytest -q tests/release
|
||||
- name: Build and smoke exact pull-request or main source image
|
||||
run: |
|
||||
uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
|
||||
|
||||
@@ -3,14 +3,15 @@ id: "ac-0001"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 포트폴리오 목록의 ledger 투자금 집계 필드
|
||||
# 포트폴리오 목록의 source-independent 투자금 집계 필드
|
||||
|
||||
## Criterion
|
||||
|
||||
`GET /v1/portfolios`의 각 `PortfolioItem`은 기존 응답 필드를 유지하면서 `net_contributed_capital`과 `cumulative_profit_withdrawn`을 float 값으로 포함해야 한다. 전자는 순투자현금, 후자는 누적 수익 인출액을 뜻하며, 해당 portfolio에 유효한 manual ledger가 없거나 집계 결과가 없으면 두 값은 각각 `0.0`이어야 한다.
|
||||
`GET /v1/portfolios`의 각 `PortfolioItem`은 기존 응답 필드를 유지하면서 `net_contributed_capital`과 `cumulative_profit_withdrawn`을 float 값으로 포함해야 한다. 전자는 모든 source의 유효한 `INVEST` gross 합계에서 유효한 `WITHDRAW` 합계를 뺀 순투자원금이고, 후자는 원금과 분리한 모든 source의 유효한 `PROFIT` 합계다. 해당 portfolio에 유효한 대상 ledger event가 없으면 두 값은 각각 `0.0`이어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: ledger 집계가 있는 portfolio는 두 필드에 해당 집계값을 반환하고, ledger 집계가 없는 portfolio는 두 필드를 `0.0`으로 반환한다.
|
||||
- 실패·경계 조건: 빈 목록은 `portfolios: []`, `total: 0`을 반환하며 신규 필드 추가 때문에 기존 응답 필드가 제거되거나 값의 의미가 바뀌지 않는다.
|
||||
- 검사 방법: `tests/test_v1_portfolios_list.py`의 목록 집계값 결합·기본값·빈 목록·기존 필드 회귀 테스트와 OpenAPI schema의 `PortfolioItem`을 검사한다.
|
||||
- 성공 조건: broker-only와 manual/broker mixed-source portfolio가 source와 무관한 side별 합계로 두 필드를 반환하고, ledger 집계가 없는 portfolio는 두 필드를 `0.0`으로 반환한다.
|
||||
- 실패·경계 조건: `PROFIT`이 원금에 포함되거나 `buffer_out` 등 다른 side가 `INVEST`로 재해석되거나, 기존 응답 필드·token/filter 동작이 변경되면 실패한다.
|
||||
- 검사 방법: `tests/test_portfolio_ledger_aggregates.py`, `tests/test_v1_portfolios_list.py`, `tests/test_v1_portfolios_manual_ledger.py`와 disposable PostgreSQL integration을 실행한다.
|
||||
- 현재 구현 평가: focused `39 passed`, actual PostgreSQL semantic integration `1 passed`, clean full regression `661 passed, 16 skipped`이며 독립 재QC가 PASS했다.
|
||||
|
||||
@@ -3,14 +3,15 @@ id: "ac-0002"
|
||||
status: active
|
||||
---
|
||||
|
||||
# REVERSAL-aware manual ledger 산식과 목록·단건 일치
|
||||
# Source-independent REVERSAL 산식과 목록·단건 일치
|
||||
|
||||
## Criterion
|
||||
|
||||
`net_contributed_capital`은 `REVERSAL`되지 않은 manual `INVEST` 합계에서 `REVERSAL`되지 않은 manual `WITHDRAW` 합계를 뺀 값이어야 하고, `cumulative_profit_withdrawn`은 `REVERSAL`되지 않은 manual `PROFIT` 합계여야 한다. `REVERSAL`이 참조한 `INVEST`, `WITHDRAW`, `PROFIT` 원거래는 모든 return aggregate에서 제외하고 `REVERSAL` 자체는 금액으로 합산하지 않으며, 목록과 단건 `returns/summary`는 같은 유효 원장 판정과 네 집계값 의미를 사용해야 한다.
|
||||
`total_invested`는 모든 source의 유효한 `INVEST` gross 합계, `total_withdrawn`은 유효한 `WITHDRAW` 합계, `net_contributed_capital`은 두 값의 차, `cumulative_profit_withdrawn`은 원금에서 제외한 유효한 `PROFIT` 합계여야 한다. 동일 portfolio에서 matching `REVERSAL.target_ledger_entry_id`로 참조된 original `INVEST`, `WITHDRAW`, `PROFIT`은 original과 reversal의 source와 무관하게 모든 return aggregate에서 제외하고 `REVERSAL` 자체는 금액으로 합산하지 않는다. 목록과 단건 `returns/summary`는 같은 repository aggregate와 네 집계값 의미를 사용해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 미취소 `INVEST`, `WITHDRAW`, `PROFIT` 조합에서 `PROFIT`은 `WITHDRAW`와 분리되고 목록과 단건 summary의 `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`이 일치한다.
|
||||
- 실패·경계 조건: `INVEST`, `WITHDRAW`, `PROFIT`을 각각 reversal하면 해당 원거래 효과가 제거되며 reversal row 자체가 합계에 더해지지 않는다.
|
||||
- 검사 방법: `tests/test_portfolio_ledger_aggregates.py`의 shared batch semantics와 `tests/test_v1_portfolios_manual_ledger.py`의 profit 분리 및 세 action reversal 회귀를 실행한다.
|
||||
- 성공 조건: broker-only와 mixed-source `INVEST`·`WITHDRAW`·`PROFIT` 조합에서 gross·withdrawn·net·profit 값이 승인 산식과 일치하고 목록과 단건 summary가 일치한다.
|
||||
- 실패·경계 조건: manual reversal→broker original 또는 broker reversal→manual original에서 original 효과가 남거나, `PROFIT`이 원금에 섞이거나 reversal row 자체가 합산되면 실패한다.
|
||||
- 검사 방법: SQL contract unit test와 실제 PostgreSQL 16 integration에서 production repository SQL, 양방향 cross-source reversal, `buffer_out` 제외를 검사한다.
|
||||
- 현재 구현 평가: source predicate가 original과 reversal 양쪽에서 제거됐고 actual PostgreSQL semantic integration 및 독립 재QC가 PASS했다.
|
||||
|
||||
@@ -3,14 +3,15 @@ id: "ac-0003"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 다중 포트폴리오 grouped aggregate와 N+1 방지
|
||||
# 다중 포트폴리오 source-independent grouped aggregate와 N+1 방지
|
||||
|
||||
## Criterion
|
||||
|
||||
목록용 repository 계약은 반환 대상인 여러 portfolio ID의 유효 manual ledger 집계를 하나의 grouped aggregate query로 계산해 portfolio ID별 결과를 반환해야 하며, 목록 크기에 비례하는 portfolio별 ledger query를 수행해서는 안 된다. 입력 ID 목록이 비어 있으면 schema 확인이나 DB 연결·query 없이 빈 mapping을 반환해야 한다.
|
||||
목록용 repository 계약은 반환 대상인 여러 portfolio ID의 source-independent valid ledger aggregate를 하나의 grouped query로 계산해 portfolio ID별 결과를 반환해야 하며, 목록 크기에 비례하는 portfolio별 ledger query를 수행해서는 안 된다. 입력 ID 목록이 비어 있으면 schema 확인이나 DB 연결·query 없이 빈 mapping을 반환해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 둘 이상의 portfolio ID를 한 번에 전달했을 때 단일 query가 실행되고 각 ID의 합계가 독립된 mapping으로 반환된다.
|
||||
- 실패·경계 조건: 빈 ID 입력은 `{}`를 반환하고 DB connection 및 schema/query 호출이 발생하지 않는다.
|
||||
- 검사 방법: `tests/test_portfolio_ledger_aggregates.py`에서 `GROUP BY ledger.portfolio_id`, portfolio ID 배열 parameter, 단일 `execute`, 빈 입력 no-call을 검사하고 `tests/test_v1_portfolios_list.py`에서 route가 summary batch를 한 번 요청하는지 확인한다.
|
||||
- 성공 조건: 둘 이상의 portfolio ID를 한 번에 전달했을 때 단일 query가 실행되고 broker/manual source가 섞여도 각 ID의 side별 합계가 독립된 mapping으로 반환된다.
|
||||
- 실패·경계 조건: source predicate가 다시 도입되거나 portfolio별 N+1 query가 발생하거나, 빈 ID 입력에서 DB 작업이 발생하거나, portfolio ID 사이 금액이 섞이면 실패한다.
|
||||
- 검사 방법: `tests/test_portfolio_ledger_aggregates.py`에서 `GROUP BY ledger.portfolio_id`, ID 배열 parameter, 단일 execute와 빈 입력 no-call을 검사하고 list route의 summary batch 요청을 검증한다.
|
||||
- 현재 구현 평가: grouped query 구조와 empty-input fast path를 유지한 채 source predicate만 제거했으며 focused·full regression과 독립 재QC가 PASS했다.
|
||||
|
||||
@@ -3,14 +3,15 @@ id: "ac-0005"
|
||||
status: active
|
||||
---
|
||||
|
||||
# additive API·문서 호환성과 reversal 회귀 보존
|
||||
# API·문서 호환성과 query-only source-independent 교정
|
||||
|
||||
## Criterion
|
||||
|
||||
ledger 투자금 집계 확장은 기존 `GET /v1/portfolios` 및 단건 returns summary 계약에 대한 additive 변경이어야 한다. OpenAPI와 repository-owned interface/runtime 문서는 `net_contributed_capital`을 순투자현금, `cumulative_profit_withdrawn`을 누적 수익 인출액으로 정의하고 `portfolio-hub`가 ledger 권위 값을 제공함을 명시해야 하며, 기존 이중 reversal 거부 동작을 유지해야 한다.
|
||||
Ledger 투자금 집계 교정은 기존 `GET /v1/portfolios` 및 단건 returns summary의 field 이름과 응답 구조를 유지해야 한다. OpenAPI schema와 repository-owned interface/data-model/runtime 문서는 source-independent valid `INVEST`·`WITHDRAW`·`PROFIT`, gross `total_invested`, `INVEST-WITHDRAW` net, 별도 `PROFIT`, source-independent matching reversal과 non-capital side 제외를 일관되게 설명해야 한다. 교정은 existing ledger row mutation/backfill 또는 `buffer_out` 재분류 없이 query semantic에 한정돼야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 기존 목록 필드와 token/filter 동작이 보존되고 `docs/interface-contract.md`, `docs/runtime-flows.md`, OpenAPI schema가 두 필드의 산식·REVERSAL·권위 경계를 일관되게 설명한다.
|
||||
- 실패·경계 조건: `PROFIT`을 `WITHDRAW` 또는 전체 실현손익으로 설명하지 않고, 동일 원거래를 두 번 reversal하려는 기존 요청은 계속 거부된다.
|
||||
- 검사 방법: 목록·manual ledger 회귀 테스트와 문서·OpenAPI 검사를 실행하고 focused suite 및 전체 프로젝트 suite를 사용해 기존 동작 회귀 여부를 판정한다.
|
||||
- 성공 조건: `app/schemas/v1.py`, `docs/interface-contract.md`, `docs/data-model.md`, `docs/runtime-flows.md`가 같은 산식·reversal·권위 경계를 설명하고 기존 field·auth·filter 계약을 보존한다.
|
||||
- 실패·경계 조건: stale manual-only 문구가 남거나 `PROFIT`을 원금/전체 실현손익으로 설명하거나, API rename, ledger mutation/backfill, `buffer_out=INVEST` 재분류 또는 권한 우회가 발생하면 실패한다.
|
||||
- 검사 방법: docs contract regression, API/list/manual ledger focused suite, 변경 diff와 migration/ledger write 부재를 검사한다.
|
||||
- 현재 구현 평가: 최초 독립 QC가 발견한 stale interface contract를 보완하고 manual-only 문구 부재 회귀 테스트를 추가했으며 재QC는 PASS(Blocker 0 / Major 0 / Minor 0)했다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
일반 사용자와 admin은 `/v1/strategies`의 목록·상세 조회에서 active 전략만 받을 수 있어야 하며, inactive 전략은 목록에서 제외하고 상세에서 전략 본문 대신 `409`를 반환하고 미존재 전략은 `404`를 반환해야 한다.
|
||||
일반 사용자와 admin은 `/v1/strategies`의 목록·상세 조회에서 active 전략만 받을 수 있어야 하며, 각 응답은 안정적인 ID, 사용자 표시용 `nick`, 내부 전략명 `name`, config, status와 revision을 반환해야 한다. inactive 전략은 목록에서 제외하고 상세에서 전략 본문 대신 `409`를 반환하며 미존재 전략은 `404`를 반환해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: active 전략은 목록과 상세에서 안정적인 ID, 이름, config, status와 revision을 반환한다.
|
||||
- 성공 조건: active 전략은 목록과 상세에서 같은 ID, `nick`, `name`, config, status와 revision을 반환하며 `nick`과 `name`이 서로 달라도 각각 보존된다.
|
||||
- 실패·경계 조건: inactive 전략은 목록에서 제외되고 일반 상세 조회는 전략 본문 대신 `409`를 반환하며, 미존재 ID는 `404`다.
|
||||
- 검사 방법: 일반·admin token의 목록/상세 API 회귀 테스트로 active/inactive/missing을 각각 검사한다.
|
||||
- 현재 구현 평가: working tree의 `app/api/v1_strategies.py`와 `tests/test_v1_strategies.py`가 이 계약을 실행 가능하게 검증하며 scoped 재실행에 포함되어 PASS했다.
|
||||
- 검사 방법: 일반·admin token의 목록/상세 API 회귀 테스트로 `nick`과 `name`의 독립 보존 및 active/inactive/missing을 각각 검사한다.
|
||||
- 현재 구현 평가: `app/api/v1_strategies.py`와 `tests/test_v1_strategies.py`가 목록·상세 `nick` 응답과 active/inactive 경계를 실행 가능하게 검증하며 scoped 재실행에 포함되어 PASS했다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
admin은 `/v1/admin/strategies`에서 active·inactive 전체 전략을 조회하고 생성·수정·상태 변경·미참조 삭제할 수 있어야 하며, 비-admin 사용자의 모든 관리 mutation은 `403`이어야 한다.
|
||||
admin은 `/v1/admin/strategies`에서 active·inactive 전체 전략을 `nick`, `name`, config, status와 revision으로 조회하고 생성·수정·상태 변경·미참조 삭제할 수 있어야 하며, 비-admin 사용자의 모든 관리 mutation은 `403`이어야 한다. 동일 `(name, config)` POST는 effective `nick`과 status까지 같을 때만 idempotent 성공해야 하고 하나라도 다르면 기존 row를 바꾸지 않고 `409`를 반환하며, 기존 전략의 변경은 PATCH만 수행해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: admin CRUD와 상태 변경이 영속화되고 관리 조회가 active·inactive를 모두 반환한다.
|
||||
- 실패·경계 조건: 일반 사용자 또는 권한 부족 token의 create/update/delete는 변경 없이 `403`이다.
|
||||
- 검사 방법: 역할별 token으로 admin 목록·생성·PATCH·DELETE와 비-admin 거부를 API 테스트로 검사한다.
|
||||
- 현재 구현 평가: working tree의 모든 admin route가 공통 `require_admin` dependency를 사용하고 admin CRUD tests 및 비-admin 관리 GET `403` test가 scoped 재실행에서 PASS했다. 비-admin POST/PATCH/DELETE 각각의 독립 negative test는 현재 없어 mutation별 executable coverage는 `PARTIAL`이다.
|
||||
- 성공 조건: admin CRUD와 상태 변경이 영속화되고 관리 조회가 active·inactive의 `nick`, `name`, config, status와 revision을 반환하며 완전히 같은 POST 재시도는 기존 row와 revision을 유지한다.
|
||||
- 실패·경계 조건: 동일 `(name, config)`지만 effective `nick` 또는 status가 다른 POST는 `409`와 무변경이고, 일반 사용자 또는 권한 부족 token의 create/update/delete도 변경 없이 `403`이다.
|
||||
- 검사 방법: 역할별 token으로 admin 목록·생성·PATCH·DELETE, 동일 POST 재시도, 충돌 POST의 row·revision 무변경과 비-admin mutation 거부를 API 테스트로 검사한다.
|
||||
- 현재 구현 평가: admin route의 공통 `require_admin`, CRUD와 `nick` 응답 테스트는 존재한다. 다만 현재 create service는 동일 `(name, config)` row의 `nick`이 다르면 POST로 기존 row를 변경할 수 있고 비-admin POST/PATCH/DELETE 각각의 독립 negative test도 없어 이 AC는 `PARTIAL`이다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
admin PATCH는 기존 전략 row의 ID를 유지하면서 요청된 `name`·`config`·`status`의 실질적 변경을 원자적으로 반영하고 revision을 증가시켜야 하며, 모든 참조 포트폴리오의 이후 portfolio-hub API 조회는 commit된 최신 설정과 revision을 반환해야 한다.
|
||||
`nick`은 전략의 독립 저장 identity이며 trim 후 비어 있지 않고 전략 간 유일해야 한다. 생성에서 `nick`을 생략하면 기존 호환성을 위한 결정적 기본값을 만들고 explicit null 또는 blank는 거부해야 하며, PATCH에서 `nick`을 생략하면 `name`·`config` 변경과 관계없이 기존 값을 보존해야 한다. admin PATCH는 기존 전략 row의 ID를 유지하면서 요청된 `nick`·`name`·`config`·`status`의 실질적 변경을 원자적으로 반영하고 revision을 증가시켜야 하며, 모든 참조 포트폴리오의 이후 portfolio-hub API 조회는 commit된 최신 `nick`, 설정과 revision을 반환해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 실질적 PATCH 뒤 ID는 동일하고 revision은 단조 증가하며 derived `nick`과 name/config가 같은 committed state에서 계산되고 참조 포트폴리오 조회에 최신 값이 나타난다.
|
||||
- 실패·경계 조건: no-op PATCH는 revision을 올리지 않고, 동시 partial PATCH도 서로의 최신 필드를 잃거나 불일치한 `nick`을 만들지 않는다.
|
||||
- 검사 방법: API/repository 회귀와 실제 PostgreSQL 동시 partial PATCH 시험으로 ID, revision, name/config/nick 및 포트폴리오 응답을 검사한다.
|
||||
- 현재 구현 평가: `FOR UPDATE` 기반 update와 revision 응답 테스트가 존재하며, 2026-08-15 실제 PostgreSQL integration 재실행 `4 passed`에 동시 partial PATCH 검증이 포함됐다.
|
||||
- 성공 조건: 사용자 지정 `nick` 생성과 `nick` 단독 PATCH 뒤 ID는 동일하고 revision은 단조 증가하며, `name`·`config`만 바꾼 PATCH는 기존 `nick`을 보존하고 참조 포트폴리오 조회에 최신 `nick`, 설정과 revision이 나타난다.
|
||||
- 실패·경계 조건: explicit null·blank·중복 `nick`은 무변경으로 거부되고 no-op PATCH는 revision을 올리지 않으며, 동시 partial PATCH도 명시적으로 바꾸지 않은 `nick`이나 다른 최신 필드를 잃지 않는다.
|
||||
- 검사 방법: API/repository 회귀와 실제 PostgreSQL 동시 partial PATCH 시험으로 omission/null/blank/중복, ID, revision, `nick` 보존·변경, name/config/status 및 포트폴리오 read-back을 검사한다.
|
||||
- 현재 구현 평가: `FOR UPDATE` 기반 update, 사용자 지정·trim·null/blank·중복·no-op·revision 테스트와 생략된 `nick` 보존 PostgreSQL 시험이 존재한다. 포트폴리오 `strategy_nick` read-back을 직접 단언하는 테스트는 없어 그 검증은 `PARTIAL`이다.
|
||||
|
||||
@@ -7,7 +7,7 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
신규 포트폴리오 생성·수정은 전역 카탈로그의 기존 active `strategy_id`만 선택할 수 있어야 하며, 전략을 inactive로 전환해도 기존 포트폴리오 참조는 보존되고 신규 선택만 차단되어야 한다.
|
||||
신규 포트폴리오 생성·수정에서 `strategy_id`를 지정하는 경우 전역 카탈로그의 기존 active ID만 선택할 수 있어야 하며, 전략을 inactive로 전환해도 기존 포트폴리오 참조는 보존되고 신규 선택만 차단되어야 한다. 전략 미지정 또는 `null`은 선택이 아니며 전략 참조를 만들지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
`/v1/portfolios`와 legacy `/portfolios`의 생성에는 active `strategy_id`가 필수이고 수정도 ID-only여야 하며, 요청의 `strategy_name`·`strategy_params`는 `422`로 거부하는 동시에 응답의 `active_strategy_id`·`strategy_name`·`strategy_params`·`strategy_revision`은 유지되어야 한다.
|
||||
`/v1/portfolios`와 legacy `/portfolios`의 생성은 `strategy_id`를 생략하거나 `null`로 전달해도 가능해야 하며, 이 경우 전략 참조를 만들거나 기본 전략을 암묵적으로 선택해서는 안 된다. `strategy_id`가 제공된 생성·수정은 ID-only여야 하고 기존 active 전략 ID만 선택할 수 있으며, 요청의 `strategy_name`·`strategy_params`는 `422`로 거부해야 한다. v1 `PortfolioItem` 응답은 현재 참조 전략의 `active_strategy_id`·`strategy_nick`·`strategy_name`·`strategy_params`·`strategy_revision`을 같은 committed row에서 반환해야 하고 참조가 없거나 해석할 수 없으면 이 전략 메타데이터는 `null`이어야 하며, legacy 응답의 기존 호환 필드는 유지되어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 명시한 active ID로만 포트폴리오가 생성·수정되고 응답에 현재 전략 identity·이름·config·revision이 나타난다.
|
||||
- 실패·경계 조건: strategy ID 누락, 제거 필드 전달 또는 malformed 요청은 새 전략 row를 만들지 않고 `422`다.
|
||||
- 검사 방법: v1·legacy create/update schema와 저장소 row count, 응답 호환 필드를 API/CLI 회귀로 검사한다.
|
||||
- 현재 구현 평가: schemas와 양쪽 portfolio API가 ID-only 입력 및 `extra="forbid"` 계약을 구현하고 scoped portfolio/CLI 회귀는 PASS했다. 다만 제거된 `strategy_name`·`strategy_params` 각각의 `422`와 응답 호환 필드 전체를 직접 단언하는 전용 negative/response test는 현재 없어 해당 executable coverage는 `PARTIAL`이다.
|
||||
- 성공 조건: strategy ID 없이 또는 `null`로 포트폴리오가 생성되고 전략 참조와 전략 메타데이터가 모두 `null`이며, 기본 전략이 자동 연결되지 않는다. 명시한 active ID로 생성·수정하면 v1 create/PATCH/list 응답에 현재 전략 ID·`nick`·이름·config·revision이 나타난다.
|
||||
- 실패·경계 조건: 제공된 unknown ID는 `404`, inactive ID는 `409`이며, 제거 필드·빈 문자열·malformed 요청은 새 전략 row를 만들지 않고 `422`다.
|
||||
- 검사 방법: v1·legacy create/update schema와 저장소 row count, strategy 미지정·explicit `null`·active/unknown/inactive ID의 create read-back, v1 create/PATCH/list 전략 메타데이터 및 기존 응답 호환 필드를 API/CLI 회귀로 검사한다.
|
||||
- 현재 구현 평가: v1 schema와 API는 strategy 미지정·`null` 생성을 구현하고 회귀 테스트가 이를 검사한다. legacy `/portfolios` schema와 생성 경로는 아직 `strategy_id`를 필수로 유지하므로 전체 계약의 executable coverage는 `PARTIAL`이며, legacy 동등 구현과 read-back 회귀가 필요하다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
전략 조회·선택·관리 API는 미존재 `404`, inactive 신규 선택과 참조 중 삭제 `409`, 제거 필드·malformed 입력 `422`, 권한 부족 `403`을 서로 구분해 반환해야 한다.
|
||||
전략 조회·선택·관리 API는 미존재 `404`, inactive 신규 선택·참조 중 삭제·중복 `nick` 및 충돌하는 동일 `(name, config)` POST는 `409`, 제거 필드·malformed 입력·explicit null 또는 trim 후 blank `nick`은 `422`, 권한 부족은 `403`으로 서로 구분해 반환해야 한다. create의 `nick` 생략은 호환 기본값 생성을 허용하고 PATCH의 `nick` 생략은 기존 값을 보존하며 모든 거부 요청은 row와 revision을 변경하지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 각 오류 조건이 승인된 상태 코드와 안정적인 비민감 오류 응답으로 구분된다.
|
||||
- 실패·경계 조건: 권한 부족을 `404`로, inactive를 `422`로, malformed 입력을 묵시적으로 무시하는 등 오류 클래스가 합쳐지지 않는다.
|
||||
- 검사 방법: 조건별 독립 API negative test로 status code와 무변경을 검사한다.
|
||||
- 현재 구현 평가: `tests/test_v1_strategies.py`와 `tests/test_v1_portfolios_crud.py`의 negative matrix가 scoped 재실행에서 PASS했다.
|
||||
- 성공 조건: omission, explicit null, blank, duplicate와 충돌 POST가 각각 승인된 성공 또는 오류 경계로 처리되고 오류 응답은 안정적이며 비민감하다.
|
||||
- 실패·경계 조건: 권한 부족을 `404`로, inactive·중복·충돌을 `422`로, null·blank를 묵시적 omission으로 합치거나 거부 요청이 row 또는 revision을 바꾸면 실패한다.
|
||||
- 검사 방법: 조건별 독립 API negative test로 status code, row identity, `nick`, status와 revision의 무변경을 검사한다.
|
||||
- 현재 구현 평가: `tests/test_v1_strategies.py`는 omission, explicit null, blank와 duplicate `nick` 경계를 검사하고 기존 negative matrix도 scoped 재실행에서 PASS했다. 충돌하는 동일 `(name, config)` POST의 `409`·무변경 계약은 현재 구현·테스트에 없어 이 부분은 `PARTIAL`이다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
Alembic data migration은 active `name="target_value"`, `config={}` 전략을 중복 없이 보장하고, 동일 `(name, config)` row가 있으면 재사용해야 하며 포트폴리오 요청의 `strategy_id` 필수 조건을 완화하지 않아야 한다.
|
||||
Alembic data migration은 active `name="target_value"`, `config={}` 전략을 중복 없이 보장하고 동일 `(name, config)` row가 있으면 재사용해야 한다. 기본 전략의 존재는 strategy 미지정 또는 `null` 포트폴리오 생성에서 암묵적 전략 선택을 유발해서는 안 된다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 빈 DB upgrade 후 기본 전략 한 건이 active, revision 1과 runtime 일치 derived identity로 존재한다.
|
||||
- 실패·경계 조건: migration 재실행이나 기존 동일 row가 중복 row를 만들지 않고, 기본 전략 존재가 implicit portfolio selection을 허용하지 않는다.
|
||||
- 검사 방법: migration unit/idempotency test와 신규 PostgreSQL DB full upgrade/head 재실행, portfolio create negative test를 수행한다.
|
||||
- 현재 구현 평가: migration unit test가 scoped 재실행에서 PASS했고, 2026-08-15 실제 PostgreSQL integration `4 passed`에서 신규 DB full upgrade와 head 재실행을 재검증했다.
|
||||
- 실패·경계 조건: migration 재실행이나 기존 동일 row가 중복 row를 만들지 않고, 기본 전략 존재가 implicit portfolio selection을 유발하지 않는다.
|
||||
- 검사 방법: migration unit/idempotency test와 신규 PostgreSQL DB full upgrade/head 재실행, strategy 미지정·explicit `null` portfolio create read-back을 수행한다.
|
||||
- 현재 구현 평가: migration unit test가 scoped 재실행에서 PASS했고, v1 route에는 strategy 미지정 생성 회귀가 있다. legacy `/portfolios`에도 동일한 생성·read-back 검증이 추가되기 전까지 executable coverage는 `PARTIAL`이다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
전략 PATCH, 포트폴리오 전략 선택, 전략 상태 변경·삭제와 거래 저장이 경합해도 최신 전략 참조와 revision은 직렬화 가능하게 보존되고, stale portfolio snapshot은 현재 전략 선택을 되살리거나 덮어쓰지 않으며 portfolio mutation과 ledger 기록은 원자적이어야 한다.
|
||||
전략의 `nick`·`name`·`config`·`status` partial PATCH, 포트폴리오 전략 선택, 전략 상태 변경·삭제와 거래 저장이 경합해도 명시적으로 변경하지 않은 독립 catalog field, 최신 전략 참조와 revision은 직렬화 가능하게 보존되고, stale portfolio snapshot은 현재 전략 선택을 되살리거나 덮어쓰지 않으며 portfolio mutation과 ledger 기록은 원자적이어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 동시 partial PATCH는 모든 변경과 단조 revision을 보존하고, A snapshot 후 B 선택·A 삭제·stale 거래 저장에서도 B 참조와 A 삭제가 유지되며 portfolio UPDATE와 ledger INSERT가 함께 commit된다.
|
||||
- 성공 조건: `nick` PATCH와 name/config/status PATCH의 양방향 동시 실행은 모든 독립 변경과 단조 revision을 보존하고, A snapshot 후 B 선택·A 삭제·stale 거래 저장에서도 B 참조와 A 삭제가 유지되며 portfolio UPDATE와 ledger INSERT가 함께 commit된다.
|
||||
- 실패·경계 조건: stale write가 삭제된 A를 복원하거나 B를 잃지 않고, transaction 실패 시 portfolio와 ledger가 부분 commit되지 않는다.
|
||||
- 검사 방법: 실제 PostgreSQL에서 동시 PATCH, select-vs-inactivate/delete, stale BUY/SELL snapshot과 transaction rollback 시나리오를 실행한다.
|
||||
- 현재 구현 평가: repository transaction/row-lock 경계와 integration tests가 존재하며, 2026-08-15 실제 PostgreSQL integration 재실행 `4 passed`에서 동시 PATCH, 선택-vs-delete, stale BUY snapshot과 migration 시나리오를 확인했다. 독립 source-time QC도 PASS를 기록한다. 다만 portfolio UPDATE 성공 후 ledger INSERT 실패를 의도적으로 주입해 rollback을 관찰하는 fault-injection test는 현재 없어 그 세부 검증은 `PARTIAL`이다.
|
||||
- 검사 방법: 실제 PostgreSQL에서 `nick`과 다른 catalog field의 양방향 동시 PATCH, select-vs-inactivate/delete, stale BUY/SELL snapshot과 transaction rollback 시나리오를 실행한다.
|
||||
- 현재 구현 평가: repository transaction/row-lock 경계와 integration tests가 존재하며, 2026-08-15 실제 PostgreSQL integration 재실행 `4 passed`에서 name/config 동시 PATCH 중 기존 `nick` 보존, 선택-vs-delete, stale BUY snapshot과 migration 시나리오를 확인했다. 명시적 `nick` PATCH와 다른 field PATCH의 양방향 경합 및 portfolio UPDATE 성공 후 ledger INSERT 실패 fault-injection test는 없어 해당 검증은 `PARTIAL`이다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
유효한 `strategy_id`와 portfolio owner가 소유한 active daemon의 server-generated UUID를 포함해 포트폴리오를 생성하면 HTTP 201 응답과 후속 portfolio 조회의 `managed_by_daemon_id`가 요청한 daemon ID와 같아야 한다.
|
||||
strategy ID의 지정 여부와 무관하게, portfolio owner가 소유한 active daemon의 server-generated UUID를 포함해 포트폴리오를 생성하면 HTTP 201 응답과 후속 portfolio 조회의 `managed_by_daemon_id`가 요청한 daemon ID와 같아야 한다. strategy ID를 생략하거나 `null`로 전달한 경우 전략 참조는 만들지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: POST 응답과 저장소 조회 및 API 후속 조회가 모두 같은 non-null daemon ID를 반환한다.
|
||||
- 성공 조건: strategy ID를 지정한 경우와 생략하거나 `null`로 전달한 경우 모두 POST 응답·저장소 조회·API 후속 조회가 같은 non-null daemon ID를 반환하며, strategy 미지정 생성의 `active_strategy_id`는 `null`이다.
|
||||
- 실패·경계 조건: persistence 경계에서 daemon ID가 누락되거나 `null`로 바뀌면 실패한다.
|
||||
- 검사 방법: API test와 migration head가 적용된 disposable PostgreSQL에서 `POST → GET` read-back을 실행한다.
|
||||
- 현재 구현 평가: `e2102b6`의 생성 handler·repository INSERT 경로와 PostgreSQL integration test가 대응하며 2차 QC에서 관련 suite가 통과했다.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: "ac-0032"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Source-neutral portfolio ledger read
|
||||
|
||||
## Criterion
|
||||
|
||||
Portfolio owner는 `GET /v1/portfolios/{portfolio_id}/ledger`로 source 미지정 시 manual·broker를 포함한 모든 자기 portfolio 원장을 `recorded_at DESC, ledger_entry_id DESC` 순서로 조회할 수 있어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- mixed manual/broker 행이 최신순으로 반환되고 source 미지정이 모든 source를 포함한다.
|
||||
- 구현 평가: `tests/test_v1_portfolios_manual_ledger.py` 및 전체 회귀 `664 passed, 16 skipped`에서 확인됐다.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: "ac-0033"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Stable keyset pagination and fail-closed cursor
|
||||
|
||||
## Criterion
|
||||
|
||||
Unified ledger 목록은 `(recorded_at, ledger_entry_id)` keyset cursor로 동시각 행의 중복·누락 없이 page를 이어야 하며 cursor는 portfolio/source에 결속되고 malformed·tampered·scope mismatch는 HTTP 422이어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- cursor 경계, 변조 및 다른 source cursor 재사용은 각각 정확한 next page 또는 422을 반환한다.
|
||||
- 구현 평가: focused cursor 회귀와 전체 회귀 `664 passed, 16 skipped`에서 확인됐다.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: "ac-0034"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Canonical cross-source ledger representation
|
||||
|
||||
## Criterion
|
||||
|
||||
Unified ledger item은 canonical 대문자 `action`을 제공하고 internal `side`를 노출하지 않으며 source별로 없는 사실은 명시적 nullable schema 값으로 반환해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- manual/broker mixed 응답이 action casing과 nullable field 계약을 유지하고 OpenAPI가 해당 schema를 노출한다.
|
||||
- 구현 평가: focused schema/API regression 및 OpenAPI inspection으로 확인됐다.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: "ac-0035"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Owner-only unified read and manual compatibility
|
||||
|
||||
## Criterion
|
||||
|
||||
Unified ledger read는 user/session portfolio owner만 허용하고 타 owner와 service token을 거부하며 기존 `/ledger/manual`의 manual-only·latest-first·limit 계약을 유지해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 타 owner/service token 거부, owner 허용 및 manual endpoint 하위호환 회귀를 실행한다.
|
||||
- 구현 평가: focused API/auth-surface `29 passed`, 전체 회귀 `664 passed, 16 skipped`에서 확인됐다.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: "ac-0036"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Unified ledger persistence and regression evidence
|
||||
|
||||
## Criterion
|
||||
|
||||
Unified ledger query는 portfolio/source keyset ordering에 맞는 PostgreSQL index migration을 제공하고 API/OpenAPI/repository 계약 회귀로 검증되어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- disposable PostgreSQL fresh upgrade가 head `20260820_0030`과 `idx_portfolio_ledger_portfolio_recorded_entry_desc` 생성을 확인하고 focused/full suite가 통과한다.
|
||||
- 구현 평가: fresh migration 확인 및 전체 회귀 `664 passed, 16 skipped`가 있다. 운영 규모 `EXPLAIN ANALYZE` latency 관찰은 비차단 후속이다.
|
||||
@@ -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": 5,
|
||||
"next_ac_number": 32
|
||||
"next_line_number": 11,
|
||||
"next_ac_number": 45
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
id: "dcy-0005"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# Portfolio ledger source-independent return aggregates
|
||||
|
||||
## Problem
|
||||
|
||||
`GET /v1/portfolios`와 `GET /v1/portfolios/{portfolio_id}/returns/summary`의 투자금 집계가 `portfolio_ledger.source = 'manual'`인 row만 포함해 broker 등 다른 producer가 기록한 유효한 원금·수익 event를 누락했다. 이 때문에 실제 ledger provenance와 API 집계값이 달라질 수 있으며, original source 제한만 제거하고 reversal source 제한을 남기면 cross-source reversal까지 잘못 집계할 수 있었다.
|
||||
|
||||
이 Discovery는 요청자가 OQ-1~OQ-5를 모두 결정하고 승인한 GitHub 이슈 #28을 canonical ProofLine 문서로 포착한다. 아래 `Scope`의 승인 이슈 본문은 canonical H2 충돌을 피하기 위해 heading만 한 단계 낮추고 나머지 내용을 최대한 원문 순서대로 보존한다.
|
||||
|
||||
## Evidence
|
||||
|
||||
### 승인 원문과 provenance
|
||||
|
||||
- 승인 원문: https://github.com/fourblocks2020/portfolio-hub/issues/28
|
||||
- 제목: `Fix investment totals: remove source='manual' filter from ledger aggregates`
|
||||
- 상태: `closed`
|
||||
- 최초 작성: `2026-08-18T01:53:37Z`
|
||||
- 승인 본문 최종 갱신: `2026-08-19T08:39:49Z`
|
||||
- 승인 본문 SHA-256: `d5e5f65500d163bd5a9c9a97f47222e96373b3b6d0e09b021f1de3a2380a6014`
|
||||
- Exact 승인 본문 snapshot: `evidence/issue-28-approved-discovery.md`
|
||||
- 승인 결과: `APPROVED`; OQ-1~OQ-5는 각각 A로 결정됐고 unresolved blocking Open Question은 없다.
|
||||
|
||||
### 구현·검증 evidence
|
||||
|
||||
- 구현 기준 commit: `02bfa602995b8e1c2f410c9bd5762f062666f2d6`
|
||||
- 구현 commit: `5eb8c042668b1c383ac59ca9be00aa1a5dfcb37a` (`fix: aggregate investment totals across ledger sources`)
|
||||
- 구현 진행 comment: https://github.com/fourblocks2020/portfolio-hub/issues/28#issuecomment-5339488232; exact snapshot `evidence/implementation-progress.md`; SHA-256 `ada9617f9fb106e890ab4b232f23cde3b457b429971b664ed9fcc34689d03c52`
|
||||
- 구현 완료 comment: https://github.com/fourblocks2020/portfolio-hub/issues/28#issuecomment-5339596866; exact snapshot `evidence/implementation-completion.md`; SHA-256 `3458db05561d843a7ecebab816f6aecc6120a6a2d62a4be151efd2876f0d3007`
|
||||
- Focused repository/API/docs contract tests: `39 passed`.
|
||||
- Disposable PostgreSQL 16 semantic integration: `1 passed`; broker-only, mixed-source, cross-source reversal 양방향과 `buffer_out` 제외를 실제 production repository SQL로 검증했다.
|
||||
- 기존 `temp/deleted_20260813/.worktrees/` artifact를 제외한 clean candidate full suite: `661 passed, 16 skipped`.
|
||||
- 변경 test Ruff와 `git diff --check`: PASS.
|
||||
- 최초 독립 QC의 stale manual-only interface contract Major를 보완한 뒤 독립 재QC: PASS, Blocker 0 / Major 0 / Minor 0.
|
||||
|
||||
### Lifecycle 경계
|
||||
|
||||
- 현재 local `main`에 구현·검증·독립 재QC·commit까지 완료됐다.
|
||||
- Push, PR, 원격 main 반영, release, production deployment/restart, production read-only pre/post check와 production ledger mutation은 수행되지 않았다.
|
||||
- 이 confirmed Discovery와 사후 approved REQ/active AC는 normative scope와 현재 구현 평가를 기록하며 terminal lifecycle closure나 미수행 외부 반영을 주장하지 않는다.
|
||||
|
||||
## Scope
|
||||
|
||||
### 승인된 이슈 본문 전체
|
||||
|
||||
### Discovery: 투자 원금 집계의 source-independent 의미 복원
|
||||
|
||||
- **상태:** `APPROVED`
|
||||
- **대상 저장소:** `fourblocks2020/portfolio-hub`
|
||||
- **대상 API:** `GET /v1/portfolios`, `GET /v1/portfolios/{portfolio_id}/returns/summary`
|
||||
- **관련 이슈:** #23의 manual-only cash-flow 집계 가정 중 Invest-total field 의미를 이 이슈가 대체한다.
|
||||
|
||||
#### Problem
|
||||
|
||||
`get_return_summaries()`는 현재 투자 원금 집계를 manual ledger row로 제한한다.
|
||||
|
||||
```sql
|
||||
WHERE ledger.source = 'manual'
|
||||
AND ledger.side IN ('invest', 'withdraw', 'profit')
|
||||
```
|
||||
|
||||
그 결과 `total_invested`와 `net_contributed_capital`은 portfolio ledger 전체의 Invest/net Invest가 아니라 manual source의 cash flow만 반영한다. Broker 등 다른 source가 기록한 유효한 `INVEST`가 제외되므로 사용자에게 표시되는 투자 원금 의미와 실제 집계가 불일치한다.
|
||||
|
||||
이 동작은 #23의 manual-cash-flow contract에서 유래했지만, Invest-total field가 모든 유효한 투자 원금을 나타내야 한다면 적합하지 않다.
|
||||
|
||||
#### Evidence
|
||||
|
||||
- Production 관찰 사례인 portfolio `353200`에는 broker-origin `invest`, `buffer_out`, `buy` ledger entry가 있고 manual `INVEST`/`WITHDRAW`가 없다.
|
||||
- 현재 portfolio API는 모든 `source='broker'` row를 집계에서 제외하므로 `net_contributed_capital: 0.0`을 반환한다.
|
||||
- 같은 사례에서 broker `invest` amount는 `0`, `buffer_out`은 `800,000`이다. 이 이슈의 source-filter 수정만으로 `buffer_out`을 `INVEST`로 간주하거나 zero-amount event를 교정하지는 않는다.
|
||||
|
||||
#### Target Semantics
|
||||
|
||||
승인된 목표 semantics는 다음과 같다.
|
||||
|
||||
```text
|
||||
total_invested = sum(valid INVEST entries from every source)
|
||||
total_withdrawn = sum(valid WITHDRAW entries from every source)
|
||||
net_contributed_capital = total_invested - total_withdrawn
|
||||
cumulative_profit_withdrawn = sum(valid PROFIT entries from every source)
|
||||
```
|
||||
|
||||
`valid`는 원본 ledger entry가 `REVERSAL.target_ledger_entry_id`로 연결된 matching reversal에 의해 취소되지 않았음을 뜻한다.
|
||||
|
||||
`total_invested`는 gross value인 `Σ INVEST`로 유지한다. `WITHDRAW`는 `total_withdrawn`과 `net_contributed_capital`에 반영하고, `PROFIT`는 투자 원금 계산에서 제외하여 `cumulative_profit_withdrawn`으로만 별도 집계한다.
|
||||
|
||||
#### Scope
|
||||
|
||||
- 투자 원금 aggregate에서 `ledger.source = 'manual'` predicate를 제거하고 모든 source의 유효한 `INVEST`·`WITHDRAW`·`PROFIT`을 side별로 집계한다.
|
||||
- `INVEST`, `WITHDRAW`, `PROFIT` accounting side 의미는 유지한다.
|
||||
- Original entry와 동일 portfolio에서 `target_ledger_entry_id`가 일치하는 `REVERSAL`이 있으면 reversal 자체의 source와 무관하게 original entry를 제외한다.
|
||||
- Portfolio list와 single-portfolio returns summary가 동일한 aggregate semantics를 사용하게 한다.
|
||||
- Non-manual `broker` INVEST와 mixed manual/broker INVEST·WITHDRAW·reversal 회귀를 검증한다.
|
||||
- API/interface 문서에서 `total_invested`와 `net_contributed_capital`의 manual-only 표현을 승인된 의미로 갱신한다.
|
||||
- 기존 owner/admin/service visibility와 authorization 경계를 변경하지 않는다.
|
||||
|
||||
#### Out of Scope
|
||||
|
||||
- `buffer_out`, `buy` 등 non-INVEST event를 INVEST로 재분류하는 작업
|
||||
- Zero-amount broker `invest`를 non-zero event로 정규화하는 ledger-event modelling
|
||||
- Existing ledger row의 mutation, backfill 또는 rewrite
|
||||
- Ledger producer의 event emission 변경
|
||||
- Reversal model 또는 `REVERSAL.target_ledger_entry_id` 의미 변경
|
||||
- Owner/admin/service visibility 및 authorization 변경
|
||||
- 이 Discovery 승인만으로 배포·릴리스·production mutation을 수행하는 것
|
||||
|
||||
#### Risks and Constraints
|
||||
|
||||
- Source predicate 제거는 과거 모든 source row에 즉시 적용되는 query semantic correction이므로 기존 응답 값이 증가하거나 달라질 수 있다.
|
||||
- Side label이 같다는 이유만으로 `buffer_out`·`buy` 같은 다른 accounting event를 합산하면 안 된다.
|
||||
- Reversal exclusion을 source-independent 집계와 함께 유지하지 않으면 reversed principal이 중복 반영될 수 있다.
|
||||
- List와 detail summary가 다른 query path를 사용한다면 동일 fixture로 semantic parity를 검증해야 한다.
|
||||
- `total_invested`의 의미 변경은 API field rename 없이 기존 field contract를 교정하는 선택인지 명시해야 한다.
|
||||
- Production 사례 `353200`은 source-filter defect와 separate ledger-event modelling gap을 동시에 포함하므로, source-filter 수정만으로 `800,000`이 투자 원금으로 표시된다고 보장해서는 안 된다.
|
||||
|
||||
#### Open Questions
|
||||
|
||||
##### OQ-1 — Invest-total 집계의 source 범위
|
||||
|
||||
- [x] **A. 모든 source의 유효한 원금 event를 포함하되 `total_invested`는 gross `Σ INVEST`로 유지한다. — 결정**
|
||||
- `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = Σ valid INVEST - Σ valid WITHDRAW`로 계산한다.
|
||||
- `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn = Σ valid PROFIT`으로만 별도 집계한다.
|
||||
- `ledger.source = 'manual'` predicate를 제거하고 source와 무관하게 accounting side와 reversal을 적용한다.
|
||||
- [ ] **B. `manual`과 `broker`만 포함하는 allowlist를 사용한다.**
|
||||
- 현재 알려진 두 source만 포함하지만 새 source가 추가되면 다시 누락될 수 있다.
|
||||
- [ ] **C. Manual-only 의미를 유지하고 별도 all-source field를 추가한다.**
|
||||
- 기존 값은 보존하지만 API schema와 UI 소비 계약이 확장되며 본 이슈의 단순 semantic correction 범위를 넘어간다.
|
||||
|
||||
##### OQ-2 — `buffer_out` 및 zero-amount broker `invest` 처리
|
||||
|
||||
- [x] **A. 이 이슈에서는 현행 accounting side를 그대로 사용하고 별도 ledger-event modelling 이슈로 분리한다. — 결정**
|
||||
- `buffer_out`을 INVEST로 추론하지 않으며 source-filter defect만 교정한다.
|
||||
- Portfolio `353200`의 `800,000` 표시 문제는 별도 결정 없이는 해결됐다고 주장하지 않는다.
|
||||
- [ ] **B. `buffer_out`을 이 집계에서 INVEST로 간주한다.**
|
||||
- 사용자 표시 값은 달라질 수 있지만 accounting event 의미를 query layer에서 재해석한다.
|
||||
- [ ] **C. Broker producer가 non-zero `INVEST`를 기록하도록 이 이슈 범위를 확장한다.**
|
||||
- Producer contract, historical data, deduplication과 migration 결정이 추가로 필요하다.
|
||||
|
||||
##### OQ-3 — 기존 ledger data 처리 방식
|
||||
|
||||
- [x] **A. Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다. — 결정**
|
||||
- 모든 기존 유효 row가 새 query에 자동 반영되며 원장 provenance를 보존한다.
|
||||
- [ ] **B. Source 또는 side를 migration으로 backfill한다.**
|
||||
- Historical ledger mutation 범위, 대상 식별과 rollback/audit 계약이 별도로 필요하다.
|
||||
- [ ] **C. 별도 aggregate projection/table을 만들어 재계산한다.**
|
||||
- 지속 materialization, replay, ordering 및 consistency ownership이 추가된다.
|
||||
|
||||
##### OQ-4 — API compatibility 방식
|
||||
|
||||
- [x] **A. 기존 field 이름을 유지하고 의미를 all-source Invest-total로 교정한다. — 결정**
|
||||
- `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`의 문서를 함께 갱신한다.
|
||||
- [ ] **B. 새 field를 추가하고 기존 field는 manual-only로 유지한다.**
|
||||
- 하위 호환은 보존하지만 response schema와 consumer 선택이 복잡해진다.
|
||||
- [ ] **C. Versioned endpoint에서만 새 의미를 제공한다.**
|
||||
- 가장 강한 contract 분리를 제공하지만 새 API version과 migration 계획이 필요하다.
|
||||
|
||||
##### OQ-5 — 배포 후 검증 경계
|
||||
|
||||
- [x] **A. Repository tests에 더해 승인된 read-only production query/API pre/post check를 별도 배포 절차에 포함한다. — 결정**
|
||||
- Source-independent 합계, reversal 제외와 list/detail parity를 확인하되 production row는 변경하지 않는다.
|
||||
- `353200`은 source-filter 제거 여부를 확인하는 관찰 사례로만 사용하고 `buffer_out=INVEST`를 기대하지 않는다.
|
||||
- [ ] **B. Repository tests만 수행하고 production 검증은 하지 않는다.**
|
||||
- 구현 검증은 단순하지만 실제 source 분포와 응답 변화 확인이 남는다.
|
||||
- [ ] **C. Synthetic staging data에서만 post-deploy 검증한다.**
|
||||
- Production read를 피하지만 실제 historical source/reversal 조합을 완전히 대표하지 못할 수 있다.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
OQ가 확정되면 아래 criterion을 선택 결과에 맞춰 canonical requirement로 전환한다.
|
||||
|
||||
- [ ] Investment aggregate에서 original ledger row의 source predicate가 제거되고 모든 source의 유효 row가 side별로 집계된다.
|
||||
- [ ] `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = total_invested - total_withdrawn`이며 `PROFIT`는 원금 계산에서 제외되고 `cumulative_profit_withdrawn`으로만 집계된다.
|
||||
- [ ] 동일 portfolio에서 matching `target_ledger_entry_id`를 가진 `REVERSAL`이 존재하면 reversal source와 무관하게 original entry가 제외된다.
|
||||
- [ ] `GET /v1/portfolios`와 single-portfolio returns summary가 동일한 aggregate semantics를 사용한다.
|
||||
- [ ] Non-manual `broker` INVEST의 non-zero amount 회귀가 검증된다.
|
||||
- [ ] Mixed manual/broker INVEST·WITHDRAW, reversal과 source-independent total이 검증된다.
|
||||
- [ ] API/interface 문서가 승인된 field 의미를 정확히 설명한다.
|
||||
- [ ] Existing owner/admin/service visibility 경계가 유지된다.
|
||||
- [ ] Out-of-scope인 `buffer_out` 재분류나 historical ledger mutation이 암묵적으로 포함되지 않는다.
|
||||
|
||||
#### Approval Record
|
||||
|
||||
- **승인 상태:** `APPROVED`
|
||||
- **승인 결정:** OQ-1-A, OQ-2-A, OQ-3-A, OQ-4-A, OQ-5-A
|
||||
- **OQ-1:** 모든 source의 유효한 row를 side별로 집계한다. `total_invested`는 gross `Σ INVEST`, `net_contributed_capital`은 `Σ INVEST - Σ WITHDRAW`이며 `PROFIT`는 원금 계산에서 제외한다.
|
||||
- **OQ-2:** `buffer_out` 재분류와 zero-amount broker `invest` 교정은 별도 ledger-event modelling으로 분리한다.
|
||||
- **OQ-3:** Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다.
|
||||
- **OQ-4:** 기존 field 이름을 유지하고 all-source Invest-total 의미로 문서와 contract를 교정한다.
|
||||
- **OQ-5:** Repository 검증에 더해 별도 배포 절차에서 승인된 read-only production query/API pre/post check를 수행한다. Production row mutation 권한은 포함하지 않는다.
|
||||
- **Reversal 적용:** all-source original 집계에서 matching reversal도 source와 무관하게 적용한다. `reversal.source = 'manual'` predicate를 보존하면 broker original reversal이 누락되므로 허용하지 않는다.
|
||||
- **추가 결정 검토:** 현재 구현·data-flow 검토에서 승인을 중지할 추가 material OQ는 발견되지 않았다.
|
||||
- **권한 경계:** 이 승인은 이 Discovery와 bounded implementation/verification scope를 승인한다. Push, PR, release, deployment, production mutation 또는 issue closure 권한을 자동으로 부여하지 않는다.
|
||||
|
||||
|
||||
### AC admission 분류
|
||||
|
||||
- `update`: 기존 active `ac-0001`, `ac-0002`, `ac-0003`, `ac-0005`의 manual-only 의미를 source-independent 계약과 현재 검증 상태로 교정한다.
|
||||
- `satisfy`: 기존 active `ac-0004`의 목록 가시성·인증·필터 격리는 의미 변경 없이 이번 구현이 충족한다.
|
||||
- `create`: 없음. 동일 behavior를 새 AC로 중복하지 않는다.
|
||||
- `retire`: 없음.
|
||||
- Release evidence와 terminal closure: 없음.
|
||||
- Housekeeping: GitHub comment exact snapshot 두 건을 Line evidence로 보존한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Existing ledger row mutation 또는 historical migration/backfill
|
||||
- `buffer_out`을 `INVEST`로 재분류하거나 zero-amount broker event를 교정하는 ledger-event modelling
|
||||
- 별도 aggregate projection/table 및 성능 근거 없는 schema 확장
|
||||
- API field rename 또는 breaking response change
|
||||
- Push, PR, 원격 main 반영, release, production deployment/restart
|
||||
- Production write/mutation과 credential 기록
|
||||
- 배포 절차의 승인된 read-only production pre/post check를 이미 완료했다고 주장하는 것
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- 모든 source를 집계하므로 producer가 accounting side를 잘못 기록하면 그 modelling 오류가 aggregate에 노출될 수 있다. 이번 범위는 side 재분류가 아니라 유효한 기존 side의 source filter 교정이다.
|
||||
- Cross-source reversal을 놓치면 original row가 잘못 남으므로 original과 reversal 양쪽의 source 제한을 제거하고 실제 PostgreSQL 테스트로 검증했다.
|
||||
- 배포 전 실제 production data parity는 아직 미확인이다. 승인된 read-only pre/post check는 별도 배포 절차에서 수행해야 하며 mutation 권한을 포함하지 않는다.
|
||||
|
||||
### Open Questions
|
||||
|
||||
없음. OQ-1~OQ-5는 승인 이슈 본문에서 모두 결정됐으며 추가 blocking decision은 발견되지 않았다.
|
||||
@@ -0,0 +1,50 @@
|
||||
## 구현 완료
|
||||
|
||||
### 구현 내용
|
||||
|
||||
- Portfolio return aggregate에서 original ledger의 `source='manual'` 제한을 제거해 모든 source의 유효한 `INVEST`·`WITHDRAW`·`PROFIT`을 side별로 집계한다.
|
||||
- `total_invested`는 gross `Σ INVEST`, `total_withdrawn`은 `Σ WITHDRAW`, `net_contributed_capital`은 두 값의 차로 유지한다.
|
||||
- `PROFIT`는 투자 원금 계산에서 제외하고 `cumulative_profit_withdrawn`으로만 별도 집계한다.
|
||||
- Matching `REVERSAL.target_ledger_entry_id`는 original과 reversal의 source와 무관하게 original entry를 제외한다.
|
||||
- `buffer_out` 등 non-capital side는 `INVEST`로 재해석하거나 집계하지 않는다.
|
||||
- Existing ledger row mutation/backfill 없이 query semantic만 교정했다.
|
||||
- List와 단건 returns summary가 동일한 repository aggregate를 사용하도록 유지했다.
|
||||
- API schema, data model, runtime flow와 외부 interface contract를 all-source 의미로 동기화했다.
|
||||
|
||||
### 실제 검증 결과
|
||||
|
||||
- Focused repository/API/docs contract tests: **39 passed**
|
||||
- Disposable PostgreSQL 16 semantic integration: **1 passed**
|
||||
- Non-zero broker-only INVEST
|
||||
- Manual/broker mixed INVEST·WITHDRAW·PROFIT
|
||||
- Manual reversal → broker original
|
||||
- Broker reversal → manual original
|
||||
- `buffer_out` 집계 제외
|
||||
- Clean candidate full suite: **661 passed, 16 skipped**
|
||||
- 변경 테스트 범위 Ruff: **PASS**
|
||||
- `git diff --check`: **PASS**
|
||||
- 최초 독립 QC: Blocker 0 / Major 1 / Minor 0 — stale manual-only interface contract 발견
|
||||
- 보완 후 독립 재QC: **PASS — Blocker 0 / Major 0 / Minor 0**
|
||||
|
||||
원본 checkout의 full suite에서는 기존 `temp/deleted_20260813/.worktrees/` 복제본을 grep하는 auth-surface tests 3건만 실패했다. 해당 pre-existing artifact를 수정·삭제하지 않고 제외한 clean candidate copy에서 전체 회귀가 통과했다.
|
||||
|
||||
### 비차단 후속 권고
|
||||
|
||||
- 배포 절차에서 승인된 read-only production pre/post check로 실제 portfolio의 source-independent 합계와 list/detail parity를 확인한다.
|
||||
- Portfolio `353200`은 source-filter 교정 관찰에만 사용하며 `buffer_out=INVEST` 또는 zero-amount broker `invest` 교정 완료를 주장하지 않는다.
|
||||
- `buffer_out` 재분류나 producer의 ledger-event modelling 변경이 필요하면 별도 Discovery로 결정한다.
|
||||
|
||||
### 완료 및 외부 반영 경계
|
||||
|
||||
- 로컬 `main` 구현·검증·독립 재QC·커밋 완료:
|
||||
- `5eb8c042668b1c383ac59ca9be00aa1a5dfcb37a`
|
||||
- `fix: aggregate investment totals across ledger sources`
|
||||
- Tracked worktree/index: clean
|
||||
- 원격 push: **수행하지 않음**
|
||||
- PR: **생성하지 않음**
|
||||
- Release: **수행하지 않음**
|
||||
- Production deployment/restart: **수행하지 않음**
|
||||
- Production read-only pre/post check: **배포 절차에서 수행 예정이며 현재 미수행**
|
||||
- Production ledger mutation: **수행하지 않음**
|
||||
|
||||
따라서 이 완료는 로컬 제품 구현·검증·커밋 완료를 의미하며 원격 통합, release 또는 production deployment 완료를 의미하지 않는다.
|
||||
@@ -0,0 +1,46 @@
|
||||
## 구현 진행 상황
|
||||
|
||||
- **대상:** Fix investment totals: remove source='manual' filter from ledger aggregates
|
||||
- **브랜치:** `main`
|
||||
- **기준:** `main` (`02bfa602995b8e1c2f410c9bd5762f062666f2d6`)
|
||||
- **상태:** 로컬 `main` 구현·검증·독립 재QC·커밋 완료 — 미푸시, 미배포
|
||||
- **최종 업데이트:** 2026-08-19 17:34:13 KST
|
||||
|
||||
### 구현 계획 및 작업 목록
|
||||
|
||||
- [x] 투자 원금 집계를 모든 source로 확장
|
||||
- `total_invested`는 유효한 모든 `INVEST`의 gross 합계로 유지하고, `net_contributed_capital`은 `INVEST - WITHDRAW`로 계산한다.
|
||||
- [x] 수익 인출을 투자 원금과 분리
|
||||
- `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn`으로만 집계한다.
|
||||
- [x] Source와 무관한 reversal 제외 보장
|
||||
- 동일 portfolio의 matching `REVERSAL.target_ledger_entry_id`가 있으면 original row와 reversal의 source와 관계없이 원본을 제외한다.
|
||||
- [x] 기존 ledger data와 accounting side 경계 보존
|
||||
- Existing ledger row를 mutation/backfill하지 않고 query semantic만 교정하며 `buffer_out` 등 다른 side를 `INVEST`로 재해석하지 않는다.
|
||||
- [x] List와 단건 returns summary의 동일한 의미 유지
|
||||
- `GET /v1/portfolios`와 `GET /v1/portfolios/{portfolio_id}/returns/summary`가 같은 repository aggregate를 사용함을 검증한다.
|
||||
- [x] Non-manual 및 mixed-source 회귀 테스트 추가
|
||||
- Non-zero broker `INVEST`, manual/broker 혼합 `INVEST`·`WITHDRAW`·`PROFIT`, source-independent reversal을 검증한다.
|
||||
- [x] API 및 interface 문서 갱신
|
||||
- `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`의 all-source 의미와 `PROFIT` 분리 경계를 명시한다.
|
||||
- [x] 전체 회귀와 변경 범위 정적 검증
|
||||
- Focused tests, full repository tests, 변경 범위 lint와 diff 검사를 실행한다.
|
||||
- [x] 독립 QC 및 완료 경계 기록
|
||||
- Blocker/Major/Minor를 독립 검토하고 local 구현·검증과 push·release·deployment를 구분해 기록한다.
|
||||
|
||||
### 진행 기록
|
||||
|
||||
- 2026-08-19 17:23:19 KST — Issue #28의 승인된 OQ-1-A~OQ-5-A와 구현 경계를 확인했다.
|
||||
- 2026-08-19 17:23:19 KST — 별도 worktree 없이 clean local `main`의 기준 SHA를 고정하고 구현·검증을 시작했다.
|
||||
- 2026-08-19 17:25:55 KST — Original과 reversal의 `source='manual'` predicate를 제거하고 side별 gross INVEST, WITHDRAW, PROFIT 분리 계산을 구현했다.
|
||||
- 2026-08-19 17:25:55 KST — API schema와 data-model/runtime-flow 문서를 all-source semantics, PROFIT 분리, source-independent reversal 경계로 갱신했다.
|
||||
- 2026-08-19 17:25:55 KST — Focused API/repository tests `38 passed`; disposable PostgreSQL 16에서 broker-only, mixed source, 양방향 cross-source reversal, `buffer_out` 제외 semantic test `1 passed`.
|
||||
- 2026-08-19 17:25:55 KST — 전체 파일 Ruff에는 candidate 이전부터 존재한 오류가 있어 변경 범위와 구분해 기록했고, 신규 파일 lint 및 diff 검사는 PASS했다.
|
||||
- 2026-08-19 17:28:28 KST — 원본 checkout full suite는 기존 `temp/deleted_20260813/.worktrees/` 복제본을 grep하는 auth surface tests 3건만 실패하고 `657 passed, 16 skipped`였다. 해당 pre-existing artifact를 제외한 clean candidate copy에서 full suite `660 passed, 16 skipped`로 PASS했다.
|
||||
- 2026-08-19 17:28:28 KST — 구현 범위의 독립 read-only QC를 시작했다. QC 결과 반영 전에는 최종 완료로 표시하지 않는다.
|
||||
- 2026-08-19 17:32:35 KST — 최초 독립 QC는 Blocker 0 / Major 1 / Minor 0으로, `docs/interface-contract.md`의 manual-only 외부 계약이 구현과 충돌한다고 판정했다.
|
||||
- 2026-08-19 17:32:35 KST — 외부 interface contract의 list/summary 의미, source-independent reversal, non-capital side 제외와 change log를 갱신하고 문서 계약 회귀 테스트를 추가했다.
|
||||
- 2026-08-19 17:32:35 KST — 보완 후 focused `39 passed`, test-file Ruff·diff check PASS, clean candidate full suite `661 passed, 16 skipped`로 재검증하고 독립 재QC를 시작했다.
|
||||
- 2026-08-19 17:34:13 KST — 독립 재QC가 PASS(Blocker 0 / Major 0 / Minor 0)했다.
|
||||
- 2026-08-19 17:34:13 KST — 로컬 `main`에 `5eb8c042668b1c383ac59ca9be00aa1a5dfcb37a` (`fix: aggregate investment totals across ledger sources`)로 커밋했다. Push, PR, release, deployment와 production read-only pre/post check는 수행하지 않았다.
|
||||
|
||||
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Discovery: 투자 원금 집계의 source-independent 의미 복원
|
||||
|
||||
- **상태:** `APPROVED`
|
||||
- **대상 저장소:** `fourblocks2020/portfolio-hub`
|
||||
- **대상 API:** `GET /v1/portfolios`, `GET /v1/portfolios/{portfolio_id}/returns/summary`
|
||||
- **관련 이슈:** #23의 manual-only cash-flow 집계 가정 중 Invest-total field 의미를 이 이슈가 대체한다.
|
||||
|
||||
## Problem
|
||||
|
||||
`get_return_summaries()`는 현재 투자 원금 집계를 manual ledger row로 제한한다.
|
||||
|
||||
```sql
|
||||
WHERE ledger.source = 'manual'
|
||||
AND ledger.side IN ('invest', 'withdraw', 'profit')
|
||||
```
|
||||
|
||||
그 결과 `total_invested`와 `net_contributed_capital`은 portfolio ledger 전체의 Invest/net Invest가 아니라 manual source의 cash flow만 반영한다. Broker 등 다른 source가 기록한 유효한 `INVEST`가 제외되므로 사용자에게 표시되는 투자 원금 의미와 실제 집계가 불일치한다.
|
||||
|
||||
이 동작은 #23의 manual-cash-flow contract에서 유래했지만, Invest-total field가 모든 유효한 투자 원금을 나타내야 한다면 적합하지 않다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Production 관찰 사례인 portfolio `353200`에는 broker-origin `invest`, `buffer_out`, `buy` ledger entry가 있고 manual `INVEST`/`WITHDRAW`가 없다.
|
||||
- 현재 portfolio API는 모든 `source='broker'` row를 집계에서 제외하므로 `net_contributed_capital: 0.0`을 반환한다.
|
||||
- 같은 사례에서 broker `invest` amount는 `0`, `buffer_out`은 `800,000`이다. 이 이슈의 source-filter 수정만으로 `buffer_out`을 `INVEST`로 간주하거나 zero-amount event를 교정하지는 않는다.
|
||||
|
||||
## Target Semantics
|
||||
|
||||
승인된 목표 semantics는 다음과 같다.
|
||||
|
||||
```text
|
||||
total_invested = sum(valid INVEST entries from every source)
|
||||
total_withdrawn = sum(valid WITHDRAW entries from every source)
|
||||
net_contributed_capital = total_invested - total_withdrawn
|
||||
cumulative_profit_withdrawn = sum(valid PROFIT entries from every source)
|
||||
```
|
||||
|
||||
`valid`는 원본 ledger entry가 `REVERSAL.target_ledger_entry_id`로 연결된 matching reversal에 의해 취소되지 않았음을 뜻한다.
|
||||
|
||||
`total_invested`는 gross value인 `Σ INVEST`로 유지한다. `WITHDRAW`는 `total_withdrawn`과 `net_contributed_capital`에 반영하고, `PROFIT`는 투자 원금 계산에서 제외하여 `cumulative_profit_withdrawn`으로만 별도 집계한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- 투자 원금 aggregate에서 `ledger.source = 'manual'` predicate를 제거하고 모든 source의 유효한 `INVEST`·`WITHDRAW`·`PROFIT`을 side별로 집계한다.
|
||||
- `INVEST`, `WITHDRAW`, `PROFIT` accounting side 의미는 유지한다.
|
||||
- Original entry와 동일 portfolio에서 `target_ledger_entry_id`가 일치하는 `REVERSAL`이 있으면 reversal 자체의 source와 무관하게 original entry를 제외한다.
|
||||
- Portfolio list와 single-portfolio returns summary가 동일한 aggregate semantics를 사용하게 한다.
|
||||
- Non-manual `broker` INVEST와 mixed manual/broker INVEST·WITHDRAW·reversal 회귀를 검증한다.
|
||||
- API/interface 문서에서 `total_invested`와 `net_contributed_capital`의 manual-only 표현을 승인된 의미로 갱신한다.
|
||||
- 기존 owner/admin/service visibility와 authorization 경계를 변경하지 않는다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `buffer_out`, `buy` 등 non-INVEST event를 INVEST로 재분류하는 작업
|
||||
- Zero-amount broker `invest`를 non-zero event로 정규화하는 ledger-event modelling
|
||||
- Existing ledger row의 mutation, backfill 또는 rewrite
|
||||
- Ledger producer의 event emission 변경
|
||||
- Reversal model 또는 `REVERSAL.target_ledger_entry_id` 의미 변경
|
||||
- Owner/admin/service visibility 및 authorization 변경
|
||||
- 이 Discovery 승인만으로 배포·릴리스·production mutation을 수행하는 것
|
||||
|
||||
## Risks and Constraints
|
||||
|
||||
- Source predicate 제거는 과거 모든 source row에 즉시 적용되는 query semantic correction이므로 기존 응답 값이 증가하거나 달라질 수 있다.
|
||||
- Side label이 같다는 이유만으로 `buffer_out`·`buy` 같은 다른 accounting event를 합산하면 안 된다.
|
||||
- Reversal exclusion을 source-independent 집계와 함께 유지하지 않으면 reversed principal이 중복 반영될 수 있다.
|
||||
- List와 detail summary가 다른 query path를 사용한다면 동일 fixture로 semantic parity를 검증해야 한다.
|
||||
- `total_invested`의 의미 변경은 API field rename 없이 기존 field contract를 교정하는 선택인지 명시해야 한다.
|
||||
- Production 사례 `353200`은 source-filter defect와 separate ledger-event modelling gap을 동시에 포함하므로, source-filter 수정만으로 `800,000`이 투자 원금으로 표시된다고 보장해서는 안 된다.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### OQ-1 — Invest-total 집계의 source 범위
|
||||
|
||||
- [x] **A. 모든 source의 유효한 원금 event를 포함하되 `total_invested`는 gross `Σ INVEST`로 유지한다. — 결정**
|
||||
- `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = Σ valid INVEST - Σ valid WITHDRAW`로 계산한다.
|
||||
- `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn = Σ valid PROFIT`으로만 별도 집계한다.
|
||||
- `ledger.source = 'manual'` predicate를 제거하고 source와 무관하게 accounting side와 reversal을 적용한다.
|
||||
- [ ] **B. `manual`과 `broker`만 포함하는 allowlist를 사용한다.**
|
||||
- 현재 알려진 두 source만 포함하지만 새 source가 추가되면 다시 누락될 수 있다.
|
||||
- [ ] **C. Manual-only 의미를 유지하고 별도 all-source field를 추가한다.**
|
||||
- 기존 값은 보존하지만 API schema와 UI 소비 계약이 확장되며 본 이슈의 단순 semantic correction 범위를 넘어간다.
|
||||
|
||||
### OQ-2 — `buffer_out` 및 zero-amount broker `invest` 처리
|
||||
|
||||
- [x] **A. 이 이슈에서는 현행 accounting side를 그대로 사용하고 별도 ledger-event modelling 이슈로 분리한다. — 결정**
|
||||
- `buffer_out`을 INVEST로 추론하지 않으며 source-filter defect만 교정한다.
|
||||
- Portfolio `353200`의 `800,000` 표시 문제는 별도 결정 없이는 해결됐다고 주장하지 않는다.
|
||||
- [ ] **B. `buffer_out`을 이 집계에서 INVEST로 간주한다.**
|
||||
- 사용자 표시 값은 달라질 수 있지만 accounting event 의미를 query layer에서 재해석한다.
|
||||
- [ ] **C. Broker producer가 non-zero `INVEST`를 기록하도록 이 이슈 범위를 확장한다.**
|
||||
- Producer contract, historical data, deduplication과 migration 결정이 추가로 필요하다.
|
||||
|
||||
### OQ-3 — 기존 ledger data 처리 방식
|
||||
|
||||
- [x] **A. Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다. — 결정**
|
||||
- 모든 기존 유효 row가 새 query에 자동 반영되며 원장 provenance를 보존한다.
|
||||
- [ ] **B. Source 또는 side를 migration으로 backfill한다.**
|
||||
- Historical ledger mutation 범위, 대상 식별과 rollback/audit 계약이 별도로 필요하다.
|
||||
- [ ] **C. 별도 aggregate projection/table을 만들어 재계산한다.**
|
||||
- 지속 materialization, replay, ordering 및 consistency ownership이 추가된다.
|
||||
|
||||
### OQ-4 — API compatibility 방식
|
||||
|
||||
- [x] **A. 기존 field 이름을 유지하고 의미를 all-source Invest-total로 교정한다. — 결정**
|
||||
- `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`의 문서를 함께 갱신한다.
|
||||
- [ ] **B. 새 field를 추가하고 기존 field는 manual-only로 유지한다.**
|
||||
- 하위 호환은 보존하지만 response schema와 consumer 선택이 복잡해진다.
|
||||
- [ ] **C. Versioned endpoint에서만 새 의미를 제공한다.**
|
||||
- 가장 강한 contract 분리를 제공하지만 새 API version과 migration 계획이 필요하다.
|
||||
|
||||
### OQ-5 — 배포 후 검증 경계
|
||||
|
||||
- [x] **A. Repository tests에 더해 승인된 read-only production query/API pre/post check를 별도 배포 절차에 포함한다. — 결정**
|
||||
- Source-independent 합계, reversal 제외와 list/detail parity를 확인하되 production row는 변경하지 않는다.
|
||||
- `353200`은 source-filter 제거 여부를 확인하는 관찰 사례로만 사용하고 `buffer_out=INVEST`를 기대하지 않는다.
|
||||
- [ ] **B. Repository tests만 수행하고 production 검증은 하지 않는다.**
|
||||
- 구현 검증은 단순하지만 실제 source 분포와 응답 변화 확인이 남는다.
|
||||
- [ ] **C. Synthetic staging data에서만 post-deploy 검증한다.**
|
||||
- Production read를 피하지만 실제 historical source/reversal 조합을 완전히 대표하지 못할 수 있다.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
OQ가 확정되면 아래 criterion을 선택 결과에 맞춰 canonical requirement로 전환한다.
|
||||
|
||||
- [ ] Investment aggregate에서 original ledger row의 source predicate가 제거되고 모든 source의 유효 row가 side별로 집계된다.
|
||||
- [ ] `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = total_invested - total_withdrawn`이며 `PROFIT`는 원금 계산에서 제외되고 `cumulative_profit_withdrawn`으로만 집계된다.
|
||||
- [ ] 동일 portfolio에서 matching `target_ledger_entry_id`를 가진 `REVERSAL`이 존재하면 reversal source와 무관하게 original entry가 제외된다.
|
||||
- [ ] `GET /v1/portfolios`와 single-portfolio returns summary가 동일한 aggregate semantics를 사용한다.
|
||||
- [ ] Non-manual `broker` INVEST의 non-zero amount 회귀가 검증된다.
|
||||
- [ ] Mixed manual/broker INVEST·WITHDRAW, reversal과 source-independent total이 검증된다.
|
||||
- [ ] API/interface 문서가 승인된 field 의미를 정확히 설명한다.
|
||||
- [ ] Existing owner/admin/service visibility 경계가 유지된다.
|
||||
- [ ] Out-of-scope인 `buffer_out` 재분류나 historical ledger mutation이 암묵적으로 포함되지 않는다.
|
||||
|
||||
## Approval Record
|
||||
|
||||
- **승인 상태:** `APPROVED`
|
||||
- **승인 결정:** OQ-1-A, OQ-2-A, OQ-3-A, OQ-4-A, OQ-5-A
|
||||
- **OQ-1:** 모든 source의 유효한 row를 side별로 집계한다. `total_invested`는 gross `Σ INVEST`, `net_contributed_capital`은 `Σ INVEST - Σ WITHDRAW`이며 `PROFIT`는 원금 계산에서 제외한다.
|
||||
- **OQ-2:** `buffer_out` 재분류와 zero-amount broker `invest` 교정은 별도 ledger-event modelling으로 분리한다.
|
||||
- **OQ-3:** Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다.
|
||||
- **OQ-4:** 기존 field 이름을 유지하고 all-source Invest-total 의미로 문서와 contract를 교정한다.
|
||||
- **OQ-5:** Repository 검증에 더해 별도 배포 절차에서 승인된 read-only production query/API pre/post check를 수행한다. Production row mutation 권한은 포함하지 않는다.
|
||||
- **Reversal 적용:** all-source original 집계에서 matching reversal도 source와 무관하게 적용한다. `reversal.source = 'manual'` predicate를 보존하면 broker original reversal이 누락되므로 허용하지 않는다.
|
||||
- **추가 결정 검토:** 현재 구현·data-flow 검토에서 승인을 중지할 추가 material OQ는 발견되지 않았다.
|
||||
- **권한 경계:** 이 승인은 이 Discovery와 bounded implementation/verification scope를 승인한다. Push, PR, release, deployment, production mutation 또는 issue closure 권한을 자동으로 부여하지 않는다.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0005"
|
||||
---
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
id: "req-0005"
|
||||
status: approved
|
||||
discovery: "dcy-0005"
|
||||
criteria:
|
||||
create: []
|
||||
update:
|
||||
- "ac-0001"
|
||||
- "ac-0002"
|
||||
- "ac-0003"
|
||||
- "ac-0005"
|
||||
retire: []
|
||||
satisfy:
|
||||
- "ac-0004"
|
||||
---
|
||||
|
||||
# Portfolio ledger source-independent return aggregates
|
||||
|
||||
## Objective
|
||||
|
||||
Portfolio return aggregate가 producer source와 무관하게 유효한 accounting event를 반영하도록 교정하면서 gross 투자원금, 회수원금, 순투자원금과 수익 인출을 분리하고, source-independent reversal·목록/단건 일치·기존 권한 경계를 보존한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- 모든 source의 유효한 `INVEST`, `WITHDRAW`, `PROFIT` row를 side별로 집계한다.
|
||||
- `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = total_invested - total_withdrawn`, `cumulative_profit_withdrawn = Σ valid PROFIT` 의미를 사용한다.
|
||||
- `PROFIT`을 원금에서 제외하고 별도 수익 인출 합계로 유지한다.
|
||||
- 동일 portfolio의 matching `REVERSAL.target_ledger_entry_id`가 존재하면 original과 reversal의 source와 무관하게 original `INVEST`·`WITHDRAW`·`PROFIT`을 제외한다.
|
||||
- `buffer_out` 등 다른 side를 원금·수익 aggregate에 포함하거나 `INVEST`로 재해석하지 않는다.
|
||||
- 목록은 반환 대상 ID만 하나의 grouped query로 집계하고 단건 summary는 같은 repository aggregate에 위임한다.
|
||||
- Existing API field 이름과 owner/admin/service authorization 및 filter 경계를 유지한다.
|
||||
- API schema, data model, runtime flow와 external interface contract를 source-independent 의미로 동기화한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Existing ledger row mutation, migration 또는 backfill 없이 SELECT query semantic만 교정한다.
|
||||
- 별도 aggregate projection/table 또는 schema migration을 추가하지 않는다.
|
||||
- Credential, token, password 또는 connection string을 문서와 evidence에 기록하지 않는다.
|
||||
- Production 확인은 승인된 read-only query/API pre/post check로 제한하며 별도 배포 절차가 소유한다.
|
||||
|
||||
### Current Implementation Assessment
|
||||
|
||||
- 승인 범위는 local `main` commit `5eb8c042668b1c383ac59ca9be00aa1a5dfcb37a`에 구현됐다.
|
||||
- Focused repository/API/docs contract tests `39 passed`, disposable PostgreSQL 16 semantic integration `1 passed`, clean candidate full suite `661 passed, 16 skipped`, 변경 test Ruff와 `git diff --check`가 통과했다.
|
||||
- 최초 독립 QC의 stale manual-only interface contract Major를 보완한 후 독립 재QC는 PASS(Blocker 0 / Major 0 / Minor 0)했다.
|
||||
- exact GitHub 진행·완료 comment는 `line-0005/evidence/implementation-progress.md`와 `implementation-completion.md`에 보존한다.
|
||||
- 현재 평가는 local 구현·검증·commit 완료이며 push, PR, 원격 main 반영, release, production deployment/restart와 read-only production pre/post check 완료를 주장하지 않는다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- `buffer_out` 재분류 또는 zero-amount broker event producer 교정
|
||||
- Historical ledger mutation/backfill
|
||||
- API field rename 또는 breaking response change
|
||||
- 별도 projection/table, migration 또는 성능 redesign
|
||||
- Push, PR, 원격 main 반영, release, production deployment/restart
|
||||
- Production ledger mutation 또는 배포 lifecycle closure
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: "dcy-0006"
|
||||
status: confirmed
|
||||
---
|
||||
|
||||
# [portfolio-hub] 포트폴리오 전체 원장 조회 API
|
||||
|
||||
## Problem
|
||||
|
||||
기존 `GET /v1/portfolios/{portfolio_id}/ledger/manual`는 manual source만 반환하므로, portfolio owner가 broker 체결/원장 이벤트를 포함한 전체 원장을 하나의 안정적인 read 계약으로 조회할 수 없었다. 소비자 UI는 owner가 제공한 원장 사실을 표시할 뿐 action·잔고 효과·PnL을 재계산해서는 안 된다.
|
||||
|
||||
## Evidence
|
||||
|
||||
- 승인 issue: `https://github.com/fourblocks2020/portfolio-hub/issues/29`; 전체 원문은 `evidence/issue-29-approved-discovery.md`에 body SHA-256 `833b5c8e2dd4bb6c90331bef58a0fdee48534fc1458a82b646f1581877428b66`으로 보존했다.
|
||||
- 진행 comment: `evidence/implementation-progress.md`, body SHA-256 `e90881b5df47b17650169c0858e7f39c300682ab70d3a88032b6fe41d31f65c7`.
|
||||
- 완료 comment: `evidence/implementation-completion.md`, body SHA-256 `ce25425fa95afad70221be88a8df88fe39b13f706bbc4f8508a0be34a8327187`.
|
||||
- 구현 commit `ac742b1150832f6c1cf58744308b4f3ea6803fd4`에서 통합 endpoint, repository keyset query, `20260820_0030` index migration 및 회귀가 확인됐다.
|
||||
- 실행 evidence: 전체 `pytest -q`는 `664 passed, 16 skipped`; disposable PostgreSQL fresh migration에서 head와 `idx_portfolio_ledger_portfolio_recorded_entry_desc` 생성을 확인했다.
|
||||
|
||||
## Scope
|
||||
|
||||
- owner-only `GET /v1/portfolios/{portfolio_id}/ledger`가 manual과 broker를 포함한 모든 source를 기본 최신순(`recorded_at DESC, ledger_entry_id DESC`)으로 반환한다.
|
||||
- optional non-empty exact `source` filter, `(recorded_at, ledger_entry_id)` keyset pagination 및 `next_cursor` 종료 계약을 제공한다. cursor는 portfolio/source 조건에 결속되고 malformed·tampered·다른 조건 재사용은 HTTP 422다.
|
||||
- 통합 item은 canonical 대문자 `action`을 노출하고 internal `side`는 노출하지 않는다. source별로 없는 사실은 0/추정값이 아닌 schema nullable 값이다.
|
||||
- user/session owner만 허용한다. owner 밖 사용자와 service token은 기존 정책에 따라 거부한다.
|
||||
- PostgreSQL index/migration, OpenAPI, mixed source/page/cursor/권한/manual 하위호환 회귀를 포함한다.
|
||||
- AC admission: 기존 active AC는 aggregate 의미를 소유할 뿐 source-neutral unified read contract를 소유하지 않으므로 durable behavior를 `create`한다.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- ledger 생성·수정·삭제, REVERSAL 실행 또는 기존 원장 재해석.
|
||||
- broker order/fill 생성, strategy signal, outbox/producer 변경.
|
||||
- API/UI가 원장에 없는 경제 효과·잔고·PnL을 추론하는 동작.
|
||||
- portfolio return aggregate 의미 변경(#28), trex-web UI, push/PR/merge, 배포·릴리스.
|
||||
|
||||
## Risks and Unknowns
|
||||
|
||||
### Risks
|
||||
|
||||
- 운영 cursor secret은 장기 랜덤 값으로 관리해야 하며 교체 시 기존 cursor의 만료·무효화 정책을 운영 문서에 명시해야 한다.
|
||||
- 운영 규모 row가 있는 환경에서 source-filter query의 `EXPLAIN ANALYZE` index 사용과 latency 관찰이 필요하다.
|
||||
@@ -0,0 +1,35 @@
|
||||
<!-- Source URL: https://github.com/fourblocks2020/portfolio-hub/issues/29#issuecomment-5350993550
|
||||
Source author: genichin
|
||||
Source created_at: 2026-08-20T03:28:21Z
|
||||
Body SHA-256: ce25425fa95afad70221be88a8df88fe39b13f706bbc4f8508a0be34a8327187
|
||||
Snapshot: exact source body begins after this header.
|
||||
-->
|
||||
|
||||
## 구현 완료
|
||||
|
||||
포트폴리오 소유자가 manual·broker를 포함한 모든 출처의 원장을 cursor 기반으로 조회하는 통합 API를 로컬 `main`에 구현·검증·커밋했습니다.
|
||||
|
||||
### 구현 내용
|
||||
|
||||
- `GET /v1/portfolios/{portfolio_id}/ledger`를 추가해 모든 source의 원장을 `recorded_at DESC, ledger_entry_id DESC` 순서로 반환합니다.
|
||||
- optional exact `source` filter, HMAC-signed opaque cursor 및 portfolio/source 조건 결속을 구현했습니다. malformed·tampered·다른 조건의 cursor는 HTTP 422로 거부합니다.
|
||||
- 통합 item은 canonical 대문자 `action`만 노출하며 `side`는 노출하지 않습니다. 출처별로 없는 사실은 추정값 대신 nullable field로 표현합니다.
|
||||
- PostgreSQL repository keyset query와 `(portfolio_id, recorded_at DESC, ledger_entry_id DESC)` index migration (`20260820_0030`)을 추가했습니다.
|
||||
- source filter, owner/service-token 권한 경계, cursor 오류, OpenAPI, release migration-head contract 및 `temp/` 제외 source-surface 검사를 회귀로 보완했습니다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
전체 회귀와 변경 범위 검증을 완료했습니다.
|
||||
|
||||
- `pytest -q` → `664 passed, 16 skipped`
|
||||
- focused API·release·auth-surface 검증 → `29 passed`
|
||||
- disposable PostgreSQL fresh migration → head `20260820_0030`, `idx_portfolio_ledger_portfolio_recorded_entry_desc` 생성 확인
|
||||
- 변경 범위 lint 및 `git diff --check` → 통과
|
||||
- 로컬 커밋 → `ac742b1150832f6c1cf58744308b4f3ea6803fd4` (`feat(portfolios): add unified ledger history API`)
|
||||
|
||||
### 비차단 후속 권고
|
||||
|
||||
- 운영 환경에서는 `PORTFOLIOHUB_LEDGER_CURSOR_SECRET`를 장기 랜덤 secret으로 설정하고, cursor secret 교체 시 기존 cursor의 유효기간·무효화 정책을 운영 문서에 명시하는 것을 권고합니다.
|
||||
- 실제 운영 규모의 ledger row가 있는 환경에서 `EXPLAIN ANALYZE`로 source-filter 포함 query의 index 사용과 latency를 추가 관찰하는 것을 권고합니다.
|
||||
|
||||
> 현재 상태는 **로컬 구현·검증·커밋 완료**입니다. push·PR·배포·릴리스 완료를 의미하지 않습니다.
|
||||
@@ -0,0 +1,36 @@
|
||||
<!-- Source URL: https://github.com/fourblocks2020/portfolio-hub/issues/29#issuecomment-5349683566
|
||||
Source author: genichin
|
||||
Source created_at: 2026-08-20T00:30:07Z
|
||||
Body SHA-256: e90881b5df47b17650169c0858e7f39c300682ab70d3a88032b6fe41d31f65c7
|
||||
Snapshot: exact source body begins after this header.
|
||||
-->
|
||||
|
||||
## 구현 진행 상황
|
||||
|
||||
- **대상:** [portfolio-hub] discovery: 포트폴리오 전체 원장 조회 API (#29)
|
||||
- **브랜치:** `main`
|
||||
- **기준:** `main` (`fe22be0b6686f346f4e6a5c9b64f7b3469e75810`)
|
||||
- **상태:** 로컬 main 구현·검증·커밋 완료 — 미푸시, 미배포, 미릴리스
|
||||
- **최종 업데이트:** 2026-08-20T11:05:00+09:00 (KST)
|
||||
|
||||
### 구현 계획 및 작업 목록
|
||||
|
||||
- [x] 통합 원장 조회 API의 응답·입력 계약을 추가했다.
|
||||
- 모든 출처를 하나의 대문자 `action` 모델로 표현하고, 출처별로 없는 사실은 `null`로 명시한다.
|
||||
- [x] source 필터와 cursor 기반 페이지 조회를 구현했다.
|
||||
- cursor는 portfolio·source 조건과 정렬 기준에 결속하며, 잘못되거나 다른 조건의 cursor는 HTTP 422로 거부한다.
|
||||
- [x] 소유자 권한과 기존 manual 전용 조회의 하위호환성을 검증했다.
|
||||
- user/session owner만 허용하고 기존 `/ledger/manual` 계약을 변경하지 않았다.
|
||||
- [x] API/OpenAPI 문서와 단위·통합 회귀 검증을 추가·실행했다.
|
||||
- manual·broker 혼합, source filter, 권한 경계와 cursor 오류를 검증했다.
|
||||
- [x] 실제 PostgreSQL query plan 및 index/migration을 검증했다.
|
||||
- disposable PostgreSQL DB에서 `20260820_0030` migration head와 `idx_portfolio_ledger_portfolio_recorded_entry_desc` 생성을 확인했다.
|
||||
|
||||
### 진행 기록
|
||||
|
||||
- 2026-08-20T09:29:48+09:00 (KST): 승인된 Discovery(OQ-001~007)와 현재 `main` 기준 SHA를 확인했다. 별도 구현 worktree는 만들지 않고 `main`에서 구현·검증을 시작한다.
|
||||
- 2026-08-20T10:40:54+09:00 (KST): source-중립 `GET /v1/portfolios/{portfolio_id}/ledger` vertical slice를 추가했다. focused `tests/test_v1_portfolios_manual_ledger.py`는 `14 passed`였다.
|
||||
- 2026-08-20T10:55:00+09:00 (KST): disposable PostgreSQL fresh upgrade에서 migration head와 unified-ledger keyset index 생성을 확인했다.
|
||||
- 2026-08-20T11:05:00+09:00 (KST): 권한/source/OpenAPI/release-contract 회귀를 보완했다. 전체 회귀 `664 passed, 16 skipped`, 변경 범위 lint와 `git diff --check`를 통과했고, 로컬 `main`에 `ac742b1`로 커밋했다. `temp/`는 후보 source가 아니므로 auth-surface 정적 검사의 재귀 대상에서 제외했다.
|
||||
|
||||
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- Source URL: https://github.com/fourblocks2020/portfolio-hub/issues/29
|
||||
Source author: genichin
|
||||
Source created_at: 2026-08-18T10:04:45Z
|
||||
Body SHA-256: 833b5c8e2dd4bb6c90331bef58a0fdee48534fc1458a82b646f1581877428b66
|
||||
Snapshot: exact source body begins after this header.
|
||||
-->
|
||||
|
||||
## 배경
|
||||
|
||||
`trex-web` 포트폴리오 상세 화면은 현재 다음 manual 전용 계약만 사용합니다.
|
||||
|
||||
```http
|
||||
GET /v1/portfolios/{portfolio_id}/ledger/manual?limit=50
|
||||
```
|
||||
|
||||
이 계약은 수동으로 기록한 원장만 표시하므로, broker에서 유입된 체결/원장 이벤트를 포함한 포트폴리오의 통합 원장 화면을 제공할 수 없습니다.
|
||||
|
||||
현재 provider 확인 근거:
|
||||
|
||||
- `app/api/v1_portfolios.py`의 manual 목록 route는 `require_read` 뒤에 user/session token 및 portfolio owner를 확인한다.
|
||||
- `app/repositories/postgres.py`의 `list_manual_ledger_entries()`는 `WHERE portfolio_id = %s AND source = 'manual'`로 필터하고 `recorded_at DESC, ledger_entry_id DESC`로 정렬한다.
|
||||
- 도메인 `LedgerEntry`는 `source`를 보유하며 기본값은 `broker`; manual 기록은 `source='manual'`이다.
|
||||
- 현재 응답의 `total`은 전체 일치 건수가 아니라 반환된 `items` 길이이다.
|
||||
|
||||
## 목표
|
||||
|
||||
포트폴리오 소유자가 manual과 broker를 포함한 **모든 출처의 원장 기록**을 하나의 안정적이고 page 가능한 read 계약으로 조회할 수 있게 한다. 소비자 UI는 owner가 제공한 원장 사실을 표시할 뿐, 원장 action이나 잔고 효과를 재계산하지 않는다.
|
||||
|
||||
## 제안 API 초안
|
||||
|
||||
```http
|
||||
GET /v1/portfolios/{portfolio_id}/ledger?limit=50
|
||||
GET /v1/portfolios/{portfolio_id}/ledger?limit=50&cursor=...
|
||||
GET /v1/portfolios/{portfolio_id}/ledger?source=manual&limit=50
|
||||
GET /v1/portfolios/{portfolio_id}/ledger?source=broker&limit=50
|
||||
```
|
||||
|
||||
- `source` 미지정은 모든 출처를 최신순으로 반환한다.
|
||||
- `source`가 지정되면 비어 있지 않은 provenance 문자열을 exact match로 사용한다. 현재 결과가 없는 값도 오류가 아니라 빈 page(`items=[]`, `next_cursor=null`)를 반환한다.
|
||||
- 기본 정렬은 `recorded_at DESC, ledger_entry_id DESC`이며, cursor는 두 값을 함께 고정해 동률에서도 중복/누락 없이 다음 페이지를 반환해야 한다.
|
||||
- 응답 `items`는 적어도 `ledger_entry_id`, `portfolio_id`, `source`, canonical 대문자 `action`, `recorded_at`, `executed_at`(있는 경우), order/execution 식별자(있는 경우), 수량·가격·수수료·세금·note, 기록 후 owner snapshot, reversal 참조와 confirmation evidence를 표현해야 한다.
|
||||
- 새 통합 endpoint는 `side`를 노출하지 않고 canonical 대문자 `action`만 노출한다. 공통 필드와 출처별 필드는 response schema에 명시하며, 해당 사실에 값이 없을 때만 `null`을 반환한다. broker 원장과 manual 원장의 필드 공백을 0이나 추정값으로 채우지 않는다.
|
||||
|
||||
## Open Questions
|
||||
|
||||
각 OQ에서 하나의 선택지를 선택해 주세요. 선택은 Discovery 결정을 위한 것이며 구현·push·배포 승인을 뜻하지 않습니다.
|
||||
|
||||
### OQ-001 — pagination 계약
|
||||
|
||||
- [x] **A. Cursor pagination** — `recorded_at`과 `ledger_entry_id` 기반 opaque cursor, `next_cursor` 반환.
|
||||
- [ ] **B. Offset pagination** — `offset`과 `limit`으로 페이지 이동.
|
||||
- [ ] **C. 단일 무제한 응답** — 모든 원장을 한 응답에 반환.
|
||||
|
||||
**권장안: A** — 원장은 계속 증가하며, 최신순 목록에 새 행이 들어와도 cursor가 offset보다 중복·누락에 강하다. 무제한 응답은 응답 크기와 DB 부하 경계가 없다.
|
||||
|
||||
### OQ-002 — source 필터 정책
|
||||
|
||||
- [x] **A. 전체 출처가 기본값이며, 선택적 `source` filter를 제공한다.**
|
||||
- [ ] **B. 전체 출처만 제공하며, source filter는 제공하지 않는다.**
|
||||
- [ ] **C. manual/broker 전용 endpoint를 계속 분리하고 통합 endpoint는 만들지 않는다.**
|
||||
|
||||
**권장안: A** — 통합 운영 화면의 기본 요구를 충족하면서, manual-only 감사 화면 같은 소비자는 같은 안정적 계약에서 명시적으로 좁힐 수 있다. 기존 `/ledger/manual`은 하위호환성을 위해 유지한다.
|
||||
|
||||
### OQ-003 — read 권한 경계
|
||||
|
||||
- [x] **A. 현재 manual 조회와 동일하게 user/session token + portfolio owner만 허용한다.**
|
||||
- [ ] **B. owner 외 admin도 명시적으로 허용한다.**
|
||||
- [ ] **C. daemon service token에도 portfolio scope 내 조회를 허용한다.**
|
||||
|
||||
**권장안: A** — 최초 소비자인 trex-web의 사용자 상세 조회를 최소 권한으로 충족한다. admin/service 조회는 실제 운영 소비자와 audit 노출 범위를 별도로 확정한 뒤 additive하게 설계하는 편이 안전하다.
|
||||
|
||||
### OQ-004 — 전체 건수(`total`) 의미
|
||||
|
||||
- [x] **A. `next_cursor`만 반환하고 전체 건수는 제공하지 않는다.**
|
||||
- [ ] **B. 정확한 전체 일치 건수를 `total`로 반환한다.**
|
||||
- [ ] **C. 현재 page의 반환 건수를 `total`로 반환한다.**
|
||||
|
||||
**권장안: A** — 현재 manual endpoint의 `total=len(items)`와 전역 건수 의미를 혼동하지 않으며, 목록 UI에 필요한 다음 페이지 판단도 충족한다. 정확한 total은 필요성이 확인될 때 별도 비용·일관성 계약으로 추가한다.
|
||||
|
||||
### OQ-005 — 통합 원장 item의 정규 응답 계약
|
||||
|
||||
- [x] **A. 하나의 canonical `action` 필드를 대문자 enum으로 제공하고, 모든 공통·출처별 필드는 schema에 명시적으로 nullable로 선언한다. — 결정**
|
||||
- 새 통합 endpoint는 `side`를 노출하지 않는다.
|
||||
- `action`은 manual/broker 모두에서 동일한 대문자 vocabulary를 사용한다.
|
||||
- 값이 없는 source별 사실은 0 또는 추정값으로 대체하지 않고 `null`로 표현한다.
|
||||
- [ ] B. `action`과 `side`를 모두 노출하고 source별 field 존재·동치·casing 규칙을 명시한다.
|
||||
- [ ] C. broker와 manual을 별도 item type/discriminator schema로 제공한다.
|
||||
|
||||
### OQ-006 — cursor scope 및 오류 계약
|
||||
|
||||
- [x] **A. opaque cursor는 `portfolio_id`, 선택된 `source` filter 및 `(recorded_at, ledger_entry_id)`를 결속한다. malformed·tampered·다른 portfolio/source에 재사용한 cursor는 HTTP 422로 거부한다. 마지막 page의 `next_cursor`는 `null`이다. (권장)**
|
||||
- 다른 목록 조건의 cursor 혼용을 fail-closed하고, 소비자는 `next_cursor is null`로 종료를 판정한다.
|
||||
- [ ] B. cursor에는 `(recorded_at, ledger_entry_id)`만 담고, portfolio/source 불일치는 구현이 현재 query 결과로 처리한다.
|
||||
- cursor 재사용의 의미와 오류가 모호해진다.
|
||||
- [ ] C. cursor pagination을 유지하되 cursor 오류·scope 동작은 구현 단계에서 정한다.
|
||||
- 외부 API의 실패 동작을 구현자 재량으로 남긴다.
|
||||
|
||||
### OQ-007 — `source` parameter의 허용값 및 결과 없음 의미
|
||||
|
||||
- [x] **A. `source`는 비어 있지 않은 provenance 문자열을 exact match로 허용하며, 알 수 없는 값은 빈 page(`items=[]`, `next_cursor=null`)를 반환한다. — 결정**
|
||||
- all-source 기본값과 미래 producer source를 별도 API 변경 없이 지원한다.
|
||||
- source 값은 portfolio owner 권한 검증 뒤에만 평가한다.
|
||||
- [ ] B. `source`는 현재 `manual`·`broker` enum만 허용하고 그 외 값은 HTTP 422로 거부한다.
|
||||
- [ ] C. known source 조회는 허용하되, source 목록을 API metadata로 제공한 뒤 그 목록 밖 값의 처리 규칙을 별도 계약으로 정한다.
|
||||
|
||||
## 포함 범위
|
||||
|
||||
- source-중립 통합 원장 read endpoint, request/response schema, repository query와 cursor semantics.
|
||||
- portfolio ownership 및 선택된 권한 모델의 명시적 검증.
|
||||
- manual과 broker 행 혼합, 동일 `recorded_at` tie-breaker, page 경계, source filter, token/타인 portfolio 경계에 대한 계약 테스트.
|
||||
- 필요한 DB index 및 migration 여부 검토. 실제 PostgreSQL에서 pagination query plan과 migration을 검증한다.
|
||||
- OpenAPI/API interface 문서 갱신.
|
||||
- 기존 `GET /v1/portfolios/{portfolio_id}/ledger/manual` 계약의 하위호환성 회귀 테스트.
|
||||
|
||||
## 제외 범위
|
||||
|
||||
- ledger 생성·수정·삭제, `REVERSAL` 실행 또는 기존 원장의 재해석.
|
||||
- broker 주문/체결 생성, strategy signal, outbox/producer 동작 변경.
|
||||
- 원장에 없는 경제적 효과·잔고·PnL을 API나 trex-web이 추론하는 기능.
|
||||
- portfolio return aggregate 의미 변경(관련 이슈 #28의 별도 범위).
|
||||
- trex-web UI 구현, push/PR/merge, 운영 배포·릴리스.
|
||||
|
||||
## 수용 기준 초안
|
||||
|
||||
- [ ] OQ-001~007 선택이 issue 본문에 결정으로 기록된다.
|
||||
- [ ] 소유자가 자신의 portfolio에 대해 선택된 source 범위의 원장을 결정된 정렬·pagination 규칙으로 조회할 수 있다.
|
||||
- [ ] 서로 다른 출처의 행이 누락 없이 공통 read model로 표현되며, 새 통합 endpoint는 대문자 canonical `action`만 노출한다. 없는 source별 사실은 추정값이 아닌 schema의 명시된 nullable 값이다.
|
||||
- [ ] cursor 페이지 경계에서 동일 시각 행의 중복·누락이 없고, cursor의 portfolio/source scope 및 malformed·tampered cursor 거부 동작은 OQ-006 결정과 일치한다.
|
||||
- [ ] 선택된 권한 범위 밖의 token 또는 타인 portfolio는 기존 정책에 맞는 거부 응답을 받는다.
|
||||
- [ ] `source` 필터가 선택되면 지정 provenance와 exact match인 row만 반환하고, 결과 없는 non-empty 값은 빈 page(`items=[]`, `next_cursor=null`)를 반환한다. 미지정 기본 동작은 OQ-002·OQ-007 결정과 일치한다.
|
||||
- [ ] `/ledger/manual`의 source=manual 전용·최신순·limit 계약은 하위호환으로 유지된다.
|
||||
- [ ] API schema/OpenAPI와 실제 응답이 일치하고, focused 및 전체 회귀 테스트가 통과한다.
|
||||
|
||||
## 관련
|
||||
|
||||
- 소비자 맥락: `fourblocks2020/trex-web#15`
|
||||
- 별도 집계 의미 변경: #28
|
||||
- 작성 시각: 2026-08-18T19:04:00+09:00
|
||||
|
||||
> **Discovery 상태: APPROVED** — requester가 OQ-001~007을 모두 명시적으로 결정했고, 2026-08-20 KST에 현재 provider/data-flow/ownership 계약과 대조하여 승인했다. 이 승인은 구현·커밋·push·PR·merge·배포·릴리스 승인을 포함하지 않습니다.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0006"
|
||||
---
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: "req-0006"
|
||||
status: approved
|
||||
discovery: "dcy-0006"
|
||||
criteria:
|
||||
create: ["ac-0032", "ac-0033", "ac-0034", "ac-0035", "ac-0036"]
|
||||
update: []
|
||||
retire: []
|
||||
satisfy: []
|
||||
---
|
||||
|
||||
# 포트폴리오 전체 원장 조회 API
|
||||
|
||||
## Objective
|
||||
|
||||
Portfolio owner가 manual·broker를 포함한 모든 원장 source를 안정적이고 page 가능한 단일 read 계약으로 조회하도록 한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- `GET /v1/portfolios/{portfolio_id}/ledger`의 source-neutral 목록, optional exact `source`, latest-first keyset pagination과 signed opaque cursor.
|
||||
- canonical uppercase `action`, nullable source-specific facts, owner-only authorization, OpenAPI/repository/index migration 및 회귀.
|
||||
|
||||
## Constraints
|
||||
|
||||
- 기존 `/ledger/manual` 계약을 변경하지 않는다.
|
||||
- cursor는 portfolio/source와 tuple ordering에 결속하며 잘못된 cursor는 fail-closed HTTP 422다.
|
||||
- source에 없는 사실을 0 또는 추정값으로 만들지 않는다.
|
||||
|
||||
### 현재 구현 평가
|
||||
|
||||
구현 commit `ac742b1150832f6c1cf58744308b4f3ea6803fd4`에서 이 REQ의 모든 AC 동작이 구현됐다. `pytest -q`는 `664 passed, 16 skipped`; focused API/release/auth-surface는 `29 passed`; disposable PostgreSQL fresh migration에서 `20260820_0030`과 index 생성을 확인했다. local main은 origin/main보다 1 commit ahead이며 push·PR·배포·릴리스는 수행하지 않았다. 이 평가는 구현 evidence이며 approved/active status는 normative authority다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Ledger write/reversal 실행, broker/strategy/outbox producer 변경, UI 구현, aggregate #28 변경, push·PR·배포·릴리스는 포함하지 않는다.
|
||||
@@ -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`
|
||||
|
||||
+349
-41
@@ -8,18 +8,37 @@ REQ-043: GET /v1/portfolios/{id}/holdings/cost-basis — current holding WAC cos
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, Permission, require_read, require_trade_write
|
||||
from app.core.config import get_settings
|
||||
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
|
||||
from app.domain.models import Portfolio, PortfolioComment
|
||||
from app.domain.models import CorporateAction, Portfolio, PortfolioComment
|
||||
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
|
||||
from app.domain.services import PORTFOLIO_NOT_FOUND_MESSAGE
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.repositories.portfolio_repository import LedgerTradeConflictError
|
||||
from app.schemas.corporate_actions import (
|
||||
CorporateActionRequest,
|
||||
CorporateActionResponse,
|
||||
)
|
||||
from app.schemas.v1 import (
|
||||
CorporateActionLedgerHistoryItem,
|
||||
DividendLedgerHistoryItem,
|
||||
DividendManualLedgerResponse,
|
||||
HoldingsCostBasisResponse,
|
||||
LedgerHistoryItem,
|
||||
LedgerHistoryResponse,
|
||||
LedgerTradeRequest,
|
||||
LedgerTradeResponse,
|
||||
ManualLedgerHistoryResponse,
|
||||
@@ -35,7 +54,6 @@ from app.schemas.v1 import (
|
||||
PortfolioReturnSummaryResponse,
|
||||
PortfolioTagListResponse,
|
||||
PortfolioUpdateRequest,
|
||||
HoldingsCostBasisResponse,
|
||||
RealizedPnlItem,
|
||||
RealizedPnlResponse,
|
||||
TagApplyRequest,
|
||||
@@ -44,7 +62,10 @@ from app.schemas.v1 import (
|
||||
TradeHistoryItem,
|
||||
TradeHistoryResponse,
|
||||
)
|
||||
from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError
|
||||
from app.services.strategy_set_service import (
|
||||
StrategyInactiveError,
|
||||
StrategyNotFoundError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/portfolios", tags=["v1"])
|
||||
@@ -108,6 +129,7 @@ def _portfolio_to_item(
|
||||
# REQ-055: 전략·시장 메타데이터 additive 필드
|
||||
active_strategy_id=portfolio.active_strategy_id,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_nick=portfolio.strategy_nick,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
strategy_revision=portfolio.strategy_revision,
|
||||
markets=portfolio.markets,
|
||||
@@ -174,11 +196,13 @@ def create_portfolio_v1(
|
||||
)
|
||||
|
||||
name = body.name or f"{body.broker}-{body.asset_id[:8]}"
|
||||
strategy_set = repo.get_strategy_set(body.strategy_id)
|
||||
if strategy_set is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {body.strategy_id}")
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}")
|
||||
strategy_set = None
|
||||
if body.strategy_id is not None:
|
||||
strategy_set = repo.get_strategy_set(body.strategy_id)
|
||||
if strategy_set is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {body.strategy_id}")
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}")
|
||||
|
||||
_require_valid_daemon_uuid(body.managed_by_daemon_id)
|
||||
|
||||
@@ -193,8 +217,8 @@ def create_portfolio_v1(
|
||||
asset_quantity=body.asset_quantity,
|
||||
asset_price=body.asset_price,
|
||||
profit_buffer=body.profit_buffer,
|
||||
active_strategy_id=strategy_set.id,
|
||||
avabile_strategies=[strategy_set.id],
|
||||
active_strategy_id=strategy_set.id if strategy_set is not None else None,
|
||||
avabile_strategies=[strategy_set.id] if strategy_set is not None else None,
|
||||
managed_by_daemon_id=body.managed_by_daemon_id,
|
||||
)
|
||||
except HTTPException:
|
||||
@@ -581,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
|
||||
@@ -636,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",
|
||||
@@ -664,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,
|
||||
@@ -671,18 +710,41 @@ def record_ledger_trade_v1(
|
||||
)
|
||||
|
||||
|
||||
def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
||||
def _manual_entry_to_response(entry) -> ManualLedgerResponse | DividendManualLedgerResponse:
|
||||
action = (entry.action or entry.side or "").upper()
|
||||
amount = entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None
|
||||
if action == "DIVIDEND":
|
||||
amount_decimal = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price))
|
||||
tax_decimal = entry.tax_amount_decimal if entry.tax_amount_decimal is not None else Decimal(str(entry.tax_amount))
|
||||
return DividendManualLedgerResponse(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
action=action,
|
||||
amount=str(amount_decimal),
|
||||
quantity=entry.quantity,
|
||||
price=entry.price,
|
||||
fee=entry.fee_amount,
|
||||
tax=str(tax_decimal),
|
||||
executed_at=entry.executed_at,
|
||||
note=entry.note,
|
||||
recorded_at=entry.recorded_at,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
asset_quantity_after=entry.asset_quantity_after,
|
||||
profit_buffer_after=entry.profit_buffer_after,
|
||||
total_value_after=entry.total_value_after,
|
||||
source=entry.source or "manual",
|
||||
target_ledger_entry_id=entry.target_ledger_entry_id,
|
||||
confirmation_evidence=entry.confirmation_evidence,
|
||||
)
|
||||
return ManualLedgerResponse(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
action=action,
|
||||
amount=amount,
|
||||
amount=float(entry.price) if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None,
|
||||
quantity=entry.quantity,
|
||||
price=entry.price,
|
||||
fee=entry.fee_amount,
|
||||
tax=entry.tax_amount,
|
||||
tax=float(entry.tax_amount),
|
||||
executed_at=entry.executed_at,
|
||||
note=entry.note,
|
||||
recorded_at=entry.recorded_at,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
@@ -696,6 +758,8 @@ def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
||||
|
||||
|
||||
def _manual_error_code(message: str) -> str:
|
||||
if "POTENTIAL_DUPLICATE_DIVIDEND" in message:
|
||||
return "POTENTIAL_DUPLICATE_DIVIDEND"
|
||||
if "LEDGER_ENTRY_NOT_FOUND" in message or "원장 항목을 찾을 수 없습니다" in message:
|
||||
return "LEDGER_ENTRY_NOT_FOUND"
|
||||
if "INSUFFICIENT_CASH" in message or "현금" in message:
|
||||
@@ -709,16 +773,142 @@ def _manual_error_code(message: str) -> str:
|
||||
return "MANUAL_LEDGER_CONFLICT"
|
||||
|
||||
|
||||
def _extract_duplicate_dividend_entry_id(message: str) -> int | None:
|
||||
prefix = "POTENTIAL_DUPLICATE_DIVIDEND:"
|
||||
if not message.startswith(prefix):
|
||||
return None
|
||||
try:
|
||||
return int(message.removeprefix(prefix))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _manual_error_message(code: str) -> str:
|
||||
if code == "LEDGER_ENTRY_NOT_FOUND":
|
||||
return "Target ledger entry not found"
|
||||
if code == "POTENTIAL_DUPLICATE_DIVIDEND":
|
||||
return "Potential duplicate dividend entry detected"
|
||||
if code == "INSUFFICIENT_CASH":
|
||||
return "Insufficient cash balance"
|
||||
if code == "INSUFFICIENT_QUANTITY":
|
||||
return "Insufficient asset quantity"
|
||||
if code == "INSUFFICIENT_BUFFER":
|
||||
return "Insufficient profit buffer"
|
||||
if code == "LEDGER_ENTRY_ALREADY_REVERSED":
|
||||
return "Ledger entry is already reversed"
|
||||
return "Manual ledger request could not be processed"
|
||||
|
||||
|
||||
def _encode_ledger_cursor(*, portfolio_id: str, source: str | None, recorded_at: datetime, ledger_entry_id: int) -> str:
|
||||
payload = {
|
||||
"portfolio_id": portfolio_id,
|
||||
"source": source,
|
||||
"recorded_at": recorded_at.isoformat(),
|
||||
"ledger_entry_id": ledger_entry_id,
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
|
||||
signature = hmac.new(get_settings().ledger_cursor_secret.encode(), raw, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(raw + signature).decode().rstrip("=")
|
||||
|
||||
|
||||
def _decode_ledger_cursor(cursor: str, *, portfolio_id: str, source: str | None) -> tuple[datetime, int]:
|
||||
try:
|
||||
encoded = cursor.encode()
|
||||
raw_and_signature = base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
|
||||
if base64.urlsafe_b64encode(raw_and_signature).rstrip(b"=") != encoded:
|
||||
raise ValueError
|
||||
raw, signature = raw_and_signature[:-32], raw_and_signature[-32:]
|
||||
expected = hmac.new(get_settings().ledger_cursor_secret.encode(), raw, hashlib.sha256).digest()
|
||||
payload = json.loads(raw)
|
||||
recorded_at = datetime.fromisoformat(payload["recorded_at"])
|
||||
ledger_entry_id = int(payload["ledger_entry_id"])
|
||||
if not hmac.compare_digest(signature, expected) or payload["portfolio_id"] != portfolio_id or payload["source"] != source:
|
||||
raise ValueError
|
||||
if recorded_at.tzinfo is None:
|
||||
raise ValueError
|
||||
return recorded_at, ledger_entry_id
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError, binascii.Error):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid ledger cursor") from None
|
||||
|
||||
|
||||
def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem | DividendLedgerHistoryItem | CorporateActionLedgerHistoryItem:
|
||||
if getattr(entry, "corporate_action", None):
|
||||
event = entry.corporate_action
|
||||
return CorporateActionLedgerHistoryItem(
|
||||
ledger_entry_id=entry.ledger_entry_id, portfolio_id=entry.portfolio_id,
|
||||
source="corporate_action", event_id=str(event["corporate_action_id"]),
|
||||
event_type=event["action_type"], effective_date=event["effective_date"],
|
||||
quantity_before=event["quantity_before"], quantity_after=event["quantity_after"],
|
||||
average_price_before=event["average_price_before"], average_price_after=event["average_price_after"],
|
||||
total_cost_basis=event["total_cost_basis"], cash_balance=event["cash_balance"],
|
||||
profit_buffer=event["profit_buffer"], evidence_ref=event["evidence_ref"],
|
||||
actor_user_id=event.get("actor_user_id"), recorded_at=entry.recorded_at,
|
||||
execution_venue=entry.execution_venue, order_venue=entry.order_venue,
|
||||
)
|
||||
action = (entry.action or entry.side or "").upper()
|
||||
if action == "DIVIDEND":
|
||||
amount_decimal = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price))
|
||||
tax_decimal = entry.tax_amount_decimal if entry.tax_amount_decimal is not None else Decimal(str(entry.tax_amount))
|
||||
return DividendLedgerHistoryItem(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
source=entry.source or "broker",
|
||||
action=action,
|
||||
recorded_at=entry.recorded_at,
|
||||
executed_at=entry.executed_at,
|
||||
order_id=entry.order_id,
|
||||
execution_id=entry.execution_id,
|
||||
quantity=entry.quantity,
|
||||
amount=str(amount_decimal),
|
||||
price=entry.price,
|
||||
fee_amount=entry.fee_amount,
|
||||
tax_amount=str(tax_decimal),
|
||||
note=entry.note,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
asset_quantity_after=entry.asset_quantity_after,
|
||||
profit_buffer_after=entry.profit_buffer_after,
|
||||
total_value_after=entry.total_value_after,
|
||||
target_ledger_entry_id=entry.target_ledger_entry_id,
|
||||
confirmation_evidence=entry.confirmation_evidence,
|
||||
execution_venue=entry.execution_venue,
|
||||
order_venue=entry.order_venue,
|
||||
)
|
||||
return LedgerHistoryItem(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
source=entry.source or "broker",
|
||||
action=action,
|
||||
recorded_at=entry.recorded_at,
|
||||
executed_at=entry.executed_at,
|
||||
order_id=entry.order_id,
|
||||
execution_id=entry.execution_id,
|
||||
quantity=entry.quantity,
|
||||
amount=float(entry.price) if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None,
|
||||
price=entry.price,
|
||||
fee_amount=entry.fee_amount,
|
||||
tax_amount=float(entry.tax_amount),
|
||||
note=entry.note,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
asset_quantity_after=entry.asset_quantity_after,
|
||||
profit_buffer_after=entry.profit_buffer_after,
|
||||
total_value_after=entry.total_value_after,
|
||||
target_ledger_entry_id=entry.target_ledger_entry_id,
|
||||
confirmation_evidence=entry.confirmation_evidence,
|
||||
execution_venue=entry.execution_venue,
|
||||
order_venue=entry.order_venue,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{portfolio_id}/ledger/manual",
|
||||
response_model=ManualLedgerResponse,
|
||||
response_model=ManualLedgerResponse | DividendManualLedgerResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def record_manual_ledger_v1(
|
||||
portfolio_id: str,
|
||||
body: ManualLedgerRequest,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
) -> ManualLedgerResponse:
|
||||
) -> ManualLedgerResponse | DividendManualLedgerResponse:
|
||||
if auth.token_type in {"service", "client"} or auth.user_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="manual ledger requires user/session token")
|
||||
|
||||
@@ -742,15 +932,26 @@ def record_manual_ledger_v1(
|
||||
entry = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action=body.action,
|
||||
amount=float(body.amount) if body.amount is not None else None,
|
||||
quantity=float(body.quantity) if body.quantity is not None else None,
|
||||
price=float(body.price) if body.price is not None else None,
|
||||
fee=float(body.fee),
|
||||
tax=float(body.tax),
|
||||
amount=body.amount,
|
||||
quantity=body.quantity,
|
||||
price=body.price,
|
||||
fee=body.fee,
|
||||
tax=body.tax,
|
||||
executed_at=(
|
||||
datetime(
|
||||
body.executed_at.year,
|
||||
body.executed_at.month,
|
||||
body.executed_at.day,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
if body.executed_at is not None
|
||||
else None
|
||||
),
|
||||
target_ledger_entry_id=body.target_ledger_entry_id,
|
||||
note=body.note,
|
||||
actor_user_id=auth.user_id,
|
||||
confirmation_evidence=evidence,
|
||||
confirm_duplicate=body.confirm_duplicate,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -759,9 +960,18 @@ def record_manual_ledger_v1(
|
||||
code = _manual_error_code(message)
|
||||
if code == "LEDGER_ENTRY_NOT_FOUND":
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
detail: str | dict[str, object] = {"code": code, "message": _manual_error_message(code)}
|
||||
elif code == "POTENTIAL_DUPLICATE_DIVIDEND":
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
detail = {
|
||||
"code": code,
|
||||
"message": _manual_error_message(code),
|
||||
"matched_ledger_entry_id": _extract_duplicate_dividend_entry_id(message),
|
||||
}
|
||||
else:
|
||||
status_code = status.HTTP_409_CONFLICT if code != "MANUAL_LEDGER_CONFLICT" else status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
raise HTTPException(status_code=status_code, detail={"code": code, "message": message}) from exc
|
||||
detail = {"code": code, "message": _manual_error_message(code)}
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
return _manual_entry_to_response(entry)
|
||||
@@ -787,6 +997,43 @@ def list_manual_ledger_v1(
|
||||
return ManualLedgerHistoryResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{portfolio_id}/ledger", response_model=LedgerHistoryResponse)
|
||||
def list_ledger_v1(
|
||||
portfolio_id: str,
|
||||
source: str | None = Query(default=None, min_length=1),
|
||||
cursor: str | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
auth: AuthContext = Depends(require_read),
|
||||
) -> LedgerHistoryResponse:
|
||||
if auth.token_type in {"service", "client"} or auth.user_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ledger requires user/session token")
|
||||
before_recorded_at = before_ledger_entry_id = None
|
||||
if cursor is not None:
|
||||
before_recorded_at, before_ledger_entry_id = _decode_ledger_cursor(cursor, portfolio_id=portfolio_id, source=source)
|
||||
repo = _get_repo()
|
||||
try:
|
||||
_get_owner_portfolio_or_404(auth, portfolio_id, repo)
|
||||
entries = repo.list_ledger_entries(
|
||||
portfolio_id,
|
||||
source=source,
|
||||
before_recorded_at=before_recorded_at,
|
||||
before_ledger_entry_id=before_ledger_entry_id,
|
||||
limit=limit + 1,
|
||||
)
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
page, extra = entries[:limit], entries[limit:]
|
||||
next_cursor = None
|
||||
if extra and page:
|
||||
last = page[-1]
|
||||
if last.recorded_at is None:
|
||||
raise RuntimeError("ledger entry is missing recorded_at")
|
||||
next_cursor = _encode_ledger_cursor(
|
||||
portfolio_id=portfolio_id, source=source, recorded_at=last.recorded_at, ledger_entry_id=last.ledger_entry_id
|
||||
)
|
||||
return LedgerHistoryResponse(items=[_ledger_entry_to_history_item(entry) for entry in page], next_cursor=next_cursor)
|
||||
|
||||
|
||||
@router.get("/{portfolio_id}/returns/summary", response_model=PortfolioReturnSummaryResponse)
|
||||
def get_portfolio_return_summary_v1(
|
||||
portfolio_id: str,
|
||||
@@ -1056,27 +1303,48 @@ 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
|
||||
cost_basis_per_unit = wac_result.current_wac if current_quantity > 0 else None
|
||||
total_cost_basis = (
|
||||
cost_basis_per_unit * current_quantity
|
||||
if cost_basis_per_unit is not None and current_quantity > 0
|
||||
else None
|
||||
)
|
||||
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
|
||||
if cost_basis_per_unit is not None and current_quantity > 0
|
||||
else None
|
||||
)
|
||||
|
||||
return HoldingsCostBasisResponse(
|
||||
portfolio_id=portfolio_id,
|
||||
@@ -1087,3 +1355,43 @@ def get_holdings_cost_basis(
|
||||
cost_basis_from=wac_result.cost_basis_from,
|
||||
data_completeness_warning=wac_result.data_completeness_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{portfolio_id}/corporate-actions", response_model=CorporateActionResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_corporate_action(
|
||||
portfolio_id: str,
|
||||
payload: CorporateActionRequest,
|
||||
response: Response,
|
||||
auth: AuthContext = Depends(require_trade_write), # noqa: B008 - FastAPI dependency declaration
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
||||
):
|
||||
if not idempotency_key or not idempotency_key.strip():
|
||||
raise HTTPException(status_code=422, detail="Idempotency-Key must not be empty")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
portfolio = repo.get(portfolio_id)
|
||||
if portfolio is None:
|
||||
raise HTTPException(status_code=404, detail="Portfolio not found")
|
||||
if portfolio.owner_user_id != auth.user_id and auth.permission != Permission.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Portfolio ownership mismatch")
|
||||
if portfolio.managed_by_daemon_id and not payload.confirm_daemon_managed:
|
||||
raise HTTPException(status_code=409, detail="DAEMON_MANAGED_CONFIRMATION_REQUIRED")
|
||||
from app.domain.corporate_actions import canonical_payload_hash
|
||||
digest = canonical_payload_hash(payload)
|
||||
# The repository derives all WAC and immutable snapshot fields under its row lock.
|
||||
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, payload.action_type, payload.effective_date, payload.quantity_before, payload.quantity_after, Decimal(0), Decimal(0), Decimal(0), Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), payload.evidence_ref, idempotency_key, digest, 0, auth.user_id)
|
||||
saved = repo.record_corporate_action(
|
||||
portfolio_id,
|
||||
action=action,
|
||||
idempotency_key=idempotency_key,
|
||||
payload_hash=digest,
|
||||
resume_partial=payload.resume_partial,
|
||||
)
|
||||
return CorporateActionResponse(**saved.__dict__)
|
||||
except ValueError as exc:
|
||||
code = str(exc)
|
||||
if code == PORTFOLIO_NOT_FOUND_MESSAGE:
|
||||
raise HTTPException(status_code=404, detail="Portfolio not found") from exc
|
||||
raise HTTPException(status_code=409 if code.startswith(("IDEMPOTENCY", "CORPORATE_ACTION_")) else 400, detail=code) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
@@ -11,7 +12,11 @@ from app.core.config import Settings, get_settings
|
||||
from app.domain.models import StrategySet
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.portfolio import CreateStrategySetRequest, UpdateStrategySetRequest
|
||||
from app.schemas.v1 import StrategyCreateRequest, StrategyResponse, StrategyUpdateRequest
|
||||
from app.schemas.v1 import (
|
||||
StrategyCreateRequest,
|
||||
StrategyResponse,
|
||||
StrategyUpdateRequest,
|
||||
)
|
||||
from app.services.strategy_set_service import (
|
||||
StrategyConflictError,
|
||||
StrategyInactiveError,
|
||||
@@ -44,6 +49,7 @@ def _response(strategy: StrategySet) -> StrategyResponse:
|
||||
parsed = {}
|
||||
return StrategyResponse(
|
||||
strategy_id=strategy.id,
|
||||
nick=strategy.nick,
|
||||
name=strategy.name,
|
||||
config=parsed if isinstance(parsed, dict) else {},
|
||||
status=strategy.status,
|
||||
@@ -53,7 +59,7 @@ def _response(strategy: StrategySet) -> StrategyResponse:
|
||||
)
|
||||
|
||||
|
||||
def _raise_strategy_error(exc: ValueError) -> None:
|
||||
def _raise_strategy_error(exc: ValueError) -> NoReturn:
|
||||
if isinstance(exc, StrategyNotFoundError):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if isinstance(exc, (StrategyInactiveError, StrategyInUseError, StrategyConflictError)):
|
||||
@@ -129,14 +135,18 @@ def create_admin_strategy(
|
||||
) -> StrategyResponse:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
strategy = service.create(
|
||||
CreateStrategySetRequest(
|
||||
name=body.name,
|
||||
config=_canonical_config(body.config),
|
||||
status=body.status,
|
||||
try:
|
||||
strategy = service.create(
|
||||
CreateStrategySetRequest(
|
||||
name=body.name,
|
||||
nick=body.nick,
|
||||
config=_canonical_config(body.config),
|
||||
status=body.status,
|
||||
)
|
||||
)
|
||||
)
|
||||
return _response(strategy)
|
||||
return _response(strategy)
|
||||
except ValueError as exc:
|
||||
_raise_strategy_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
@@ -155,6 +165,7 @@ def update_admin_strategy(
|
||||
strategy_id,
|
||||
UpdateStrategySetRequest(
|
||||
name=body.name,
|
||||
nick=body.nick,
|
||||
config=_canonical_config(body.config) if body.config is not None else None,
|
||||
status=body.status,
|
||||
),
|
||||
|
||||
@@ -274,6 +274,66 @@ def sell_portfolio(
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("corporate-action")
|
||||
def corporate_action_portfolio(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(...),
|
||||
user: str = typer.Option(..., "--user"),
|
||||
action_type: str = typer.Option("stock_consolidation", "--type"),
|
||||
effective_date: str = typer.Option(..., "--effective-date"),
|
||||
quantity_before: float = typer.Option(..., "--quantity-before", min=0.0000001),
|
||||
quantity_after: float = typer.Option(..., "--quantity-after", min=0.0000001),
|
||||
evidence_ref: str = typer.Option(..., "--evidence-ref"),
|
||||
idempotency_key: str = typer.Option(..., "--idempotency-key"),
|
||||
resume_partial: bool = typer.Option(
|
||||
False,
|
||||
"--resume-partial",
|
||||
help="Resume only a verified quantity-only partial corporate action.",
|
||||
),
|
||||
force: bool = typer.Option(False, "--force"),
|
||||
) -> None:
|
||||
"""Record an auditable stock consolidation without a trade."""
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domain.corporate_actions import (
|
||||
calculate_stock_consolidation,
|
||||
canonical_payload_hash,
|
||||
)
|
||||
from app.domain.models import CorporateAction
|
||||
if action_type != "stock_consolidation":
|
||||
ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_TYPE")
|
||||
raise typer.Exit(code=1)
|
||||
try:
|
||||
day = date.fromisoformat(effective_date)
|
||||
except ValueError:
|
||||
ctx.obj.console.print("Error: INVALID_EFFECTIVE_DATE")
|
||||
raise typer.Exit(code=1)
|
||||
if quantity_after >= quantity_before:
|
||||
ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_RATIO")
|
||||
raise typer.Exit(code=1)
|
||||
_confirm(force, f"Record stock consolidation {quantity_before:g} → {quantity_after:g}; cash/profit buffer unchanged?")
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
portfolio = repository.get(portfolio_id)
|
||||
if not idempotency_key.strip():
|
||||
raise ValueError("Idempotency-Key must not be empty")
|
||||
payload = {"action_type": action_type, "effective_date": day.isoformat(), "quantity_before": str(quantity_before), "quantity_after": str(quantity_after), "evidence_ref": evidence_ref, "confirm_daemon_managed": False, "resume_partial": resume_partial}
|
||||
digest = canonical_payload_hash(payload)
|
||||
calc = calculate_stock_consolidation(Decimal(str(quantity_before)), Decimal(str(quantity_after)), Decimal(0))
|
||||
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, action_type, day, calc.quantity_before, calc.quantity_after, calc.average_price_before, calc.average_price_after, calc.total_cost_basis, Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), evidence_ref, idempotency_key, digest, 0, user)
|
||||
return repository.record_corporate_action(
|
||||
portfolio_id,
|
||||
action=action,
|
||||
idempotency_key=idempotency_key,
|
||||
payload_hash=digest,
|
||||
resume_partial=resume_partial,
|
||||
)
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("invest")
|
||||
def invest_portfolio(
|
||||
ctx: typer.Context,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-1
@@ -18,7 +18,7 @@ if env_file.exists():
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "PortfolioHub"
|
||||
app_version: str = "0.1.1"
|
||||
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")
|
||||
@@ -27,6 +27,7 @@ class Settings(BaseSettings):
|
||||
read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS")
|
||||
trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_TOKENS")
|
||||
admin_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_ADMIN_TOKENS")
|
||||
ledger_cursor_secret: str = Field(default="development-ledger-cursor-secret", validation_alias="PORTFOLIOHUB_LEDGER_CURSOR_SECRET")
|
||||
db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED")
|
||||
session_token_ttl_hours: int = Field(default=12, validation_alias="PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS")
|
||||
client_token_default_ttl_days: int = Field(default=365, validation_alias="PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""portfolio_ledger: keyset pagination index for unified ledger reads
|
||||
|
||||
Revision ID: 20260820_0030
|
||||
Revises: 20260814_0029
|
||||
Create Date: 2026-08-20 10:50:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "20260820_0030"
|
||||
down_revision = "20260814_0029"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_INDEX = "idx_portfolio_ledger_portfolio_recorded_entry_desc"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().execute(
|
||||
text(
|
||||
f"CREATE INDEX IF NOT EXISTS {_INDEX} "
|
||||
"ON portfolio_ledger (portfolio_id, recorded_at DESC, ledger_entry_id DESC)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().execute(text(f"DROP INDEX IF EXISTS {_INDEX}"))
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Additive storage for auditable, idempotent stock consolidations."""
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "20260913_0031"
|
||||
down_revision = "20260820_0030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_corporate_actions (
|
||||
corporate_action_id UUID PRIMARY KEY,
|
||||
portfolio_id TEXT NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE RESTRICT,
|
||||
asset_id UUID NOT NULL REFERENCES assets(asset_id) ON DELETE RESTRICT,
|
||||
action_type TEXT NOT NULL CHECK (action_type = 'stock_consolidation'),
|
||||
effective_date DATE NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
actor_user_id TEXT, evidence_ref TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL, payload_hash TEXT NOT NULL,
|
||||
quantity_before NUMERIC(20,8) NOT NULL, quantity_after NUMERIC(20,8) NOT NULL,
|
||||
average_price_before NUMERIC(20,8) NOT NULL, average_price_after NUMERIC(20,8) NOT NULL,
|
||||
total_cost_basis NUMERIC(20,8) NOT NULL, cash_balance NUMERIC(20,8) NOT NULL,
|
||||
profit_buffer NUMERIC(20,8) NOT NULL, ledger_entry_id BIGINT,
|
||||
UNIQUE (portfolio_id, idempotency_key)
|
||||
)"""))
|
||||
op.get_bind().execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_corporate_actions_portfolio "
|
||||
"ON portfolio_corporate_actions (portfolio_id, effective_date)"
|
||||
))
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().execute(text("DROP TABLE IF EXISTS portfolio_corporate_actions"))
|
||||
@@ -0,0 +1,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")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pure domain rules for portfolio stock consolidations."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
STORAGE_QUANTUM = Decimal("0.00000001")
|
||||
|
||||
|
||||
def quantize_stored_decimal(value: Decimal) -> Decimal:
|
||||
"""Round a monetary value to the NUMERIC(*, 8) persistence contract."""
|
||||
return value.quantize(STORAGE_QUANTUM, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def canonical_payload_hash(payload: object) -> str:
|
||||
if hasattr(payload, "model_dump"):
|
||||
excluded_fields = (
|
||||
{"resume_partial"} if not getattr(payload, "resume_partial", False) else set()
|
||||
)
|
||||
payload = payload.model_dump(mode="json", exclude_none=True, exclude=excluded_fields)
|
||||
elif isinstance(payload, dict) and payload.get("resume_partial") is False:
|
||||
payload = {key: value for key, value in payload.items() if key != "resume_partial"}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
class CorporateActionError(ValueError):
|
||||
code = "INVALID_CORPORATE_ACTION_RATIO"
|
||||
def __init__(self, message: str, code: str | None = None):
|
||||
super().__init__(message); self.code = code or self.code
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StockConsolidation:
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
ratio: Decimal
|
||||
|
||||
def calculate_stock_consolidation(quantity_before: Decimal, quantity_after: Decimal, average_price_before: Decimal) -> StockConsolidation:
|
||||
q0, q1, wac = map(Decimal, (quantity_before, quantity_after, average_price_before))
|
||||
if q0 <= 0 or q1 <= 0 or wac < 0:
|
||||
raise CorporateActionError("quantities must be positive and price non-negative")
|
||||
ratio = q1 / q0
|
||||
if ratio >= 1 or ratio <= 0:
|
||||
raise CorporateActionError("stock consolidation must reduce quantity")
|
||||
if q1 != q1.to_integral_value() or q0 != q0.to_integral_value():
|
||||
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
|
||||
# Preserve the authoritative pre-action total independently. A per-unit WAC
|
||||
# rounded to the storage scale cannot always multiply back to that total
|
||||
# exactly, and that harmless decimal residue is not a fractional share.
|
||||
total = quantize_stored_decimal(q0 * wac)
|
||||
before = quantize_stored_decimal(wac)
|
||||
after = quantize_stored_decimal(total / q1)
|
||||
return StockConsolidation(q0, q1, before, after, total, ratio)
|
||||
|
||||
def apply_corporate_action_to_wac(quantity: Decimal, wac: Decimal, quantity_after: Decimal) -> tuple[Decimal, Decimal]:
|
||||
result = calculate_stock_consolidation(quantity, quantity_after, wac)
|
||||
return result.quantity_after, result.average_price_after
|
||||
+32
-3
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class MarketCode(str, Enum):
|
||||
@@ -120,6 +121,7 @@ class Portfolio:
|
||||
profit_buffer: float = 0.0
|
||||
active_strategy_id: str | None = None
|
||||
strategy_name: str | None = None # REQ-055: strategy_sets.name JOIN 결과
|
||||
strategy_nick: str | None = None # REQ-055: strategy_sets.nick JOIN 결과
|
||||
strategy_params: dict[str, Any] = field(default_factory=dict) # REQ-055: strategy_sets.config 파싱 결과
|
||||
strategy_revision: int | None = None
|
||||
markets: list[str] = field(default_factory=list) # REQ-055: assets.markets JOIN 결과
|
||||
@@ -130,17 +132,42 @@ class Portfolio:
|
||||
managed_by_daemon_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorporateAction:
|
||||
corporate_action_id: str
|
||||
portfolio_id: str
|
||||
asset_id: str | None
|
||||
action_type: str
|
||||
effective_date: date
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
cash_balance: Decimal
|
||||
profit_buffer: Decimal
|
||||
evidence_ref: str
|
||||
idempotency_key: str
|
||||
payload_hash: str
|
||||
ledger_entry_id: int
|
||||
actor_user_id: str | None = None
|
||||
recorded_at: datetime | None = None
|
||||
idempotent_replay: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerEntry:
|
||||
portfolio_id: str
|
||||
quantity: float
|
||||
price: float
|
||||
price_decimal: Decimal | None = None
|
||||
ledger_entry_id: int = 0
|
||||
order_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
side: str | None = None
|
||||
fee_amount: float = 0.0
|
||||
tax_amount: float = 0.0
|
||||
tax_amount_decimal: Decimal | None = None
|
||||
executed_at: datetime | None = None
|
||||
recorded_at: datetime | None = None
|
||||
cash_balance_after: float = 0.0
|
||||
@@ -154,6 +181,9 @@ 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:
|
||||
if self.side is None and self.action:
|
||||
@@ -194,4 +224,3 @@ class BrokerCredential:
|
||||
credential_version: int = 1
|
||||
last_rotated_at: datetime | None = None
|
||||
daemon_id: str | None = None
|
||||
|
||||
|
||||
+77
-21
@@ -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]],
|
||||
@@ -170,6 +173,21 @@ def calculate_realized_pnl(
|
||||
if running_qty < 0:
|
||||
running_qty = 0.0
|
||||
|
||||
elif side == "stock_consolidation":
|
||||
before = float(row.get("quantity_before", running_qty))
|
||||
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
|
||||
if before <= 0 or after <= 0 or running_qty <= 0:
|
||||
warning_triggered = True
|
||||
continue
|
||||
if abs(running_qty - before) > 1e-9:
|
||||
warning_triggered = True
|
||||
running_qty = after
|
||||
total_cost_basis = row.get("total_cost_basis")
|
||||
if total_cost_basis is not None:
|
||||
running_wac = float(total_cost_basis) / after
|
||||
else:
|
||||
running_wac = float(row.get("average_price_after", running_wac * before / after))
|
||||
|
||||
warning: str | None = None
|
||||
if warning_triggered:
|
||||
warning = (
|
||||
@@ -189,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(
|
||||
@@ -225,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()
|
||||
@@ -237,23 +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":
|
||||
quantity_after = row.get("quantity_after")
|
||||
total_cost_basis = row.get("total_cost_basis")
|
||||
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 산출이 부정확할 수 있습니다. "
|
||||
@@ -261,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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Callable, Protocol
|
||||
|
||||
from app.domain.models import LedgerEntry, LiveAssetSource, Portfolio, StrategySet
|
||||
|
||||
_NOT_FOUND_MSG = "포트폴리오를 찾을 수 없습니다"
|
||||
PORTFOLIO_NOT_FOUND_MESSAGE = "포트폴리오를 찾을 수 없습니다"
|
||||
_INVEST_ACTION_MAP = {
|
||||
"deposit": "INVEST",
|
||||
"profit": "PROFIT",
|
||||
@@ -250,7 +250,7 @@ def apply_reset(
|
||||
portfolio = repo.reset(portfolio_id)
|
||||
return portfolio, False
|
||||
except ValueError as exc:
|
||||
if str(exc) != _NOT_FOUND_MSG:
|
||||
if str(exc) != PORTFOLIO_NOT_FOUND_MESSAGE:
|
||||
raise
|
||||
symbol = infer_symbol(portfolio_id)
|
||||
portfolio = repo.get_or_create(portfolio_id, symbol, initial_cash=0.0)
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, CorporateAction, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
||||
|
||||
|
||||
class LedgerTradeConflictError(ValueError):
|
||||
"""A duplicate broker-ledger key disagrees with its persisted payload."""
|
||||
|
||||
|
||||
class PortfolioRepository(Protocol):
|
||||
@@ -82,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]: ...
|
||||
@@ -243,7 +250,14 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ...
|
||||
|
||||
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet: ...
|
||||
def create_strategy_set(
|
||||
self,
|
||||
name: str,
|
||||
config: str,
|
||||
*,
|
||||
nick: str | None = None,
|
||||
status: str = "active",
|
||||
) -> StrategySet: ...
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None: ...
|
||||
|
||||
@@ -254,6 +268,7 @@ class PortfolioRepository(Protocol):
|
||||
id: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
nick: str | None = None,
|
||||
config: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> StrategySet: ...
|
||||
@@ -275,6 +290,16 @@ class PortfolioRepository(Protocol):
|
||||
note: str = "",
|
||||
) -> None: ...
|
||||
|
||||
def record_corporate_action(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: CorporateAction,
|
||||
idempotency_key: str,
|
||||
payload_hash: str,
|
||||
resume_partial: bool = False,
|
||||
) -> CorporateAction: ...
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ...
|
||||
|
||||
def list_trades_by_date(
|
||||
@@ -305,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(
|
||||
@@ -312,19 +339,31 @@ class PortfolioRepository(Protocol):
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: str,
|
||||
amount: float | None = None,
|
||||
quantity: float | None = None,
|
||||
price: float | None = None,
|
||||
fee: float = 0.0,
|
||||
tax: float = 0.0,
|
||||
amount: Decimal | None = None,
|
||||
quantity: Decimal | None = None,
|
||||
price: Decimal | None = None,
|
||||
fee: Decimal = Decimal("0"),
|
||||
tax: Decimal = Decimal("0"),
|
||||
executed_at: datetime | None = None,
|
||||
target_ledger_entry_id: int | None = None,
|
||||
note: str = "",
|
||||
actor_user_id: str | None = None,
|
||||
confirmation_evidence: str | None = None,
|
||||
confirm_duplicate: bool = False,
|
||||
) -> LedgerEntry: ...
|
||||
|
||||
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: ...
|
||||
|
||||
def list_ledger_entries(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
source: str | None = None,
|
||||
before_recorded_at: datetime | None = None,
|
||||
before_ledger_entry_id: int | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[LedgerEntry]: ...
|
||||
|
||||
def get_return_summary(self, portfolio_id: str) -> dict[str, float]: ...
|
||||
|
||||
def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]: ...
|
||||
@@ -344,4 +383,3 @@ class PortfolioRepository(Protocol):
|
||||
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: ...
|
||||
|
||||
def delete_comment(self, portfolio_id: str, comment_id: str) -> None: ...
|
||||
|
||||
|
||||
+731
-109
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
# Corporate actions (stock_consolidation-only, issue #34)
|
||||
class CorporateActionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
action_type: Literal["stock_consolidation"] = "stock_consolidation"
|
||||
effective_date: date
|
||||
quantity_before: Decimal = Field(..., gt=0, max_digits=20, decimal_places=8)
|
||||
quantity_after: Decimal = Field(..., gt=0, max_digits=20, decimal_places=8)
|
||||
evidence_ref: str = Field(..., min_length=1, max_length=500)
|
||||
confirm_daemon_managed: bool = False
|
||||
resume_partial: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_ratio(self):
|
||||
if self.quantity_after >= self.quantity_before:
|
||||
raise ValueError("quantity_after must be less than quantity_before")
|
||||
return self
|
||||
|
||||
class CorporateActionResponse(BaseModel):
|
||||
corporate_action_id: str
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
action_type: Literal["stock_consolidation"]
|
||||
effective_date: date
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
cash_balance: Decimal
|
||||
profit_buffer: Decimal
|
||||
evidence_ref: str
|
||||
actor_user_id: str | None = None
|
||||
recorded_at: datetime
|
||||
idempotent_replay: bool = False
|
||||
@@ -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):
|
||||
@@ -186,6 +188,7 @@ class CreateStrategySetRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
nick: str | None = Field(default=None, min_length=1)
|
||||
config: str
|
||||
status: Literal["active", "inactive"] = "active"
|
||||
|
||||
@@ -194,6 +197,7 @@ class UpdateStrategySetRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = None
|
||||
nick: str | None = Field(default=None, min_length=1)
|
||||
config: str | None = None
|
||||
status: Literal["active", "inactive"] | None = None
|
||||
|
||||
|
||||
+188
-22
@@ -6,8 +6,7 @@ from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET/POST/PATCH /v1/tags (REQ-004)
|
||||
@@ -109,14 +108,18 @@ 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"]
|
||||
quantity: Decimal = Field(..., gt=0)
|
||||
price: Decimal = Field(..., gt=0)
|
||||
fee_amount: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
tax_amount: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
fee_amount: Decimal = Field(default=Decimal(0), ge=0)
|
||||
tax_amount: Decimal = Field(default=Decimal(0), ge=0)
|
||||
executed_at: datetime
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
|
||||
|
||||
class LedgerTradeResponse(BaseModel):
|
||||
@@ -130,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
|
||||
@@ -148,6 +153,7 @@ ManualLedgerAction = Literal[
|
||||
"SELL",
|
||||
"WITHDRAW",
|
||||
"PROFIT",
|
||||
"DIVIDEND",
|
||||
"REVERSAL",
|
||||
]
|
||||
|
||||
@@ -156,22 +162,27 @@ class ManualLedgerRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: ManualLedgerAction
|
||||
amount: Decimal | None = Field(default=None, gt=0)
|
||||
quantity: Decimal | None = Field(default=None, gt=0)
|
||||
price: Decimal | None = Field(default=None, gt=0)
|
||||
fee: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
tax: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
amount: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
|
||||
quantity: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
|
||||
price: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
|
||||
fee: Decimal = Field(default=Decimal(0), ge=0, max_digits=20, decimal_places=8)
|
||||
tax: Decimal = Field(default=Decimal(0), ge=0, max_digits=20, decimal_places=8)
|
||||
executed_at: date | None = None
|
||||
target_ledger_entry_id: int | None = None
|
||||
note: str = ""
|
||||
confirm_daemon_managed_manual_write: bool = False
|
||||
confirm_duplicate: bool = False
|
||||
confirmation_evidence: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_action_fields(self) -> "ManualLedgerRequest":
|
||||
def validate_action_fields(self) -> ManualLedgerRequest:
|
||||
amount_actions = {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"}
|
||||
trade_actions = {"BUY", "SELL"}
|
||||
provided_fields = self.model_fields_set
|
||||
fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields
|
||||
fee_provided = "fee" in provided_fields
|
||||
executed_at_provided = "executed_at" in provided_fields
|
||||
confirm_duplicate_requested = bool(self.confirm_duplicate)
|
||||
if self.action in amount_actions:
|
||||
if self.amount is None:
|
||||
raise ValueError("amount is required for this action")
|
||||
@@ -180,17 +191,33 @@ class ManualLedgerRequest(BaseModel):
|
||||
or self.price is not None
|
||||
or self.target_ledger_entry_id is not None
|
||||
or fee_or_tax_provided
|
||||
or executed_at_provided
|
||||
or confirm_duplicate_requested
|
||||
):
|
||||
raise ValueError("unexpected numeric/target field for amount action")
|
||||
elif self.action == "DIVIDEND":
|
||||
if self.amount is None:
|
||||
raise ValueError("amount is required for this action")
|
||||
if self.executed_at is None:
|
||||
raise ValueError("executed_at is required for DIVIDEND")
|
||||
if self.quantity is not None or self.price is not None or self.target_ledger_entry_id is not None or fee_provided:
|
||||
raise ValueError("unexpected numeric/target field for DIVIDEND")
|
||||
elif self.action in trade_actions:
|
||||
if self.quantity is None or self.price is None:
|
||||
raise ValueError("quantity and price are required for BUY/SELL")
|
||||
if self.amount is not None or self.target_ledger_entry_id is not None:
|
||||
if self.amount is not None or self.target_ledger_entry_id is not None or executed_at_provided or confirm_duplicate_requested:
|
||||
raise ValueError("unexpected amount/target field for BUY/SELL")
|
||||
elif self.action == "REVERSAL":
|
||||
if self.target_ledger_entry_id is None:
|
||||
raise ValueError("target_ledger_entry_id is required for REVERSAL")
|
||||
if self.amount is not None or self.quantity is not None or self.price is not None or fee_or_tax_provided:
|
||||
if (
|
||||
self.amount is not None
|
||||
or self.quantity is not None
|
||||
or self.price is not None
|
||||
or fee_or_tax_provided
|
||||
or executed_at_provided
|
||||
or confirm_duplicate_requested
|
||||
):
|
||||
raise ValueError("unexpected numeric field for REVERSAL")
|
||||
return self
|
||||
|
||||
@@ -198,12 +225,34 @@ class ManualLedgerRequest(BaseModel):
|
||||
class ManualLedgerResponse(BaseModel):
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
action: str
|
||||
action: Literal["INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "BUY", "SELL", "REVERSAL"]
|
||||
amount: float | None = None
|
||||
quantity: float = 0.0
|
||||
price: float = 0.0
|
||||
fee: float = 0.0
|
||||
tax: float = 0.0
|
||||
executed_at: datetime | None = None
|
||||
note: str = ""
|
||||
recorded_at: datetime | None = None
|
||||
cash_balance_after: float = 0.0
|
||||
asset_quantity_after: float = 0.0
|
||||
profit_buffer_after: float = 0.0
|
||||
total_value_after: float = 0.0
|
||||
source: str = "manual"
|
||||
target_ledger_entry_id: int | None = None
|
||||
confirmation_evidence: str | None = None
|
||||
|
||||
|
||||
class DividendManualLedgerResponse(BaseModel):
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
action: Literal["DIVIDEND"]
|
||||
amount: str
|
||||
quantity: float = 0.0
|
||||
price: float = 0.0
|
||||
fee: float = 0.0
|
||||
tax: str = "0"
|
||||
executed_at: datetime | None = None
|
||||
note: str = ""
|
||||
recorded_at: datetime | None = None
|
||||
cash_balance_after: float = 0.0
|
||||
@@ -216,16 +265,109 @@ class ManualLedgerResponse(BaseModel):
|
||||
|
||||
|
||||
class ManualLedgerHistoryResponse(BaseModel):
|
||||
items: list[ManualLedgerResponse]
|
||||
items: list[ManualLedgerResponse | DividendManualLedgerResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class LedgerHistoryItem(BaseModel):
|
||||
"""Source-neutral, owner-visible ledger fact for GET /ledger."""
|
||||
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
source: str
|
||||
action: Literal["INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "BUY", "SELL", "REVERSAL"]
|
||||
recorded_at: datetime
|
||||
executed_at: datetime | None = None
|
||||
order_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
quantity: float | None = None
|
||||
amount: float | None = None
|
||||
price: float | None = None
|
||||
fee_amount: float | None = None
|
||||
tax_amount: float | None = None
|
||||
note: str | None = None
|
||||
cash_balance_after: float | None = None
|
||||
asset_quantity_after: float | None = None
|
||||
profit_buffer_after: float | None = None
|
||||
total_value_after: float | None = None
|
||||
target_ledger_entry_id: int | None = None
|
||||
confirmation_evidence: str | None = None
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
|
||||
|
||||
class DividendLedgerHistoryItem(BaseModel):
|
||||
"""Source-neutral, owner-visible ledger fact for GET /ledger."""
|
||||
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
source: str
|
||||
action: Literal["DIVIDEND"]
|
||||
recorded_at: datetime
|
||||
executed_at: datetime | None = None
|
||||
order_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
quantity: float | None = None
|
||||
amount: str
|
||||
price: float | None = None
|
||||
fee_amount: float | None = None
|
||||
tax_amount: str | None = None
|
||||
note: str | None = None
|
||||
cash_balance_after: float | None = None
|
||||
asset_quantity_after: float | None = None
|
||||
profit_buffer_after: float | None = None
|
||||
total_value_after: float | None = None
|
||||
target_ledger_entry_id: int | None = None
|
||||
confirmation_evidence: str | None = None
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
|
||||
|
||||
class CorporateActionLedgerHistoryItem(BaseModel):
|
||||
"""Auditable corporate-action event in the unified ledger read model."""
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
source: Literal["corporate_action"]
|
||||
event_id: str
|
||||
event_type: Literal["stock_consolidation"]
|
||||
effective_date: date
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
cash_balance: Decimal
|
||||
profit_buffer: Decimal
|
||||
evidence_ref: str
|
||||
actor_user_id: str | None = None
|
||||
recorded_at: datetime
|
||||
execution_venue: Literal["KRX", "NXT"] | None = None
|
||||
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
|
||||
|
||||
|
||||
class LedgerHistoryResponse(BaseModel):
|
||||
items: list[LedgerHistoryItem | DividendLedgerHistoryItem | CorporateActionLedgerHistoryItem]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class PortfolioReturnSummaryResponse(BaseModel):
|
||||
portfolio_id: str
|
||||
total_invested: float = 0.0
|
||||
total_withdrawn: float = 0.0
|
||||
net_contributed_capital: float = 0.0
|
||||
cumulative_profit_withdrawn: float = 0.0
|
||||
total_invested: float = Field(
|
||||
default=0.0,
|
||||
description="총투자원금: source와 무관한 유효 INVEST의 gross 합계",
|
||||
)
|
||||
total_withdrawn: float = Field(
|
||||
default=0.0,
|
||||
description="총회수원금: source와 무관한 유효 WITHDRAW 합계",
|
||||
)
|
||||
net_contributed_capital: float = Field(
|
||||
default=0.0,
|
||||
description="순투자원금: total_invested - total_withdrawn (PROFIT 제외)",
|
||||
)
|
||||
cumulative_profit_withdrawn: float = Field(
|
||||
default=0.0,
|
||||
description="누적 수익 인출액: source와 무관한 유효 PROFIT 합계 (원금 계산 제외)",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -242,7 +384,7 @@ class PortfolioCreateRequest(BaseModel):
|
||||
asset_quantity: float = Field(default=0.0, ge=0.0)
|
||||
asset_price: float = Field(default=0.0, ge=0.0)
|
||||
profit_buffer: float = Field(default=0.0, ge=0.0)
|
||||
strategy_id: str = Field(..., min_length=1)
|
||||
strategy_id: str | None = Field(default=None, min_length=1)
|
||||
managed_by_daemon_id: str | None = None
|
||||
user_id: str | None = None # admin-only: override owner
|
||||
|
||||
@@ -273,11 +415,11 @@ class PortfolioItem(BaseModel):
|
||||
profit_buffer: float = 0.0
|
||||
net_contributed_capital: float = Field(
|
||||
default=0.0,
|
||||
description="순투자현금: 유효 manual INVEST 합계 - 유효 manual WITHDRAW 합계",
|
||||
description="순투자원금: source와 무관한 유효 INVEST 합계 - 유효 WITHDRAW 합계 (PROFIT 제외)",
|
||||
)
|
||||
cumulative_profit_withdrawn: float = Field(
|
||||
default=0.0,
|
||||
description="누적 수익 인출액: 유효 manual PROFIT 합계",
|
||||
description="누적 수익 인출액: source와 무관한 유효 PROFIT 합계 (원금 계산 제외)",
|
||||
)
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
managed_by_daemon_id: str | None = None
|
||||
@@ -286,6 +428,7 @@ class PortfolioItem(BaseModel):
|
||||
# REQ-055: 전략·시장 메타데이터 additive 필드
|
||||
active_strategy_id: str | None = None
|
||||
strategy_name: str | None = None
|
||||
strategy_nick: str | None = None
|
||||
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
||||
strategy_revision: int | None = Field(default=None, ge=1)
|
||||
markets: list[str] = Field(default_factory=list)
|
||||
@@ -305,6 +448,7 @@ StrategyStatus = Literal["active", "inactive"]
|
||||
|
||||
class StrategyResponse(BaseModel):
|
||||
strategy_id: str
|
||||
nick: str
|
||||
name: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
status: StrategyStatus
|
||||
@@ -317,19 +461,41 @@ class StrategyCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(..., min_length=1)
|
||||
nick: str | None = Field(default=None, min_length=1)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
status: StrategyStatus = "active"
|
||||
|
||||
@field_validator("nick")
|
||||
@classmethod
|
||||
def normalize_nick(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
raise ValueError("nick must not be null")
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("nick must not be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class StrategyUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1)
|
||||
nick: str | None = Field(default=None, min_length=1)
|
||||
config: dict[str, Any] | None = None
|
||||
status: StrategyStatus | None = None
|
||||
|
||||
@field_validator("nick")
|
||||
@classmethod
|
||||
def normalize_nick(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
raise ValueError("nick must not be null")
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("nick must not be blank")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_change(self) -> "StrategyUpdateRequest":
|
||||
def require_change(self) -> StrategyUpdateRequest:
|
||||
if not self.model_fields_set:
|
||||
raise ValueError("at least one field is required")
|
||||
return self
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -46,26 +46,49 @@ class StrategySetService:
|
||||
return self.get(strategy_set_id, active_only=True)
|
||||
|
||||
def create(self, payload: CreateStrategySetRequest) -> StrategySet:
|
||||
create_strategy = cast(
|
||||
Callable[..., StrategySet] | None,
|
||||
getattr(self._repository, "create_strategy_set", None),
|
||||
)
|
||||
if create_strategy is not None:
|
||||
strategy = create_strategy(payload.name, payload.config, status=payload.status)
|
||||
else:
|
||||
strategy = self._repository.get_or_create_strategy_set(payload.name, payload.config)
|
||||
if payload.status != strategy.status:
|
||||
strategy = self.update(
|
||||
strategy.id,
|
||||
UpdateStrategySetRequest(status=payload.status),
|
||||
try:
|
||||
create_strategy = cast(
|
||||
Callable[..., StrategySet] | None,
|
||||
getattr(self._repository, "create_strategy_set", None),
|
||||
)
|
||||
return strategy
|
||||
if create_strategy is not None:
|
||||
strategy = create_strategy(
|
||||
payload.name,
|
||||
payload.config,
|
||||
nick=payload.nick,
|
||||
status=payload.status,
|
||||
)
|
||||
else:
|
||||
strategy = self._repository.get_or_create_strategy_set(payload.name, payload.config)
|
||||
if payload.nick is not None and payload.nick != strategy.nick:
|
||||
strategy = self.update(
|
||||
strategy.id,
|
||||
UpdateStrategySetRequest(nick=payload.nick),
|
||||
)
|
||||
if payload.status != strategy.status:
|
||||
strategy = self.update(
|
||||
strategy.id,
|
||||
UpdateStrategySetRequest(status=payload.status),
|
||||
)
|
||||
return strategy
|
||||
except StrategyConflictError:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise StrategyConflictError(str(exc)) from exc
|
||||
|
||||
def update(self, strategy_set_id: str, payload: UpdateStrategySetRequest) -> StrategySet:
|
||||
try:
|
||||
if payload.nick is None:
|
||||
return self._repository.update_strategy_set(
|
||||
strategy_set_id,
|
||||
name=payload.name,
|
||||
config=payload.config,
|
||||
status=payload.status,
|
||||
)
|
||||
return self._repository.update_strategy_set(
|
||||
strategy_set_id,
|
||||
name=payload.name,
|
||||
nick=payload.nick,
|
||||
config=payload.config,
|
||||
status=payload.status,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# PortfolioHub v0.1.1 container contract
|
||||
# PortfolioHub v0.4.0 container contract
|
||||
|
||||
## Publication is not deployment
|
||||
|
||||
Only a requester-authorized push of the strict tag `v0.1.1` 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.1.1`
|
||||
- `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.1.1`. 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.1.1 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,48 +66,56 @@ 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.1.1 | `0.1.1` | 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.1.1 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.1.1 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.1.1`.
|
||||
- **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 0028 -> 0029 policy
|
||||
## One-shot migration and 0029 -> 0032 policy
|
||||
|
||||
The installed image contains exactly **29** migration files and one Alembic head, `20260814_0029`. 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 `20260715_0028`; for an older revision, review and apply the full ordered chain first;
|
||||
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 `20260814_0029`;
|
||||
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 `0029`, 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.
|
||||
|
||||
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 `0028` and `0029`. 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.
|
||||
v0.2.1 includes migration `0030`, an idempotent index on `(portfolio_id, recorded_at DESC, ledger_entry_id DESC)` for unified-ledger keyset reads. Operators at `0029` must run the same-image one-shot migration before rollout; it advances to `20260820_0030` without mutating ledger rows.
|
||||
|
||||
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.
|
||||
|
||||
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.1.1 preserves Bearer authentication and the caller route surface observed in sibling source. 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 29-file migration inventory, isolated PostgreSQL migration twice, readiness at `0029`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it.
|
||||
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.
|
||||
|
||||
Route presence is **not** a positive cross-repository caller-compatibility claim. Direct source comparison found the following unresolved consumers, which the requester explicitly separated as BLOCKED follow-up rather than adding legacy shims to portfolio-hub:
|
||||
|
||||
- trex-web omits required `strategy_id` when creating a portfolio, so that mutation currently returns `422`;
|
||||
- trex-web may create a portfolio without `strategy_id`; portfolio-hub must preserve that strategy-unassigned request as `active_strategy_id=null` rather than selecting a default strategy implicitly;
|
||||
- trex-web can omit required `account_ref` when creating or rotating credentials, so those requests currently return `422`;
|
||||
- trex-web sends daemon activation state through the general PATCH route, which can return `200` without changing activation; it must use the activate/deactivate routes;
|
||||
- portfolio-hub returns singular `broker` from `/v1/user_info`, while trex-web types `brokers[]`;
|
||||
|
||||
+13
-1
@@ -341,12 +341,24 @@
|
||||
- BUFFER_IN: cash_balance -> profit_buffer.
|
||||
- BUY: cash_balance -> asset_quantity.
|
||||
- SELL: asset_quantity -> cash_balance.
|
||||
- DIVIDEND: external net cash dividend -> profit_buffer only; `tax_amount` is reference metadata and does not affect snapshot math.
|
||||
- REVERSAL: append-only inverse effect of `target_ledger_entry_id`.
|
||||
|
||||
### Return Aggregate Semantics
|
||||
|
||||
- `total_invested`: source와 무관한 유효 `INVEST`의 gross 합계.
|
||||
- `total_withdrawn`: source와 무관한 유효 `WITHDRAW` 합계.
|
||||
- `net_contributed_capital`: `total_invested - total_withdrawn`; `PROFIT`는 원금 계산에서 제외한다.
|
||||
- `cumulative_profit_withdrawn`: source와 무관한 유효 `PROFIT` 합계이며 원금과 별도로 노출한다.
|
||||
- 동일 portfolio에서 matching `REVERSAL.target_ledger_entry_id`가 존재하면 original과 reversal의 source와 무관하게 original entry를 집계에서 제외한다.
|
||||
- `buffer_out` 등 다른 accounting side를 `INVEST`로 재해석하지 않으며 existing ledger row를 mutation/backfill하지 않는다.
|
||||
|
||||
### State Rules
|
||||
|
||||
- Manual writes lock the portfolio row while calculating/appending/updating snapshots to avoid lost updates.
|
||||
- Manual writes reject insufficient cash/quantity/buffer with HTTP 409 rather than creating a new negative state.
|
||||
- DIVIDEND duplicate candidate (`portfolio_id`, `executed_at` UTC date, decimal `amount`, `note`) returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` unless request confirms with `confirm_duplicate=true`.
|
||||
- DIVIDEND uses date-only input stored as `executed_at` UTC midnight (`00:00:00Z`).
|
||||
- `total_value_after` for manual entries is the portfolio snapshot value after the mutation and includes cash, asset valuation, and profit buffer.
|
||||
|
||||
## Shared Consistency Rules
|
||||
@@ -360,7 +372,7 @@
|
||||
|
||||
- Alembic revisions in migrations/versions/ are the ordered schema history for persistent environments.
|
||||
- app/repositories/postgres.py includes ensure_schema() for bootstrap safety, but long-term schema evolution should run through Alembic.
|
||||
- Current Alembic head is `20260814_0029`; its strategy columns, default seed, and constraints are guarded for ensure_schema-first and repeatable execution.
|
||||
- Current Alembic head is `20260913_0031`; migration `0031` additively stores corporate actions for the corporate-action endpoint. Operators must migrate an environment to `0031` before using that endpoint. The preceding `0029` strategy columns, `0030` unified-ledger keyset index, and `0031` corporate-action storage are guarded for ensure_schema-first and repeatable execution.
|
||||
- Comments, tags, auth tokens, and ledger rows are normalized into dedicated tables keyed to users or portfolios.
|
||||
|
||||
## Current Gaps / Planned Changes
|
||||
|
||||
+43
-19
@@ -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
|
||||
@@ -302,11 +302,11 @@
|
||||
### Endpoint: `POST /v1/portfolios`
|
||||
|
||||
- Auth: any valid token; service/client → HTTP 403
|
||||
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_id": "<required strategy ID>", "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
|
||||
- Response: HTTP 201 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str>", "strategy_name": "<str>", "strategy_params": {<dict>}, "strategy_revision": <int>, ...}`
|
||||
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_id": "<active strategy ID|null (optional)>", "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
|
||||
- Response: HTTP 201 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str|null>", "strategy_name": "<str|null>", "strategy_params": {<dict>}, "strategy_revision": <int|null>, ...}`
|
||||
- Note: `symbol` is populated from the server-side joined asset row (`portfolios.asset_id -> assets.asset_id -> assets.symbol`), not from client-side catalog reconstruction.
|
||||
- Note: a non-null `managed_by_daemon_id` must be a daemon server-generated UUID that exists, is active, and is owned by the target portfolio owner (admin `user_id` override uses the final target owner, not the calling admin). Daemon validation and the portfolio write share one atomic transaction (`FOR SHARE` daemon lock); a selected daemon that is deactivated concurrently is rejected rather than committed.
|
||||
- Error: HTTP 403 if service/client token; HTTP 404 if `strategy_id` is unknown or the daemon does not exist/is not owned by the portfolio owner; HTTP 409 if the strategy is inactive or the daemon is inactive; HTTP 422 for a missing/invalid `strategy_id`, an unknown/inactive asset, or a non-UUID `managed_by_daemon_id`
|
||||
- Error: HTTP 403 if service/client token; HTTP 404 if a supplied `strategy_id` is unknown or the daemon does not exist/is not owned by the portfolio owner; HTTP 409 if a supplied strategy is inactive or the daemon is inactive; HTTP 422 for an invalid `strategy_id`, an unknown/inactive asset, or a non-UUID `managed_by_daemon_id`
|
||||
|
||||
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}`
|
||||
|
||||
@@ -347,10 +347,13 @@
|
||||
- Response: HTTP 200 `PortfolioListResponse {"portfolios": [{"portfolio_id": "<uuid>", "asset_id": "<uuid>", "symbol": "<assets.symbol|null>", "net_contributed_capital": <float>, "cumulative_profit_withdrawn": <float>, ...}], "total": <int>}`
|
||||
- Note: list items are serialized from the server-side `portfolios JOIN assets` result; `symbol` must equal the joined `assets.symbol` for each returned `asset_id`.
|
||||
- Ledger aggregate fields:
|
||||
- `net_contributed_capital` (**순투자현금**) = 유효 manual `INVEST` 합계 - 유효 manual `WITHDRAW` 합계.
|
||||
- `cumulative_profit_withdrawn` (**누적 수익 인출액**) = 유효 manual `PROFIT` 합계. 매매상 실현손익 전체가 아니다.
|
||||
- `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`, `WITHDRAW`, `PROFIT` 원거래는 유효 집계에서 제외하며 `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
|
||||
- 목록에 반환될 portfolio ID 집합만 한 번의 grouped aggregate로 조회한다. 해당 유효 manual ledger가 없는 항목은 두 필드 모두 `0.0`이다.
|
||||
- `total_invested` (**총투자원금**, summary response) = source와 무관한 유효 `INVEST`의 gross 합계.
|
||||
- `total_withdrawn` (**총회수원금**, summary response) = source와 무관한 유효 `WITHDRAW` 합계.
|
||||
- `net_contributed_capital` (**순투자원금**) = `total_invested - total_withdrawn`. `PROFIT`는 원금 계산에서 제외한다.
|
||||
- `cumulative_profit_withdrawn` (**누적 수익 인출액**) = source와 무관한 유효 `PROFIT` 합계. 원금 또는 매매상 실현손익 전체가 아니다.
|
||||
- 동일 portfolio에서 matching `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`, `WITHDRAW`, `PROFIT` 원거래는 original과 reversal의 source와 무관하게 제외하며 `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
|
||||
- `buffer_out` 등 `INVEST`/`WITHDRAW`/`PROFIT` 이외 side는 원금·수익 aggregate에 포함하거나 `INVEST`로 재해석하지 않는다.
|
||||
- 목록에 반환될 portfolio ID 집합만 한 번의 grouped aggregate로 조회한다. 해당 유효 ledger가 없는 항목은 aggregate field가 `0.0`이다.
|
||||
- 이 필드들은 portfolio-hub가 소유한 ledger 집계값이다. 소비자는 ledger event를 재해석하지 않는다.
|
||||
|
||||
### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags`
|
||||
@@ -404,23 +407,25 @@
|
||||
### 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)
|
||||
|
||||
- Auth: DB user/session token with portfolio ownership. Service/client tokens and env tokens are HTTP 403 fail-closed.
|
||||
- Purpose: trex-web/user 수동 장부 append. Existing `/ledger/trades` remains daemon/service-token broker execution write only.
|
||||
- Body: `ManualLedgerRequest` with `action` enum `INVEST|BUFFER_OUT|BUFFER_IN|BUY|SELL|WITHDRAW|PROFIT|REVERSAL`.
|
||||
- Body: `ManualLedgerRequest` with `action` enum `INVEST|BUFFER_OUT|BUFFER_IN|BUY|SELL|WITHDRAW|PROFIT|DIVIDEND|REVERSAL`.
|
||||
- `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `WITHDRAW`, `PROFIT` require `amount`.
|
||||
- `DIVIDEND` requires `amount` and date-only `executed_at` (`YYYY-MM-DD`); `executed_at` is stored as UTC midnight (`00:00:00Z`). Optional non-negative `tax` is metadata only.
|
||||
- `BUY`, `SELL` require `quantity` and `price`; optional `fee`, `tax` default 0.
|
||||
- `REVERSAL` requires `target_ledger_entry_id`; direct mutation/delete of prior rows is not supported.
|
||||
- Duplicate candidate `DIVIDEND` (same portfolio, same date, same decimal `amount`, same `note`, and original not already reversed): first POST returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` with `matched_ledger_entry_id`; retry with `confirm_duplicate=true` records the entry.
|
||||
- `REVERSAL` requires `target_ledger_entry_id`; direct mutation/delete of prior rows is not supported. Reversing `DIVIDEND` reduces `profit_buffer` by the original net amount under existing reversal conflict/insufficient-balance policy.
|
||||
- `QUANTITY_ADJUSTMENT` is not part of this API.
|
||||
- Response: HTTP 201 `ManualLedgerResponse` with `ledger_entry_id`, `portfolio_id`, `action`, `source=manual`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`, `target_ledger_entry_id`, `confirmation_evidence`.
|
||||
- Response: HTTP 201 `ManualLedgerResponse` with `ledger_entry_id`, `portfolio_id`, `action`, `source=manual`, `executed_at`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`, `target_ledger_entry_id`, `confirmation_evidence`.
|
||||
- Validation/conflict policy:
|
||||
- disabled portfolio → HTTP 409 `PORTFOLIO_DISABLED`.
|
||||
- daemon-managed portfolio without `confirm_daemon_managed_manual_write=true` → HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`.
|
||||
@@ -432,12 +437,30 @@
|
||||
|
||||
- Auth: same owner-scoped user/session token policy as manual write.
|
||||
- Query: `limit` 1..200, default 50.
|
||||
- Response: `ManualLedgerHistoryResponse {"items": [ManualLedgerResponse], "total": <int>}` ordered newest first.
|
||||
- `GET /v1/portfolios/{id}/ledger` returns corporate-action events as a distinct `CorporateActionLedgerHistoryItem` with event ID/type, effective date, before/after quantity and WAC, total cost basis, cash/profit buffer, evidence, actor, and recorded time. These events are never represented as BUY/SELL trades.
|
||||
- For stock consolidations, WAC fields are rounded half-up to the persisted 8-decimal scale. `total_cost_basis` is preserved independently as the authoritative pre-action acquisition cost; a sub-cent residue when `average_price_after * quantity_after` is recomputed does not make an integral-share consolidation invalid.
|
||||
- `POST /v1/portfolios/{id}/corporate-actions` updates quantity and the post-action reference price, appends the `portfolio_ledger` snapshot (including non-null `total_value_after`), and appends the corporate-action audit row in one explicit database transaction. A failure in any statement rolls back every statement.
|
||||
- The post-action reference price is `asset_price_before * quantity_before / quantity_after`; ledger `total_value_after` is `cash_balance_after + asset_quantity_after * post_action_reference_price`. Cash and profit buffer are unchanged.
|
||||
- `resume_partial=false` is the compatible default. `resume_partial=true` is accepted only when the locked portfolio quantity already equals `quantity_after`, trade-ledger replay still proves `quantity_before`, and both corporate-action audit and corporate-action ledger rows are absent. Same-key replay is idempotent; a different-key recovery after repair is rejected.
|
||||
- `GET /v1/portfolios/{id}/trades` remains restricted to BUY/SELL history.
|
||||
- `portfolio corporate-action` is the operator command; it requires an idempotency key and confirmation unless `--force` is supplied. `--resume-partial` enables only the guarded recovery mode above.
|
||||
|
||||
### Endpoint: `GET /v1/portfolios/{portfolio_id}/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)
|
||||
|
||||
- Auth: owner-scoped user/session token.
|
||||
- Response: `PortfolioReturnSummaryResponse` with `total_invested`, `total_withdrawn`, `net_contributed_capital`, and `cumulative_profit_withdrawn`.
|
||||
- Aggregate semantics: `total_invested`는 source-independent valid `INVEST`의 gross 합계, `total_withdrawn`은 valid `WITHDRAW` 합계, `net_contributed_capital`은 두 값의 차이며 `PROFIT`는 원금에서 제외하고 `cumulative_profit_withdrawn`으로만 노출한다.
|
||||
- Reversal semantics: 동일 portfolio의 matching `REVERSAL.target_ledger_entry_id`는 original과 reversal의 source와 무관하게 original을 제외한다. `buffer_out` 등 다른 side는 집계하지 않는다.
|
||||
- Consistency: `GET /v1/portfolios`와 동일한 grouped repository aggregate를 portfolio ID 하나에 위임한다.
|
||||
|
||||
## Interface: `Tag Management API (BAT-004)`
|
||||
|
||||
@@ -562,6 +585,7 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-08-19 KST: Issue #28 — portfolio return aggregate를 manual-only에서 source-independent semantics로 교정; gross `total_invested`, `INVEST-WITHDRAW` net, separate `PROFIT`, cross-source reversal과 non-capital side 제외를 명시.
|
||||
- 2026-07-14 KST: BAT-024 (REQ-056) — added owner-scoped manual ledger API, manual history, and return summary contracts; clarified PROFIT vs WITHDRAW return semantics and service-token separation from `/ledger/trades`.
|
||||
- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 명세 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체; auth 섹션 + daemon authorization 갱신; 구 path HTTP 404 명시.
|
||||
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface baseline 회복: "v1 Portfolio API" 섹션 신규 추가 (REQ-025~028 CRUD + REQ-030 tag 서브 라우트 + REQ-031 comment CRUD); POST/DELETE /v1/portfolios/{id}/tags에 disabled 보호(409) 및 service/client 403 명세 추가; GET /v1/portfolios/{id}/tags 신설; ledger path "pending REQ-029" 마킹; Source Discovery/Batch 헤더 갱신.
|
||||
|
||||
@@ -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 흐름 대상으로 검토.
|
||||
@@ -138,13 +138,13 @@
|
||||
|-----------|-----------|------|
|
||||
| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index, BAT-006/REQ-017: `AND revoked_at IS NULL` 조건 추가) | Alembic revision 20260520_0022에도 CONCURRENTLY 버전 존재 |
|
||||
| Alembic 전용 | `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades` | ensure_schema()에 없음 — v1 API 전제 조건 |
|
||||
| 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets (`status`, `revision`, `updated_at` 포함), portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic `20260814_0029` 실행 시 충돌 없음; 기본 전략 seed/constraints도 존재 여부 guard 적용 |
|
||||
| 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets (`status`, `revision`, `updated_at` 포함), portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic `20260913_0031`까지 실행 시 충돌 없음; 기본 전략 seed/constraints, unified-ledger keyset index, additive corporate-action storage도 존재 여부 guard 적용 |
|
||||
|
||||
### 허용 실행 순서
|
||||
|
||||
1. `init-db` 선행 → App 시작 → 정상 운영 (권장)
|
||||
2. App 시작 (ensure_schema() 선행) → `migrate-db` 후속 실행 (legacy stamp 경로)
|
||||
3. 이미 Alembic 관리 DB → `migrate-db` 증분 upgrade
|
||||
3. 이미 Alembic 관리 DB → `migrate-db` 증분 upgrade through `20260913_0031` before using the corporate-action endpoint
|
||||
|
||||
### 운영 제약
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Requirements
|
||||
|
||||
각 requirement는 `req-NNN/` 폴더에 정본과 관련 검증 자료를 함께 둔다. 정본 requirement 문서만 Markdown body 앞에 JSON front matter를 둔다.
|
||||
|
||||
## Directory layout and file naming
|
||||
|
||||
```text
|
||||
docs/requirements/
|
||||
README.md
|
||||
TEMPLATE.md
|
||||
req-NNN/
|
||||
req-NNN_<slug>.md
|
||||
test-catalog.md
|
||||
test-evidence.md
|
||||
history/
|
||||
provenance-reapproval-request.md
|
||||
README.md
|
||||
```
|
||||
|
||||
- `NNN`: requirement ID의 세 자리 decimal number. 폴더명과 정본 파일의 번호는 일치해야 한다.
|
||||
- `<slug>`: requirement title을 나타내는 lowercase kebab-case ASCII slug
|
||||
- `req-NNN/`는 하나의 requirement 묶음이며, 그 안의 `req-NNN_<slug>.md`만 정본이다.
|
||||
- `test-catalog.md`는 AC와 실행 가능한 테스트의 연결을, `test-evidence.md`는 특정 commit·환경에서의 실행 결과를 기록한다. 이들은 보조 문서이며 별도 REQ ID, revision 또는 approval status를 부여하지 않는다.
|
||||
- 과거 승인 요청·정정 절차 기록은 필요할 때 `history/`에 보존한다. 당시 요청서와 현재 완료 상태를 구분하고, 완료 상태 및 승인 근거는 `history/README.md`에 기록한다.
|
||||
- `docs/requirements/` 바로 아래에는 공통 README/TEMPLATE와 requirement별 폴더를 둔다. 개별 REQ나 보조 문서를 이 위치에 혼재시키지 않는다.
|
||||
- 문서 검색·수집은 하위 폴더까지 탐색하고, 파일 접두사만이 아니라 JSON front matter의 `schema`와 `id`로 정본을 식별한다.
|
||||
- 배치 변경만으로 승인된 정본의 내용·ID·revision·status를 변경하지 않는다.
|
||||
|
||||
## Metadata schema
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "proofline.requirement/v1",
|
||||
"id": "REQ-NNN",
|
||||
"title": "사람이 읽는 제목",
|
||||
"revision": "major.minor",
|
||||
"status": "draft | approved | retired",
|
||||
"depends_on": ["REQ-NNN"],
|
||||
"applies_to": ["algorithm-name"],
|
||||
"language": "ko"
|
||||
}
|
||||
```
|
||||
|
||||
- `id`: repository 안에서 고유한 requirement identifier
|
||||
- `revision`: 사람이 읽는 requirement revision. `draft` 상태에서 본문을 수정해도 올리지 않으며, `approved` 상태로 전환할 때 또는 승인된 requirement의 의미를 변경하기 위해 재승인할 때만 올린다.
|
||||
- `status`: requirement lifecycle 상태. `approved`는 명시적인 사용자 승인을 의미한다.
|
||||
- `depends_on`: 선행 requirement identifier 목록
|
||||
- `applies_to`: requirement가 적용되는 algorithm 또는 product 영역
|
||||
|
||||
## Python parsing
|
||||
|
||||
front matter body는 JSON이므로 별도 dependency 없이 Python 표준 라이브러리로 읽을 수 있다.
|
||||
|
||||
```python
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
text = Path("docs/requirements/req-001/req-001_manual-cash-dividend.md").read_text(encoding="utf-8")
|
||||
_, metadata_json, _ = text.split("---", 2)
|
||||
metadata = json.loads(metadata_json)
|
||||
```
|
||||
|
||||
JSON front matter의 `revision`과 `status`가 문서의 단일 source of truth다.
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
{
|
||||
"schema": "proofline.requirement/v1",
|
||||
"id": "REQ-NNN",
|
||||
"title": "요구사항 제목",
|
||||
"revision": "0.1",
|
||||
"status": "draft",
|
||||
"depends_on": [],
|
||||
"applies_to": ["product-area"],
|
||||
"language": "ko"
|
||||
}
|
||||
---
|
||||
|
||||
# REQ-NNN — 요구사항 제목
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 요구사항으로 달성해야 하는 사용자 또는 시스템의 결과를 기술한다.
|
||||
|
||||
## 2. 배경
|
||||
|
||||
현재 문제, 요구사항이 필요한 이유, 관련된 운영·도메인 맥락을 기술한다. 구현 방법보다 해결해야 할 문제에 집중한다.
|
||||
|
||||
## 3. 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 이 요구사항이 제공하거나 변경해야 하는 동작
|
||||
- 영향을 받는 API, 데이터, 제품 영역 또는 운영 흐름
|
||||
|
||||
### 제외
|
||||
|
||||
- 이번 요구사항에서 다루지 않는 기능
|
||||
- 별도 requirement 또는 후속 작업으로 분리할 항목
|
||||
|
||||
## 4. 용어와 기준
|
||||
|
||||
- **용어**: 이 문서에서 사용하는 의미
|
||||
- **정본 기준**: 값이나 상태의 canonical source
|
||||
|
||||
## 5. 요구사항
|
||||
|
||||
`REQ-NNN`은 문서와 기능 묶음의 identifier다. 고객에게 전달할 요구사항명세리스트와 구현 추적에서는 독립적으로 구현·검증할 수 있는 각 요구사항을 `REQ-NNN.01`, `REQ-NNN.02` 형식으로 식별한다.
|
||||
|
||||
각 요구사항에는 하네스가 변경 영향과 필요한 회귀 범위를 판정할 수 있도록 `Impact`를 기록한다.
|
||||
|
||||
### REQ-NNN.01 — 요구사항 이름
|
||||
|
||||
시스템은 관찰 가능한 결과를 명확하게 기술한 조건을 충족해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: `<영향받는 API, event 또는 없음>`
|
||||
- Persistence: `<영향받는 table, state 또는 없음>`
|
||||
- Consumers: `<영향받는 downstream consumer 또는 없음>`
|
||||
- Migration: `<필요 여부와 범위>`
|
||||
- Compatibility: `<유지해야 하는 기존 동작>`
|
||||
- Deployment prerequisites: `<배포 전제조건 또는 없음>`
|
||||
|
||||
### REQ-NNN.02 — 요구사항 이름
|
||||
|
||||
시스템은 정상 동작뿐 아니라 실패·경계·변경 없음 조건도 명확히 충족해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: `<영향받는 API, event 또는 없음>`
|
||||
- Persistence: `<영향받는 table, state 또는 없음>`
|
||||
- Consumers: `<영향받는 downstream consumer 또는 없음>`
|
||||
- Migration: `<필요 여부와 범위>`
|
||||
- Compatibility: `<유지해야 하는 기존 동작>`
|
||||
- Deployment prerequisites: `<배포 전제조건 또는 없음>`
|
||||
|
||||
## 6. 제약과 불변조건
|
||||
|
||||
- 구현 방식과 무관하게 항상 유지해야 하는 데이터·상태 불변조건
|
||||
- 기존 호출자와 저장 데이터에 대해 유지해야 하는 하위호환 조건
|
||||
- 인증·권한·민감정보 처리 조건
|
||||
- 원자성, 정밀도, 동시성 또는 순서 보장 조건
|
||||
|
||||
## 7. Acceptance Criteria
|
||||
|
||||
Acceptance Criterion은 소속 요구사항을 포함한 `REQ-NNN.01-AC01` 형식으로 식별한다. 문서 전체에서만 고유한 `AC-001` 형식은 사용하지 않는다.
|
||||
|
||||
### REQ-NNN.01-AC01 — 판정 가능한 결과
|
||||
|
||||
**Criterion**
|
||||
|
||||
- 하나의 지속적인 요구사항을 PASS 또는 FAIL로 판정할 수 있게 기술한다.
|
||||
- 특정 함수명이나 내부 구현보다 외부에서 관찰 가능한 결과를 명시한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 정상 경로 검증 방법
|
||||
- 실패 및 경계 조건 검증 방법
|
||||
- 변경되면 안 되는 상태의 검증 방법
|
||||
- 필요한 경우 실제 persistence backend 또는 production entry point 검증 방법
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `unit | API | integration | end-to-end`
|
||||
- Backend: `<검증 대상 backend 또는 없음>`
|
||||
- Cases: `positive | negative | boundary | unchanged-state | concurrency`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 성공 시 반드시 변경되어야 하는 상태
|
||||
- 성공하더라도 변경되면 안 되는 상태
|
||||
- 실패 시 부분 기록 없이 유지되어야 하는 상태
|
||||
|
||||
### REQ-NNN.02-AC01 — 판정 가능한 결과
|
||||
|
||||
**Criterion**
|
||||
|
||||
- 두 번째 독립 요구사항을 기술한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- Criterion 전체를 입증할 검증 절차와 기대 결과를 기술한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `unit | API | integration | end-to-end`
|
||||
- Backend: `<검증 대상 backend 또는 없음>`
|
||||
- Cases: `positive | negative | boundary | unchanged-state | concurrency`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 성공 시 반드시 변경되어야 하는 상태
|
||||
- 성공하더라도 변경되면 안 되는 상태
|
||||
- 실패 시 부분 기록 없이 유지되어야 하는 상태
|
||||
|
||||
## 8. 오류 및 충돌 계약
|
||||
|
||||
- 입력 오류: 상태 코드 또는 오류 분류와 무변경 조건
|
||||
- 상태 충돌: 재시도·확인·중복 처리 정책
|
||||
- 저장 실패: transaction rollback과 부분 기록 방지 조건
|
||||
|
||||
## 9. 데이터 및 인터페이스 계약
|
||||
|
||||
- 입력 field와 필수·선택 여부
|
||||
- 출력 field와 의미
|
||||
- 저장해야 하는 canonical 값과 metadata
|
||||
- 시간대, 단위, 정밀도 및 식별자 규칙
|
||||
|
||||
## 10. 하위호환성
|
||||
|
||||
- 유지해야 하는 기존 동작과 호출 방식
|
||||
- 기존 데이터에 적용할 규칙
|
||||
- 의도적으로 변경되는 동작이 있다면 그 경계
|
||||
|
||||
## 11. 검증 계획
|
||||
|
||||
- Unit/API contract 검증
|
||||
- Persistence migration 및 round-trip 검증
|
||||
- 동시성·중복·재시도 검증
|
||||
- 기존 기능 회귀 검증
|
||||
- OpenAPI 또는 사용자-facing 문서 정합성 검증
|
||||
|
||||
### Test Traceability Contract
|
||||
|
||||
- Requirement 문서에는 검증 의도와 판정 조건만 기록한다.
|
||||
- 실행 가능한 테스트 정의는 별도 test catalog artifact에서 관리한다.
|
||||
- 각 테스트 케이스는 안정적인 identifier를 사용하고 `verifies`로 Acceptance Criterion을 참조한다.
|
||||
- 하나의 테스트가 여러 Criterion을 검증할 수 있으며, 각 연결을 빠짐없이 기록한다.
|
||||
- 실행 결과는 requirement 또는 test definition에 덮어쓰지 않고 별도 evidence artifact에 기록한다.
|
||||
- Evidence는 requirement revision, 검증한 commit, 환경과 실행 시각에 결합한다.
|
||||
- 테스트 정의의 존재, 자동화 여부, 특정 commit에서의 실행 여부와 PASS 결과를 서로 다른 상태로 취급한다.
|
||||
|
||||
Test definition 예시:
|
||||
|
||||
```text
|
||||
Test ID: TC-NNN-001
|
||||
Verifies:
|
||||
- REQ-NNN.01-AC01
|
||||
Test reference: <test file, case 또는 harness selector>
|
||||
Automated: true | false
|
||||
```
|
||||
|
||||
Execution evidence 예시:
|
||||
|
||||
```text
|
||||
Test ID: TC-NNN-001
|
||||
Requirement revision: REQ-NNN@major.minor
|
||||
Commit: <commit SHA>
|
||||
Environment: <runtime 및 backend>
|
||||
Result: PASS | FAIL | BLOCKED
|
||||
Executed at: <timestamp>
|
||||
```
|
||||
|
||||
## 12. 추적성
|
||||
|
||||
- 근거 이슈: `<issue URL 또는 ID>`
|
||||
- 구현 PR: `<PR URL 또는 ID>`
|
||||
- 관련 설계 문서: `<repository-relative path>`
|
||||
- 관련 requirement: `<REQ-NNN 또는 없음>`
|
||||
|
||||
## 13. 확정된 결정
|
||||
|
||||
- 사용자와 확정한 제품·정책 결정을 기록한다.
|
||||
- 확정된 결정은 Open Question으로 다시 남기지 않는다.
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
`approved` 전 반드시 결정해야 하며, 각 항목에는 선택지, 권장안, 결정 주체와 종료 조건을 둔다. 미결정 항목이 없으면 `없음`으로 기록한다.
|
||||
|
||||
- 없음
|
||||
|
||||
## 15. 승인 기록
|
||||
|
||||
- 상태: `draft`
|
||||
- 승인 주체: 미승인
|
||||
- 승인 근거: 없음
|
||||
@@ -0,0 +1,11 @@
|
||||
# REQ-001 Approval History
|
||||
|
||||
`provenance-reapproval-request.md`는 승인 기록 정정을 요청했던 당시 문서이며, 현재 승인 대기 요청이 아니다. 역사적 기록 보존을 위해 당시 본문·경로·digest 표기를 수정하지 않았다.
|
||||
|
||||
- 현재 정본: [REQ-001 — 사용자 입력 현금 배당 원장 기록](../req-001_manual-cash-dividend.md)
|
||||
- 정정 범위: 승인 기록에서 혼동한 OQ 선택지 문구만 정정. 요구사항 계약 변경 없음.
|
||||
- 최종 재승인: 사용자가 변경된 exact diff와 digest를 최종 재승인함.
|
||||
- 승인 근거: https://github.com/fourblocks2020/portfolio-hub/pull/33#issuecomment-5602775793
|
||||
- 최종 재승인된 정본 SHA-256: `1319d8f008f81d125610d49591dde8ee43d3294e0fdb01a5e69fb01306009b47`
|
||||
|
||||
이번 폴더 재배치는 정본 내용·ID·revision·status를 변경하지 않는다. 요청서 안의 기존 경로는 당시 위치이며, 현재 위치는 위 정본 링크를 따른다.
|
||||
@@ -0,0 +1,25 @@
|
||||
# REQ-001 Provenance Reapproval Request
|
||||
|
||||
- 대상 requirement: `REQ-001` (`docs/requirements/req-001_manual-cash-dividend.md`)
|
||||
- 현재 canonical SHA-256: `b9498b5d9b6dd4638d701f68273dbbde1a37dbbaa74d1d3c9923fb1702b6f076`
|
||||
- 상태: 승인된 canonical 본문은 변경하지 않음
|
||||
|
||||
## 요청 배경
|
||||
|
||||
`REQ-001` 승인 기록의 문구(`OQ-001 선택지 A`)가 본문 계약(`tax` 허용) 및 이슈 #31의 확정 OQ-001(B)와 provenance 상 충돌한다는 리뷰 코멘트가 확인되었다.
|
||||
|
||||
## 요청 사항
|
||||
|
||||
승인된 canonical 파일 자체를 구현 PR에서 직접 수정하지 않고, 아래 절차 중 하나를 요청한다.
|
||||
|
||||
1. `REQ-001` 한정 1회 예외를 승인하여 provenance 문구를 정정하고 재승인한다.
|
||||
2. 또는 별도 canonical artifact(새 revision)로 provenance 정정본을 생성하고 재승인한다.
|
||||
|
||||
## 정정 대상 문구(제안)
|
||||
|
||||
- 기존: `이 대화에서 REQ-001 승인과 OQ-001 선택지 A를 명시적으로 확정함`
|
||||
- 제안: `이 대화에서 REQ-001 승인과 비-DIVIDEND confirm_duplicate=false 허용/true HTTP 422 결정을 명시적으로 확정함`
|
||||
|
||||
## 비고
|
||||
|
||||
본 문서는 절차 요청 artifact이며, `REQ-001` canonical bytes/revision/status를 직접 변경하지 않는다.
|
||||
@@ -0,0 +1,591 @@
|
||||
---
|
||||
{
|
||||
"schema": "proofline.requirement/v1",
|
||||
"id": "REQ-001",
|
||||
"title": "사용자 입력 현금 배당 원장 기록",
|
||||
"revision": "1.0",
|
||||
"status": "approved",
|
||||
"depends_on": [],
|
||||
"applies_to": ["portfolio-hub", "manual-ledger"],
|
||||
"language": "ko"
|
||||
}
|
||||
---
|
||||
|
||||
# REQ-001 — 사용자 입력 현금 배당 원장 기록
|
||||
|
||||
## 1. 목적
|
||||
|
||||
사용자는 Broker에서 자동으로 수집하기 어려운 현금 배당의 실제 실수령액을 portfolio-hub의 manual ledger에 직접 기록할 수 있어야 한다. 기록된 배당은 전략 주문 가능 현금과 분리하여 `profit_buffer`에 반영하고, 입력 사실과 변경 후 portfolio 상태를 원장에서 조회하고 정정할 수 있어야 한다.
|
||||
|
||||
## 2. 배경
|
||||
|
||||
초기 범위에서는 Broker별 배당 조회 기능의 가용성과 형식 차이에 의존하지 않고 사용자가 실제 입금 내역을 직접 기록한다. 배당 실수령액은 투자 원금이나 주문 가능 현금이 아니므로 `cash_balance`에 합산하지 않는다. 잘못 입력한 기록은 기존 append-only `REVERSAL` 흐름으로 상쇄하며, 원본 원장을 수정하거나 삭제하지 않는다.
|
||||
|
||||
이 문서는 GitHub 이슈 #31의 확정 계약과 PR #33의 구현 증거를 요구사항으로 정리한 초안이다. 구현 코드와 테스트는 의도의 근거이지만, 이 문서의 `approved` 전환은 별도의 명시적인 사용자 승인을 필요로 한다.
|
||||
|
||||
## 3. 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- manual ledger action `DIVIDEND`
|
||||
- 배당 실수령액, 선택적 세금 metadata, 입금일과 메모 입력
|
||||
- 입력값 검증과 OpenAPI 계약
|
||||
- PostgreSQL 원장 저장과 portfolio snapshot 갱신
|
||||
- 중복 후보 경고와 사용자 확인 후 재제출
|
||||
- manual ledger 및 unified ledger 조회
|
||||
- 기존 `REVERSAL`을 이용한 배당 정정
|
||||
- Decimal 정밀도와 transaction 원자성
|
||||
- 기존 manual ledger action 하위호환
|
||||
- API·저장소·실제 PostgreSQL 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- Broker 배당 자동 수집 또는 동기화
|
||||
- 외화 환산과 다통화 손익
|
||||
- 배당 총액(gross amount) 입력
|
||||
- 세금 자동 계산·역산과 세율 정책
|
||||
- 배당수익 별도 집계
|
||||
- 실현손익 또는 기간 수익률 breakdown 변경
|
||||
- 배당금을 `cash_balance`나 전략 주문 가능 현금으로 이동하는 기능
|
||||
- portfolio-hub CLI의 배당 입력·표시 UX
|
||||
- trex-web의 `현금 배당` label, 입력 form과 중복 확인 UX
|
||||
- reversal UI
|
||||
|
||||
## 4. 용어와 기준
|
||||
|
||||
- **실수령액(net cash dividend)**: 세금 등을 제외하고 사용자 계좌에 실제 입금된 배당금. API에서는 `amount`로 표현한다.
|
||||
- **세금 metadata**: 사용자가 참고 목적으로 입력하는 `tax`. 자동 계산하지 않으며 portfolio 상태에 영향을 주지 않는다.
|
||||
- **입금일**: 사용자가 date-only 형식으로 입력하는 배당 입금 날짜. 기존 `executed_at`에 UTC 자정으로 저장한다.
|
||||
- **기록 시각**: 서버가 원장 항목을 append한 시각인 `recorded_at`.
|
||||
- **중복 후보**: 동일 portfolio, 동일 UTC 입금일, 동일 Decimal `amount`, 동일 `note`를 가진 reversal되지 않은 기존 `DIVIDEND`.
|
||||
- **정본 기준**: 배당 사실과 변경 후 상태는 PostgreSQL `portfolio_ledger`의 append-only 기록 및 그 기록과 같은 transaction에서 갱신된 portfolio snapshot을 기준으로 한다.
|
||||
|
||||
## 5. 요구사항
|
||||
|
||||
`REQ-001`은 사용자 입력 현금 배당 기능 묶음의 identifier다. 고객 요구사항명세리스트와 구현 추적에서는 독립적으로 구현·검증할 수 있는 다음 항목을 사용한다.
|
||||
|
||||
### REQ-001.01 — 현금 배당 입력 계약
|
||||
|
||||
시스템은 portfolio 소유 사용자가 manual ledger API를 통해 실제 배당 실수령액, 입금일, 선택적 세금 metadata와 메모를 입력할 수 있게 해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: `POST /v1/portfolios/{portfolio_id}/ledger/manual`, OpenAPI
|
||||
- Persistence: `portfolio_ledger`
|
||||
- Consumers: portfolio-hub API client, 후속 trex-web 연계
|
||||
- Migration: 신규 입금일 column 없이 기존 원장 metadata 구조 사용
|
||||
- Compatibility: 기존 manual action의 유효 입력 계약 유지
|
||||
- Deployment prerequisites: 기존 portfolio-hub PostgreSQL migration head 적용
|
||||
|
||||
### REQ-001.02 — 배당에 따른 portfolio 상태 변경
|
||||
|
||||
시스템은 유효한 `DIVIDEND`의 `amount`만큼 `profit_buffer`를 증가시키고, `cash_balance`와 `asset_quantity`는 변경하지 않아야 한다. `total_value_after`는 증가한 buffer를 반영해야 하며 `tax`는 모든 상태 계산에서 제외해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: manual ledger 생성 응답, ledger 조회 응답
|
||||
- Persistence: portfolio snapshot, `portfolio_ledger` after-snapshot
|
||||
- Consumers: portfolio valuation과 unified ledger 소비자
|
||||
- Migration: 없음
|
||||
- Compatibility: 기존 action의 상태 계산식 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.03 — 입금일과 기록 시각 분리
|
||||
|
||||
시스템은 date-only 입금일을 기존 `executed_at`에 UTC 자정(`00:00:00Z`)으로 저장하고, `recorded_at`은 서버 append 시각으로 독립적으로 유지해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: manual ledger 요청·응답, unified ledger 응답
|
||||
- Persistence: 기존 `portfolio_ledger.executed_at`, `recorded_at`
|
||||
- Consumers: 입금일 표시와 중복 판정 소비자
|
||||
- Migration: 입금일 전용 DB column 추가 없음
|
||||
- Compatibility: 기존 원장의 `recorded_at` 의미 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.04 — 배당 원장 저장과 조회
|
||||
|
||||
시스템은 배당을 `source=manual`, canonical `action=DIVIDEND`로 append하고, unified ledger에서 배당 금액과 metadata 및 변경 후 snapshot을 직접 조회할 수 있게 해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: manual ledger 응답, `GET /v1/portfolios/{portfolio_id}/ledger`, OpenAPI
|
||||
- Persistence: `portfolio_ledger`
|
||||
- Consumers: portfolio-hub API client, trex-web 원장 화면
|
||||
- Migration: 기존 manual ledger metadata column 사용
|
||||
- Compatibility: broker 원장과 기존 manual action의 unified ledger 표현 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.05 — 중복 후보 경고와 확인 재제출
|
||||
|
||||
시스템은 중복 후보를 자동 기록하지 않고 HTTP 409 경고와 일치한 원장 ID를 반환해야 한다. 사용자가 `confirm_duplicate=true`로 확인한 재요청은 실제 예외 기록으로 허용해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: manual ledger 요청의 `confirm_duplicate`, HTTP 409 오류 응답
|
||||
- Persistence: 중복 후보 조회와 조건부 append
|
||||
- Consumers: 중복 확인 UX를 제공하는 후속 trex-web
|
||||
- Migration: 없음
|
||||
- Compatibility: 기존 호출자가 새 배당 전용 field를 생략한 경우의 기존 계약 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.06 — DIVIDEND reversal
|
||||
|
||||
시스템은 기존 append-only `REVERSAL`로 원본 배당의 실수령액만큼 `profit_buffer`를 감소시켜 배당 효과를 상쇄해야 한다. 원본 원장을 수정하거나 삭제해서는 안 된다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: 기존 manual `REVERSAL` 요청·응답
|
||||
- Persistence: 원본 `DIVIDEND`, 새 `REVERSAL`, portfolio snapshot
|
||||
- Consumers: manual ledger와 unified ledger 소비자
|
||||
- Migration: 없음
|
||||
- Compatibility: 기존 잔액 부족·대상 없음·중복 reversal 정책 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
### REQ-001.07 — Decimal 정밀도와 원자적 정합성
|
||||
|
||||
시스템은 수용한 Decimal 금액을 입력, 중복 비교, 원장 저장, snapshot 계산과 reversal에서 같은 의미로 유지해야 한다. 원장과 portfolio snapshot이 같은 값을 표현할 수 없는 요청은 어떤 상태도 기록하기 전에 원자적으로 거부해야 한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: Decimal 입력 검증과 충돌 응답
|
||||
- Persistence: Decimal 원장 값과 portfolio snapshot
|
||||
- Consumers: 잔액·원장·reversal 정합성에 의존하는 모든 소비자
|
||||
- Migration: column type 변경 없음
|
||||
- Compatibility: 수용된 값의 정합성을 보장하면서 기존 manual action 계약 유지
|
||||
- Deployment prerequisites: 실제 PostgreSQL 경계값 검증
|
||||
|
||||
### REQ-001.08 — 기존 manual ledger 하위호환
|
||||
|
||||
시스템은 `DIVIDEND` 도입으로 기존 manual ledger action의 입력, 상태 변경, 충돌과 reversal 계약을 변경해서는 안 된다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: 기존 manual ledger API 전체
|
||||
- Persistence: 기존 action의 원장 및 snapshot 갱신
|
||||
- Consumers: 기존 portfolio-hub API client
|
||||
- Migration: 없음
|
||||
- Compatibility: `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `BUY`, `SELL`, `WITHDRAW`, `PROFIT`, `REVERSAL` 유지
|
||||
- Deployment prerequisites: 기존 action 회귀 검증
|
||||
|
||||
### REQ-001.09 — 권한과 민감정보 비노출
|
||||
|
||||
시스템은 기존 portfolio 소유권과 manual-write 권한 정책을 유지하고, 배당 기능으로 인해 API·로그·오류 응답에 secret 또는 account 식별정보를 추가로 노출해서는 안 된다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: 인증된 manual ledger API와 오류 응답
|
||||
- Persistence: actor와 기존 감사 metadata
|
||||
- Consumers: 사용자 및 운영 로그 소비자
|
||||
- Migration: 없음
|
||||
- Compatibility: 기존 인증·권한·daemon-managed portfolio 확인 정책 유지
|
||||
- Deployment prerequisites: 없음
|
||||
|
||||
## 6. 제약과 불변조건
|
||||
|
||||
- `amount`는 0보다 큰 Decimal이어야 한다.
|
||||
- `tax`는 선택 입력이며 기본값은 `0`이고 음수일 수 없다.
|
||||
- `amount`, `tax`와 관련 수치는 원장의 `NUMERIC(20,8)` 범위와 scale을 준수해야 한다.
|
||||
- `tax`는 metadata이며 `profit_buffer`, `cash_balance`, `asset_quantity`, `total_value_after` 계산에 영향을 주지 않는다.
|
||||
- `quantity`, 거래 `price`, `fee`, 주문·체결 ID와 reversal 대상 ID는 `DIVIDEND` 입력 대상이 아니다.
|
||||
- date-only 입금일은 서버 timezone과 무관하게 UTC 자정으로 변환한다.
|
||||
- 원장 append와 portfolio snapshot 갱신은 원자적으로 함께 성공하거나 실패해야 한다.
|
||||
- 실패하거나 거부된 요청은 원장과 portfolio snapshot을 모두 변경하지 않는다.
|
||||
- 원장은 append-only이며 원본 배당을 직접 수정하거나 삭제하지 않는다.
|
||||
- 중복 비교는 binary float 근사가 아니라 저장 계약과 동일한 Decimal 의미를 사용한다.
|
||||
- `profit_buffer`가 부족한 `DIVIDEND` reversal은 기존 잔액 부족 정책에 따라 거부한다.
|
||||
|
||||
|
||||
## 7. Acceptance Criteria
|
||||
|
||||
### REQ-001.01-AC01 — 유효한 배당 입력 수용
|
||||
|
||||
**Criterion**
|
||||
|
||||
portfolio 소유 사용자가 `action=DIVIDEND`, 유효한 `amount`, date-only `executed_at`, 선택적 `tax`와 `note`를 제출하면 시스템은 manual ledger 항목을 생성하고 HTTP 201을 반환한다. `tax` 생략 시 저장 의미는 `0`이다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- `amount>0`, `tax>=0`, `executed_at=YYYY-MM-DD`인 정상 요청이 HTTP 201인지 확인한다.
|
||||
- `tax`를 생략한 요청의 저장·응답 값이 `0`인지 확인한다.
|
||||
- 생성 응답의 `action`, `source`, `amount`, `tax`, `executed_at`과 after-snapshot을 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 성공 시 원장 항목이 정확히 하나 추가되어야 한다.
|
||||
- 성공 시 `tax`는 portfolio 상태를 변경하지 않아야 한다.
|
||||
- 실패한 입력은 원장과 snapshot을 변경하지 않아야 한다.
|
||||
|
||||
### REQ-001.01-AC02 — 잘못된 배당 입력 거부
|
||||
|
||||
**Criterion**
|
||||
|
||||
시스템은 `amount` 또는 입금일이 없거나, 허용 범위를 벗어난 수치 또는 `DIVIDEND`와 무관한 field가 포함된 요청을 원장 기록 전에 거부한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 누락·0·음수·8자리 초과 소수·저장 범위 초과 `amount`를 검증한다.
|
||||
- 누락되거나 잘못된 date-only `executed_at`을 검증한다.
|
||||
- 음수·8자리 초과 소수·저장 범위를 벗어난 `tax`를 검증한다.
|
||||
- `quantity`, `price`, `fee`, `target_ledger_entry_id` 입력을 검증한다.
|
||||
- 각 거부가 HTTP 422이며 상태가 변하지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `unit | API`
|
||||
- Backend: 없음
|
||||
- Cases: `negative | boundary | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 거부된 요청은 원장 항목을 만들지 않아야 한다.
|
||||
- 거부된 요청은 모든 portfolio snapshot을 유지해야 한다.
|
||||
|
||||
### REQ-001.02-AC01 — 배당 상태 효과
|
||||
|
||||
**Criterion**
|
||||
|
||||
유효한 배당 기록 후 `profit_buffer_after`는 직전 값에 `amount`를 더한 값이어야 한다. `cash_balance_after`와 `asset_quantity_after`는 직전 값과 같아야 하며, `total_value_after`는 증가한 buffer를 반영해야 한다. `tax` 값은 이 계산에 영향을 주지 않는다.
|
||||
|
||||
**Verification**
|
||||
|
||||
서로 다른 `amount`와 `tax` 조합으로 기록 전후 portfolio와 ledger after-snapshot을 비교하고 각 계산식을 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- `profit_buffer` 외의 현금·수량 상태는 변경되지 않아야 한다.
|
||||
- 동일 `amount`에 대해 `tax` 값이 달라도 상태 효과는 같아야 한다.
|
||||
- ledger after-snapshot과 portfolio snapshot은 같아야 한다.
|
||||
|
||||
### REQ-001.03-AC01 — UTC 입금일 저장
|
||||
|
||||
**Criterion**
|
||||
|
||||
`YYYY-MM-DD` 입금일은 `executed_at`의 해당 UTC 날짜 `00:00:00Z`로 저장·반환되어야 한다. `recorded_at`은 서버 append 시각이어야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 서로 다른 날짜를 입력해 UTC 자정 저장과 조회를 확인한다.
|
||||
- 서버 환경 timezone과 무관한 결과를 확인한다.
|
||||
- `recorded_at`이 입력한 입금일로 대체되지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- date-only 입력은 시간대 변환으로 전날 또는 다음 날이 되어서는 안 된다.
|
||||
- `recorded_at`의 감사 의미는 유지되어야 한다.
|
||||
|
||||
### REQ-001.04-AC01 — Unified ledger 배당 조회
|
||||
|
||||
**Criterion**
|
||||
|
||||
기록된 배당은 manual ledger와 unified ledger에서 `source=manual`, `action=DIVIDEND`, `executed_at`, canonical `amount`, `tax_amount`, `note` 및 변경 후 snapshot으로 조회되어야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 배당 생성 후 `source=manual` filter를 사용해 unified ledger를 조회한다.
|
||||
- canonical `amount`가 별도 field로 존재하며 거래 `price` 해석에만 의존하지 않는지 확인한다.
|
||||
- OpenAPI의 request·response schema에 같은 field와 nullability가 나타나는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 조회는 원장과 portfolio 상태를 변경하지 않아야 한다.
|
||||
- broker entry와 기존 manual entry의 기존 표현을 변경하지 않아야 한다.
|
||||
|
||||
### REQ-001.05-AC01 — 최초 중복 후보 경고
|
||||
|
||||
**Criterion**
|
||||
|
||||
동일 portfolio, UTC 입금일, Decimal `amount`, `note`의 reversal되지 않은 기존 배당이 있고 `confirm_duplicate`가 없거나 `false`이면 시스템은 새 항목을 기록하지 않고 HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND`와 `matched_ledger_entry_id`를 반환한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 첫 요청을 기록한 뒤 동일 요청을 재제출한다.
|
||||
- 409 code와 원본 entry ID를 확인한다.
|
||||
- 원장 수와 portfolio snapshot이 변하지 않았는지 확인한다.
|
||||
- Decimal 값이 다른 요청을 같은 값으로 오판하지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `negative | boundary | unchanged-state | concurrency`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 경고 응답은 새 원장을 만들지 않아야 한다.
|
||||
- 경고 응답은 `profit_buffer`를 변경하지 않아야 한다.
|
||||
- 일치 ID는 실제 중복 후보를 정확히 가리켜야 한다.
|
||||
|
||||
### REQ-001.05-AC02 — 확인된 중복과 reversal된 원본 처리
|
||||
|
||||
**Criterion**
|
||||
|
||||
중복 후보 요청에 `confirm_duplicate=true`가 있으면 시스템은 별도 배당 항목을 기록해야 한다. 이미 reversal된 배당 원본은 새로운 중복 후보에서 제외해야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 409 이후 동일 요청에 `confirm_duplicate=true`를 보내 HTTP 201과 별도 entry ID를 확인한다.
|
||||
- 원본을 reversal한 뒤 동일 배당을 다시 제출해 경고 없이 기록되는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 확인된 중복은 각각 독립적인 append-only 원장이어야 한다.
|
||||
- reversal된 원본은 중복 억제 상태를 영구히 남기지 않아야 한다.
|
||||
|
||||
### REQ-001.05-AC03 — 동시 중복 요청의 일관성
|
||||
|
||||
**Criterion**
|
||||
|
||||
동일 portfolio에 확인되지 않은 같은 배당 요청이 동시에 도착하더라도 새 배당 항목은 최대 하나만 기록되어야 하며 최종 원장과 portfolio snapshot은 일치해야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
실제 PostgreSQL에서 같은 요청을 동시에 제출해 확인되지 않은 요청 둘이 모두 자동 기록되지 않으며, 거부된 요청은 상태를 변경하지 않고 최종 원장 수와 `profit_buffer`가 허용된 기록 수와 일치하는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `concurrency | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- lost update가 발생하지 않아야 한다.
|
||||
- 원장 수와 최종 snapshot 사이에 불일치가 없어야 한다.
|
||||
- 경고로 거부된 transaction은 부분 상태를 남기지 않아야 한다.
|
||||
|
||||
### REQ-001.06-AC01 — 배당 reversal
|
||||
|
||||
**Criterion**
|
||||
|
||||
유효한 `DIVIDEND`를 reversal하면 원본 `amount`만큼 `profit_buffer`가 감소하고 원본 이전 값으로 복원되어야 한다. 원본과 reversal 원장은 모두 남아야 하며 현금과 수량은 변경되지 않아야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 정상 금액과 최소 단위 `0.00000001`을 각각 기록하고 reversal한다.
|
||||
- 원본 이전 buffer로 정확히 복원되는지 확인한다.
|
||||
- 원본 보존, reversal 대상 ID, 중복 reversal 거부와 buffer 부족 거부를 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | negative | boundary | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 원본 원장은 수정·삭제되지 않아야 한다.
|
||||
- reversal은 `cash_balance`와 `asset_quantity`를 변경하지 않아야 한다.
|
||||
- 실패한 reversal은 새 원장이나 부분 snapshot을 남기지 않아야 한다.
|
||||
|
||||
### REQ-001.07-AC01 — Decimal round-trip
|
||||
|
||||
**Criterion**
|
||||
|
||||
API가 수용한 최대 8자리 소수의 `amount`와 `tax`는 PostgreSQL 저장, 조회, 중복 비교와 reversal에서 같은 Decimal 의미를 유지해야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- `0.00000001` 등 경계값을 기록하고 DB text 표현·API 응답·reversal 결과를 비교한다.
|
||||
- float로 변환하면 같아질 수 있는 서로 다른 Decimal 입력이 동일한 중복으로 판정되지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | boundary`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 수용된 원장 값과 snapshot 효과가 달라져서는 안 된다.
|
||||
- reversal 기준은 원본의 정확한 Decimal 금액이어야 한다.
|
||||
|
||||
### REQ-001.08-AC01 — 기존 action 회귀 없음
|
||||
|
||||
**Criterion**
|
||||
|
||||
기존 manual action은 `DIVIDEND` 도입 전과 같은 유효 입력, 상태 효과와 충돌 정책을 유지해야 한다. 새 배당 전용 field를 사용하지 않는 기존 호출자는 변경 없이 동작해야 한다. 비-`DIVIDEND` action에 `confirm_duplicate=false`를 명시한 요청은 허용하고, `confirm_duplicate=true`를 명시한 요청은 원장 기록 전에 HTTP 422로 거부해야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 기존 action 전체의 정상·잔액 부족·reversal 경로를 실행한다.
|
||||
- 새 배당 전용 field를 생략한 기존 요청과 저장 데이터의 회귀를 확인한다.
|
||||
- 비-`DIVIDEND` action에 `confirm_duplicate=false`를 명시한 요청이 기존 action과 동일하게 처리되는지 확인한다.
|
||||
- 비-`DIVIDEND` action에 `confirm_duplicate=true`를 명시한 요청이 HTTP 422이며 상태가 변하지 않는지 확인한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL
|
||||
- Cases: `positive | negative | boundary | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 기존 action의 계산식과 원장 표현이 변경되지 않아야 한다.
|
||||
- 새 배당 전용 field의 기본값은 기존 호출자를 깨뜨리지 않아야 한다.
|
||||
- 비-`DIVIDEND` action의 `confirm_duplicate=true` 거부는 원장과 portfolio snapshot을 변경하지 않아야 한다.
|
||||
|
||||
### REQ-001.09-AC01 — 권한 유지와 민감정보 비노출
|
||||
|
||||
**Criterion**
|
||||
|
||||
배당 기록은 기존 portfolio 소유권과 manual-write 확인 정책을 통과한 사용자에게만 허용되어야 한다. 응답·오류·로그는 이 기능으로 인해 secret, credential 또는 불필요한 account 식별정보를 추가로 노출하지 않아야 한다.
|
||||
|
||||
**Verification**
|
||||
|
||||
- 기존 인증·소유권·daemon-managed portfolio 확인 경로를 실행한다.
|
||||
- 정상 응답, 422, 409와 persistence 오류의 body 및 rendered log를 민감정보 sentinel로 검사한다.
|
||||
|
||||
**Verification Classification**
|
||||
|
||||
- Level: `API | integration`
|
||||
- Backend: PostgreSQL, application log
|
||||
- Cases: `positive | negative | unchanged-state`
|
||||
|
||||
**State Invariants**
|
||||
|
||||
- 권한이 없는 요청은 원장과 snapshot을 변경하지 않아야 한다.
|
||||
- 오류 경로에서도 credential과 connection 정보가 노출되지 않아야 한다.
|
||||
|
||||
## 8. 오류 및 충돌 계약
|
||||
|
||||
- 유효하지 않은 schema 입력은 HTTP 422로 거부하고 상태를 변경하지 않는다.
|
||||
- 중복 후보의 최초 요청은 HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND`와 `matched_ledger_entry_id`를 반환한다.
|
||||
- 사용자 확인이 필요한 daemon-managed portfolio 정책과 disabled portfolio 정책은 기존 HTTP 409 계약을 유지한다.
|
||||
- reversal 대상 없음, 중복 reversal과 잔액 부족은 기존 manual ledger 충돌 계약을 유지한다.
|
||||
- 입력 검증 또는 저장 실패로 요청을 기록할 수 없으면 부분 기록 없이 거부한다.
|
||||
- DB 오류 또는 transaction 실패 시 원장 append와 portfolio snapshot update를 모두 rollback한다.
|
||||
|
||||
## 9. 데이터 및 인터페이스 계약
|
||||
|
||||
### 요청
|
||||
|
||||
- `action`: `DIVIDEND`
|
||||
- `amount`: 필수, `>0`, Decimal, 최대 20자리·소수점 이하 최대 8자리
|
||||
- `tax`: 선택, 기본값 `0`, `>=0`, Decimal, 최대 20자리·소수점 이하 최대 8자리
|
||||
- `executed_at`: 필수 date-only `YYYY-MM-DD`
|
||||
- `note`: 선택 문자열
|
||||
- `confirm_duplicate`: 선택 boolean, 기본값 `false`
|
||||
|
||||
`confirm_duplicate`는 `DIVIDEND`의 중복 확인 계약에 사용한다. 비-`DIVIDEND` action에서는 명시적 `false`를 허용하고 명시적 `true`를 HTTP 422로 거부한다.
|
||||
|
||||
### 허용하지 않는 DIVIDEND 입력
|
||||
|
||||
- `quantity`
|
||||
- 거래 `price`
|
||||
- `fee`
|
||||
- `target_ledger_entry_id`
|
||||
- 주문 또는 체결 식별자
|
||||
|
||||
### 생성 응답과 unified ledger
|
||||
|
||||
- `ledger_entry_id`
|
||||
- `portfolio_id`
|
||||
- `source=manual`
|
||||
- `action=DIVIDEND`
|
||||
- canonical `amount`
|
||||
- `tax` 또는 unified ledger의 `tax_amount`
|
||||
- UTC 자정의 `executed_at`
|
||||
- 서버 append 시각 `recorded_at`
|
||||
- `note`
|
||||
- `cash_balance_after`
|
||||
- `asset_quantity_after`
|
||||
- `profit_buffer_after`
|
||||
- `total_value_after`
|
||||
|
||||
### 저장 계약
|
||||
|
||||
- 입금일 전용 DB column을 추가하지 않고 기존 `executed_at`을 사용한다.
|
||||
- `tax`는 참고 metadata로 저장한다.
|
||||
- 배당 사실과 after-snapshot은 `source=manual`인 append-only 원장으로 보존한다.
|
||||
|
||||
## 10. 하위호환성
|
||||
|
||||
- 기존 manual ledger action의 필수·선택 field와 상태 계산을 유지한다.
|
||||
- 기존 broker ledger write와 unified ledger 조회 계약을 유지한다.
|
||||
- 새 배당 전용 field를 사용하지 않는 기존 action 요청을 변경 없이 허용한다.
|
||||
- `REVERSAL`의 대상 없음, 잔액 부족과 중복 reversal 정책을 변경하지 않는다.
|
||||
|
||||
## 11. 검증 계획
|
||||
|
||||
- API schema와 OpenAPI에서 `DIVIDEND` action 및 입력·응답 field 검증
|
||||
- 배당 기록 전후 portfolio와 ledger after-snapshot 비교
|
||||
- UTC 입금일과 서버 `recorded_at` 분리 검증
|
||||
- 최초 중복 409, 확인 재제출, reversal된 원본 제외 검증
|
||||
- 동시 중복 요청의 최대 1건 기록과 최종 원장·snapshot 일치 검증
|
||||
- 최소 단위와 저장 범위 경계값의 실제 PostgreSQL Decimal round-trip 검증
|
||||
- 입력 또는 저장 실패의 pre-write rejection과 rollback 검증
|
||||
- 기존 action과 기존 저장 데이터의 하위호환 회귀
|
||||
- 응답·오류·로그의 민감정보 비노출 검증
|
||||
- 문서와 OpenAPI 계약 정합성 검증
|
||||
|
||||
### Test Traceability Contract
|
||||
|
||||
- 이 requirement 문서에는 검증 의도와 판정 조건만 기록한다.
|
||||
- 실행 가능한 테스트 정의는 별도 test catalog artifact에서 관리한다.
|
||||
- 각 테스트는 안정적인 identifier와 `verifies` 목록으로 하나 이상의 `REQ-001.XX-ACYY`를 참조한다.
|
||||
- 테스트 정의의 존재, 자동화 여부, 특정 commit에서의 실행 여부와 PASS 결과를 별도 상태로 관리한다.
|
||||
- 실행 결과는 requirement나 test definition을 수정하지 않고 별도 evidence에 기록한다.
|
||||
- Evidence는 최소한 `REQ-001` revision, 검증한 commit SHA, runtime/backend, 결과와 실행 시각을 포함한다.
|
||||
|
||||
## 12. 추적성
|
||||
|
||||
- 근거 이슈: `https://github.com/fourblocks2020/portfolio-hub/issues/31`
|
||||
- 구현 PR: `https://github.com/fourblocks2020/portfolio-hub/pull/33`
|
||||
- 검토한 PR head: `6df3e4ba029cc465bf28030767190d6af55c5402`
|
||||
- 관련 설계 문서:
|
||||
- `docs/interface-contract.md`
|
||||
- `docs/data-model.md`
|
||||
- `docs/runtime-flows.md`
|
||||
- 관련 requirement: 없음
|
||||
|
||||
## 13. 확정된 결정
|
||||
|
||||
- 사용자는 실수령액 `amount`를 입력한다.
|
||||
- `tax`는 기본값 `0`인 선택적 non-negative metadata이며 자동 계산하지 않는다.
|
||||
- 입금일은 새 DB column 없이 기존 `executed_at`에 저장한다.
|
||||
- date-only 입금일은 UTC 자정으로 변환한다.
|
||||
- 최초 중복 후보 요청은 409로 거부하고 `confirm_duplicate=true`인 재요청만 기록한다.
|
||||
- 구현 범위는 portfolio-hub API·DB·OpenAPI·테스트이며 UI는 별도 연계 범위다.
|
||||
- 잘못 입력한 배당은 기존 append-only `REVERSAL`로 정정한다.
|
||||
- 비-`DIVIDEND` action에서는 `confirm_duplicate=false`를 허용하고 `confirm_duplicate=true`를 HTTP 422로 거부한다.
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
- 없음
|
||||
|
||||
## 15. 승인 기록
|
||||
|
||||
- 상태: `approved`
|
||||
- 승인 주체: `genichin`
|
||||
- 승인 근거: 이 대화에서 REQ-001 승인과 비-DIVIDEND confirm_duplicate=false 허용/true HTTP 422 결정을 명시적으로 확정함
|
||||
@@ -0,0 +1,112 @@
|
||||
# REQ-001 Test Catalog
|
||||
|
||||
Test ID: TC-REQ-001-001
|
||||
Verifies:
|
||||
- REQ-001.01-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_tax_defaults_to_zero_when_omitted
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_tax_omitted_persists_zero_on_real_postgres
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-002
|
||||
Verifies:
|
||||
- REQ-001.01-AC02
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_invalid_amount_tax_and_unrelated_fields_without_state_change
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_sub_minimum_numeric_precision
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_amount_exceeding_numeric_20_8
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-003
|
||||
Verifies:
|
||||
- REQ-001.02-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_tax_defaults_to_zero_when_omitted
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-004
|
||||
Verifies:
|
||||
- REQ-001.03-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_executed_at_uses_utc_midnight_independent_of_server_timezone
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-005
|
||||
Verifies:
|
||||
- REQ-001.04-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_includes_dividend_fields
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_openapi_contains_nullable_amount
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_history_item_rejects_dividend_numeric_branch_payload
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-006
|
||||
Verifies:
|
||||
- REQ-001.05-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_requires_confirmation
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-007
|
||||
Verifies:
|
||||
- REQ-001.05-AC02
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_requires_confirmation
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_reversed_dividend_is_excluded_from_future_duplicate_candidate_matching
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-008
|
||||
Verifies:
|
||||
- REQ-001.05-AC03
|
||||
Test reference:
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_cold_repositories_concurrent_dividend_requests_follow_duplicate_contract
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-009
|
||||
Verifies:
|
||||
- REQ-001.06-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_reversal_decreases_profit_buffer
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_duplicate_reversal_is_rejected_without_state_change
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_reversal_insufficient_buffer_is_rejected_without_state_change
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-010
|
||||
Verifies:
|
||||
- REQ-001.07-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_check_does_not_collapse_decimals_that_can_alias_in_float
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_tax_does_not_collapse_decimals_that_can_alias_in_float
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_amount_alias_prone_decimals_are_not_misdetected_as_duplicate_in_api_on_real_postgres
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_existing_buffer_plus_min_dividend_is_rejected_before_snapshot_divergence
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_tax_alias_prone_decimals_round_trip_distinctly_on_real_postgres
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-011
|
||||
Verifies:
|
||||
- REQ-001.08-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_false_is_accepted_for_non_dividend_actions
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_true_is_rejected_for_non_dividend_actions
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_keeps_non_dividend_amount_as_number
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_existing_high_precision_snapshot_still_allows_legacy_manual_actions
|
||||
- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_invest_and_large_dividend_tax_metadata_are_accepted_when_snapshot_is_safe
|
||||
Automated: true
|
||||
|
||||
Test ID: TC-REQ-001-012
|
||||
Verifies:
|
||||
- REQ-001.09-AC01
|
||||
Test reference:
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_manual_write_rejects_service_token
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_owner_mismatch_returns_403
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_daemon_managed_requires_confirmation_then_records_evidence
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_manual_ledger_conflict_message_is_sanitized
|
||||
- tests/test_v1_portfolios_manual_ledger.py::test_manual_ledger_conflict_secret_is_not_rendered_in_logs
|
||||
Automated: true
|
||||
@@ -0,0 +1,78 @@
|
||||
# REQ-001 Test Evidence
|
||||
|
||||
## Evidence Batch: local API regression run
|
||||
|
||||
Requirement revision: `REQ-001@1.0`
|
||||
Commit: `8368e7ba165b560af6ae813fa2dce715657515f5`
|
||||
Environment: `local sandbox / pytest / fake repository`
|
||||
Executed at: `2026-09-09T13:41:00Z`
|
||||
Run selector: `pytest -q -o addopts='' tests/test_v1_portfolios_manual_ledger.py`
|
||||
Run result: `38 passed`
|
||||
|
||||
Test ID: TC-REQ-001-002
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-003
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-004
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-005
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-006
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-007
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-009
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-010
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-012
|
||||
Result: PASS
|
||||
|
||||
## Evidence Batch: local real PostgreSQL selector availability
|
||||
|
||||
Requirement revision: `REQ-001@1.0`
|
||||
Commit: `8368e7ba165b560af6ae813fa2dce715657515f5`
|
||||
Environment: `local sandbox / PostgreSQL DSN not configured`
|
||||
Executed at: `2026-09-09T14:19:00Z`
|
||||
Run selector: `pytest -q -o addopts='' tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_amount_alias_prone_decimals_are_not_misdetected_as_duplicate_in_api_on_real_postgres`
|
||||
Run result: `1 skipped`
|
||||
|
||||
## Evidence Batch: independent real PostgreSQL re-review run
|
||||
|
||||
Requirement revision: `REQ-001@1.0`
|
||||
Commit: `db35d083dec75bc79b5a97b015b5c40056c12772`
|
||||
Environment: `disposable PostgreSQL / independent re-review (review 5155656169)`
|
||||
Executed at: `2026-09-09T14:25:22Z`
|
||||
Run selector: `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py (module)`
|
||||
Run result: `10 passed, 2 failed` (`2 failed` are pre-existing stale migration-head baseline)
|
||||
|
||||
Test ID: TC-REQ-001-001
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-003
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-008
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-009
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-010
|
||||
Result: PASS
|
||||
|
||||
Test ID: TC-REQ-001-011
|
||||
Result: PASS
|
||||
|
||||
## Evidence-only follow-up integrity note
|
||||
|
||||
- Tested code/test commit for the latest PostgreSQL batch: `db35d083dec75bc79b5a97b015b5c40056c12772`
|
||||
- This follow-up updates evidence text only; no application/test source files were changed after the tested commit.
|
||||
@@ -152,9 +152,9 @@
|
||||
2. `service` token: `auth.daemon_id is None` → HTTP 403. `repo.list_portfolios(managed_by_daemon_id=auth.daemon_id, ...)`.
|
||||
3. `user`/`admin` token: admin with `?user_id=<id>` → filter by that user; else filter by `auth.user_id`.
|
||||
4. `?include_disabled=false` (default) → `status="enabled"` filter applied. `?include_disabled=true` → no status filter.
|
||||
5. 필터링된 반환 대상 portfolio ID만 `repo.get_return_summaries(ids)`에 전달한다. repository는 한 번의 grouped query로 유효 manual ledger를 집계한다.
|
||||
6. `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`/`WITHDRAW`/`PROFIT`은 제외한다. `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
|
||||
7. 각 `PortfolioItem`에 순투자현금(`net_contributed_capital`)과 누적 수익 인출액(`cumulative_profit_withdrawn`)을 결합한다. 집계 row가 없으면 두 값은 `0.0`이다.
|
||||
5. 필터링된 반환 대상 portfolio ID만 `repo.get_return_summaries(ids)`에 전달한다. repository는 한 번의 grouped query로 source와 무관한 유효 ledger를 side별 집계한다.
|
||||
6. 동일 portfolio에서 matching `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`/`WITHDRAW`/`PROFIT`은 original과 reversal의 source와 무관하게 제외한다. `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
|
||||
7. `total_invested`는 유효 `INVEST`의 gross 합계, `total_withdrawn`은 유효 `WITHDRAW` 합계, `net_contributed_capital`은 두 값의 차이다. `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn`으로만 집계한다. 집계 row가 없으면 값은 `0.0`이다.
|
||||
8. Returns HTTP 200 `PortfolioListResponse`.
|
||||
|
||||
단건 `GET /v1/portfolios/{portfolio_id}/returns/summary`도 같은 grouped aggregate 구현을 portfolio ID 하나로 호출하므로 목록과 유효 원장 의미가 일치한다.
|
||||
@@ -280,11 +280,12 @@
|
||||
3. Disabled portfolio receives HTTP 409 `PORTFOLIO_DISABLED`.
|
||||
4. If `managed_by_daemon_id IS NOT NULL` and request lacks `confirm_daemon_managed_manual_write=true`, the API returns HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION` with daemon id and required confirmation marker.
|
||||
5. Valid actions dispatch to a single repository transaction that locks the portfolio row (`FOR UPDATE OF p`), calculates the action effect, appends a `portfolio_ledger` row with `source='manual'`, and updates the portfolio snapshot.
|
||||
6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. Insufficient state returns HTTP 409 with structured code.
|
||||
6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. DIVIDEND requires `amount` and date-only `executed_at`, stores `executed_at` at UTC midnight, and increases only `profit_buffer` (`tax` metadata only). Insufficient state returns HTTP 409 with structured code.
|
||||
7. REVERSAL requires `target_ledger_entry_id`, rejects duplicate reversal targets, and appends a reversing row instead of mutating/deleting the target.
|
||||
8. Response includes after-snapshot: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`.
|
||||
9. `GET /v1/portfolios/{portfolio_id}/ledger/manual` returns newest manual entries only (`source='manual'`).
|
||||
10. `GET /v1/portfolios/{portfolio_id}/returns/summary` returns `net_contributed_capital=sum(INVEST)-sum(WITHDRAW)` and `cumulative_profit_withdrawn=sum(PROFIT)` separately.
|
||||
8. Duplicate DIVIDEND candidate (same portfolio/date/amount/note and original not reversed) returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` with matched entry id; `confirm_duplicate=true` re-submit records it.
|
||||
9. Response includes after-snapshot: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`.
|
||||
10. `GET /v1/portfolios/{portfolio_id}/ledger/manual` returns newest manual entries only (`source='manual'`).
|
||||
11. `GET /v1/portfolios/{portfolio_id}/returns/summary` returns `net_contributed_capital=sum(INVEST)-sum(WITHDRAW)` and `cumulative_profit_withdrawn=sum(PROFIT)` separately.
|
||||
|
||||
## Flow: `v1 API — POST /auth/service-tokens`
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portfoliohub"
|
||||
version = "0.1.1"
|
||||
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 = 29
|
||||
EXPECTED_ALEMBIC_HEAD = "20260814_0029"
|
||||
EXPECTED_MIGRATION_COUNT = 32
|
||||
EXPECTED_ALEMBIC_HEAD = "20260919_0032"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Reject Ruff findings introduced since a stable release tag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _run(command: list[str], *, cwd: Path, accepted: tuple[int, ...] = (0,)) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False)
|
||||
if result.returncode not in accepted:
|
||||
raise RuntimeError(
|
||||
f"command failed ({result.returncode}): {' '.join(command)}\n{result.stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def changed_python_paths(repo: Path, base: str) -> list[str]:
|
||||
result = _run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--diff-filter=ACMR",
|
||||
f"{base}...HEAD",
|
||||
"--",
|
||||
"*.py",
|
||||
],
|
||||
cwd=repo,
|
||||
)
|
||||
return sorted(path for path in result.stdout.split("\0") if path)
|
||||
|
||||
|
||||
def _source_line(root: Path, relative_path: str, row: int) -> str:
|
||||
lines = (root / relative_path).read_text(encoding="utf-8").splitlines()
|
||||
return lines[row - 1].strip() if 0 < row <= len(lines) else ""
|
||||
|
||||
|
||||
def normalized_findings(
|
||||
diagnostics: list[dict[str, object]], root: Path
|
||||
) -> Counter[tuple[str, str, str, str]]:
|
||||
findings: Counter[tuple[str, str, str, str]] = Counter()
|
||||
for diagnostic in diagnostics:
|
||||
filename = Path(str(diagnostic["filename"])).resolve()
|
||||
relative_path = filename.relative_to(root.resolve()).as_posix()
|
||||
location = diagnostic["location"]
|
||||
if not isinstance(location, dict):
|
||||
raise TypeError("Ruff diagnostic location must be an object")
|
||||
row = int(location["row"])
|
||||
finding = (
|
||||
relative_path,
|
||||
str(diagnostic["code"]),
|
||||
str(diagnostic["message"]),
|
||||
_source_line(root, relative_path, row),
|
||||
)
|
||||
findings[finding] += 1
|
||||
return findings
|
||||
|
||||
|
||||
def _ruff_diagnostics(root: Path, paths: list[str]) -> list[dict[str, object]]:
|
||||
if not paths:
|
||||
return []
|
||||
ruff = shutil.which("ruff")
|
||||
if ruff is None:
|
||||
raise RuntimeError("ruff executable is not available")
|
||||
result = _run(
|
||||
[ruff, "check", *paths, "--output-format=json"],
|
||||
cwd=root,
|
||||
accepted=(0, 1),
|
||||
)
|
||||
payload = json.loads(result.stdout)
|
||||
if not isinstance(payload, list):
|
||||
raise TypeError("Ruff JSON output must be a list")
|
||||
return payload
|
||||
|
||||
|
||||
def introduced_findings(repo: Path, base: str) -> tuple[list[str], Counter[tuple[str, str, str, str]]]:
|
||||
paths = changed_python_paths(repo, base)
|
||||
if not paths:
|
||||
return paths, Counter()
|
||||
|
||||
current = normalized_findings(_ruff_diagnostics(repo, paths), repo)
|
||||
with tempfile.TemporaryDirectory(prefix="portfoliohub-ruff-base-") as parent:
|
||||
baseline_root = Path(parent) / "tree"
|
||||
_run(["git", "worktree", "add", "--detach", str(baseline_root), base], cwd=repo)
|
||||
try:
|
||||
baseline_paths = [path for path in paths if (baseline_root / path).is_file()]
|
||||
baseline = normalized_findings(
|
||||
_ruff_diagnostics(baseline_root, baseline_paths), baseline_root
|
||||
)
|
||||
finally:
|
||||
_run(
|
||||
["git", "worktree", "remove", "--force", str(baseline_root)],
|
||||
cwd=repo,
|
||||
)
|
||||
return paths, current - baseline
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", required=True, help="stable comparison tag")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = Path(
|
||||
_run(["git", "rev-parse", "--show-toplevel"], cwd=Path.cwd()).stdout.strip()
|
||||
)
|
||||
ruff_version = _run([shutil.which("ruff") or "ruff", "--version"], cwd=repo).stdout.strip()
|
||||
paths, introduced = introduced_findings(repo, args.base)
|
||||
print(f"base={args.base} head=HEAD selector=ACMR-python files={len(paths)} tool={ruff_version}")
|
||||
for path in paths:
|
||||
print(f"selected={path}")
|
||||
if introduced:
|
||||
for (path, code, message, source), count in sorted(introduced.items()):
|
||||
print(f"NEW {count}x {path}: {code} {message} | {source}")
|
||||
print(f"introduced_findings={sum(introduced.values())}")
|
||||
return 1
|
||||
print("introduced_findings=0")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -33,8 +33,8 @@ class RecordingRunner:
|
||||
if command[:3] == ["docker", "run", "--rm"] and "urllib.request" in command[-1]:
|
||||
enabled = "TARGET:8000" not in command[-1] and "plain" not in command[-1]
|
||||
return image_release.Result('{"status":"ok","db_enabled":' + str(enabled).lower() + '}')
|
||||
if command[:3] == ["docker", "run", "--rm"] and "alembic_version" in command[-1]: return image_release.Result("20260814_0029\n")
|
||||
if command[:3] == ["docker", "run", "--rm"] and "health-check" in command: return image_release.Result('{"database_revision":"20260814_0029"}\n')
|
||||
if command[:3] == ["docker", "run", "--rm"] and "alembic_version" in command[-1]: return image_release.Result("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):
|
||||
@@ -65,21 +65,25 @@ def test_checkout_identity_is_exact():
|
||||
|
||||
def test_project_version_must_match_release_tag(tmp_path):
|
||||
project = tmp_path / "pyproject.toml"
|
||||
project.write_text('[project]\nversion = "0.1.1"\n')
|
||||
assert image_release.project_release_version(project) == "v0.1.1"
|
||||
assert image_release.validate_release_event("push", "refs/tags/v0.1.1", COMMIT, "v0.1.1") == "v0.1.1"
|
||||
project.write_text('[project]\nversion = "0.1.3"\n')
|
||||
assert image_release.project_release_version(project) == "v0.1.3"
|
||||
assert image_release.validate_release_event("push", "refs/tags/v0.1.3", COMMIT, "v0.1.3") == "v0.1.3"
|
||||
with pytest.raises(ValueError, match="project version"):
|
||||
image_release.validate_release_event("push", "refs/tags/v0.1.2", COMMIT, "v0.1.1")
|
||||
image_release.validate_release_event("push", "refs/tags/v0.1.4", COMMIT, "v0.1.3")
|
||||
|
||||
def test_installed_smoke_has_import_cli_exact_inventory_and_cleanup():
|
||||
inventory = tuple(
|
||||
[f"202601{i:02d}_{i:04d}_migration.py" for i in range(1, 29)]
|
||||
+ ["20260814_0029_strategy_catalog_v1.py"]
|
||||
[f"202601{i:02d}_{i:04d}_migration.py" for i in range(1, 30)]
|
||||
+ [
|
||||
"20260820_0030_portfolio_ledger_keyset_index.py",
|
||||
"20260913_0031_corporate_actions.py",
|
||||
"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 "20260814_0029" 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)
|
||||
@@ -47,10 +52,10 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive
|
||||
assert len(revision) == 40 and all(char in "0123456789abcdef" for char in revision)
|
||||
verify = VERIFY.read_text()
|
||||
assert "tomllib" in verify and 'RELEASE_VERSION=${RELEASE_VERSION}' in verify
|
||||
assert 'RELEASE_VERSION=v0.1.1' not in verify
|
||||
assert 'RELEASE_VERSION=v0.1.3' not in verify
|
||||
|
||||
|
||||
def test_release_version_and_migration_contract_are_v011_head_0029():
|
||||
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_are_v011_head_0029():
|
||||
for path in (REPO / "app/db/alembic/versions").glob("*.py")
|
||||
if path.name != "__init__.py"
|
||||
)
|
||||
assert 'version = "0.1.1"' in project
|
||||
assert 'app_version: str = "0.1.1"' in config
|
||||
assert len(migrations) == 29
|
||||
assert migrations[-1].startswith("20260814_0029")
|
||||
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,6 +90,9 @@ def test_operations_separate_publication_deployment_migration_and_db_rollback():
|
||||
"Image rollback is **not** database rollback",
|
||||
"read-only root filesystem",
|
||||
"10001:10001",
|
||||
"PortfolioHub v0.4.0 container contract",
|
||||
"v0.2.1 includes migration `0030`",
|
||||
"trex-web v0.2.3 displays `strategy_nick`",
|
||||
):
|
||||
assert value in text
|
||||
for forbidden in (
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from collections import Counter
|
||||
|
||||
from scripts.ruff_ratchet import normalized_findings
|
||||
|
||||
|
||||
def test_normalized_findings_ignore_line_number_changes(tmp_path):
|
||||
source = tmp_path / "sample.py"
|
||||
source.write_text("\nvalue = missing\n", encoding="utf-8")
|
||||
diagnostics = [
|
||||
{
|
||||
"filename": str(source),
|
||||
"code": "F821",
|
||||
"message": "Undefined name `missing`",
|
||||
"location": {"row": 2, "column": 9},
|
||||
}
|
||||
]
|
||||
|
||||
assert normalized_findings(diagnostics, tmp_path) == Counter(
|
||||
{("sample.py", "F821", "Undefined name `missing`", "value = missing"): 1}
|
||||
)
|
||||
|
||||
|
||||
def test_normalized_findings_preserve_duplicate_count(tmp_path):
|
||||
source = tmp_path / "sample.py"
|
||||
source.write_text("missing\n", encoding="utf-8")
|
||||
diagnostic = {
|
||||
"filename": str(source),
|
||||
"code": "F821",
|
||||
"message": "Undefined name `missing`",
|
||||
"location": {"row": 1, "column": 1},
|
||||
}
|
||||
|
||||
assert normalized_findings([diagnostic, diagnostic], tmp_path).total() == 2
|
||||
+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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user