154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from app.core.contracts import RequestContext
|
|
from app.model.product import ProductMarketQuoteSnapshot
|
|
from app.service.asset_allocation_service import AssetAllocationService
|
|
from app.service.dynamic_allocation_optimizer import AssetClassMarketMetric
|
|
|
|
|
|
def test_short_horizon_and_daily_liquidity_increase_cash_allocation() -> None:
|
|
weights = AssetAllocationService._weights("C4", 12, "daily", Decimal("30"))
|
|
assert weights == {"cash_management_etf": 35, "bond_etf": 20, "equity_etf": 45}
|
|
assert sum(weights.values()) == 100
|
|
|
|
|
|
def test_drawdown_cap_limits_equity_and_preserves_total_weight() -> None:
|
|
weights = AssetAllocationService._weights("C5", 60, "within_30_days", Decimal("10"))
|
|
assert weights["equity_etf"] == 20
|
|
assert sum(weights.values()) == 100
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allocation_uses_internal_profile_without_returning_risk_level(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def profile(_self: object, _context: RequestContext) -> dict[str, object]:
|
|
return {"risk_level": "C3"}
|
|
|
|
async def goal(_self: object, _context: RequestContext) -> dict[str, object]:
|
|
return {
|
|
"status": "confirmed",
|
|
"annualized_return_lower_pct": "6",
|
|
"investment_horizon_months": 36,
|
|
"liquidity_requirement": "within_30_days",
|
|
"max_drawdown_pct": "15",
|
|
"benchmark_name": "test benchmark",
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"app.service.asset_allocation_service.CustomerProfileService.current_for_agent", profile
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.service.asset_allocation_service.InvestmentGoalService.current_for_agent", goal
|
|
)
|
|
result = await AssetAllocationService().generate_for_agent(RequestContext(
|
|
user_id="7",
|
|
trace_id="allocation-test",
|
|
roles=("customer",),
|
|
permissions=(
|
|
"asset-allocation:generate:self",
|
|
"customer-profile:read:self",
|
|
"investment-goal:read:self",
|
|
),
|
|
))
|
|
assert result["status"] == "ready"
|
|
assert "risk_level" not in result
|
|
assert sum(item["target_pct"] for item in result["allocation"]) == 100
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allocation_applies_dynamic_metrics_to_the_strategic_baseline(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def profile(_self: object, _context: RequestContext) -> dict[str, object]:
|
|
return {"risk_level": "C3"}
|
|
|
|
async def goal(_self: object, _context: RequestContext) -> dict[str, object]:
|
|
return {
|
|
"status": "confirmed",
|
|
"annualized_return_lower_pct": "6",
|
|
"investment_horizon_months": 36,
|
|
"liquidity_requirement": "within_30_days",
|
|
"max_drawdown_pct": "15",
|
|
"benchmark_name": "test benchmark",
|
|
}
|
|
|
|
async def metrics(_risk_level: str) -> list[AssetClassMarketMetric]:
|
|
return [
|
|
AssetClassMarketMetric("cash_management_etf", Decimal("1"), Decimal("0"),
|
|
Decimal("50000000"), 1),
|
|
AssetClassMarketMetric("bond_etf", Decimal("5"), Decimal("-3"),
|
|
Decimal("8000000"), 2),
|
|
AssetClassMarketMetric("equity_etf", Decimal("15"), Decimal("-20"),
|
|
Decimal("10000000"), 3),
|
|
]
|
|
|
|
monkeypatch.setattr(
|
|
"app.service.asset_allocation_service.CustomerProfileService.current_for_agent", profile
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.service.asset_allocation_service.InvestmentGoalService.current_for_agent", goal
|
|
)
|
|
result = await AssetAllocationService(market_metric_loader=metrics).generate_for_agent(
|
|
RequestContext(
|
|
user_id="7",
|
|
trace_id="dynamic-allocation-test",
|
|
roles=("customer",),
|
|
permissions=(
|
|
"asset-allocation:generate:self",
|
|
"customer-profile:read:self",
|
|
"investment-goal:read:self",
|
|
),
|
|
)
|
|
)
|
|
|
|
assert result["optimization"]["dynamic"] is True
|
|
assert result["optimization"]["metric_coverage_pct"] == "100.00"
|
|
assert result["optimization"]["factor_evidence"]
|
|
assert result["optimization"]["strategic_allocation"] != {
|
|
item["asset_class"]: item["target_pct"] for item in result["allocation"]
|
|
}
|
|
|
|
|
|
def test_liquidity_prefers_history_then_uses_quote_evidence() -> None:
|
|
quote = ProductMarketQuoteSnapshot(
|
|
id=1,
|
|
product_id=1,
|
|
observed_at=None,
|
|
source="test",
|
|
last_price=Decimal("1.25"),
|
|
volume=Decimal("400"),
|
|
turnover_amount=Decimal("60000"),
|
|
quote_status="active",
|
|
created_at=None,
|
|
)
|
|
assert AssetAllocationService._liquidity(Decimal("70000"), quote) == (
|
|
Decimal("70000"),
|
|
"historical_turnover",
|
|
)
|
|
assert AssetAllocationService._liquidity(None, quote) == (
|
|
Decimal("60000"),
|
|
"latest_quote_turnover",
|
|
)
|
|
|
|
|
|
def test_liquidity_estimates_from_quote_volume_or_reports_unavailable() -> None:
|
|
quote = ProductMarketQuoteSnapshot(
|
|
id=1,
|
|
product_id=1,
|
|
observed_at=None,
|
|
source="test",
|
|
last_price=Decimal("1.25"),
|
|
volume=Decimal("400"),
|
|
turnover_amount=None,
|
|
quote_status="active",
|
|
created_at=None,
|
|
)
|
|
assert AssetAllocationService._liquidity(None, quote) == (
|
|
Decimal("50000"),
|
|
"estimated_from_quote_volume",
|
|
)
|
|
assert AssetAllocationService._liquidity(None, None) == (None, "unavailable")
|