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