fix corporate action replay invariants

This commit is contained in:
2026-09-13 14:12:04 +09:00
parent 14c118f503
commit cb19c35d23
3 changed files with 73 additions and 31 deletions
+3 -4
View File
@@ -1324,11 +1324,10 @@ def create_corporate_action(portfolio_id: str, payload: CorporateActionRequest,
raise HTTPException(status_code=403, detail="Portfolio ownership mismatch")
if portfolio.managed_by_daemon_id and not payload.confirm_daemon_managed:
raise HTTPException(status_code=409, detail="DAEMON_MANAGED_CONFIRMATION_REQUIRED")
from app.domain.corporate_actions import calculate_stock_consolidation, canonical_payload_hash
# The repository validates and derives WAC from the locked ledger replay.
calc = calculate_stock_consolidation(payload.quantity_before, payload.quantity_after, Decimal("0"))
from app.domain.corporate_actions import canonical_payload_hash
digest = canonical_payload_hash(payload)
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, payload.action_type, payload.effective_date, 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)), payload.evidence_ref, idempotency_key, digest, 0, auth.user_id)
# The repository derives all WAC and immutable snapshot fields under its row lock.
action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, payload.action_type, payload.effective_date, payload.quantity_before, payload.quantity_after, Decimal("0"), Decimal("0"), Decimal("0"), Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), payload.evidence_ref, idempotency_key, digest, 0, auth.user_id)
saved = repo.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash=digest)
return CorporateActionResponse(**saved.__dict__)
except ValueError as exc:
+30 -27
View File
@@ -445,24 +445,7 @@ class PostgresPortfolioRepository:
ON portfolios (broker, status, updated_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_corporate_actions (
corporate_action_id UUID PRIMARY KEY,
portfolio_id TEXT NOT NULL,
asset_id UUID NOT NULL,
action_type TEXT NOT NULL CHECK (action_type = 'stock_consolidation'),
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,
UNIQUE (portfolio_id, idempotency_key)
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_comments (
@@ -1140,6 +1123,8 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row)
self._attach_tags(cur, [portfolio])
return portfolio
@@ -1658,7 +1643,7 @@ class PostgresPortfolioRepository:
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))
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.
@@ -1673,12 +1658,19 @@ class PostgresPortfolioRepository:
action.average_price_after = (action.total_cost_basis / action.quantity_after).quantize(Decimal("0.00000001"))
if action.average_price_after * action.quantity_after != action.total_cost_basis:
raise ValueError("FRACTIONAL_RESULT_UNSUPPORTED")
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))
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[1] != payload_hash:
if prior[8] != 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.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:
@@ -1798,9 +1790,12 @@ class PostgresPortfolioRepository:
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note,
ca.corporate_action_id, ca.effective_date, ca.quantity_before,
ca.quantity_after, ca.average_price_after
strategy_set_id, note, source,
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,
ca.idempotency_key, ca.payload_hash, ca.ledger_entry_id, ca.recorded_at
FROM portfolio_ledger AS ledger
LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = ledger.ledger_entry_id
WHERE ledger.portfolio_id = %s
@@ -1812,8 +1807,16 @@ class PostgresPortfolioRepository:
entries = []
for row in rows:
entry = self._row_to_ledger_entry(row)
if len(row) > 17 and row[17] is not None:
entry.corporate_action = {"corporate_action_id": str(row[17]), "effective_date": row[18], "quantity_before": Decimal(str(row[19])), "quantity_after": Decimal(str(row[20])), "average_price_after": Decimal(str(row[21]))}
if len(row) > 18 and row[18] is not None:
entry.corporate_action = {
"corporate_action_id": str(row[18]), "action_type": row[19], "effective_date": row[20],
"quantity_before": Decimal(str(row[21])), "quantity_after": Decimal(str(row[22])),
"average_price_before": Decimal(str(row[23])), "average_price_after": Decimal(str(row[24])),
"total_cost_basis": Decimal(str(row[25])), "cash_balance": Decimal(str(row[26])),
"profit_buffer": Decimal(str(row[27])), "evidence_ref": row[28], "actor_user_id": row[29],
"idempotency_key": row[30], "payload_hash": row[31], "ledger_entry_id": int(row[32]),
"recorded_at": row[33],
}
entries.append(entry)
return entries
+40
View File
@@ -0,0 +1,40 @@
from datetime import date, datetime, timezone
from decimal import Decimal
from pathlib import Path
from app.domain.corporate_actions import canonical_payload_hash
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
from app.schemas.corporate_actions import CorporateActionRequest
def test_buy_consolidation_sell_replays_wac_and_realized_pnl():
rows = [
{"side": "buy", "quantity": 10, "price": 100, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 1, tzinfo=timezone.utc)},
{"side": "stock_consolidation", "quantity": 0, "price": 0, "quantity_before": 10, "quantity_after": 5, "average_price_after": 200, "executed_at": datetime(2026, 1, 2, tzinfo=timezone.utc)},
{"side": "sell", "quantity": 2, "price": 250, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 3, tzinfo=timezone.utc)},
]
assert calculate_current_wac(rows).current_wac == 200
result = calculate_realized_pnl(rows)
assert result.items[0].cost_basis_per_unit == 200
assert result.total_realized_pnl == 100
def test_canonical_hash_same_payload_and_changed_payload_differ():
a = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal("10"), quantity_after=Decimal("5"), evidence_ref="ev")
b = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal("10"), quantity_after=Decimal("4"), evidence_ref="ev")
assert canonical_payload_hash(a) == canonical_payload_hash(a.model_copy())
assert canonical_payload_hash(a) != canonical_payload_hash(b)
def test_runtime_schema_has_one_corporate_action_ddl_and_replay_mapping_starts_at_18():
source = Path("app/repositories/postgres.py").read_text()
assert source.count("CREATE TABLE IF NOT EXISTS portfolio_corporate_actions") == 1
assert "strategy_set_id, note, source," in source
assert "ca.corporate_action_id, ca.action_type" in source
assert 'row[18] is not None' in source
def test_joined_corporate_action_metadata_is_complete_in_mapping_contract():
source = Path("app/repositories/postgres.py").read_text()
for field in ("action_type", "quantity_before", "quantity_after", "average_price_before", "average_price_after", "total_cost_basis", "cash_balance", "profit_buffer", "evidence_ref", "actor_user_id", "idempotency_key", "payload_hash", "recorded_at"):
assert f'"{field}"' in source