fix: make corporate actions atomic and recoverable
Publish verified PortfolioHub image / verify (push) Canceled after 0s
Publish verified PortfolioHub image / publish (push) Canceled after 0s

This commit is contained in:
2026-09-13 19:33:07 +09:00
parent 2b470c735f
commit c7d8235bde
16 changed files with 711 additions and 94 deletions
+235 -68
View File
@@ -14,7 +14,10 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.corporate_actions import calculate_stock_consolidation
from app.domain.corporate_actions import (
calculate_stock_consolidation,
quantize_stored_decimal,
)
from app.domain.errors import (
DaemonInactiveError,
DaemonInUseError,
@@ -1629,76 +1632,240 @@ 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."""
def record_corporate_action(
self,
portfolio_id: str,
*,
action: CorporateAction,
idempotency_key: str,
payload_hash: str,
resume_partial: bool = False,
) -> CorporateAction:
"""Persist an immutable action and snapshot in one explicit transaction."""
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")
consolidation = calculate_stock_consolidation(
conn = self._connect()
def hydrate_replay(prior: tuple) -> CorporateAction:
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
prior_sql = """
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
"""
# Repository connections are autocommit connections. A connection
# context alone therefore cannot protect this multi-statement write.
with conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
SELECT asset_quantity, cash_balance, profit_buffer, asset_id,
status, asset_price
FROM portfolios
WHERE portfolio_id=%s
FOR UPDATE
""",
(portfolio_id,),
)
row = cur.fetchone()
if row is None:
raise ValueError("portfolio not found")
cur.execute(prior_sql, (portfolio_id, idempotency_key))
prior = cur.fetchone()
if prior:
return hydrate_replay(prior)
snapshot_quantity = Decimal(str(row[0]))
cash_balance = Decimal(str(row[1]))
profit_buffer = Decimal(str(row[2]))
reference_price_before = quantize_stored_decimal(Decimal(str(row[5])))
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 cash_balance != action.cash_balance or profit_buffer != 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 trade ledger, never the
# portfolio reference price or the already-mutated snapshot.
cur.execute(
"""
SELECT side, quantity, price, fee_amount, tax_amount, executed_at
FROM portfolio_ledger
WHERE portfolio_id=%s AND side IN ('buy','sell')
ORDER BY executed_at ASC NULLS FIRST, ledger_entry_id ASC
""",
(portfolio_id,),
)
history = [
{
"side": ledger_row[0],
"quantity": ledger_row[1],
"price": ledger_row[2],
"fee_amount": ledger_row[3],
"tax_amount": ledger_row[4],
"executed_at": ledger_row[5],
}
for ledger_row in cur.fetchall()
]
from app.domain.pnl import calculate_current_wac
wac = calculate_current_wac(history)
if Decimal(str(wac.current_quantity)) != action.quantity_before:
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
if resume_partial:
if snapshot_quantity != action.quantity_after:
raise ValueError("CORPORATE_ACTION_RECOVERY_STATE_MISMATCH")
cur.execute(
"""
SELECT
EXISTS (
SELECT 1 FROM portfolio_corporate_actions
WHERE portfolio_id=%s
),
EXISTS (
SELECT 1 FROM portfolio_ledger
WHERE portfolio_id=%s AND source='corporate_action'
)
""",
(portfolio_id, portfolio_id),
)
audit_exists, ledger_exists = cur.fetchone()
if audit_exists or ledger_exists:
raise ValueError("CORPORATE_ACTION_RECOVERY_ALREADY_RECORDED")
elif snapshot_quantity != action.quantity_before:
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
consolidation = calculate_stock_consolidation(
action.quantity_before,
action.quantity_after,
Decimal(str(wac.current_wac or 0)),
)
action.average_price_before = consolidation.average_price_before
action.average_price_after = consolidation.average_price_after
action.total_cost_basis = consolidation.total_cost_basis
action.cash_balance = cash_balance
action.profit_buffer = profit_buffer
consolidation_ratio = action.quantity_before / action.quantity_after
reference_price_after = quantize_stored_decimal(
reference_price_before * consolidation_ratio
)
total_value_after = quantize_stored_decimal(
cash_balance + action.quantity_after * reference_price_after
)
cur.execute(
"""
UPDATE portfolios
SET asset_quantity=%s, asset_price=%s, updated_at=NOW()
WHERE portfolio_id=%s
""",
(action.quantity_after, reference_price_after, portfolio_id),
)
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, side, source, quantity, price,
cash_balance_after, asset_quantity_after, total_value_after,
profit_buffer_after, executed_at, note
) VALUES (
%s, NOW(), 'stock_consolidation', 'corporate_action', 0, %s,
%s, %s, %s, %s, NOW(), %s
)
RETURNING ledger_entry_id, recorded_at
""",
(
portfolio_id,
reference_price_after,
cash_balance,
action.quantity_after,
total_value_after,
profit_buffer,
action.evidence_ref,
),
)
lid, recorded = cur.fetchone()
action.ledger_entry_id = int(lid)
action.recorded_at = 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,
Decimal(str(wac.current_wac or 0)),
)
action.average_price_before = consolidation.average_price_before
action.average_price_after = consolidation.average_price_after
action.total_cost_basis = consolidation.total_cost_basis
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))
action.average_price_before,
action.average_price_after,
action.total_cost_basis,
cash_balance,
profit_buffer,
lid,
),
)
return action
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: