feat(portfolio): expose corporate action and replay consolidation

This commit is contained in:
2026-09-13 13:47:46 +09:00
parent 7a45bec3cb
commit 77468631b1
2 changed files with 49 additions and 2 deletions
+27 -2
View File
@@ -18,12 +18,12 @@ import uuid
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from app.core.auth import AuthContext, Permission, require_read, require_trade_write
from app.core.config import get_settings
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
from app.domain.models import Portfolio, PortfolioComment
from app.domain.models import CorporateAction, Portfolio, PortfolioComment
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import (
@@ -55,6 +55,7 @@ from app.schemas.v1 import (
TradeHistoryItem,
TradeHistoryResponse,
)
from app.schemas.corporate_actions import CorporateActionRequest, CorporateActionResponse
from app.services.strategy_set_service import (
StrategyInactiveError,
StrategyNotFoundError,
@@ -1295,3 +1296,27 @@ def get_holdings_cost_basis(
cost_basis_from=wac_result.cost_basis_from,
data_completeness_warning=wac_result.data_completeness_warning,
)
@router.post("/{portfolio_id}/corporate-actions", response_model=CorporateActionResponse, status_code=status.HTTP_201_CREATED)
def create_corporate_action(portfolio_id: str, payload: CorporateActionRequest, response: Response, auth: AuthContext = Depends(require_trade_write), idempotency_key: str = Header(..., alias="Idempotency-Key")):
repo = _get_repo()
try:
portfolio = repo.get(portfolio_id)
if portfolio is None:
raise HTTPException(status_code=404, detail="Portfolio not found")
if portfolio.owner_user_id != auth.user_id and auth.permission != Permission.ADMIN:
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
calc = calculate_stock_consolidation(payload.quantity_before, payload.quantity_after, Decimal(str(getattr(portfolio, "price", 0))))
digest = hashlib.sha256(payload.model_dump_json().encode()).hexdigest()
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)
saved = repo.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash=digest)
return CorporateActionResponse(**saved.__dict__)
except ValueError as exc:
code = str(exc)
raise HTTPException(status_code=409 if code.startswith(("IDEMPOTENCY", "CORPORATE_ACTION_")) else 400, detail=code) from exc
finally:
_close_repo(repo)
+22
View File
@@ -170,6 +170,17 @@ def calculate_realized_pnl(
if running_qty < 0:
running_qty = 0.0
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
running_wac = float(row.get("average_price_after", running_wac * before / after))
warning: str | None = None
if warning_triggered:
warning = (
@@ -252,6 +263,17 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
if running_qty < 0:
running_qty = 0.0
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
running_wac = float(row.get("average_price_after", running_wac * before / after))
warning: str | None = None
if warning_triggered:
warning = (