From a4664f5991934129c6adf015c3517985f6d20059 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sun, 13 Sep 2026 13:52:50 +0900 Subject: [PATCH] feat(portfolio): expose corporate actions in unified ledger --- app/api/v1_portfolios.py | 15 +++++++++- app/cli/commands/portfolio.py | 40 +++++++++++++++++++++++++ app/domain/models.py | 1 + app/repositories/postgres.py | 17 +++++++++-- app/schemas/v1.py | 22 +++++++++++++- docs/interface-contract.md | 5 +++- tests/release/test_release_contracts.py | 4 +-- 7 files changed, 96 insertions(+), 8 deletions(-) diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 7c1fcb3..eb8041c 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -31,6 +31,7 @@ from app.schemas.v1 import ( DividendManualLedgerResponse, HoldingsCostBasisResponse, LedgerHistoryItem, + CorporateActionLedgerHistoryItem, LedgerHistoryResponse, LedgerTradeRequest, LedgerTradeResponse, @@ -810,7 +811,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)) diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index 83e157a..460c930 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -274,6 +274,46 @@ 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.""" + from datetime import date + from decimal import Decimal + from app.domain.corporate_actions import calculate_stock_consolidation + 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) + calc = calculate_stock_consolidation(Decimal(str(quantity_before)), Decimal(str(quantity_after)), Decimal(str(portfolio.price))) + action = CorporateAction("", 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, "", 0, user) + return repository.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash="") + 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, diff --git a/app/domain/models.py b/app/domain/models.py index b563498..31f653e 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -181,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: diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 075936f..c08de64 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -1674,7 +1674,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 """, @@ -2431,15 +2431,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: diff --git a/app/schemas/v1.py b/app/schemas/v1.py index d0796a5..278a9bc 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -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 diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 4819b82..346b2dc 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -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": }` 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) diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index e062a9b..f847763 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -60,8 +60,8 @@ def test_release_version_and_migration_contract_are_v021_head_0030(): ) 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 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]=="**"