fix(portfolio): tolerate consolidation WAC residue
This commit is contained in:
+1
-1
@@ -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")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user