feat(portfolio): add atomic corporate action persistence

This commit is contained in:
2026-09-13 13:43:21 +09:00
parent dc755889b3
commit 7a45bec3cb
3 changed files with 72 additions and 3 deletions
+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(
+45 -1
View File
@@ -7,7 +7,7 @@ import json
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import UTC, date, datetime
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
@@ -22,6 +22,7 @@ from app.domain.errors import (
StrategyNotFoundError,
)
from app.domain.models import (
CorporateAction,
Asset,
AuthToken,
BrokerCredential,
@@ -332,6 +333,23 @@ 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,
asset_id UUID, action_type TEXT NOT NULL, 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 NOT NULL,
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 (
@@ -1616,6 +1634,32 @@ 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:
with conn.cursor() as cur:
cur.execute("SELECT asset_quantity, cash_balance, profit_buffer, asset_id 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, payload_hash, 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[1] != payload_hash:
raise ValueError("IDEMPOTENCY_KEY_REUSE")
action.corporate_action_id, action.ledger_entry_id, action.recorded_at = str(prior[0]), int(prior[2]), prior[3]
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 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: