fix(portfolios): replay corporate-action cost basis (#38)
This commit is contained in:
+37
-16
@@ -1282,27 +1282,48 @@ def get_holdings_cost_basis(
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
|
||||
# Build rows for domain function
|
||||
rows = [
|
||||
{
|
||||
"side": e.side,
|
||||
"quantity": e.quantity,
|
||||
"price": e.price,
|
||||
"fee_amount": e.fee_amount,
|
||||
"executed_at": e.executed_at,
|
||||
# Build rows for domain function, preserving joined corporate-action metadata.
|
||||
rows = []
|
||||
corporate_action_fields = (
|
||||
"corporate_action_id",
|
||||
"action_type",
|
||||
"quantity_before",
|
||||
"quantity_after",
|
||||
"average_price_after",
|
||||
"total_cost_basis",
|
||||
)
|
||||
for entry in ledger_entries:
|
||||
row = {
|
||||
"side": entry.side,
|
||||
"quantity": entry.quantity,
|
||||
"price": entry.price,
|
||||
"fee_amount": entry.fee_amount,
|
||||
"executed_at": entry.executed_at,
|
||||
"ledger_entry_id": entry.ledger_entry_id,
|
||||
}
|
||||
for e in ledger_entries
|
||||
]
|
||||
if entry.corporate_action:
|
||||
row.update(
|
||||
{
|
||||
field: entry.corporate_action.get(field)
|
||||
for field in corporate_action_fields
|
||||
}
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
wac_result = calculate_current_wac(rows)
|
||||
|
||||
current_quantity = portfolio.asset_quantity
|
||||
cost_basis_per_unit = wac_result.current_wac if current_quantity > 0 else None
|
||||
total_cost_basis = (
|
||||
cost_basis_per_unit * current_quantity
|
||||
if cost_basis_per_unit is not None and current_quantity > 0
|
||||
else None
|
||||
)
|
||||
if wac_result.total_cost_basis is not None and wac_result.current_quantity > 0:
|
||||
current_quantity = wac_result.current_quantity
|
||||
total_cost_basis = wac_result.total_cost_basis
|
||||
cost_basis_per_unit = total_cost_basis / current_quantity
|
||||
else:
|
||||
cost_basis_per_unit = wac_result.current_wac if current_quantity > 0 else None
|
||||
total_cost_basis = (
|
||||
cost_basis_per_unit * current_quantity
|
||||
if cost_basis_per_unit is not None and current_quantity > 0
|
||||
else None
|
||||
)
|
||||
|
||||
return HoldingsCostBasisResponse(
|
||||
portfolio_id=portfolio_id,
|
||||
|
||||
+59
-33
@@ -57,6 +57,9 @@ class CurrentWacResult:
|
||||
data_completeness_warning: str | None
|
||||
"""Non-None when ledger history is incomplete (sell before buy, oversell, etc.)."""
|
||||
|
||||
total_cost_basis: float | None = None
|
||||
"""Authoritative current total after a corporate action, when available."""
|
||||
|
||||
|
||||
def calculate_realized_pnl(
|
||||
rows: list[dict[str, Any]],
|
||||
@@ -204,29 +207,32 @@ def calculate_realized_pnl(
|
||||
def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
"""Calculate the current holding WAC (weighted average cost) from a time-ordered ledger.
|
||||
|
||||
Replays the full ledger history (buy/sell) and returns the running WAC state,
|
||||
representing the cost basis of currently held (unsold) shares.
|
||||
Replays the full ledger history (buy/sell/corporate action) and returns the
|
||||
running WAC state representing the cost basis of currently held shares.
|
||||
|
||||
Args:
|
||||
rows: List of portfolio_ledger row dicts, each containing:
|
||||
side (str), quantity (float), price (float),
|
||||
executed_at (datetime).
|
||||
Must be sorted by executed_at ASC.
|
||||
rows: Time-ordered portfolio-ledger dictionaries. BUY/SELL rows contain
|
||||
side, quantity, price, fee_amount and executed_at. A
|
||||
stock_consolidation row must also contain a positive
|
||||
quantity_after and authoritative total_cost_basis.
|
||||
Rows must be sorted by executed_at ASC (and ledger_entry_id ASC
|
||||
for ties) by the repository.
|
||||
|
||||
Returns:
|
||||
CurrentWacResult with:
|
||||
- current_wac: WAC per unit (None when current_quantity == 0)
|
||||
- current_quantity: remaining quantity after all trades
|
||||
- current_wac: WAC per unit (None for no holding or unusable
|
||||
corporate-action metadata)
|
||||
- current_quantity: remaining replay quantity
|
||||
- cost_basis_from: earliest buy date used
|
||||
- data_completeness_warning: non-None when history is incomplete
|
||||
- total_cost_basis: authoritative current total after a corporate
|
||||
action, otherwise None for the legacy BUY/SELL path
|
||||
|
||||
WAC Rules (same as calculate_realized_pnl, REQ-041, buy-fee-in-cost-basis):
|
||||
buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty)
|
||||
running_qty += qty
|
||||
(buy_fee = fee_amount of the buy row, incorporated into cost basis)
|
||||
sell: running_qty -= qty (WAC per unit unchanged on sell)
|
||||
Incomplete history (sell with running_qty=0, or sell qty > running_qty):
|
||||
data_completeness_warning is set; calculation continues with best estimate.
|
||||
WAC rules preserve the existing BUY/SELL behavior. A stock consolidation
|
||||
replaces the replay quantity with quantity_after, preserves
|
||||
total_cost_basis as authoritative, and derives unit cost by division.
|
||||
Missing required corporate-action metadata fails closed instead of falling
|
||||
back to the pre-action WAC.
|
||||
"""
|
||||
if not rows:
|
||||
return CurrentWacResult(
|
||||
@@ -240,6 +246,8 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
running_qty: float = 0.0
|
||||
cost_basis_from: datetime | None = None
|
||||
warning_triggered: bool = False
|
||||
corporate_action_metadata_missing: bool = False
|
||||
authoritative_total_cost_basis: float | None = None
|
||||
|
||||
for row in rows:
|
||||
side = str(row.get("side", "")).lower()
|
||||
@@ -252,38 +260,47 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
# Include buy fee in cost basis (매수 수수료 편입)
|
||||
new_total_qty = running_qty + qty
|
||||
if new_total_qty > 0:
|
||||
running_wac = (running_wac * running_qty + price * qty + fee) / new_total_qty
|
||||
if authoritative_total_cost_basis is not None:
|
||||
authoritative_total_cost_basis += price * qty + fee
|
||||
running_wac = authoritative_total_cost_basis / new_total_qty
|
||||
else:
|
||||
running_wac = (
|
||||
running_wac * running_qty + price * qty + fee
|
||||
) / new_total_qty
|
||||
running_qty = new_total_qty
|
||||
if cost_basis_from is None and executed_at is not None:
|
||||
cost_basis_from = executed_at
|
||||
|
||||
elif side == "sell":
|
||||
if running_qty <= 0:
|
||||
warning_triggered = True
|
||||
elif qty > running_qty + 1e-9:
|
||||
if running_qty <= 0 or qty > running_qty + 1e-9:
|
||||
warning_triggered = True
|
||||
|
||||
running_qty -= qty
|
||||
if running_qty < 0:
|
||||
running_qty = 0.0
|
||||
if authoritative_total_cost_basis is not None:
|
||||
authoritative_total_cost_basis = running_wac * running_qty
|
||||
|
||||
elif side == "stock_consolidation":
|
||||
before = float(row.get("quantity_before", running_qty))
|
||||
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
|
||||
if before <= 0 or after <= 0 or running_qty <= 0:
|
||||
warning_triggered = True
|
||||
continue
|
||||
if abs(running_qty - before) > 1e-9:
|
||||
warning_triggered = True
|
||||
running_qty = after
|
||||
quantity_after = row.get("quantity_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))
|
||||
if quantity_after is None or total_cost_basis is None:
|
||||
corporate_action_metadata_missing = True
|
||||
continue
|
||||
after = float(quantity_after)
|
||||
if after <= 0:
|
||||
corporate_action_metadata_missing = True
|
||||
continue
|
||||
running_qty = after
|
||||
authoritative_total_cost_basis = float(total_cost_basis)
|
||||
running_wac = authoritative_total_cost_basis / after
|
||||
|
||||
warning: str | None = None
|
||||
if warning_triggered:
|
||||
if corporate_action_metadata_missing:
|
||||
warning = (
|
||||
"기업행동 metadata 불완전: 양수 quantity_after와 total_cost_basis가 필요합니다."
|
||||
)
|
||||
elif warning_triggered:
|
||||
warning = (
|
||||
"이력 불완전: 보유 수량보다 많은 매도 또는 buy 이력 없는 sell이 감지되어 "
|
||||
"cost_basis 산출이 부정확할 수 있습니다. "
|
||||
@@ -291,10 +308,19 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
)
|
||||
|
||||
return CurrentWacResult(
|
||||
current_wac=running_wac if running_qty > 0 else None,
|
||||
current_wac=(
|
||||
running_wac
|
||||
if running_qty > 0 and not corporate_action_metadata_missing
|
||||
else None
|
||||
),
|
||||
current_quantity=running_qty,
|
||||
cost_basis_from=cost_basis_from,
|
||||
data_completeness_warning=warning,
|
||||
total_cost_basis=(
|
||||
authoritative_total_cost_basis
|
||||
if running_qty > 0 and not corporate_action_metadata_missing
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user