feat(portfolio): expose corporate actions in unified ledger

This commit is contained in:
2026-09-13 13:52:50 +09:00
parent 77468631b1
commit a4664f5991
7 changed files with 96 additions and 8 deletions
+14 -1
View File
@@ -31,6 +31,7 @@ from app.schemas.v1 import (
DividendManualLedgerResponse, DividendManualLedgerResponse,
HoldingsCostBasisResponse, HoldingsCostBasisResponse,
LedgerHistoryItem, LedgerHistoryItem,
CorporateActionLedgerHistoryItem,
LedgerHistoryResponse, LedgerHistoryResponse,
LedgerTradeRequest, LedgerTradeRequest,
LedgerTradeResponse, 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 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() action = (entry.action or entry.side or "").upper()
if action == "DIVIDEND": if action == "DIVIDEND":
amount_decimal = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price)) amount_decimal = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price))
+40
View File
@@ -274,6 +274,46 @@ def sell_portfolio(
render_output(ctx.obj.console, ctx.obj.output_format, detail) 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") @portfolio_app.command("invest")
def invest_portfolio( def invest_portfolio(
ctx: typer.Context, ctx: typer.Context,
+1
View File
@@ -181,6 +181,7 @@ class LedgerEntry:
target_ledger_entry_id: int | None = None target_ledger_entry_id: int | None = None
confirmation_evidence: str | None = None confirmation_evidence: str | None = None
actor_user_id: str | None = None actor_user_id: str | None = None
corporate_action: dict[str, Any] | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.side is None and self.action: if self.side is None and self.action:
+14 -3
View File
@@ -1674,7 +1674,7 @@ class PostgresPortfolioRepository:
total_value_after::float8, profit_buffer_after::float8, total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note strategy_set_id, note
FROM portfolio_ledger 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 ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s LIMIT %s
""", """,
@@ -2431,15 +2431,26 @@ class PostgresPortfolioRepository:
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price, SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after, fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id, 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 FROM portfolio_ledger
LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = portfolio_ledger.ledger_entry_id
WHERE """ + " AND ".join(clauses) + """ WHERE """ + " AND ".join(clauses) + """
ORDER BY recorded_at DESC, ledger_entry_id DESC ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s LIMIT %s
""", """,
params, 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]]: def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]:
if not portfolio_ids: if not portfolio_ids:
+21 -1
View File
@@ -313,8 +313,28 @@ class DividendLedgerHistoryItem(BaseModel):
confirmation_evidence: str | None = None confirmation_evidence: str | None = None
class CorporateActionLedgerHistoryItem(BaseModel):
"""Auditable corporate-action event in the unified ledger read model."""
ledger_entry_id: int
portfolio_id: str
source: Literal["corporate_action"]
event_id: str
event_type: Literal["stock_consolidation"]
effective_date: date
quantity_before: Decimal
quantity_after: Decimal
average_price_before: Decimal
average_price_after: Decimal
total_cost_basis: Decimal
cash_balance: Decimal
profit_buffer: Decimal
evidence_ref: str
actor_user_id: str | None = None
recorded_at: datetime
class LedgerHistoryResponse(BaseModel): class LedgerHistoryResponse(BaseModel):
items: list[LedgerHistoryItem | DividendLedgerHistoryItem] items: list[LedgerHistoryItem | DividendLedgerHistoryItem | CorporateActionLedgerHistoryItem]
next_cursor: str | None = None next_cursor: str | None = None
+4 -1
View File
@@ -437,7 +437,10 @@
- Auth: same owner-scoped user/session token policy as manual write. - Auth: same owner-scoped user/session token policy as manual write.
- Query: `limit` 1..200, default 50. - 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) ### Endpoint: `GET /v1/portfolios/{portfolio_id}/returns/summary` (REQ-056, BAT-024)
+2 -2
View File
@@ -60,8 +60,8 @@ def test_release_version_and_migration_contract_are_v021_head_0030():
) )
assert 'version = "0.2.1"' in project assert 'version = "0.2.1"' in project
assert 'app_version: str = "0.2.1"' in config assert 'app_version: str = "0.2.1"' in config
assert len(migrations) == 30 assert len(migrations) == 31
assert migrations[-1].startswith("20260820_0030") assert migrations[-1].startswith("20260913_0031_")
def test_dockerignore_is_deny_by_default_required_inputs_only(): def test_dockerignore_is_deny_by_default_required_inputs_only():
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**" lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"