feat(portfolio): expose corporate action and replay consolidation
This commit is contained in:
@@ -18,12 +18,12 @@ import uuid
|
|||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
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.auth import AuthContext, Permission, require_read, require_trade_write
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
|
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.domain.pnl import calculate_current_wac, calculate_realized_pnl
|
||||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||||
from app.schemas.v1 import (
|
from app.schemas.v1 import (
|
||||||
@@ -55,6 +55,7 @@ from app.schemas.v1 import (
|
|||||||
TradeHistoryItem,
|
TradeHistoryItem,
|
||||||
TradeHistoryResponse,
|
TradeHistoryResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.corporate_actions import CorporateActionRequest, CorporateActionResponse
|
||||||
from app.services.strategy_set_service import (
|
from app.services.strategy_set_service import (
|
||||||
StrategyInactiveError,
|
StrategyInactiveError,
|
||||||
StrategyNotFoundError,
|
StrategyNotFoundError,
|
||||||
@@ -1295,3 +1296,27 @@ def get_holdings_cost_basis(
|
|||||||
cost_basis_from=wac_result.cost_basis_from,
|
cost_basis_from=wac_result.cost_basis_from,
|
||||||
data_completeness_warning=wac_result.data_completeness_warning,
|
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)
|
||||||
|
|||||||
@@ -170,6 +170,17 @@ def calculate_realized_pnl(
|
|||||||
if running_qty < 0:
|
if running_qty < 0:
|
||||||
running_qty = 0.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
|
warning: str | None = None
|
||||||
if warning_triggered:
|
if warning_triggered:
|
||||||
warning = (
|
warning = (
|
||||||
@@ -252,6 +263,17 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
|||||||
if running_qty < 0:
|
if running_qty < 0:
|
||||||
running_qty = 0.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
|
warning: str | None = None
|
||||||
if warning_triggered:
|
if warning_triggered:
|
||||||
warning = (
|
warning = (
|
||||||
|
|||||||
Reference in New Issue
Block a user