fix corporate action runtime blockers
This commit is contained in:
@@ -24,6 +24,7 @@ from app.core.auth import AuthContext, Permission, require_read, require_trade_w
|
|||||||
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 CorporateAction, Portfolio, PortfolioComment
|
from app.domain.models import CorporateAction, Portfolio, PortfolioComment
|
||||||
|
from app.domain.services import PORTFOLIO_NOT_FOUND_MESSAGE
|
||||||
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 (
|
||||||
@@ -1332,7 +1333,7 @@ def create_corporate_action(portfolio_id: str, payload: CorporateActionRequest,
|
|||||||
return CorporateActionResponse(**saved.__dict__)
|
return CorporateActionResponse(**saved.__dict__)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
code = str(exc)
|
code = str(exc)
|
||||||
if code == "portfolio not found":
|
if code == PORTFOLIO_NOT_FOUND_MESSAGE:
|
||||||
raise HTTPException(status_code=404, detail="Portfolio not found") from exc
|
raise HTTPException(status_code=404, detail="Portfolio not found") from exc
|
||||||
raise HTTPException(status_code=409 if code.startswith(("IDEMPOTENCY", "CORPORATE_ACTION_")) else 400, detail=code) from exc
|
raise HTTPException(status_code=409 if code.startswith(("IDEMPOTENCY", "CORPORATE_ACTION_")) else 400, detail=code) from exc
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Callable, Protocol
|
|||||||
|
|
||||||
from app.domain.models import LedgerEntry, LiveAssetSource, Portfolio, StrategySet
|
from app.domain.models import LedgerEntry, LiveAssetSource, Portfolio, StrategySet
|
||||||
|
|
||||||
_NOT_FOUND_MSG = "포트폴리오를 찾을 수 없습니다"
|
PORTFOLIO_NOT_FOUND_MESSAGE = "포트폴리오를 찾을 수 없습니다"
|
||||||
_INVEST_ACTION_MAP = {
|
_INVEST_ACTION_MAP = {
|
||||||
"deposit": "INVEST",
|
"deposit": "INVEST",
|
||||||
"profit": "PROFIT",
|
"profit": "PROFIT",
|
||||||
@@ -250,7 +250,7 @@ def apply_reset(
|
|||||||
portfolio = repo.reset(portfolio_id)
|
portfolio = repo.reset(portfolio_id)
|
||||||
return portfolio, False
|
return portfolio, False
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
if str(exc) != _NOT_FOUND_MSG:
|
if str(exc) != PORTFOLIO_NOT_FOUND_MESSAGE:
|
||||||
raise
|
raise
|
||||||
symbol = infer_symbol(portfolio_id)
|
symbol = infer_symbol(portfolio_id)
|
||||||
portfolio = repo.get_or_create(portfolio_id, symbol, initial_cash=0.0)
|
portfolio = repo.get_or_create(portfolio_id, symbol, initial_cash=0.0)
|
||||||
|
|||||||
@@ -1654,7 +1654,7 @@ class PostgresPortfolioRepository:
|
|||||||
return action
|
return action
|
||||||
if row[4] != "enabled":
|
if row[4] != "enabled":
|
||||||
raise ValueError("CORPORATE_ACTION_PORTFOLIO_DISABLED")
|
raise ValueError("CORPORATE_ACTION_PORTFOLIO_DISABLED")
|
||||||
if row[3] != action.asset_id:
|
if str(row[3]) != str(action.asset_id):
|
||||||
raise ValueError("CORPORATE_ACTION_ASSET_MISMATCH")
|
raise ValueError("CORPORATE_ACTION_ASSET_MISMATCH")
|
||||||
if Decimal(str(row[1])) != action.cash_balance or Decimal(str(row[2])) != action.profit_buffer:
|
if Decimal(str(row[1])) != action.cash_balance or Decimal(str(row[2])) != action.profit_buffer:
|
||||||
raise ValueError("CORPORATE_ACTION_INVARIANT_MISMATCH")
|
raise ValueError("CORPORATE_ACTION_INVARIANT_MISMATCH")
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api import v1_portfolios
|
||||||
|
from app.domain.models import CorporateAction, Portfolio
|
||||||
|
from app.main import create_app
|
||||||
|
from app.repositories.postgres import PostgresPortfolioRepository
|
||||||
|
|
||||||
|
|
||||||
|
class MissingPortfolioRepo:
|
||||||
|
def get(self, portfolio_id):
|
||||||
|
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_corporate_action_maps_postgres_missing_portfolio_to_404(monkeypatch):
|
||||||
|
app = create_app()
|
||||||
|
repo = MissingPortfolioRepo()
|
||||||
|
monkeypatch.setattr(v1_portfolios, "_get_repo", lambda: repo)
|
||||||
|
app.dependency_overrides[v1_portfolios.require_trade_write] = lambda: type("Auth", (), {"user_id": "u1", "permission": "trade_write"})()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/v1/portfolios/missing/corporate-actions",
|
||||||
|
headers={"Idempotency-Key": "idem-1"},
|
||||||
|
json={"effective_date": "2026-01-01", "quantity_before": "10", "quantity_after": "5", "evidence_ref": "ev"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["detail"] == "Portfolio not found"
|
||||||
|
|
||||||
|
|
||||||
|
def _action(asset_id="asset-1"):
|
||||||
|
return CorporateAction("ca-1", "pf-1", asset_id, "stock_consolidation", date(2026, 1, 1), Decimal("0"), Decimal("1"), Decimal("0"), Decimal("0"), Decimal("0"), Decimal("10"), Decimal("0"), "ev", "idem", "hash", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_for_row(asset_id):
|
||||||
|
repo = object.__new__(PostgresPortfolioRepository)
|
||||||
|
repo.ensure_schema = MagicMock()
|
||||||
|
conn = MagicMock()
|
||||||
|
cur = MagicMock()
|
||||||
|
conn.__enter__.return_value = conn
|
||||||
|
conn.cursor.return_value.__enter__.return_value = cur
|
||||||
|
repo._connect = MagicMock(return_value=conn)
|
||||||
|
cur.fetchone.side_effect = [(Decimal("0"), Decimal("10"), Decimal("0"), asset_id, "enabled"), None, None, None, (7, date(2026, 1, 1))]
|
||||||
|
cur.fetchall.return_value = []
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_corporate_action_accepts_uuid_row_against_string_domain_id():
|
||||||
|
asset = UUID("12345678-1234-5678-1234-567812345678")
|
||||||
|
action = _action(str(asset))
|
||||||
|
saved = _repo_for_row(asset).record_corporate_action("pf-1", action=action, idempotency_key="idem", payload_hash="hash")
|
||||||
|
assert saved.ledger_entry_id == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_corporate_action_rejects_different_asset_id():
|
||||||
|
repo = _repo_for_row(UUID("12345678-1234-5678-1234-567812345678"))
|
||||||
|
with pytest.raises(ValueError, match="CORPORATE_ACTION_ASSET_MISMATCH"):
|
||||||
|
repo.record_corporate_action("pf-1", action=_action("87654321-4321-8765-4321-876543218765"), idempotency_key="idem", payload_hash="hash")
|
||||||
Reference in New Issue
Block a user