15 Commits
Author SHA1 Message Date
genichin 4520fbbfbd ci(release): enforce changed-scope Ruff gate
Publish verified PortfolioHub image / verify (push) Canceled after 0s
Publish verified PortfolioHub image / publish (push) Canceled after 0s
2026-09-13 15:41:35 +09:00
genichin 1010d257ad chore(release): prepare v0.3.1 2026-09-13 15:32:27 +09:00
genichin 5b443f718e chore(release): prepare v0.3.0
Publish verified PortfolioHub image / verify (push) Canceled after 0s
Publish verified PortfolioHub image / publish (push) Canceled after 0s
2026-09-13 15:03:06 +09:00
genichin a4e4951ed6 Merge pull request #35 from fourblocks2020/feat/issue-34-corporate-actions
feat(portfolio): record stock consolidation ledger events
2026-09-13 14:43:39 +09:00
genichin 54e4fe4213 fix: sync image migration inventory to 0031 2026-09-13 14:27:03 +09:00
genichin fa824dfd36 fix corporate action runtime blockers 2026-09-13 14:22:41 +09:00
genichin 925c6b4fa4 fix corporate action replay and trade filtering 2026-09-13 14:17:32 +09:00
genichin cb19c35d23 fix corporate action replay invariants 2026-09-13 14:12:04 +09:00
genichin 14c118f503 fix corporate action ledger replay invariants 2026-09-13 14:04:30 +09:00
genichin 48735bbb4a fix(portfolio): align corporate action schema definitions 2026-09-13 13:55:49 +09:00
genichin a4664f5991 feat(portfolio): expose corporate actions in unified ledger 2026-09-13 13:52:50 +09:00
genichin 77468631b1 feat(portfolio): expose corporate action and replay consolidation 2026-09-13 13:47:46 +09:00
genichin 7a45bec3cb feat(portfolio): add atomic corporate action persistence 2026-09-13 13:43:21 +09:00
genichin dc755889b3 feat(portfolio): add stock consolidation domain and schema 2026-09-13 13:40:08 +09:00
genichin dfcafade94 docs(requirements): group REQ-001 documents by requirement 2026-09-10 11:22:43 +09:00
34 changed files with 802 additions and 60 deletions
+6 -2
View File
@@ -31,7 +31,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-depth: 0
- run: python scripts/image_release.py validate-checkout --actual "$(git rev-parse HEAD)" --expected "${GITHUB_SHA}"
- run: python scripts/image_release.py validate-event --event "${GITHUB_EVENT_NAME}" --ref "${GITHUB_REF}" --commit "${GITHUB_SHA}" --project pyproject.toml
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
@@ -41,7 +41,11 @@ jobs:
- run: uv sync --extra dev --locked
- run: uv run pytest -q
- name: Honest scoped lint
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py tests/release
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
- name: Reject Ruff findings introduced by v0.3.0 or its patch
run: |
uv run python scripts/ruff_ratchet.py --base v0.2.1
uv run python scripts/ruff_ratchet.py --base v0.3.0
- name: Record exact source migration inventory
run: uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
- name: Create excluded synthetic canary
+7 -1
View File
@@ -15,6 +15,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
@@ -22,7 +24,11 @@ jobs:
- run: uv sync --extra dev --locked
- run: uv run pytest -q
- name: Honest scoped lint for release additions
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py tests/release
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
- name: Reject Ruff findings introduced by v0.3.0 or its patch
run: |
uv run python scripts/ruff_ratchet.py --base v0.2.1
uv run python scripts/ruff_ratchet.py --base v0.3.0
- name: Validate container contracts without registry writes
run: uv run pytest -q tests/release
- name: Build and smoke exact pull-request or main source image
+55 -3
View File
@@ -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,37 @@ def get_holdings_cost_basis(
cost_basis_from=wac_result.cost_basis_from,
data_completeness_warning=wac_result.data_completeness_warning,
)
@router.post("/{portfolio_id}/corporate-actions", response_model=CorporateActionResponse, status_code=status.HTTP_201_CREATED)
def create_corporate_action(
portfolio_id: str,
payload: CorporateActionRequest,
response: Response,
auth: AuthContext = Depends(require_trade_write), # noqa: B008 - FastAPI dependency declaration
idempotency_key: str = Header(..., alias="Idempotency-Key"),
):
if not idempotency_key or not idempotency_key.strip():
raise HTTPException(status_code=422, detail="Idempotency-Key must not be empty")
repo = _get_repo()
try:
portfolio = repo.get(portfolio_id)
if portfolio is None:
raise HTTPException(status_code=404, detail="Portfolio not found")
if portfolio.owner_user_id != auth.user_id and auth.permission != Permission.ADMIN:
raise HTTPException(status_code=403, detail="Portfolio ownership mismatch")
if portfolio.managed_by_daemon_id and not payload.confirm_daemon_managed:
raise HTTPException(status_code=409, detail="DAEMON_MANAGED_CONFIRMATION_REQUIRED")
from app.domain.corporate_actions import canonical_payload_hash
digest = canonical_payload_hash(payload)
# The repository derives all WAC and immutable snapshot fields under its row lock.
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, payload.action_type, payload.effective_date, payload.quantity_before, payload.quantity_after, Decimal(0), Decimal(0), Decimal(0), Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), payload.evidence_ref, idempotency_key, digest, 0, auth.user_id)
saved = repo.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash=digest)
return CorporateActionResponse(**saved.__dict__)
except ValueError as exc:
code = str(exc)
if code == PORTFOLIO_NOT_FOUND_MESSAGE:
raise HTTPException(status_code=404, detail="Portfolio not found") from exc
raise HTTPException(status_code=409 if code.startswith(("IDEMPOTENCY", "CORPORATE_ACTION_")) else 400, detail=code) from exc
finally:
_close_repo(repo)
+49
View File
@@ -274,6 +274,55 @@ def sell_portfolio(
render_output(ctx.obj.console, ctx.obj.output_format, detail)
@portfolio_app.command("corporate-action")
def corporate_action_portfolio(
ctx: typer.Context,
name: str = typer.Argument(...),
user: str = typer.Option(..., "--user"),
action_type: str = typer.Option("stock_consolidation", "--type"),
effective_date: str = typer.Option(..., "--effective-date"),
quantity_before: float = typer.Option(..., "--quantity-before", min=0.0000001),
quantity_after: float = typer.Option(..., "--quantity-after", min=0.0000001),
evidence_ref: str = typer.Option(..., "--evidence-ref"),
idempotency_key: str = typer.Option(..., "--idempotency-key"),
force: bool = typer.Option(False, "--force"),
) -> None:
"""Record an auditable stock consolidation without a trade."""
import uuid
from datetime import date
from decimal import Decimal
from app.domain.corporate_actions import (
calculate_stock_consolidation,
canonical_payload_hash,
)
from app.domain.models import CorporateAction
if action_type != "stock_consolidation":
ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_TYPE")
raise typer.Exit(code=1)
try:
day = date.fromisoformat(effective_date)
except ValueError:
ctx.obj.console.print("Error: INVALID_EFFECTIVE_DATE")
raise typer.Exit(code=1)
if quantity_after >= quantity_before:
ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_RATIO")
raise typer.Exit(code=1)
_confirm(force, f"Record stock consolidation {quantity_before:g} → {quantity_after:g}; cash/profit buffer unchanged?")
def _callback(service, repository, app_context):
portfolio_id = _resolve_portfolio_id(repository, name, user)
portfolio = repository.get(portfolio_id)
if not idempotency_key.strip():
raise ValueError("Idempotency-Key must not be empty")
payload = {"action_type": action_type, "effective_date": day.isoformat(), "quantity_before": str(quantity_before), "quantity_after": str(quantity_after), "evidence_ref": evidence_ref, "confirm_daemon_managed": False}
digest = canonical_payload_hash(payload)
calc = calculate_stock_consolidation(Decimal(str(quantity_before)), Decimal(str(quantity_after)), Decimal(0))
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, action_type, day, calc.quantity_before, calc.quantity_after, calc.average_price_before, calc.average_price_after, calc.total_cost_basis, Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), evidence_ref, idempotency_key, digest, 0, user)
return repository.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash=digest)
detail = _with_service_and_repo(ctx, _callback)
render_output(ctx.obj.console, ctx.obj.output_format, detail)
@portfolio_app.command("invest")
def invest_portfolio(
ctx: typer.Context,
+1 -1
View File
@@ -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.1"
environment: str = "development"
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
@@ -0,0 +1,32 @@
"""Additive storage for auditable, idempotent stock consolidations."""
from alembic import op
from sqlalchemy import text
revision = "20260913_0031"
down_revision = "20260820_0030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.get_bind().execute(text("""
CREATE TABLE IF NOT EXISTS portfolio_corporate_actions (
corporate_action_id UUID PRIMARY KEY,
portfolio_id TEXT NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE RESTRICT,
asset_id UUID NOT NULL REFERENCES assets(asset_id) ON DELETE RESTRICT,
action_type TEXT NOT NULL CHECK (action_type = 'stock_consolidation'),
effective_date DATE NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
actor_user_id TEXT, evidence_ref TEXT NOT NULL,
idempotency_key TEXT NOT NULL, payload_hash TEXT NOT NULL,
quantity_before NUMERIC(20,8) NOT NULL, quantity_after NUMERIC(20,8) NOT NULL,
average_price_before NUMERIC(20,8) NOT NULL, average_price_after NUMERIC(20,8) NOT NULL,
total_cost_basis NUMERIC(20,8) NOT NULL, cash_balance NUMERIC(20,8) NOT NULL,
profit_buffer NUMERIC(20,8) NOT NULL, ledger_entry_id BIGINT,
UNIQUE (portfolio_id, idempotency_key)
)"""))
op.get_bind().execute(text(
"CREATE INDEX IF NOT EXISTS idx_corporate_actions_portfolio "
"ON portfolio_corporate_actions (portfolio_id, effective_date)"
))
def downgrade() -> None:
op.get_bind().execute(text("DROP TABLE IF EXISTS portfolio_corporate_actions"))
+47
View File
@@ -0,0 +1,47 @@
"""Pure domain rules for portfolio stock consolidations."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
def canonical_payload_hash(payload: object) -> str:
if hasattr(payload, "model_dump"):
payload = payload.model_dump(mode="json", exclude_none=True)
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
return hashlib.sha256(encoded).hexdigest()
class CorporateActionError(ValueError):
code = "INVALID_CORPORATE_ACTION_RATIO"
def __init__(self, message: str, code: str | None = None):
super().__init__(message); self.code = code or self.code
@dataclass(frozen=True)
class StockConsolidation:
quantity_before: Decimal
quantity_after: Decimal
average_price_before: Decimal
average_price_after: Decimal
total_cost_basis: Decimal
ratio: Decimal
def calculate_stock_consolidation(quantity_before: Decimal, quantity_after: Decimal, average_price_before: Decimal) -> StockConsolidation:
q0, q1, wac = map(Decimal, (quantity_before, quantity_after, average_price_before))
if q0 <= 0 or q1 <= 0 or wac < 0:
raise CorporateActionError("quantities must be positive and price non-negative")
ratio = q1 / q0
if ratio >= 1 or ratio <= 0:
raise CorporateActionError("stock consolidation must reduce quantity")
if q1 != q1.to_integral_value() or q0 != q0.to_integral_value():
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
total = (q0 * wac).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
after = (total / q1).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if after * q1 != total:
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
return StockConsolidation(q0, q1, wac, after, total, ratio)
def apply_corporate_action_to_wac(quantity: Decimal, wac: Decimal, quantity_after: Decimal) -> tuple[Decimal, Decimal]:
result = calculate_stock_consolidation(quantity, quantity_after, wac)
return result.quantity_after, result.average_price_after
+25 -1
View File
@@ -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:
+22
View File
@@ -170,6 +170,17 @@ def calculate_realized_pnl(
if running_qty < 0:
running_qty = 0.0
elif side == "stock_consolidation":
before = float(row.get("quantity_before", running_qty))
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
if before <= 0 or after <= 0 or running_qty <= 0:
warning_triggered = True
continue
if abs(running_qty - before) > 1e-9:
warning_triggered = True
running_qty = after
running_wac = float(row.get("average_price_after", running_wac * before / after))
warning: str | None = None
if warning_triggered:
warning = (
@@ -252,6 +263,17 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
if running_qty < 0:
running_qty = 0.0
elif side == "stock_consolidation":
before = float(row.get("quantity_before", running_qty))
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
if before <= 0 or after <= 0 or running_qty <= 0:
warning_triggered = True
continue
if abs(running_qty - before) > 1e-9:
warning_triggered = True
running_qty = after
running_wac = float(row.get("average_price_after", running_wac * before / after))
warning: str | None = None
if warning_triggered:
warning = (
+2 -2
View File
@@ -7,7 +7,7 @@ from typing import Callable, Protocol
from app.domain.models import LedgerEntry, LiveAssetSource, Portfolio, StrategySet
_NOT_FOUND_MSG = "포트폴리오를 찾을 수 없습니다"
PORTFOLIO_NOT_FOUND_MESSAGE = "포트폴리오를 찾을 수 없습니다"
_INVEST_ACTION_MAP = {
"deposit": "INVEST",
"profit": "PROFIT",
@@ -250,7 +250,7 @@ def apply_reset(
portfolio = repo.reset(portfolio_id)
return portfolio, False
except ValueError as exc:
if str(exc) != _NOT_FOUND_MSG:
if str(exc) != PORTFOLIO_NOT_FOUND_MESSAGE:
raise
symbol = infer_symbol(portfolio_id)
portfolio = repo.get_or_create(portfolio_id, symbol, initial_cash=0.0)
+3 -1
View File
@@ -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,8 @@ class PortfolioRepository(Protocol):
note: str = "",
) -> None: ...
def record_corporate_action(self, portfolio_id: str, *, action: CorporateAction, idempotency_key: str, payload_hash: str) -> CorporateAction: ...
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ...
def list_trades_by_date(
+139 -10
View File
@@ -25,6 +25,7 @@ from app.domain.models import (
Asset,
AuthToken,
BrokerCredential,
CorporateAction,
Daemon,
LedgerEntry,
Portfolio,
@@ -332,6 +333,32 @@ class PostgresPortfolioRepository:
)
cur.execute("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_corporate_actions (
corporate_action_id UUID PRIMARY KEY,
portfolio_id TEXT NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE RESTRICT,
asset_id UUID NOT NULL REFERENCES assets(asset_id) ON DELETE RESTRICT,
action_type TEXT NOT NULL CHECK (action_type = 'stock_consolidation'),
effective_date DATE NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
actor_user_id TEXT,
evidence_ref TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
payload_hash TEXT NOT NULL,
quantity_before NUMERIC(20,8) NOT NULL,
quantity_after NUMERIC(20,8) NOT NULL,
average_price_before NUMERIC(20,8) NOT NULL,
average_price_after NUMERIC(20,8) NOT NULL,
total_cost_basis NUMERIC(20,8) NOT NULL,
cash_balance NUMERIC(20,8) NOT NULL,
profit_buffer NUMERIC(20,8) NOT NULL,
ledger_entry_id BIGINT,
UNIQUE (portfolio_id, idempotency_key)
)
"""
)
cur.execute("CREATE INDEX IF NOT EXISTS idx_corporate_actions_portfolio ON portfolio_corporate_actions (portfolio_id, effective_date)")
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_ledger (
@@ -418,6 +445,7 @@ class PostgresPortfolioRepository:
ON portfolios (broker, status, updated_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_comments (
@@ -1095,6 +1123,8 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row)
self._attach_tags(cur, [portfolio])
return portfolio
@@ -1598,6 +1628,75 @@ class PostgresPortfolioRepository:
),
)
def record_corporate_action(self, portfolio_id: str, *, action: CorporateAction, idempotency_key: str, payload_hash: str) -> CorporateAction:
"""Persist an immutable action and snapshot atomically."""
self.ensure_schema()
with self._connect() as conn: # noqa: SIM117 - keep transaction and cursor lifetimes explicit
with conn.cursor() as cur:
cur.execute("SELECT asset_quantity, cash_balance, profit_buffer, asset_id, status FROM portfolios WHERE portfolio_id=%s FOR UPDATE", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("portfolio not found")
cur.execute("SELECT corporate_action_id, portfolio_id, asset_id, action_type, effective_date, actor_user_id, evidence_ref, idempotency_key, payload_hash, quantity_before, quantity_after, average_price_before, average_price_after, total_cost_basis, cash_balance, profit_buffer, ledger_entry_id, recorded_at FROM portfolio_corporate_actions WHERE portfolio_id=%s AND idempotency_key=%s", (portfolio_id, idempotency_key))
prior = cur.fetchone()
if prior:
if prior[8] != payload_hash:
raise ValueError("IDEMPOTENCY_KEY_REUSE")
(action.corporate_action_id, action.portfolio_id, action.asset_id, action.action_type,
action.effective_date, action.actor_user_id, action.evidence_ref,
action.idempotency_key, action.payload_hash, action.quantity_before,
action.quantity_after, action.average_price_before, action.average_price_after,
action.total_cost_basis, action.cash_balance, action.profit_buffer,
ledger_id, action.recorded_at) = prior
action.corporate_action_id = str(action.corporate_action_id)
action.ledger_entry_id = int(ledger_id)
action.idempotent_replay = True
return action
if row[4] != "enabled":
raise ValueError("CORPORATE_ACTION_PORTFOLIO_DISABLED")
if str(row[3]) != str(action.asset_id):
raise ValueError("CORPORATE_ACTION_ASSET_MISMATCH")
if Decimal(str(row[1])) != action.cash_balance or Decimal(str(row[2])) != action.profit_buffer:
raise ValueError("CORPORATE_ACTION_INVARIANT_MISMATCH")
cur.execute("SELECT 1 FROM portfolio_ledger WHERE portfolio_id=%s AND side IN ('buy','sell') AND executed_at::date >= %s LIMIT 1", (portfolio_id, action.effective_date))
if cur.fetchone() is not None:
raise ValueError("CORPORATE_ACTION_HISTORICAL_CONFLICT")
# Derive WAC from the authoritative locked ledger, never portfolios.price.
cur.execute("SELECT side, quantity, price, fee_amount, tax_amount, executed_at FROM portfolio_ledger WHERE portfolio_id=%s ORDER BY executed_at ASC NULLS FIRST, ledger_entry_id ASC", (portfolio_id,))
history = [{"side": r[0], "quantity": r[1], "price": r[2], "fee_amount": r[3], "tax_amount": r[4], "executed_at": r[5]} for r in cur.fetchall()]
from app.domain.pnl import calculate_current_wac
wac = calculate_current_wac(history)
if Decimal(str(row[0])) != action.quantity_before or Decimal(str(wac.current_quantity)) != action.quantity_before:
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
action.average_price_before = Decimal(str(wac.current_wac or 0))
action.total_cost_basis = (action.average_price_before * action.quantity_before).quantize(Decimal("0.00000001"))
action.average_price_after = (action.total_cost_basis / action.quantity_after).quantize(Decimal("0.00000001"))
if action.average_price_after * action.quantity_after != action.total_cost_basis:
raise ValueError("FRACTIONAL_RESULT_UNSUPPORTED")
cur.execute("SELECT corporate_action_id, portfolio_id, asset_id, action_type, effective_date, actor_user_id, evidence_ref, idempotency_key, payload_hash, quantity_before, quantity_after, average_price_before, average_price_after, total_cost_basis, cash_balance, profit_buffer, ledger_entry_id, recorded_at FROM portfolio_corporate_actions WHERE portfolio_id=%s AND idempotency_key=%s", (portfolio_id, idempotency_key))
prior = cur.fetchone()
if prior:
if prior[8] != payload_hash:
raise ValueError("IDEMPOTENCY_KEY_REUSE")
(action.corporate_action_id, action.portfolio_id, action.asset_id, action.action_type,
action.effective_date, action.actor_user_id, action.evidence_ref,
action.idempotency_key, action.payload_hash, action.quantity_before,
action.quantity_after, action.average_price_before, action.average_price_after,
action.total_cost_basis, action.cash_balance, action.profit_buffer,
ledger_id, action.recorded_at) = prior
action.corporate_action_id = str(action.corporate_action_id)
action.ledger_entry_id = int(ledger_id)
action.idempotent_replay = True
return action
if Decimal(str(row[0])) != action.quantity_before:
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
cur.execute("UPDATE portfolios SET asset_quantity=%s, updated_at=NOW() WHERE portfolio_id=%s", (float(action.quantity_after), portfolio_id))
cur.execute("INSERT INTO portfolio_ledger (portfolio_id, recorded_at, side, source, quantity, price, cash_balance_after, asset_quantity_after, profit_buffer_after, executed_at, note) VALUES (%s,NOW(),'stock_consolidation','corporate_action',0,0,%s,%s,%s,NOW(),%s) RETURNING ledger_entry_id, recorded_at", (portfolio_id, float(action.cash_balance), float(action.quantity_after), float(action.profit_buffer), action.evidence_ref))
lid, recorded = cur.fetchone()
action.ledger_entry_id, action.recorded_at = int(lid), recorded
cur.execute("INSERT INTO portfolio_corporate_actions (corporate_action_id,portfolio_id,asset_id,action_type,effective_date,actor_user_id,evidence_ref,idempotency_key,payload_hash,quantity_before,quantity_after,average_price_before,average_price_after,total_cost_basis,cash_balance,profit_buffer,ledger_entry_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", (action.corporate_action_id,portfolio_id,action.asset_id,action.action_type,action.effective_date,action.actor_user_id,action.evidence_ref,idempotency_key,payload_hash,action.quantity_before,action.quantity_after,action.average_price_before,action.average_price_after,action.total_cost_basis,action.cash_balance,action.profit_buffer,lid))
return action
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
self.ensure_schema()
with self._connect().cursor() as cur:
@@ -1612,7 +1711,7 @@ class PostgresPortfolioRepository:
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
FROM portfolio_ledger
WHERE portfolio_id = %s
WHERE portfolio_id = %s AND side IN ('buy', 'sell')
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s
""",
@@ -1636,7 +1735,7 @@ class PostgresPortfolioRepository:
boundaries (start of day / start of next day).
"""
self.ensure_schema()
clauses = ["portfolio_id = %s"]
clauses = ["portfolio_id = %s", "side IN ('buy','sell')"]
params: list[object] = [portfolio_id]
if date is not None:
@@ -1706,16 +1805,35 @@ class PostgresPortfolioRepository:
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
FROM portfolio_ledger
WHERE portfolio_id = %s
AND side IN ('buy', 'sell')
ORDER BY executed_at ASC, ledger_entry_id ASC
strategy_set_id, note, source,
ca.corporate_action_id, ca.action_type, ca.effective_date,
ca.quantity_before, ca.quantity_after, ca.average_price_before,
ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
ca.profit_buffer, ca.evidence_ref, ca.actor_user_id,
ca.idempotency_key, ca.payload_hash, ca.ledger_entry_id, ca.recorded_at
FROM portfolio_ledger AS ledger
LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = ledger.ledger_entry_id
WHERE ledger.portfolio_id = %s
ORDER BY ledger.executed_at ASC, ledger.ledger_entry_id ASC
""",
(portfolio_id,),
)
rows = cur.fetchall()
return [self._row_to_ledger_entry(row) for row in rows]
entries = []
for row in rows:
entry = self._row_to_ledger_entry(row)
if len(row) > 18 and row[18] is not None:
entry.corporate_action = {
"corporate_action_id": str(row[18]), "action_type": row[19], "effective_date": row[20],
"quantity_before": Decimal(str(row[21])), "quantity_after": Decimal(str(row[22])),
"average_price_before": Decimal(str(row[23])), "average_price_after": Decimal(str(row[24])),
"total_cost_basis": Decimal(str(row[25])), "cash_balance": Decimal(str(row[26])),
"profit_buffer": Decimal(str(row[27])), "evidence_ref": row[28], "actor_user_id": row[29],
"idempotency_key": row[30], "payload_hash": row[31], "ledger_entry_id": int(row[32]),
"recorded_at": row[33],
}
entries.append(entry)
return entries
def insert_ledger_trade(
self,
@@ -2369,15 +2487,26 @@ class PostgresPortfolioRepository:
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
ca.corporate_action_id, ca.action_type, ca.effective_date,
ca.quantity_before, ca.quantity_after, ca.average_price_before,
ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
ca.profit_buffer, ca.evidence_ref, ca.actor_user_id
FROM portfolio_ledger
LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = portfolio_ledger.ledger_entry_id
WHERE """ + " AND ".join(clauses) + """
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s
""",
params,
)
return [self._row_to_ledger_entry(row) for row in cur.fetchall()]
result = []
for row in cur.fetchall():
entry = self._row_to_ledger_entry(row)
if row[21] is not None:
entry.corporate_action = {"corporate_action_id": str(row[21]), "action_type": row[22], "effective_date": row[23], "quantity_before": Decimal(str(row[24])), "quantity_after": Decimal(str(row[25])), "average_price_before": Decimal(str(row[26])), "average_price_after": Decimal(str(row[27])), "total_cost_basis": Decimal(str(row[28])), "cash_balance": Decimal(str(row[29])), "profit_buffer": Decimal(str(row[30])), "evidence_ref": row[31], "actor_user_id": row[32]}
result.append(entry)
return result
def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]:
if not portfolio_ids:
+40
View File
@@ -0,0 +1,40 @@
from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
# Corporate actions (stock_consolidation-only, issue #34)
class CorporateActionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action_type: Literal["stock_consolidation"] = "stock_consolidation"
effective_date: date
quantity_before: Decimal = Field(..., gt=0, max_digits=20, decimal_places=8)
quantity_after: Decimal = Field(..., gt=0, max_digits=20, decimal_places=8)
evidence_ref: str = Field(..., min_length=1, max_length=500)
confirm_daemon_managed: bool = False
@model_validator(mode="after")
def valid_ratio(self):
if self.quantity_after >= self.quantity_before:
raise ValueError("quantity_after must be less than quantity_before")
return self
class CorporateActionResponse(BaseModel):
corporate_action_id: str
ledger_entry_id: int
portfolio_id: str
action_type: Literal["stock_consolidation"]
effective_date: date
quantity_before: Decimal
quantity_after: Decimal
average_price_before: Decimal
average_price_after: Decimal
total_cost_basis: Decimal
cash_balance: Decimal
profit_buffer: Decimal
evidence_ref: str
actor_user_id: str | None = None
recorded_at: datetime
idempotent_replay: bool = False
+21 -1
View File
@@ -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
+17 -15
View File
@@ -1,11 +1,11 @@
# PortfolioHub v0.2.1 container contract
# PortfolioHub v0.3.1 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.1` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors:
- `ghcr.io/fourblocks2020/portfolio-hub:<source-commit-first-12>`
- `ghcr.io/fourblocks2020/portfolio-hub:v0.2.1`
- `ghcr.io/fourblocks2020/portfolio-hub:v0.3.1`
It never publishes `latest`. Both selectors must report the same registry digest; deployment and rollback must use the immutable identity:
@@ -13,9 +13,9 @@ It never publishes `latest`. Both selectors must report the same registry digest
ghcr.io/fourblocks2020/portfolio-hub@sha256:<64-lowercase-hex-digest>
```
OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.2.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.1`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration.
The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.2.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.1 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.1 | `0.3.1` | no | health/OpenAPI version metadata |
`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.2.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.1 application does not open Redis or Loki connections. They are not production dependencies of this image.
### Legacy and sibling mapping
The existing database variable names remain `TIMESCALEDB_*`; v0.2.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.1 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container.
## Liveness and readiness
- **Liveness:** unauthenticated `GET /health` must return HTTP 200 with `status=ok`. It is intentionally process-only and does not query PostgreSQL. `db_enabled` only reports configuration; it is not readiness.
- **Readiness:** run `pfhub --format json health-check` with the same runtime environment. With DB enabled it performs a repository ping, reads `alembic_version` without migration, compares it with the single packaged Alembic head, and exits nonzero on connectivity, missing schema, multiple heads, or revision mismatch.
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.2.1`.
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.3.1`.
Do not route traffic from a static `/health` success alone. Readiness checks never apply, stamp, or repair migrations.
## One-shot migration and 0029 -> 0030 policy
## One-shot migration and 0029 -> 0031 policy
The installed image contains exactly **30** migration files and one Alembic head, `20260820_0030`. The application default command does not invoke Alembic. Migration is a separate pre-start operation using the exact same digest and database environment:
The installed image contains exactly **31** migration files and one Alembic head, `20260913_0031`. The application default command does not invoke Alembic. Migration is a separate pre-start operation using the exact same digest and database environment:
1. back up the database and record the current immutable image digest and `alembic_version`;
2. require the normal upgrade origin `20260814_0029`; for an older revision, review and apply the full ordered chain first;
3. stop writes or otherwise establish an operator-approved maintenance boundary;
4. execute `pfhub migrate-db` once with restart disabled;
5. stop rollout on any nonzero result and preserve sanitized logs;
6. run `pfhub --format json health-check` and require both `database_revision` and `expected_revision` to equal `20260820_0030`;
6. run `pfhub --format json health-check` and require both `database_revision` and `expected_revision` to equal `20260913_0031`;
7. start application replicas and verify `/health`, `/openapi.json`, authenticated caller paths, and strategy data;
8. a repeated `pfhub migrate-db` is expected to be idempotent and remain at `0030`, but it is verification—not an application-start hook.
8. a repeated `pfhub migrate-db` is expected to be idempotent and remain at `0031`, but it is verification—not an application-start hook.
Migration `0029` is additive/idempotent around the strategy catalog: it preserves existing strategy IDs and portfolio references, reuses the canonical `target_value`/`{}` strategy, and backfills catalog data. Before migration, inspect duplicate or invalid strategy references and take a backup. After migration, verify strategy listing and portfolio active-strategy fields before resuming writes.
v0.2.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.1 preserves Bearer authentication, retains all-source unified-ledger reads, and adds stock-consolidation corporate-action recording and replay. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 31-file migration inventory, isolated PostgreSQL migration twice, readiness at `0031`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it. The changed-scope Ruff gate uses the locked linter and the ACMR Python path selector, compares normalized findings against both v0.2.1 (the reported v0.3.0 scope) and v0.3.0 (the patch scope), and rejects every newly introduced finding.
The strategy catalog API remains additive: strategy responses expose `nick`, admin create/update requests may set it, and portfolio responses expose the current strategy as `strategy_nick`. Existing clients may omit `nick`; supplied values are trimmed and blank-only values are rejected. trex-web v0.2.3 displays `strategy_nick`, but that dependency does not resolve or waive the separate caller blockers below.
+1 -1
View File
@@ -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
+4 -1
View File
@@ -437,7 +437,10 @@
- Auth: same owner-scoped user/session token policy as manual write.
- Query: `limit` 1..200, default 50.
- Response: `ManualLedgerHistoryResponse {"items": [ManualLedgerResponse], "total": <int>}` ordered newest first.
- `GET /v1/portfolios/{id}/ledger` returns corporate-action events as a distinct `CorporateActionLedgerHistoryItem` with event ID/type, effective date, before/after quantity and WAC, total cost basis, cash/profit buffer, evidence, actor, and recorded time. These events are never represented as BUY/SELL trades.
- `GET /v1/portfolios/{id}/trades` remains restricted to BUY/SELL history.
- `portfolio corporate-action` is the operator command; it requires an idempotency key and confirmation unless `--force` is supplied.
### Endpoint: `GET /v1/portfolios/{portfolio_id}/returns/summary` (REQ-056, BAT-024)
+2 -2
View File
@@ -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
### 운영 제약
+20 -5
View File
@@ -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
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portfoliohub"
version = "0.2.1"
version = "0.3.1"
description = "PortfolioHub API for portfolio management and ledger access"
readme = "README.md"
requires-python = ">=3.11"
+2 -2
View File
@@ -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:
+128
View File
@@ -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())
+4 -4
View File
@@ -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:
+6 -6
View File
@@ -50,7 +50,7 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive
assert 'RELEASE_VERSION=v0.1.3' not in verify
def test_release_version_and_migration_contract_are_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 +58,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.1"' in project
assert 'app_version: str = "0.3.1"' in config
assert len(migrations) == 31
assert migrations[-1].startswith("20260913_0031_")
def test_dockerignore_is_deny_by_default_required_inputs_only():
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"
@@ -85,7 +85,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.1 container contract",
"v0.2.1 includes migration `0030`",
"trex-web v0.2.3 displays `strategy_nick`",
):
+33
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
from decimal import Decimal
from app.domain.corporate_actions import calculate_stock_consolidation
def test_stock_consolidation_preserves_cost_and_scales_wac():
result = calculate_stock_consolidation(Decimal(1452), Decimal(726), Decimal("1086.23"))
assert result.average_price_after == Decimal("2172.46")
assert result.total_cost_basis == Decimal("1577205.96000000")
assert result.ratio == Decimal("0.5")
def test_stock_consolidation_rejects_fractional_result():
import pytest
from app.domain.corporate_actions import CorporateActionError
with pytest.raises(CorporateActionError) as exc:
calculate_stock_consolidation(Decimal(3), Decimal("1.5"), Decimal(10))
assert exc.value.code == "FRACTIONAL_RESULT_UNSUPPORTED"
+40
View File
@@ -0,0 +1,40 @@
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from app.domain.corporate_actions import canonical_payload_hash
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
from app.schemas.corporate_actions import CorporateActionRequest
def test_buy_consolidation_sell_replays_wac_and_realized_pnl():
rows = [
{"side": "buy", "quantity": 10, "price": 100, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 1, tzinfo=UTC)},
{"side": "stock_consolidation", "quantity": 0, "price": 0, "quantity_before": 10, "quantity_after": 5, "average_price_after": 200, "executed_at": datetime(2026, 1, 2, tzinfo=UTC)},
{"side": "sell", "quantity": 2, "price": 250, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 3, tzinfo=UTC)},
]
assert calculate_current_wac(rows).current_wac == 200
result = calculate_realized_pnl(rows)
assert result.items[0].cost_basis_per_unit == 200
assert result.total_realized_pnl == 100
def test_canonical_hash_same_payload_and_changed_payload_differ():
a = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal(10), quantity_after=Decimal(5), evidence_ref="ev")
b = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal(10), quantity_after=Decimal(4), evidence_ref="ev")
assert canonical_payload_hash(a) == canonical_payload_hash(a.model_copy())
assert canonical_payload_hash(a) != canonical_payload_hash(b)
def test_runtime_schema_has_one_corporate_action_ddl_and_replay_mapping_starts_at_18():
source = Path("app/repositories/postgres.py").read_text()
assert source.count("CREATE TABLE IF NOT EXISTS portfolio_corporate_actions") == 1
assert "strategy_set_id, note, source," in source
assert "ca.corporate_action_id, ca.action_type" in source
assert 'row[18] is not None' in source
def test_joined_corporate_action_metadata_is_complete_in_mapping_contract():
source = Path("app/repositories/postgres.py").read_text()
for field in ("action_type", "quantity_before", "quantity_after", "average_price_before", "average_price_after", "total_cost_basis", "cash_balance", "profit_buffer", "evidence_ref", "actor_user_id", "idempotency_key", "payload_hash", "recorded_at"):
assert f'"{field}"' in source
+65
View File
@@ -0,0 +1,65 @@
from datetime import date
from decimal import Decimal
from unittest.mock import MagicMock
from uuid import UUID
import pytest
from fastapi.testclient import TestClient
from app.api import v1_portfolios
from app.domain.models import CorporateAction
from app.main import create_app
from app.repositories.postgres import PostgresPortfolioRepository
class MissingPortfolioRepo:
def get(self, portfolio_id):
raise ValueError("포트폴리오를 찾을 수 없습니다")
def close(self):
pass
def test_create_corporate_action_maps_postgres_missing_portfolio_to_404(monkeypatch):
app = create_app()
repo = MissingPortfolioRepo()
monkeypatch.setattr(v1_portfolios, "_get_repo", lambda: repo)
app.dependency_overrides[v1_portfolios.require_trade_write] = lambda: type("Auth", (), {"user_id": "u1", "permission": "trade_write"})()
client = TestClient(app)
response = client.post(
"/v1/portfolios/missing/corporate-actions",
headers={"Idempotency-Key": "idem-1"},
json={"effective_date": "2026-01-01", "quantity_before": "10", "quantity_after": "5", "evidence_ref": "ev"},
)
assert response.status_code == 404
assert response.json()["detail"] == "Portfolio not found"
def _action(asset_id="asset-1"):
return CorporateAction("ca-1", "pf-1", asset_id, "stock_consolidation", date(2026, 1, 1), Decimal(0), Decimal(1), Decimal(0), Decimal(0), Decimal(0), Decimal(10), Decimal(0), "ev", "idem", "hash", 0)
def _repo_for_row(asset_id):
repo = object.__new__(PostgresPortfolioRepository)
repo.ensure_schema = MagicMock()
conn = MagicMock()
cur = MagicMock()
conn.__enter__.return_value = conn
conn.cursor.return_value.__enter__.return_value = cur
repo._connect = MagicMock(return_value=conn)
cur.fetchone.side_effect = [(Decimal(0), Decimal(10), Decimal(0), asset_id, "enabled"), None, None, None, (7, date(2026, 1, 1))]
cur.fetchall.return_value = []
return repo
def test_record_corporate_action_accepts_uuid_row_against_string_domain_id():
asset = UUID("12345678-1234-5678-1234-567812345678")
action = _action(str(asset))
saved = _repo_for_row(asset).record_corporate_action("pf-1", action=action, idempotency_key="idem", payload_hash="hash")
assert saved.ledger_entry_id == 7
def test_record_corporate_action_rejects_different_asset_id():
repo = _repo_for_row(UUID("12345678-1234-5678-1234-567812345678"))
with pytest.raises(ValueError, match="CORPORATE_ACTION_ASSET_MISMATCH"):
repo.record_corporate_action("pf-1", action=_action("87654321-4321-8765-4321-876543218765"), idempotency_key="idem", payload_hash="hash")
Generated
+1 -1
View File
@@ -592,7 +592,7 @@ wheels = [
[[package]]
name = "portfoliohub"
version = "0.2.1"
version = "0.3.1"
source = { editable = "." }
dependencies = [
{ name = "alembic" },