Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d8235bde | ||
|
|
2b470c735f | ||
|
|
4520fbbfbd | ||
|
|
1010d257ad | ||
|
|
5b443f718e | ||
|
|
a4e4951ed6 | ||
|
|
54e4fe4213 | ||
|
|
fa824dfd36 | ||
|
|
925c6b4fa4 | ||
|
|
cb19c35d23 | ||
|
|
14c118f503 | ||
|
|
48735bbb4a | ||
|
|
a4664f5991 | ||
|
|
77468631b1 | ||
|
|
7a45bec3cb | ||
|
|
dc755889b3 | ||
|
|
dfcafade94 |
@@ -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,12 @@ 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 later patches
|
||||
run: |
|
||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.1
|
||||
- name: Record exact source migration inventory
|
||||
run: uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
|
||||
- name: Create excluded synthetic canary
|
||||
|
||||
@@ -11,18 +11,45 @@ permissions:
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
timescaledb:
|
||||
image: timescale/timescaledb@sha256:252a443e2936039b83dd8da1373d01e59e932d1054fa6adf1bc061f1d56ae60a
|
||||
env:
|
||||
POSTGRES_DB: portfolio_hub
|
||||
POSTGRES_USER: portfolio_hub_user
|
||||
POSTGRES_PASSWORD: change-me
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U portfolio_hub_user -d portfolio_hub"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: python -m pip install "uv==0.11.14"
|
||||
- run: uv sync --extra dev --locked
|
||||
- run: uv run pytest -q
|
||||
- name: Verify corporate-action atomicity and guarded recovery in PostgreSQL
|
||||
env:
|
||||
POSTGRES_PASSWORD: change-me
|
||||
run: |
|
||||
export PORTFOLIOHUB_TEST_POSTGRES_DSN="postgresql://portfolio_hub_user:${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres"
|
||||
uv run pytest -q tests/test_corporate_action_atomic_recovery_postgres.py
|
||||
- name: Honest scoped lint for release additions
|
||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py tests/release
|
||||
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
|
||||
- name: Reject Ruff findings introduced by v0.3.0 or later patches
|
||||
run: |
|
||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.1
|
||||
- name: Validate container contracts without registry writes
|
||||
run: uv run pytest -q tests/release
|
||||
- name: Build and smoke exact pull-request or main source image
|
||||
|
||||
@@ -18,15 +18,21 @@ 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,
|
||||
@@ -809,7 +815,19 @@ 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 | DividendLedgerHistoryItem:
|
||||
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))
|
||||
@@ -1295,3 +1313,43 @@ def get_holdings_cost_basis(
|
||||
cost_basis_from=wac_result.cost_basis_from,
|
||||
data_completeness_warning=wac_result.data_completeness_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{portfolio_id}/corporate-actions", response_model=CorporateActionResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_corporate_action(
|
||||
portfolio_id: str,
|
||||
payload: CorporateActionRequest,
|
||||
response: Response,
|
||||
auth: AuthContext = Depends(require_trade_write), # noqa: B008 - FastAPI dependency declaration
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
||||
):
|
||||
if not idempotency_key or not idempotency_key.strip():
|
||||
raise HTTPException(status_code=422, detail="Idempotency-Key must not be empty")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
portfolio = repo.get(portfolio_id)
|
||||
if portfolio is None:
|
||||
raise HTTPException(status_code=404, detail="Portfolio not found")
|
||||
if portfolio.owner_user_id != auth.user_id and auth.permission != Permission.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Portfolio ownership mismatch")
|
||||
if portfolio.managed_by_daemon_id and not payload.confirm_daemon_managed:
|
||||
raise HTTPException(status_code=409, detail="DAEMON_MANAGED_CONFIRMATION_REQUIRED")
|
||||
from app.domain.corporate_actions import canonical_payload_hash
|
||||
digest = canonical_payload_hash(payload)
|
||||
# The repository derives all WAC and immutable snapshot fields under its row lock.
|
||||
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, payload.action_type, payload.effective_date, payload.quantity_before, payload.quantity_after, Decimal(0), Decimal(0), Decimal(0), Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), payload.evidence_ref, idempotency_key, digest, 0, auth.user_id)
|
||||
saved = repo.record_corporate_action(
|
||||
portfolio_id,
|
||||
action=action,
|
||||
idempotency_key=idempotency_key,
|
||||
payload_hash=digest,
|
||||
resume_partial=payload.resume_partial,
|
||||
)
|
||||
return CorporateActionResponse(**saved.__dict__)
|
||||
except ValueError as exc:
|
||||
code = str(exc)
|
||||
if code == PORTFOLIO_NOT_FOUND_MESSAGE:
|
||||
raise HTTPException(status_code=404, detail="Portfolio not found") from exc
|
||||
raise HTTPException(status_code=409 if code.startswith(("IDEMPOTENCY", "CORPORATE_ACTION_")) else 400, detail=code) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
|
||||
@@ -274,6 +274,66 @@ def sell_portfolio(
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("corporate-action")
|
||||
def corporate_action_portfolio(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(...),
|
||||
user: str = typer.Option(..., "--user"),
|
||||
action_type: str = typer.Option("stock_consolidation", "--type"),
|
||||
effective_date: str = typer.Option(..., "--effective-date"),
|
||||
quantity_before: float = typer.Option(..., "--quantity-before", min=0.0000001),
|
||||
quantity_after: float = typer.Option(..., "--quantity-after", min=0.0000001),
|
||||
evidence_ref: str = typer.Option(..., "--evidence-ref"),
|
||||
idempotency_key: str = typer.Option(..., "--idempotency-key"),
|
||||
resume_partial: bool = typer.Option(
|
||||
False,
|
||||
"--resume-partial",
|
||||
help="Resume only a verified quantity-only partial corporate action.",
|
||||
),
|
||||
force: bool = typer.Option(False, "--force"),
|
||||
) -> None:
|
||||
"""Record an auditable stock consolidation without a trade."""
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domain.corporate_actions import (
|
||||
calculate_stock_consolidation,
|
||||
canonical_payload_hash,
|
||||
)
|
||||
from app.domain.models import CorporateAction
|
||||
if action_type != "stock_consolidation":
|
||||
ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_TYPE")
|
||||
raise typer.Exit(code=1)
|
||||
try:
|
||||
day = date.fromisoformat(effective_date)
|
||||
except ValueError:
|
||||
ctx.obj.console.print("Error: INVALID_EFFECTIVE_DATE")
|
||||
raise typer.Exit(code=1)
|
||||
if quantity_after >= quantity_before:
|
||||
ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_RATIO")
|
||||
raise typer.Exit(code=1)
|
||||
_confirm(force, f"Record stock consolidation {quantity_before:g} → {quantity_after:g}; cash/profit buffer unchanged?")
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
portfolio = repository.get(portfolio_id)
|
||||
if not idempotency_key.strip():
|
||||
raise ValueError("Idempotency-Key must not be empty")
|
||||
payload = {"action_type": action_type, "effective_date": day.isoformat(), "quantity_before": str(quantity_before), "quantity_after": str(quantity_after), "evidence_ref": evidence_ref, "confirm_daemon_managed": False, "resume_partial": resume_partial}
|
||||
digest = canonical_payload_hash(payload)
|
||||
calc = calculate_stock_consolidation(Decimal(str(quantity_before)), Decimal(str(quantity_after)), Decimal(0))
|
||||
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, action_type, day, calc.quantity_before, calc.quantity_after, calc.average_price_before, calc.average_price_after, calc.total_cost_basis, Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), evidence_ref, idempotency_key, digest, 0, user)
|
||||
return repository.record_corporate_action(
|
||||
portfolio_id,
|
||||
action=action,
|
||||
idempotency_key=idempotency_key,
|
||||
payload_hash=digest,
|
||||
resume_partial=resume_partial,
|
||||
)
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("invest")
|
||||
def invest_portfolio(
|
||||
ctx: typer.Context,
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ if env_file.exists():
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "PortfolioHub"
|
||||
app_version: str = "0.2.1"
|
||||
app_version: str = "0.3.3"
|
||||
environment: str = "development"
|
||||
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
|
||||
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Additive storage for auditable, idempotent stock consolidations."""
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "20260913_0031"
|
||||
down_revision = "20260820_0030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_corporate_actions (
|
||||
corporate_action_id UUID PRIMARY KEY,
|
||||
portfolio_id TEXT NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE RESTRICT,
|
||||
asset_id UUID NOT NULL REFERENCES assets(asset_id) ON DELETE RESTRICT,
|
||||
action_type TEXT NOT NULL CHECK (action_type = 'stock_consolidation'),
|
||||
effective_date DATE NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
actor_user_id TEXT, evidence_ref TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL, payload_hash TEXT NOT NULL,
|
||||
quantity_before NUMERIC(20,8) NOT NULL, quantity_after NUMERIC(20,8) NOT NULL,
|
||||
average_price_before NUMERIC(20,8) NOT NULL, average_price_after NUMERIC(20,8) NOT NULL,
|
||||
total_cost_basis NUMERIC(20,8) NOT NULL, cash_balance NUMERIC(20,8) NOT NULL,
|
||||
profit_buffer NUMERIC(20,8) NOT NULL, ledger_entry_id BIGINT,
|
||||
UNIQUE (portfolio_id, idempotency_key)
|
||||
)"""))
|
||||
op.get_bind().execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_corporate_actions_portfolio "
|
||||
"ON portfolio_corporate_actions (portfolio_id, effective_date)"
|
||||
))
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().execute(text("DROP TABLE IF EXISTS portfolio_corporate_actions"))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pure domain rules for portfolio stock consolidations."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
STORAGE_QUANTUM = Decimal("0.00000001")
|
||||
|
||||
|
||||
def quantize_stored_decimal(value: Decimal) -> Decimal:
|
||||
"""Round a monetary value to the NUMERIC(*, 8) persistence contract."""
|
||||
return value.quantize(STORAGE_QUANTUM, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def canonical_payload_hash(payload: object) -> str:
|
||||
if hasattr(payload, "model_dump"):
|
||||
excluded_fields = (
|
||||
{"resume_partial"} if not getattr(payload, "resume_partial", False) else set()
|
||||
)
|
||||
payload = payload.model_dump(mode="json", exclude_none=True, exclude=excluded_fields)
|
||||
elif isinstance(payload, dict) and payload.get("resume_partial") is False:
|
||||
payload = {key: value for key, value in payload.items() if key != "resume_partial"}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
class CorporateActionError(ValueError):
|
||||
code = "INVALID_CORPORATE_ACTION_RATIO"
|
||||
def __init__(self, message: str, code: str | None = None):
|
||||
super().__init__(message); self.code = code or self.code
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StockConsolidation:
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
ratio: Decimal
|
||||
|
||||
def calculate_stock_consolidation(quantity_before: Decimal, quantity_after: Decimal, average_price_before: Decimal) -> StockConsolidation:
|
||||
q0, q1, wac = map(Decimal, (quantity_before, quantity_after, average_price_before))
|
||||
if q0 <= 0 or q1 <= 0 or wac < 0:
|
||||
raise CorporateActionError("quantities must be positive and price non-negative")
|
||||
ratio = q1 / q0
|
||||
if ratio >= 1 or ratio <= 0:
|
||||
raise CorporateActionError("stock consolidation must reduce quantity")
|
||||
if q1 != q1.to_integral_value() or q0 != q0.to_integral_value():
|
||||
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
|
||||
# Preserve the authoritative pre-action total independently. A per-unit WAC
|
||||
# rounded to the storage scale cannot always multiply back to that total
|
||||
# exactly, and that harmless decimal residue is not a fractional share.
|
||||
total = quantize_stored_decimal(q0 * wac)
|
||||
before = quantize_stored_decimal(wac)
|
||||
after = quantize_stored_decimal(total / q1)
|
||||
return StockConsolidation(q0, q1, before, after, total, ratio)
|
||||
|
||||
def apply_corporate_action_to_wac(quantity: Decimal, wac: Decimal, quantity_after: Decimal) -> tuple[Decimal, Decimal]:
|
||||
result = calculate_stock_consolidation(quantity, quantity_after, wac)
|
||||
return result.quantity_after, result.average_price_after
|
||||
+25
-1
@@ -3,7 +3,7 @@
|
||||
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
|
||||
@@ -132,6 +132,29 @@ 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
|
||||
@@ -158,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:
|
||||
|
||||
@@ -170,6 +170,21 @@ def calculate_realized_pnl(
|
||||
if running_qty < 0:
|
||||
running_qty = 0.0
|
||||
|
||||
elif side == "stock_consolidation":
|
||||
before = float(row.get("quantity_before", running_qty))
|
||||
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
|
||||
if before <= 0 or after <= 0 or running_qty <= 0:
|
||||
warning_triggered = True
|
||||
continue
|
||||
if abs(running_qty - before) > 1e-9:
|
||||
warning_triggered = True
|
||||
running_qty = after
|
||||
total_cost_basis = row.get("total_cost_basis")
|
||||
if total_cost_basis is not None:
|
||||
running_wac = float(total_cost_basis) / after
|
||||
else:
|
||||
running_wac = float(row.get("average_price_after", running_wac * before / after))
|
||||
|
||||
warning: str | None = None
|
||||
if warning_triggered:
|
||||
warning = (
|
||||
@@ -252,6 +267,21 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
if running_qty < 0:
|
||||
running_qty = 0.0
|
||||
|
||||
elif side == "stock_consolidation":
|
||||
before = float(row.get("quantity_before", running_qty))
|
||||
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
|
||||
if before <= 0 or after <= 0 or running_qty <= 0:
|
||||
warning_triggered = True
|
||||
continue
|
||||
if abs(running_qty - before) > 1e-9:
|
||||
warning_triggered = True
|
||||
running_qty = after
|
||||
total_cost_basis = row.get("total_cost_basis")
|
||||
if total_cost_basis is not None:
|
||||
running_wac = float(total_cost_basis) / after
|
||||
else:
|
||||
running_wac = float(row.get("average_price_after", running_wac * before / after))
|
||||
|
||||
warning: str | None = None
|
||||
if warning_triggered:
|
||||
warning = (
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Callable, Protocol
|
||||
|
||||
from app.domain.models import LedgerEntry, LiveAssetSource, Portfolio, StrategySet
|
||||
|
||||
_NOT_FOUND_MSG = "포트폴리오를 찾을 수 없습니다"
|
||||
PORTFOLIO_NOT_FOUND_MESSAGE = "포트폴리오를 찾을 수 없습니다"
|
||||
_INVEST_ACTION_MAP = {
|
||||
"deposit": "INVEST",
|
||||
"profit": "PROFIT",
|
||||
@@ -250,7 +250,7 @@ def apply_reset(
|
||||
portfolio = repo.reset(portfolio_id)
|
||||
return portfolio, False
|
||||
except ValueError as exc:
|
||||
if str(exc) != _NOT_FOUND_MSG:
|
||||
if str(exc) != PORTFOLIO_NOT_FOUND_MESSAGE:
|
||||
raise
|
||||
symbol = infer_symbol(portfolio_id)
|
||||
portfolio = repo.get_or_create(portfolio_id, symbol, initial_cash=0.0)
|
||||
|
||||
@@ -6,7 +6,7 @@ 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):
|
||||
@@ -284,6 +284,16 @@ class PortfolioRepository(Protocol):
|
||||
note: str = "",
|
||||
) -> None: ...
|
||||
|
||||
def record_corporate_action(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: CorporateAction,
|
||||
idempotency_key: str,
|
||||
payload_hash: str,
|
||||
resume_partial: bool = False,
|
||||
) -> CorporateAction: ...
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ...
|
||||
|
||||
def list_trades_by_date(
|
||||
|
||||
+310
-10
@@ -14,6 +14,10 @@ from typing import Any
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.corporate_actions import (
|
||||
calculate_stock_consolidation,
|
||||
quantize_stored_decimal,
|
||||
)
|
||||
from app.domain.errors import (
|
||||
DaemonInactiveError,
|
||||
DaemonInUseError,
|
||||
@@ -25,6 +29,7 @@ from app.domain.models import (
|
||||
Asset,
|
||||
AuthToken,
|
||||
BrokerCredential,
|
||||
CorporateAction,
|
||||
Daemon,
|
||||
LedgerEntry,
|
||||
Portfolio,
|
||||
@@ -332,6 +337,32 @@ class PostgresPortfolioRepository:
|
||||
)
|
||||
cur.execute("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
|
||||
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_corporate_actions (
|
||||
corporate_action_id UUID PRIMARY KEY,
|
||||
portfolio_id TEXT NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE RESTRICT,
|
||||
asset_id UUID NOT NULL REFERENCES assets(asset_id) ON DELETE RESTRICT,
|
||||
action_type TEXT NOT NULL CHECK (action_type = 'stock_consolidation'),
|
||||
effective_date DATE NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
actor_user_id TEXT,
|
||||
evidence_ref TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
quantity_before NUMERIC(20,8) NOT NULL,
|
||||
quantity_after NUMERIC(20,8) NOT NULL,
|
||||
average_price_before NUMERIC(20,8) NOT NULL,
|
||||
average_price_after NUMERIC(20,8) NOT NULL,
|
||||
total_cost_basis NUMERIC(20,8) NOT NULL,
|
||||
cash_balance NUMERIC(20,8) NOT NULL,
|
||||
profit_buffer NUMERIC(20,8) NOT NULL,
|
||||
ledger_entry_id BIGINT,
|
||||
UNIQUE (portfolio_id, idempotency_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_corporate_actions_portfolio ON portfolio_corporate_actions (portfolio_id, effective_date)")
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_ledger (
|
||||
@@ -418,6 +449,7 @@ class PostgresPortfolioRepository:
|
||||
ON portfolios (broker, status, updated_at DESC)
|
||||
"""
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_comments (
|
||||
@@ -1095,6 +1127,8 @@ class PostgresPortfolioRepository:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
self._attach_tags(cur, [portfolio])
|
||||
return portfolio
|
||||
@@ -1598,6 +1632,242 @@ class PostgresPortfolioRepository:
|
||||
),
|
||||
)
|
||||
|
||||
def record_corporate_action(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: CorporateAction,
|
||||
idempotency_key: str,
|
||||
payload_hash: str,
|
||||
resume_partial: bool = False,
|
||||
) -> CorporateAction:
|
||||
"""Persist an immutable action and snapshot in one explicit transaction."""
|
||||
self.ensure_schema()
|
||||
conn = self._connect()
|
||||
|
||||
def hydrate_replay(prior: tuple) -> CorporateAction:
|
||||
if prior[8] != payload_hash:
|
||||
raise ValueError("IDEMPOTENCY_KEY_REUSE")
|
||||
(
|
||||
action.corporate_action_id,
|
||||
action.portfolio_id,
|
||||
action.asset_id,
|
||||
action.action_type,
|
||||
action.effective_date,
|
||||
action.actor_user_id,
|
||||
action.evidence_ref,
|
||||
action.idempotency_key,
|
||||
action.payload_hash,
|
||||
action.quantity_before,
|
||||
action.quantity_after,
|
||||
action.average_price_before,
|
||||
action.average_price_after,
|
||||
action.total_cost_basis,
|
||||
action.cash_balance,
|
||||
action.profit_buffer,
|
||||
ledger_id,
|
||||
action.recorded_at,
|
||||
) = prior
|
||||
action.corporate_action_id = str(action.corporate_action_id)
|
||||
action.ledger_entry_id = int(ledger_id)
|
||||
action.idempotent_replay = True
|
||||
return action
|
||||
|
||||
prior_sql = """
|
||||
SELECT corporate_action_id, portfolio_id, asset_id, action_type,
|
||||
effective_date, actor_user_id, evidence_ref, idempotency_key,
|
||||
payload_hash, quantity_before, quantity_after,
|
||||
average_price_before, average_price_after, total_cost_basis,
|
||||
cash_balance, profit_buffer, ledger_entry_id, recorded_at
|
||||
FROM portfolio_corporate_actions
|
||||
WHERE portfolio_id=%s AND idempotency_key=%s
|
||||
"""
|
||||
|
||||
# Repository connections are autocommit connections. A connection
|
||||
# context alone therefore cannot protect this multi-statement write.
|
||||
with conn.transaction(), conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT asset_quantity, cash_balance, profit_buffer, asset_id,
|
||||
status, asset_price
|
||||
FROM portfolios
|
||||
WHERE portfolio_id=%s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("portfolio not found")
|
||||
|
||||
cur.execute(prior_sql, (portfolio_id, idempotency_key))
|
||||
prior = cur.fetchone()
|
||||
if prior:
|
||||
return hydrate_replay(prior)
|
||||
|
||||
snapshot_quantity = Decimal(str(row[0]))
|
||||
cash_balance = Decimal(str(row[1]))
|
||||
profit_buffer = Decimal(str(row[2]))
|
||||
reference_price_before = quantize_stored_decimal(Decimal(str(row[5])))
|
||||
if row[4] != "enabled":
|
||||
raise ValueError("CORPORATE_ACTION_PORTFOLIO_DISABLED")
|
||||
if str(row[3]) != str(action.asset_id):
|
||||
raise ValueError("CORPORATE_ACTION_ASSET_MISMATCH")
|
||||
if cash_balance != action.cash_balance or profit_buffer != action.profit_buffer:
|
||||
raise ValueError("CORPORATE_ACTION_INVARIANT_MISMATCH")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id=%s
|
||||
AND side IN ('buy','sell')
|
||||
AND executed_at::date >= %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(portfolio_id, action.effective_date),
|
||||
)
|
||||
if cur.fetchone() is not None:
|
||||
raise ValueError("CORPORATE_ACTION_HISTORICAL_CONFLICT")
|
||||
|
||||
# Derive WAC from the authoritative trade ledger, never the
|
||||
# portfolio reference price or the already-mutated snapshot.
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT side, quantity, price, fee_amount, tax_amount, executed_at
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id=%s AND side IN ('buy','sell')
|
||||
ORDER BY executed_at ASC NULLS FIRST, ledger_entry_id ASC
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
history = [
|
||||
{
|
||||
"side": ledger_row[0],
|
||||
"quantity": ledger_row[1],
|
||||
"price": ledger_row[2],
|
||||
"fee_amount": ledger_row[3],
|
||||
"tax_amount": ledger_row[4],
|
||||
"executed_at": ledger_row[5],
|
||||
}
|
||||
for ledger_row in cur.fetchall()
|
||||
]
|
||||
from app.domain.pnl import calculate_current_wac
|
||||
|
||||
wac = calculate_current_wac(history)
|
||||
if Decimal(str(wac.current_quantity)) != action.quantity_before:
|
||||
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
|
||||
|
||||
if resume_partial:
|
||||
if snapshot_quantity != action.quantity_after:
|
||||
raise ValueError("CORPORATE_ACTION_RECOVERY_STATE_MISMATCH")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM portfolio_corporate_actions
|
||||
WHERE portfolio_id=%s
|
||||
),
|
||||
EXISTS (
|
||||
SELECT 1 FROM portfolio_ledger
|
||||
WHERE portfolio_id=%s AND source='corporate_action'
|
||||
)
|
||||
""",
|
||||
(portfolio_id, portfolio_id),
|
||||
)
|
||||
audit_exists, ledger_exists = cur.fetchone()
|
||||
if audit_exists or ledger_exists:
|
||||
raise ValueError("CORPORATE_ACTION_RECOVERY_ALREADY_RECORDED")
|
||||
elif snapshot_quantity != action.quantity_before:
|
||||
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
|
||||
|
||||
consolidation = calculate_stock_consolidation(
|
||||
action.quantity_before,
|
||||
action.quantity_after,
|
||||
Decimal(str(wac.current_wac or 0)),
|
||||
)
|
||||
action.average_price_before = consolidation.average_price_before
|
||||
action.average_price_after = consolidation.average_price_after
|
||||
action.total_cost_basis = consolidation.total_cost_basis
|
||||
action.cash_balance = cash_balance
|
||||
action.profit_buffer = profit_buffer
|
||||
|
||||
consolidation_ratio = action.quantity_before / action.quantity_after
|
||||
reference_price_after = quantize_stored_decimal(
|
||||
reference_price_before * consolidation_ratio
|
||||
)
|
||||
total_value_after = quantize_stored_decimal(
|
||||
cash_balance + action.quantity_after * reference_price_after
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET asset_quantity=%s, asset_price=%s, updated_at=NOW()
|
||||
WHERE portfolio_id=%s
|
||||
""",
|
||||
(action.quantity_after, reference_price_after, portfolio_id),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_ledger (
|
||||
portfolio_id, recorded_at, side, source, quantity, price,
|
||||
cash_balance_after, asset_quantity_after, total_value_after,
|
||||
profit_buffer_after, executed_at, note
|
||||
) VALUES (
|
||||
%s, NOW(), 'stock_consolidation', 'corporate_action', 0, %s,
|
||||
%s, %s, %s, %s, NOW(), %s
|
||||
)
|
||||
RETURNING ledger_entry_id, recorded_at
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
reference_price_after,
|
||||
cash_balance,
|
||||
action.quantity_after,
|
||||
total_value_after,
|
||||
profit_buffer,
|
||||
action.evidence_ref,
|
||||
),
|
||||
)
|
||||
lid, recorded = cur.fetchone()
|
||||
action.ledger_entry_id = int(lid)
|
||||
action.recorded_at = recorded
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_corporate_actions (
|
||||
corporate_action_id, portfolio_id, asset_id, action_type,
|
||||
effective_date, actor_user_id, evidence_ref, idempotency_key,
|
||||
payload_hash, quantity_before, quantity_after,
|
||||
average_price_before, average_price_after, total_cost_basis,
|
||||
cash_balance, profit_buffer, ledger_entry_id
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s
|
||||
)
|
||||
""",
|
||||
(
|
||||
action.corporate_action_id,
|
||||
portfolio_id,
|
||||
action.asset_id,
|
||||
action.action_type,
|
||||
action.effective_date,
|
||||
action.actor_user_id,
|
||||
action.evidence_ref,
|
||||
idempotency_key,
|
||||
payload_hash,
|
||||
action.quantity_before,
|
||||
action.quantity_after,
|
||||
action.average_price_before,
|
||||
action.average_price_after,
|
||||
action.total_cost_basis,
|
||||
cash_balance,
|
||||
profit_buffer,
|
||||
lid,
|
||||
),
|
||||
)
|
||||
return action
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
@@ -1612,7 +1882,7 @@ class PostgresPortfolioRepository:
|
||||
total_value_after::float8, profit_buffer_after::float8,
|
||||
strategy_set_id, note
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s
|
||||
WHERE portfolio_id = %s AND side IN ('buy', 'sell')
|
||||
ORDER BY recorded_at DESC, ledger_entry_id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
@@ -1636,7 +1906,7 @@ class PostgresPortfolioRepository:
|
||||
boundaries (start of day / start of next day).
|
||||
"""
|
||||
self.ensure_schema()
|
||||
clauses = ["portfolio_id = %s"]
|
||||
clauses = ["portfolio_id = %s", "side IN ('buy','sell')"]
|
||||
params: list[object] = [portfolio_id]
|
||||
|
||||
if date is not None:
|
||||
@@ -1706,16 +1976,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,
|
||||
@@ -2369,15 +2658,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:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
# Corporate actions (stock_consolidation-only, issue #34)
|
||||
class CorporateActionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
action_type: Literal["stock_consolidation"] = "stock_consolidation"
|
||||
effective_date: date
|
||||
quantity_before: Decimal = Field(..., gt=0, max_digits=20, decimal_places=8)
|
||||
quantity_after: Decimal = Field(..., gt=0, max_digits=20, decimal_places=8)
|
||||
evidence_ref: str = Field(..., min_length=1, max_length=500)
|
||||
confirm_daemon_managed: bool = False
|
||||
resume_partial: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_ratio(self):
|
||||
if self.quantity_after >= self.quantity_before:
|
||||
raise ValueError("quantity_after must be less than quantity_before")
|
||||
return self
|
||||
|
||||
class CorporateActionResponse(BaseModel):
|
||||
corporate_action_id: str
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
action_type: Literal["stock_consolidation"]
|
||||
effective_date: date
|
||||
quantity_before: Decimal
|
||||
quantity_after: Decimal
|
||||
average_price_before: Decimal
|
||||
average_price_after: Decimal
|
||||
total_cost_basis: Decimal
|
||||
cash_balance: Decimal
|
||||
profit_buffer: Decimal
|
||||
evidence_ref: str
|
||||
actor_user_id: str | None = None
|
||||
recorded_at: datetime
|
||||
idempotent_replay: bool = False
|
||||
+21
-1
@@ -313,8 +313,28 @@ class DividendLedgerHistoryItem(BaseModel):
|
||||
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 | DividendLedgerHistoryItem]
|
||||
items: list[LedgerHistoryItem | DividendLedgerHistoryItem | CorporateActionLedgerHistoryItem]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# PortfolioHub v0.2.1 container contract
|
||||
# PortfolioHub v0.3.3 container contract
|
||||
|
||||
## Publication is not deployment
|
||||
|
||||
Only a requester-authorized push of the strict tag `v0.2.1` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors:
|
||||
Only a requester-authorized push of the strict tag `v0.3.3` 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.1`
|
||||
- `ghcr.io/fourblocks2020/portfolio-hub:v0.3.3`
|
||||
|
||||
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.1`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration.
|
||||
OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.3.3`. 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.1 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
|
||||
The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.3.3 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
|
||||
|
||||
## Image runtime
|
||||
|
||||
@@ -66,46 +66,48 @@ 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.1 | `0.2.1` | no | health/OpenAPI version metadata |
|
||||
| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.3.3 | `0.3.3` | 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.1 application does not open Redis or Loki connections. They are not production dependencies of this image.
|
||||
`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.3.3 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.1 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container.
|
||||
The existing database variable names remain `TIMESCALEDB_*`; v0.3.3 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.1`.
|
||||
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.3.3`.
|
||||
|
||||
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.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.1 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.3 preserves Bearer authentication and all-source unified-ledger reads. Corporate-action quantity/reference-price updates, the complete ledger snapshot (including `total_value_after`), and the audit row execute in one explicit transaction. The guarded `resume_partial` path repairs only a verified quantity-only partial state without raw SQL. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 31-file migration inventory, isolated PostgreSQL migration twice, readiness at `0031`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it. The changed-scope Ruff gate uses the locked linter and the ACMR Python path selector, compares normalized findings against v0.2.1, v0.3.0, and v0.3.1, and rejects every newly introduced finding.
|
||||
|
||||
The strategy catalog API remains additive: strategy responses expose `nick`, admin create/update requests may set it, and portfolio responses expose the current strategy as `strategy_nick`. Existing clients may omit `nick`; supplied values are trimmed and blank-only values are rejected. trex-web v0.2.3 displays `strategy_nick`, but that dependency does not resolve or waive the separate caller blockers below.
|
||||
|
||||
|
||||
+1
-1
@@ -372,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 `20260820_0030`; the preceding `0029` strategy columns, default seed, and constraints and the `0030` unified-ledger keyset index 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
|
||||
|
||||
@@ -437,7 +437,14 @@
|
||||
|
||||
- Auth: same owner-scoped user/session token policy as manual write.
|
||||
- Query: `limit` 1..200, default 50.
|
||||
- Response: `ManualLedgerHistoryResponse {"items": [ManualLedgerResponse], "total": <int>}` ordered newest first.
|
||||
- `GET /v1/portfolios/{id}/ledger` returns corporate-action events as a distinct `CorporateActionLedgerHistoryItem` with event ID/type, effective date, before/after quantity and WAC, total cost basis, cash/profit buffer, evidence, actor, and recorded time. These events are never represented as BUY/SELL trades.
|
||||
- For stock consolidations, WAC fields are rounded half-up to the persisted 8-decimal scale. `total_cost_basis` is preserved independently as the authoritative pre-action acquisition cost; a sub-cent residue when `average_price_after * quantity_after` is recomputed does not make an integral-share consolidation invalid.
|
||||
- `POST /v1/portfolios/{id}/corporate-actions` updates quantity and the post-action reference price, appends the `portfolio_ledger` snapshot (including non-null `total_value_after`), and appends the corporate-action audit row in one explicit database transaction. A failure in any statement rolls back every statement.
|
||||
- The post-action reference price is `asset_price_before * quantity_before / quantity_after`; ledger `total_value_after` is `cash_balance_after + asset_quantity_after * post_action_reference_price`. Cash and profit buffer are unchanged.
|
||||
- `resume_partial=false` is the compatible default. `resume_partial=true` is accepted only when the locked portfolio quantity already equals `quantity_after`, trade-ledger replay still proves `quantity_before`, and both corporate-action audit and corporate-action ledger rows are absent. Same-key replay is idempotent; a different-key recovery after repair is rejected.
|
||||
- `GET /v1/portfolios/{id}/trades` remains restricted to BUY/SELL history.
|
||||
- `portfolio corporate-action` is the operator command; it requires an idempotency key and confirmation unless `--force` is supplied. `--resume-partial` enables only the guarded recovery mode above.
|
||||
|
||||
|
||||
### Endpoint: `GET /v1/portfolios/{portfolio_id}/returns/summary` (REQ-056, BAT-024)
|
||||
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
|-----------|-----------|------|
|
||||
| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index, BAT-006/REQ-017: `AND revoked_at IS NULL` 조건 추가) | Alembic revision 20260520_0022에도 CONCURRENTLY 버전 존재 |
|
||||
| Alembic 전용 | `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades` | ensure_schema()에 없음 — v1 API 전제 조건 |
|
||||
| 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets (`status`, `revision`, `updated_at` 포함), portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic `20260820_0030`까지 실행 시 충돌 없음; 기본 전략 seed/constraints와 unified-ledger keyset index도 존재 여부 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
|
||||
|
||||
### 운영 제약
|
||||
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
# Requirements
|
||||
|
||||
각 requirement 문서는 Markdown body 앞에 JSON front matter를 둔다.
|
||||
각 requirement는 `req-NNN/` 폴더에 정본과 관련 검증 자료를 함께 둔다. 정본 requirement 문서만 Markdown body 앞에 JSON front matter를 둔다.
|
||||
|
||||
## File naming
|
||||
## Directory layout and file naming
|
||||
|
||||
```text
|
||||
req-NNN_<slug>.md
|
||||
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
|
||||
- `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
|
||||
|
||||
@@ -40,7 +55,7 @@ front matter body는 JSON이므로 별도 dependency 없이 Python 표준 라이
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
text = Path("docs/requirements/req-001_algorithm-input-contract.md").read_text(encoding="utf-8")
|
||||
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)
|
||||
```
|
||||
|
||||
@@ -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를 변경하지 않는다. 요청서 안의 기존 경로는 당시 위치이며, 현재 위치는 위 정본 링크를 따른다.
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portfoliohub"
|
||||
version = "0.2.1"
|
||||
version = "0.3.3"
|
||||
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 = 30
|
||||
EXPECTED_ALEMBIC_HEAD = "20260820_0030"
|
||||
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("20260820_0030\n")
|
||||
if command[:3] == ["docker", "run", "--rm"] and "health-check" in command: return image_release.Result('{"database_revision":"20260820_0030"}\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):
|
||||
@@ -74,12 +74,12 @@ def test_project_version_must_match_release_tag(tmp_path):
|
||||
def test_installed_smoke_has_import_cli_exact_inventory_and_cleanup():
|
||||
inventory = tuple(
|
||||
[f"202601{i:02d}_{i:04d}_migration.py" for i in range(1, 30)]
|
||||
+ ["20260820_0030_portfolio_ledger_keyset_index.py"]
|
||||
+ ["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 "20260820_0030" 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:
|
||||
|
||||
@@ -17,6 +17,10 @@ def test_branch_verification_has_no_registry_write_authority_or_path():
|
||||
for forbidden in ("packages: write","docker login","docker push","REGISTRY_TOKEN","image_release.py publish"):
|
||||
assert forbidden not in text
|
||||
assert "uv sync --extra dev --locked" in text and "uv run pytest -q" in text
|
||||
assert "Verify corporate-action atomicity and guarded recovery in PostgreSQL" in text
|
||||
assert "tests/test_corporate_action_atomic_recovery_postgres.py" in text
|
||||
assert "${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres" in text
|
||||
assert "***@" not in text
|
||||
|
||||
def test_tag_publication_is_strict_single_build_handoff_without_latest():
|
||||
text=PUBLISH.read_text(); verify,publish=text.split("\n publish:",1)
|
||||
@@ -50,7 +54,7 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive
|
||||
assert 'RELEASE_VERSION=v0.1.3' not in verify
|
||||
|
||||
|
||||
def test_release_version_and_migration_contract_are_v021_head_0030():
|
||||
def test_release_version_and_migration_contract_has_current_head_0031():
|
||||
project = (REPO / "pyproject.toml").read_text()
|
||||
config = (REPO / "app/core/config.py").read_text()
|
||||
migrations = sorted(
|
||||
@@ -58,10 +62,10 @@ def test_release_version_and_migration_contract_are_v021_head_0030():
|
||||
for path in (REPO / "app/db/alembic/versions").glob("*.py")
|
||||
if path.name != "__init__.py"
|
||||
)
|
||||
assert 'version = "0.2.1"' in project
|
||||
assert 'app_version: str = "0.2.1"' in config
|
||||
assert len(migrations) == 30
|
||||
assert migrations[-1].startswith("20260820_0030")
|
||||
assert 'version = "0.3.3"' in project
|
||||
assert 'app_version: str = "0.3.3"' 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,7 +89,7 @@ def test_operations_separate_publication_deployment_migration_and_db_rollback():
|
||||
"Image rollback is **not** database rollback",
|
||||
"read-only root filesystem",
|
||||
"10001:10001",
|
||||
"PortfolioHub v0.2.1 container contract",
|
||||
"PortfolioHub v0.3.3 container contract",
|
||||
"v0.2.1 includes migration `0030`",
|
||||
"trex-web v0.2.3 displays `strategy_nick`",
|
||||
):
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Real PostgreSQL regressions for atomic corporate-action writes and recovery."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from urllib.parse import unquote, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from psycopg import sql
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.db.migrations import upgrade_to_head
|
||||
from app.domain.corporate_actions import canonical_payload_hash
|
||||
from app.domain.models import CorporateAction
|
||||
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
|
||||
from app.schemas.corporate_actions import CorporateActionRequest
|
||||
|
||||
_LOCAL_DSN_ENV = "PORTFOLIOHUB_TEST_POSTGRES_DSN"
|
||||
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
||||
_PORTFOLIO_ID = "3573f4d6-cc27-4f91-82d9-6330e5250d20"
|
||||
_CASH = Decimal("400060.62490000")
|
||||
_TOTAL_COST = Decimal("1577211.97990000")
|
||||
|
||||
|
||||
def _maintenance_dsn() -> str:
|
||||
dsn = os.environ.get(_LOCAL_DSN_ENV)
|
||||
if not dsn:
|
||||
pytest.skip(f"{_LOCAL_DSN_ENV} is required for real PostgreSQL integration tests")
|
||||
parsed = urlparse(dsn)
|
||||
if parsed.scheme not in {"postgres", "postgresql"}:
|
||||
pytest.fail("test DSN must use postgres/postgresql")
|
||||
if parsed.hostname not in _LOOPBACK_HOSTS or parsed.path.lstrip("/") != "postgres":
|
||||
pytest.fail("tests require a loopback PostgreSQL maintenance database named postgres")
|
||||
return dsn
|
||||
|
||||
|
||||
def _settings(dsn: str, database: str) -> Settings:
|
||||
parsed = urlparse(dsn)
|
||||
return Settings(
|
||||
db_enabled=True,
|
||||
timescaledb_host=parsed.hostname,
|
||||
timescaledb_port=parsed.port or 5432,
|
||||
timescaledb_name=database,
|
||||
timescaledb_user=unquote(parsed.username or ""),
|
||||
timescaledb_password=unquote(parsed.password or ""),
|
||||
timescaledb_sslmode="disable",
|
||||
timescaledb_connect_timeout_sec=3,
|
||||
timescaledb_application_name="corporate-action-atomic-test",
|
||||
)
|
||||
|
||||
|
||||
def _repo(settings: Settings) -> PostgresPortfolioRepository:
|
||||
return PostgresPortfolioRepository(
|
||||
PortfolioDBConfig(
|
||||
host=settings.active_db_host,
|
||||
port=settings.active_db_port,
|
||||
dbname=settings.active_db_name,
|
||||
user=settings.active_db_user,
|
||||
password=settings.active_db_password,
|
||||
sslmode=settings.active_db_sslmode,
|
||||
connect_timeout_sec=settings.active_db_connect_timeout_sec,
|
||||
application_name=settings.active_db_application_name,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repository() -> PostgresPortfolioRepository:
|
||||
maintenance_dsn = _maintenance_dsn()
|
||||
database = f"portfolio_hub_ca_{uuid4().hex}"
|
||||
with psycopg.connect(maintenance_dsn, autocommit=True) as conn, conn.cursor() as cur:
|
||||
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database)))
|
||||
settings = _settings(maintenance_dsn, database)
|
||||
upgrade_to_head(settings)
|
||||
repo = _repo(settings)
|
||||
try:
|
||||
yield repo
|
||||
finally:
|
||||
repo.close()
|
||||
with psycopg.connect(maintenance_dsn, autocommit=True) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
||||
"WHERE datname = %s AND pid <> pg_backend_pid()",
|
||||
(database,),
|
||||
)
|
||||
cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(database)))
|
||||
|
||||
|
||||
def _seed_daekyo(repo: PostgresPortfolioRepository, *, quantity: Decimal) -> str:
|
||||
user_id = "ca-recovery-user"
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute("INSERT INTO users (id, username) VALUES (%s, %s)", (user_id, user_id))
|
||||
cur.execute(
|
||||
"INSERT INTO assets (symbol, markets) VALUES ('019685', ARRAY['KRX']) RETURNING asset_id"
|
||||
)
|
||||
asset_id = str(cur.fetchone()[0])
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolios (
|
||||
portfolio_id, name, owner_user_id, cash_balance, asset_quantity,
|
||||
asset_price, profit_buffer, asset_id, broker, status
|
||||
) VALUES (%s, '대교우B', %s, %s, %s, 643, 0, %s, 'kis', 'enabled')
|
||||
""",
|
||||
(_PORTFOLIO_ID, user_id, _CASH, quantity, asset_id),
|
||||
)
|
||||
# The fee makes the authoritative BUY-ledger cost exactly 1,577,211.9799.
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_ledger (
|
||||
portfolio_id, recorded_at, side, source, quantity, price, fee_amount, tax_amount,
|
||||
cash_balance_after, asset_quantity_after, total_value_after,
|
||||
profit_buffer_after, strategy_set_id, note, executed_at
|
||||
) VALUES (%s, '2026-09-09T00:00:00Z', 'buy', 'broker', 1452, 1086, 339.9799, 0,
|
||||
%s, 1452, %s, 0, 'runtime', 'seed', '2026-09-09T00:00:00Z')
|
||||
""",
|
||||
(_PORTFOLIO_ID, _CASH, _CASH + Decimal(1452) * Decimal(643)),
|
||||
)
|
||||
return asset_id
|
||||
|
||||
|
||||
def _request(*, resume_partial: bool = False) -> CorporateActionRequest:
|
||||
return CorporateActionRequest(
|
||||
effective_date=date(2026, 9, 10),
|
||||
quantity_before=Decimal(1452),
|
||||
quantity_after=Decimal(726),
|
||||
evidence_ref="KIS 019685 2026-09-10",
|
||||
resume_partial=resume_partial,
|
||||
)
|
||||
|
||||
|
||||
def _action(asset_id: str, request: CorporateActionRequest, key: str) -> CorporateAction:
|
||||
digest = canonical_payload_hash(request)
|
||||
return CorporateAction(
|
||||
str(uuid4()),
|
||||
_PORTFOLIO_ID,
|
||||
asset_id,
|
||||
request.action_type,
|
||||
request.effective_date,
|
||||
request.quantity_before,
|
||||
request.quantity_after,
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
_CASH,
|
||||
Decimal(0),
|
||||
request.evidence_ref,
|
||||
key,
|
||||
digest,
|
||||
0,
|
||||
"ca-recovery-user",
|
||||
)
|
||||
|
||||
|
||||
def _record(repo: PostgresPortfolioRepository, asset_id: str, request: CorporateActionRequest, key: str):
|
||||
action = _action(asset_id, request, key)
|
||||
return repo.record_corporate_action(
|
||||
_PORTFOLIO_ID,
|
||||
action=action,
|
||||
idempotency_key=key,
|
||||
payload_hash=action.payload_hash,
|
||||
resume_partial=request.resume_partial,
|
||||
)
|
||||
|
||||
|
||||
def _state(repo: PostgresPortfolioRepository) -> tuple:
|
||||
with repo._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT asset_quantity, asset_price, cash_balance, profit_buffer "
|
||||
"FROM portfolios WHERE portfolio_id=%s",
|
||||
(_PORTFOLIO_ID,),
|
||||
)
|
||||
portfolio_row = cur.fetchone()
|
||||
assert portfolio_row is not None
|
||||
portfolio = tuple(Decimal(str(value)) for value in portfolio_row)
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM portfolio_ledger "
|
||||
"WHERE portfolio_id=%s AND source='corporate_action'",
|
||||
(_PORTFOLIO_ID,),
|
||||
)
|
||||
ledger_count = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM portfolio_corporate_actions WHERE portfolio_id=%s",
|
||||
(_PORTFOLIO_ID,),
|
||||
)
|
||||
action_count = cur.fetchone()[0]
|
||||
return (*portfolio, ledger_count, action_count)
|
||||
|
||||
|
||||
def test_stock_consolidation_persists_reference_price_total_value_and_audit(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(1452))
|
||||
|
||||
saved = _record(repository, asset_id, _request(), "ca-normal")
|
||||
|
||||
assert saved.average_price_after == Decimal("2172.46829187")
|
||||
assert saved.total_cost_basis == _TOTAL_COST
|
||||
assert _state(repository) == (
|
||||
Decimal("726.00000000"),
|
||||
Decimal("1286.00000000"),
|
||||
_CASH,
|
||||
Decimal("0E-8"),
|
||||
1,
|
||||
1,
|
||||
)
|
||||
with repository._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT price, total_value_after FROM portfolio_ledger "
|
||||
"WHERE portfolio_id=%s AND source='corporate_action'",
|
||||
(_PORTFOLIO_ID,),
|
||||
)
|
||||
assert cur.fetchone() == (
|
||||
Decimal("1286.00000000"),
|
||||
Decimal("1333696.62490000"),
|
||||
)
|
||||
|
||||
|
||||
def test_ledger_failure_rolls_back_portfolio_quantity_and_reference_price(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(1452))
|
||||
with repository._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE FUNCTION reject_corporate_action_ledger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.source = 'corporate_action' THEN
|
||||
RAISE EXCEPTION 'injected ledger failure';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE TRIGGER reject_ca_ledger BEFORE INSERT ON portfolio_ledger "
|
||||
"FOR EACH ROW EXECUTE FUNCTION reject_corporate_action_ledger()"
|
||||
)
|
||||
|
||||
with pytest.raises(psycopg.Error, match="injected ledger failure"):
|
||||
_record(repository, asset_id, _request(), "ca-failure")
|
||||
|
||||
assert _state(repository) == (
|
||||
Decimal("1452.00000000"),
|
||||
Decimal("643.00000000"),
|
||||
_CASH,
|
||||
Decimal("0E-8"),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
def test_audit_failure_rolls_back_portfolio_and_inserted_ledger(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(1452))
|
||||
before = _state(repository)
|
||||
with repository._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE FUNCTION reject_corporate_action_audit() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'injected corporate-action audit failure';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE TRIGGER reject_ca_audit BEFORE INSERT ON portfolio_corporate_actions "
|
||||
"FOR EACH ROW EXECUTE FUNCTION reject_corporate_action_audit()"
|
||||
)
|
||||
|
||||
with pytest.raises(psycopg.Error, match="injected corporate-action audit failure"):
|
||||
_record(repository, asset_id, _request(), "ca-audit-failure")
|
||||
|
||||
assert _state(repository) == before
|
||||
|
||||
|
||||
def test_explicit_resume_repairs_only_verified_quantity_only_partial_state(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(726))
|
||||
request = _request(resume_partial=True)
|
||||
|
||||
first = _record(repository, asset_id, request, "ca-recovery")
|
||||
replay = _record(repository, asset_id, request, "ca-recovery")
|
||||
|
||||
assert first.idempotent_replay is False
|
||||
assert replay.idempotent_replay is True
|
||||
assert replay.ledger_entry_id == first.ledger_entry_id
|
||||
assert _state(repository) == (
|
||||
Decimal("726.00000000"),
|
||||
Decimal("1286.00000000"),
|
||||
_CASH,
|
||||
Decimal("0E-8"),
|
||||
1,
|
||||
1,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="CORPORATE_ACTION_RECOVERY_ALREADY_RECORDED"):
|
||||
_record(repository, asset_id, request, "ca-recovery-different-key")
|
||||
assert _state(repository)[-2:] == (1, 1)
|
||||
|
||||
|
||||
def test_resume_rejects_a_normal_unapplied_snapshot(repository):
|
||||
asset_id = _seed_daekyo(repository, quantity=Decimal(1452))
|
||||
|
||||
with pytest.raises(ValueError, match="CORPORATE_ACTION_RECOVERY_STATE_MISMATCH"):
|
||||
_record(repository, asset_id, _request(resume_partial=True), "ca-invalid-recovery")
|
||||
|
||||
assert _state(repository)[-2:] == (0, 0)
|
||||
@@ -0,0 +1,31 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domain.corporate_actions import calculate_stock_consolidation
|
||||
|
||||
|
||||
def test_stock_consolidation_preserves_cost_and_scales_wac():
|
||||
result = calculate_stock_consolidation(Decimal(1452), Decimal(726), Decimal("1086.23"))
|
||||
assert result.average_price_after == Decimal("2172.46")
|
||||
assert result.total_cost_basis == Decimal("1577205.96000000")
|
||||
assert result.ratio == Decimal("0.5")
|
||||
|
||||
def test_stock_consolidation_rejects_fractional_result():
|
||||
import pytest
|
||||
|
||||
from app.domain.corporate_actions import CorporateActionError
|
||||
with pytest.raises(CorporateActionError) as exc:
|
||||
calculate_stock_consolidation(Decimal(3), Decimal("1.5"), Decimal(10))
|
||||
assert exc.value.code == "FRACTIONAL_RESULT_UNSUPPORTED"
|
||||
|
||||
|
||||
def test_stock_consolidation_allows_stored_wac_rounding_residue():
|
||||
result = calculate_stock_consolidation(
|
||||
Decimal(1452),
|
||||
Decimal(726),
|
||||
Decimal("1086.234145936639256198347107438016528926"),
|
||||
)
|
||||
|
||||
assert result.average_price_before == Decimal("1086.23414594")
|
||||
assert result.average_price_after == Decimal("2172.46829187")
|
||||
assert result.total_cost_basis == Decimal("1577211.97990000")
|
||||
assert result.total_cost_basis - result.average_price_after * result.quantity_after == Decimal("0.00000238")
|
||||
@@ -0,0 +1,75 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from app.domain.corporate_actions import canonical_payload_hash
|
||||
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
|
||||
from app.schemas.corporate_actions import CorporateActionRequest
|
||||
|
||||
|
||||
def test_buy_consolidation_sell_replays_wac_and_realized_pnl():
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 10, "price": 100, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 1, tzinfo=UTC)},
|
||||
{"side": "stock_consolidation", "quantity": 0, "price": 0, "quantity_before": 10, "quantity_after": 5, "average_price_after": 200, "executed_at": datetime(2026, 1, 2, tzinfo=UTC)},
|
||||
{"side": "sell", "quantity": 2, "price": 250, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 3, tzinfo=UTC)},
|
||||
]
|
||||
assert calculate_current_wac(rows).current_wac == 200
|
||||
result = calculate_realized_pnl(rows)
|
||||
assert result.items[0].cost_basis_per_unit == 200
|
||||
assert result.total_realized_pnl == 100
|
||||
|
||||
|
||||
def test_consolidation_replay_uses_preserved_total_cost_basis():
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 1452, "price": 1086.2341459366393, "fee_amount": 0, "executed_at": datetime(2026, 9, 9, tzinfo=UTC)},
|
||||
{
|
||||
"side": "stock_consolidation",
|
||||
"quantity_before": 1452,
|
||||
"quantity_after": 726,
|
||||
"average_price_after": Decimal("2172.46829187"),
|
||||
"total_cost_basis": Decimal("1577211.97990000"),
|
||||
"executed_at": datetime(2026, 9, 10, tzinfo=UTC),
|
||||
},
|
||||
]
|
||||
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_quantity == 726
|
||||
assert result.current_wac == float(Decimal("1577211.97990000") / Decimal(726))
|
||||
|
||||
|
||||
def test_canonical_hash_same_payload_and_changed_payload_differ():
|
||||
a = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal(10), quantity_after=Decimal(5), evidence_ref="ev")
|
||||
b = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal(10), quantity_after=Decimal(4), evidence_ref="ev")
|
||||
assert canonical_payload_hash(a) == canonical_payload_hash(a.model_copy())
|
||||
assert canonical_payload_hash(a) != canonical_payload_hash(b)
|
||||
|
||||
|
||||
def test_default_resume_flag_preserves_pre_recovery_idempotency_hash():
|
||||
request = CorporateActionRequest(
|
||||
effective_date=date(2026, 1, 2),
|
||||
quantity_before=Decimal(10),
|
||||
quantity_after=Decimal(5),
|
||||
evidence_ref="ev",
|
||||
)
|
||||
legacy_payload = request.model_dump(mode="json", exclude={"resume_partial"})
|
||||
explicit_default_payload = {**legacy_payload, "resume_partial": False}
|
||||
|
||||
assert canonical_payload_hash(request) == canonical_payload_hash(legacy_payload)
|
||||
assert canonical_payload_hash(explicit_default_payload) == canonical_payload_hash(legacy_payload)
|
||||
assert canonical_payload_hash(
|
||||
request.model_copy(update={"resume_partial": True})
|
||||
) != canonical_payload_hash(legacy_payload)
|
||||
|
||||
|
||||
def test_runtime_schema_has_one_corporate_action_ddl_and_replay_mapping_starts_at_18():
|
||||
source = Path("app/repositories/postgres.py").read_text()
|
||||
assert source.count("CREATE TABLE IF NOT EXISTS portfolio_corporate_actions") == 1
|
||||
assert "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,191 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import v1_portfolios
|
||||
from app.domain.models import CorporateAction, Portfolio
|
||||
from app.main import create_app
|
||||
from app.repositories.postgres import PostgresPortfolioRepository
|
||||
|
||||
|
||||
class MissingPortfolioRepo:
|
||||
def get(self, portfolio_id):
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class ResumeCaptureRepo:
|
||||
def __init__(self):
|
||||
self.resume_partial = None
|
||||
|
||||
def get(self, portfolio_id):
|
||||
return Portfolio(
|
||||
portfolio_id=portfolio_id,
|
||||
broker="kis",
|
||||
status="enabled",
|
||||
name="대교우B",
|
||||
symbol="019685",
|
||||
asset_id="asset-1",
|
||||
owner_user_id="u1",
|
||||
cash_balance=400060.6249,
|
||||
asset_quantity=726,
|
||||
price=643,
|
||||
profit_buffer=0,
|
||||
)
|
||||
|
||||
def record_corporate_action(
|
||||
self,
|
||||
portfolio_id,
|
||||
*,
|
||||
action,
|
||||
idempotency_key,
|
||||
payload_hash,
|
||||
resume_partial=False,
|
||||
):
|
||||
self.resume_partial = resume_partial
|
||||
action.average_price_before = Decimal("1086.23414594")
|
||||
action.average_price_after = Decimal("2172.46829187")
|
||||
action.total_cost_basis = Decimal("1577211.97990000")
|
||||
action.ledger_entry_id = 7
|
||||
action.recorded_at = datetime(2026, 9, 13, tzinfo=UTC)
|
||||
return action
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_create_corporate_action_maps_postgres_missing_portfolio_to_404(monkeypatch):
|
||||
app = create_app()
|
||||
repo = MissingPortfolioRepo()
|
||||
monkeypatch.setattr(v1_portfolios, "_get_repo", lambda: repo)
|
||||
app.dependency_overrides[v1_portfolios.require_trade_write] = lambda: type("Auth", (), {"user_id": "u1", "permission": "trade_write"})()
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/v1/portfolios/missing/corporate-actions",
|
||||
headers={"Idempotency-Key": "idem-1"},
|
||||
json={"effective_date": "2026-01-01", "quantity_before": "10", "quantity_after": "5", "evidence_ref": "ev"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Portfolio not found"
|
||||
|
||||
|
||||
def test_corporate_action_api_passes_explicit_resume_mode(monkeypatch):
|
||||
app = create_app()
|
||||
repo = ResumeCaptureRepo()
|
||||
monkeypatch.setattr(v1_portfolios, "_get_repo", lambda: repo)
|
||||
app.dependency_overrides[v1_portfolios.require_trade_write] = lambda: type(
|
||||
"Auth", (), {"user_id": "u1", "permission": "trade_write"}
|
||||
)()
|
||||
|
||||
response = TestClient(app).post(
|
||||
"/v1/portfolios/pf-1/corporate-actions",
|
||||
headers={"Idempotency-Key": "resume-1"},
|
||||
json={
|
||||
"effective_date": "2026-09-10",
|
||||
"quantity_before": "1452",
|
||||
"quantity_after": "726",
|
||||
"evidence_ref": "KIS 019685 2026-09-10",
|
||||
"resume_partial": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["quantity_after"] == "726"
|
||||
assert repo.resume_partial is True
|
||||
|
||||
|
||||
def _action(asset_id="asset-1"):
|
||||
return CorporateAction("ca-1", "pf-1", asset_id, "stock_consolidation", date(2026, 1, 1), Decimal(2), Decimal(1), Decimal(0), Decimal(0), Decimal(0), Decimal(10), Decimal(0), "ev", "idem", "hash", 0)
|
||||
|
||||
|
||||
def _repo_for_row(asset_id):
|
||||
repo = object.__new__(PostgresPortfolioRepository)
|
||||
repo.ensure_schema = MagicMock()
|
||||
conn = MagicMock()
|
||||
cur = MagicMock()
|
||||
conn.__enter__.return_value = conn
|
||||
conn.cursor.return_value.__enter__.return_value = cur
|
||||
repo._connect = MagicMock(return_value=conn)
|
||||
cur.fetchone.side_effect = [
|
||||
(Decimal(2), Decimal(10), Decimal(0), asset_id, "enabled", Decimal(643)),
|
||||
None,
|
||||
None,
|
||||
(7, date(2026, 1, 1)),
|
||||
]
|
||||
cur.fetchall.return_value = [("buy", Decimal(2), Decimal(0), Decimal(0), Decimal(0), datetime(2025, 12, 31, tzinfo=UTC))]
|
||||
return repo
|
||||
|
||||
|
||||
def test_record_corporate_action_accepts_uuid_row_against_string_domain_id():
|
||||
asset = UUID("12345678-1234-5678-1234-567812345678")
|
||||
action = _action(str(asset))
|
||||
saved = _repo_for_row(asset).record_corporate_action("pf-1", action=action, idempotency_key="idem", payload_hash="hash")
|
||||
assert saved.ledger_entry_id == 7
|
||||
|
||||
|
||||
def test_record_corporate_action_rejects_different_asset_id():
|
||||
repo = _repo_for_row(UUID("12345678-1234-5678-1234-567812345678"))
|
||||
with pytest.raises(ValueError, match="CORPORATE_ACTION_ASSET_MISMATCH"):
|
||||
repo.record_corporate_action("pf-1", action=_action("87654321-4321-8765-4321-876543218765"), idempotency_key="idem", payload_hash="hash")
|
||||
|
||||
|
||||
def test_record_corporate_action_accepts_daekyo_rounding_residue():
|
||||
repo = object.__new__(PostgresPortfolioRepository)
|
||||
repo.ensure_schema = MagicMock()
|
||||
conn = MagicMock()
|
||||
cur = MagicMock()
|
||||
conn.__enter__.return_value = conn
|
||||
conn.cursor.return_value.__enter__.return_value = cur
|
||||
repo._connect = MagicMock(return_value=conn)
|
||||
cur.fetchone.side_effect = [
|
||||
(Decimal(1452), Decimal(10), Decimal(0), "asset-1", "enabled", Decimal(643)),
|
||||
None,
|
||||
None,
|
||||
(7, datetime(2026, 9, 13, tzinfo=UTC)),
|
||||
]
|
||||
cur.fetchall.return_value = [
|
||||
(
|
||||
"buy",
|
||||
Decimal(1452),
|
||||
Decimal("1086.234145936639256198347107438016528926"),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
datetime(2026, 9, 9, tzinfo=UTC),
|
||||
)
|
||||
]
|
||||
action = CorporateAction(
|
||||
"ca-1",
|
||||
"pf-1",
|
||||
"asset-1",
|
||||
"stock_consolidation",
|
||||
date(2026, 9, 10),
|
||||
Decimal(1452),
|
||||
Decimal(726),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
Decimal(10),
|
||||
Decimal(0),
|
||||
"KIS 019685 2026-09-10",
|
||||
"idem-daekyo",
|
||||
"hash-daekyo",
|
||||
0,
|
||||
)
|
||||
|
||||
saved = repo.record_corporate_action(
|
||||
"pf-1",
|
||||
action=action,
|
||||
idempotency_key="idem-daekyo",
|
||||
payload_hash="hash-daekyo",
|
||||
)
|
||||
|
||||
assert saved.quantity_after == Decimal(726)
|
||||
assert saved.average_price_after == Decimal("2172.46829187")
|
||||
assert saved.total_cost_basis == Decimal("1577211.97990000")
|
||||
assert saved.ledger_entry_id == 7
|
||||
Reference in New Issue
Block a user