diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 0262dc2..7c1fcb3 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -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) diff --git a/app/domain/pnl.py b/app/domain/pnl.py index 2a0663b..85d65a4 100644 --- a/app/domain/pnl.py +++ b/app/domain/pnl.py @@ -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 = (