feat: add dynamic advisor asset allocation

This commit is contained in:
Windows
2026-09-11 14:44:29 +08:00
parent 8ffd08b4ce
commit ff71a1a724
15 changed files with 652 additions and 22 deletions
@@ -0,0 +1,26 @@
from app.service.agent.implementations.advisor import AdvisorAgent
def test_asset_allocation_description_is_analysis_only() -> None:
text = AdvisorAgent._describe_allocation(
{
"status": "ready",
"allocation": [
{"asset_class": "bond_etf", "label": "债券类场内基金", "target_pct": 60},
{"asset_class": "equity_etf", "label": "权益类场内基金", "target_pct": 40},
],
"optimization": {"dynamic": True},
}
)
assert "债券类场内基金 60%" in text
assert "动态历史因子优化" in text
assert "不构成交易指令" in text
assert "下单" not in text
def test_asset_allocation_description_exposes_missing_prerequisites() -> None:
assert "风险画像" in AdvisorAgent._describe_allocation({"status": "profile_required"})
assert "已确认的投资目标" in AdvisorAgent._describe_allocation(
{"status": "investment_goal_required"}
)
@@ -19,8 +19,9 @@ def test_advisor_is_registered_through_the_new_base_factory() -> None:
assert isinstance(agent, AdvisorAgent)
assert agent.definition == definition
assert definition.allowed_tools == (
"query_fund_quote", "query_investment_goal", "analyze_portfolio"
"query_fund_quote", "query_investment_goal", "analyze_portfolio",
"generate_asset_allocation",
)
assert definition.supported_intents == (
"fund_quote", "investment_goal", "portfolio_analysis"
"fund_quote", "investment_goal", "portfolio_analysis", "asset_allocation"
)
@@ -0,0 +1,65 @@
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