fix(portfolio): tolerate consolidation WAC residue
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 18:36:00 +09:00
parent 4520fbbfbd
commit 2b470c735f
14 changed files with 146 additions and 34 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ if env_file.exists():
class Settings(BaseSettings):
app_name: str = "PortfolioHub"
app_version: str = "0.3.1"
app_version: str = "0.3.2"
environment: str = "development"
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
+14 -5
View File
@@ -6,6 +6,13 @@ import json
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
STORAGE_QUANTUM = Decimal("0.00000001")
def quantize_stored_decimal(value: Decimal) -> Decimal:
"""Round a monetary value to the NUMERIC(*, 8) persistence contract."""
return value.quantize(STORAGE_QUANTUM, rounding=ROUND_HALF_UP)
def canonical_payload_hash(payload: object) -> str:
if hasattr(payload, "model_dump"):
@@ -36,11 +43,13 @@ def calculate_stock_consolidation(quantity_before: Decimal, quantity_after: Deci
raise CorporateActionError("stock consolidation must reduce quantity")
if q1 != q1.to_integral_value() or q0 != q0.to_integral_value():
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
total = (q0 * wac).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
after = (total / q1).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if after * q1 != total:
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
return StockConsolidation(q0, q1, wac, after, total, ratio)
# Preserve the authoritative pre-action total independently. A per-unit WAC
# rounded to the storage scale cannot always multiply back to that total
# exactly, and that harmless decimal residue is not a fractional share.
total = quantize_stored_decimal(q0 * wac)
before = quantize_stored_decimal(wac)
after = quantize_stored_decimal(total / q1)
return StockConsolidation(q0, q1, before, after, total, ratio)
def apply_corporate_action_to_wac(quantity: Decimal, wac: Decimal, quantity_after: Decimal) -> tuple[Decimal, Decimal]:
result = calculate_stock_consolidation(quantity, quantity_after, wac)
+10 -2
View File
@@ -179,7 +179,11 @@ def calculate_realized_pnl(
if abs(running_qty - before) > 1e-9:
warning_triggered = True
running_qty = after
running_wac = float(row.get("average_price_after", running_wac * before / after))
total_cost_basis = row.get("total_cost_basis")
if total_cost_basis is not None:
running_wac = float(total_cost_basis) / after
else:
running_wac = float(row.get("average_price_after", running_wac * before / after))
warning: str | None = None
if warning_triggered:
@@ -272,7 +276,11 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
if abs(running_qty - before) > 1e-9:
warning_triggered = True
running_qty = after
running_wac = float(row.get("average_price_after", running_wac * before / after))
total_cost_basis = row.get("total_cost_basis")
if total_cost_basis is not None:
running_wac = float(total_cost_basis) / after
else:
running_wac = float(row.get("average_price_after", running_wac * before / after))
warning: str | None = None
if warning_triggered:
+9 -5
View File
@@ -14,6 +14,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.corporate_actions import calculate_stock_consolidation
from app.domain.errors import (
DaemonInactiveError,
DaemonInUseError,
@@ -1668,11 +1669,14 @@ class PostgresPortfolioRepository:
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")
action.average_price_before = Decimal(str(wac.current_wac or 0))
action.total_cost_basis = (action.average_price_before * action.quantity_before).quantize(Decimal("0.00000001"))
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")
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
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: