66 lines
2.5 KiB
Python
66 lines
2.5 KiB
Python
from decimal import Decimal
|
|||
|
|
|
||
|
|
from app.service.asset_allocation_service import AssetAllocationService
|
||
|
|
from app.service.dynamic_allocation_optimizer import (
|
||
|
|
AssetClassMarketMetric,
|
||
|
|
DynamicAllocationOptimizer,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def metric(
|
||
|
|
asset_class: str, return_pct: str, drawdown: str, turnover: str
|
||
|
|
) -> AssetClassMarketMetric:
|
||
|
|
return AssetClassMarketMetric(
|
||
|
|
asset_class=asset_class,
|
||
|
|
trailing_120d_return_pct=Decimal(return_pct),
|
||
|
|
max_drawdown_pct=Decimal(drawdown),
|
||
|
|
average_daily_turnover_amount=Decimal(turnover),
|
||
|
|
product_count=2,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_strategic_weights_apply_horizon_liquidity_and_drawdown_constraints() -> None:
|
||
|
|
weights = AssetAllocationService._strategic_weights("C1", 6, "daily", Decimal("10"))
|
||
|
|
assert weights == {"cash_management_etf": 65, "bond_etf": 35, "equity_etf": 0}
|
||
|
|
|
||
|
|
weights = AssetAllocationService._strategic_weights("C5", 72, "over_30_days", Decimal("30"))
|
||
|
|
assert weights == {"cash_management_etf": 5, "bond_etf": 10, "equity_etf": 85}
|
||
|
|
|
||
|
|
|
||
|
|
def test_optimizer_uses_return_drawdown_and_liquidity_evidence() -> None:
|
||
|
|
metrics = [
|
||
|
|
metric("cash_management_etf", "2", "1", "20000000"),
|
||
|
|
metric("bond_etf", "6", "8", "5000000"),
|
||
|
|
metric("equity_etf", "12", "25", "1000000"),
|
||
|
|
]
|
||
|
|
result = DynamicAllocationOptimizer.optimize(
|
||
|
|
{"cash_management_etf": 15, "bond_etf": 45, "equity_etf": 40},
|
||
|
|
metrics,
|
||
|
|
return_target_lower_pct=Decimal("6"),
|
||
|
|
max_drawdown_pct=Decimal("15"),
|
||
|
|
liquidity_requirement="within_7_days",
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result.dynamic is True
|
||
|
|
assert sum(result.weights.values()) == 100
|
||
|
|
assert result.weights["equity_etf"] <= 40
|
||
|
|
assert result.metric_coverage_pct == Decimal("100")
|
||
|
|
evidence = {item["asset_class"]: item for item in result.factors}
|
||
|
|
assert evidence["equity_etf"]["composite_score"] is not None
|
||
|
|
|
||
|
|
|
||
|
|
def test_optimizer_falls_back_to_static_weights_when_coverage_is_insufficient() -> None:
|
||
|
|
strategic = {"cash_management_etf": 30, "bond_etf": 50, "equity_etf": 20}
|
||
|
|
result = DynamicAllocationOptimizer.optimize(
|
||
|
|
strategic,
|
||
|
|
[metric("bond_etf", "6", "8", "5000000")],
|
||
|
|
return_target_lower_pct=Decimal("6"),
|
||
|
|
max_drawdown_pct=Decimal("15"),
|
||
|
|
liquidity_requirement="within_7_days",
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result.dynamic is False
|
||
|
|
assert result.weights == strategic
|
||
|
|
assert result.metric_coverage_pct == Decimal("33.33333333333333333333333333")
|
||
|
|
assert result.factors[0]["composite_score"] is None
|