feat(portfolio): expose corporate actions in unified ledger
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]=="**"
|
||||||
|
|||||||
Reference in New Issue
Block a user