diff --git a/app/api/controllers/portfolio_analysis.py b/app/api/controllers/portfolio_analysis.py new file mode 100644 index 0000000..c3f41be --- /dev/null +++ b/app/api/controllers/portfolio_analysis.py @@ -0,0 +1,22 @@ +"""Read-only portfolio-analysis endpoint.""" + +from fastapi import APIRouter, Depends + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.rate_limit import enforce_rate_limit +from app.api.schemas.portfolio_analysis import PortfolioAnalysisQuery +from app.core.contracts import RequestContext +from app.service.portfolio_analysis_service import PortfolioAnalysisService + +router = APIRouter( + prefix="/api/v1/advisor", tags=["advisor-portfolio-analysis"], + dependencies=[Depends(enforce_rate_limit)], +) + + +@router.post("/portfolio-analysis") +async def analyze_portfolio( + payload: PortfolioAnalysisQuery, + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await PortfolioAnalysisService().analyze_for_agent(payload, context) diff --git a/app/api/schemas/portfolio_analysis.py b/app/api/schemas/portfolio_analysis.py new file mode 100644 index 0000000..c526088 --- /dev/null +++ b/app/api/schemas/portfolio_analysis.py @@ -0,0 +1,3 @@ +from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery + +__all__ = ["PortfolioAnalysisQuery"] diff --git a/app/core/portfolio_analysis_contracts.py b/app/core/portfolio_analysis_contracts.py new file mode 100644 index 0000000..a132538 --- /dev/null +++ b/app/core/portfolio_analysis_contracts.py @@ -0,0 +1,7 @@ +"""Read-only contract for the governed portfolio-analysis tool.""" + +from pydantic import BaseModel, ConfigDict + + +class PortfolioAnalysisQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/app/main.py b/app/main.py index bfb7943..018eac8 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from app.api.controllers.health import router as health_router from app.api.controllers.investment_goals import router as investment_goals_router from app.api.controllers.knowledge import router as knowledge_router from app.api.controllers.onboarding import router as onboarding_router +from app.api.controllers.portfolio_analysis import router as portfolio_analysis_router from app.api.controllers.public_platform import router as public_platform_router from app.api.middleware import attach_trace_id from app.core.config import get_settings @@ -49,6 +50,7 @@ def create_app() -> FastAPI: application.include_router(health_router) application.include_router(onboarding_router) application.include_router(investment_goals_router) + application.include_router(portfolio_analysis_router) application.include_router(admin_router) return application diff --git a/app/model/advisor_product.py b/app/model/advisor_product.py index 4475358..49eb1b2 100644 --- a/app/model/advisor_product.py +++ b/app/model/advisor_product.py @@ -14,6 +14,21 @@ from sqlalchemy.orm import Mapped, mapped_column from app.model.base import Base +class AdvisorProductIndustryExposure(Base): + __tablename__ = "advisor_product_industry_exposure" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + industry_code: Mapped[str] = mapped_column(String(32), nullable=False) + industry_name: Mapped[str] = mapped_column(String(128), nullable=False) + exposure_weight_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + as_of_date: Mapped[date] = mapped_column(Date, nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + class AdvisorProductReferenceSnapshot(Base): __tablename__ = "advisor_product_reference_snapshot" diff --git a/app/repository/portfolio_analysis_repository.py b/app/repository/portfolio_analysis_repository.py new file mode 100644 index 0000000..92d97b2 --- /dev/null +++ b/app/repository/portfolio_analysis_repository.py @@ -0,0 +1,75 @@ +"""Read-only repository for holdings and advisory reference data.""" + +from datetime import date + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.model.advisor_product import AdvisorProductIndustryExposure, AdvisorProductMetricSnapshot +from app.model.fund import FundHolding, FundProduct + + +class PortfolioAnalysisRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def positions(self, customer_id: int) -> list[tuple[FundHolding, FundProduct]]: + statement = ( + select(FundHolding, FundProduct) + .join(FundProduct, FundProduct.id == FundHolding.product_id) + .where( + FundHolding.customer_id == customer_id, + FundHolding.total_quantity > 0, + FundHolding.status.in_(("持有中", "held")), + ) + .order_by(FundHolding.market_value.desc(), FundHolding.id.asc()) + ) + return [(row[0], row[1]) for row in (await self.session.execute(statement)).all()] + + async def latest_industry_exposures( + self, product_ids: tuple[int, ...], as_of_date: date + ) -> dict[int, list[AdvisorProductIndustryExposure]]: + if not product_ids: + return {} + rows = await self.session.scalars( + select(AdvisorProductIndustryExposure) + .where( + AdvisorProductIndustryExposure.product_id.in_(product_ids), + AdvisorProductIndustryExposure.as_of_date <= as_of_date, + AdvisorProductIndustryExposure.status == "active", + ) + .order_by( + AdvisorProductIndustryExposure.product_id, + AdvisorProductIndustryExposure.as_of_date.desc(), + AdvisorProductIndustryExposure.id, + ) + ) + selected: dict[int, list[AdvisorProductIndustryExposure]] = {} + latest_dates: dict[int, date] = {} + for row in rows: + latest = latest_dates.setdefault(row.product_id, row.as_of_date) + if row.as_of_date == latest: + selected.setdefault(row.product_id, []).append(row) + return selected + + async def latest_metrics( + self, product_ids: tuple[int, ...], as_of_date: date + ) -> dict[int, AdvisorProductMetricSnapshot]: + if not product_ids: + return {} + rows = await self.session.scalars( + select(AdvisorProductMetricSnapshot) + .where( + AdvisorProductMetricSnapshot.product_id.in_(product_ids), + AdvisorProductMetricSnapshot.as_of_date <= as_of_date, + ) + .order_by( + AdvisorProductMetricSnapshot.product_id, + AdvisorProductMetricSnapshot.as_of_date.desc(), + AdvisorProductMetricSnapshot.id, + ) + ) + selected: dict[int, AdvisorProductMetricSnapshot] = {} + for row in rows: + selected.setdefault(row.product_id, row) + return selected diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index ee96261..a6b2919 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -8,6 +8,7 @@ from app.core.config import get_settings from app.core.errors import RecoverableAgentError from app.core.fund_contracts import FundQuoteQuery from app.core.investment_goal_contracts import InvestmentGoalQuery +from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery from app.infrastructure.fund_quote_cache import FundQuoteCache from app.infrastructure.memory_cache import MemoryCacheAdapter from app.infrastructure.vector_memory import VectorMemoryAdapter @@ -26,6 +27,7 @@ from app.service.model_gateway import ( ModelEmbeddingService, ModelGenerationService, ) +from app.service.portfolio_analysis_service import portfolio_analysis_tool from app.service.runtime_config_service import load_active_intent_configs from app.service.suitability_service import SuitabilityToolInput, suitability_tool_handler from app.service.tool_executor import ToolDefinition, ToolExecutor, ToolRegistry @@ -153,6 +155,14 @@ def get_agent_factory() -> AgentFactory: required_permission="investment-goal:read:self", allowed_roles=("customer", "advisor", "operator", "admin"), )) + registry.register(ToolDefinition( + name="analyze_portfolio", + input_model=PortfolioAnalysisQuery, + handler=cast(Any, portfolio_analysis_tool), + required_permission="portfolio-analysis:read:self", + allowed_roles=("customer", "advisor", "operator", "admin"), + timeout_seconds=10, + )) model_service = get_model_service() endpoint_resolver = DatabaseModelEndpointResolver() factory = AgentFactory( diff --git a/app/service/agent/implementations/advisor.py b/app/service/agent/implementations/advisor.py index 75f5f29..114f2ad 100644 --- a/app/service/agent/implementations/advisor.py +++ b/app/service/agent/implementations/advisor.py @@ -14,8 +14,8 @@ class AdvisorAgent(FundQueryDemoAgent): version="0.1.0", allowed_roles=("customer", "advisor", "operator", "admin"), allowed_portals=("api",), - allowed_tools=("query_fund_quote", "query_investment_goal"), - supported_intents=("fund_quote", "investment_goal"), + allowed_tools=("query_fund_quote", "query_investment_goal", "analyze_portfolio"), + supported_intents=("fund_quote", "investment_goal", "portfolio_analysis"), ) async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: @@ -29,6 +29,14 @@ class AdvisorAgent(FundQueryDemoAgent): if not isinstance(output, dict): return CoreResult(text="当前没有已确认的投资目标,暂不能用于配置或产品推荐。") return CoreResult(text=self._describe_goal(output)) + if ( + self._classified_intent is not None + and self._classified_intent.intent == "portfolio_analysis" + ): + output = await self.call_tool( + "analyze_portfolio", {}, intent="portfolio_analysis", context=context + ) + return CoreResult(text=self._describe_portfolio(output)) return await super().handle(request, context) @staticmethod @@ -40,3 +48,23 @@ class AdvisorAgent(FundQueryDemoAgent): f"{goal['investment_horizon_months']} 个月,业绩比较基准 {goal['benchmark_name']}。" "以上为目标采集结果,不构成收益承诺或交易指令。" ) + + @staticmethod + def _describe_portfolio(result: object) -> str: + if not isinstance(result, dict): + return "持仓分析暂不可用,请稍后重试。" + status = result.get("status") + if status == "no_positions": + return "当前没有可分析的场内基金持仓。" + if status == "valuation_required": + return "当前持仓缺少可用市值,暂不能计算集中度。" + summary = result.get("summary") + if not isinstance(summary, dict): + return "持仓分析数据不完整,请稍后重试。" + concentration = result.get("product_concentration") + hhi = concentration.get("hhi") if isinstance(concentration, dict) else None + return ( + f"持仓分析完成:共 {summary.get('position_count')} 个产品," + f"总市值 {summary.get('total_market_value')},产品集中度 HHI 为 {hhi}。" + "分析结果仅供参考,不生成交易指令。" + ) diff --git a/app/service/portfolio_analysis_service.py b/app/service/portfolio_analysis_service.py new file mode 100644 index 0000000..5541aad --- /dev/null +++ b/app/service/portfolio_analysis_service.py @@ -0,0 +1,185 @@ +"""Authoritative, read-only portfolio concentration and risk analysis.""" + +from collections import defaultdict +from collections.abc import Callable +from datetime import date +from decimal import Decimal +from typing import Any + +from app.core.contracts import RequestContext +from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery +from app.infrastructure.db import SessionFactory +from app.model.advisor_product import AdvisorProductIndustryExposure, AdvisorProductMetricSnapshot +from app.model.fund import FundHolding, FundProduct +from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository +from app.service.authorization_service import AuthorizationService + +HUNDRED = Decimal("100") + + +class PortfolioAnalysisService: + def __init__(self, *, session_factory: Callable[[], Any] = SessionFactory) -> None: + self.session_factory = session_factory + + async def analyze_for_agent( + self, _arguments: PortfolioAnalysisQuery, context: RequestContext + ) -> dict[str, object]: + await AuthorizationService.require(context, "portfolio-analysis:read:self") + async with self.session_factory() as session: + repository = PortfolioAnalysisRepository(session) + positions = await repository.positions(int(context.user_id)) + ids = tuple(holding.product_id for holding, _product in positions) + exposures = await repository.latest_industry_exposures(ids, date.today()) + metrics = await repository.latest_metrics(ids, date.today()) + result = self._analyze(positions, exposures, metrics) + result["graph_context"] = { + "degraded": True, + "reason": "neo4j_not_configured", + } + return result + + @classmethod + def _analyze( + cls, + positions: list[tuple[FundHolding, FundProduct]], + exposures: dict[int, list[AdvisorProductIndustryExposure]], + metrics: dict[int, AdvisorProductMetricSnapshot], + ) -> dict[str, object]: + if not positions: + return {"status": "no_positions", "position_count": 0} + valued = [ + (holding, product, holding.market_value) + for holding, product in positions + if holding.market_value is not None and holding.market_value > 0 + ] + total = sum((value for _holding, _product, value in valued), Decimal()) + if total <= 0: + return { + "status": "valuation_required", + "position_count": len(positions), + "warnings": [{ + "code": "MARKET_VALUE_UNAVAILABLE", + "severity": "high", + "message": "当前持仓缺少可用市值,暂不能计算集中度。", + }], + } + product_rows = cls._product_rows(valued, total) + industry_rows, coverage, invalid = cls._industry_rows(valued, exposures, total) + coverage_pct = (coverage / total * HUNDRED).quantize(Decimal("0.01")) + warnings: list[dict[str, str]] = [] + if Decimal(str(product_rows[0]["share_pct"])) > Decimal("30"): + warnings.append({ + "code": "SINGLE_PRODUCT_CONCENTRATION", + "severity": "high", + "message": "单一产品持仓占比较高,存在集中度风险。", + }) + industry_hhi = None + if coverage_pct >= Decimal("80") and industry_rows: + industry_hhi = cls._hhi([row["share_pct"] for row in industry_rows]) + if Decimal(str(industry_rows[0]["share_pct"])) > Decimal("40"): + warnings.append({ + "code": "SINGLE_INDUSTRY_CONCENTRATION", + "severity": "high", + "message": "单一行业穿透占比较高,存在行业集中度风险。", + }) + else: + warnings.append({ + "code": "INDUSTRY_COVERAGE_INCOMPLETE", + "severity": "medium", + "message": "行业穿透参考数据覆盖不足,暂不输出确定性行业结论。", + }) + if invalid: + warnings.append({ + "code": "INDUSTRY_EXPOSURE_INVALID", + "severity": "medium", + "message": "部分产品行业暴露数据异常,未纳入行业穿透计算。", + }) + missing_metrics = len(valued) - len(metrics) + if missing_metrics: + warnings.append({ + "code": "HISTORICAL_METRICS_INCOMPLETE", + "severity": "medium", + "message": "部分持仓缺少历史行情指标,风险分析完整性受限。", + }) + if len(valued) < len(positions): + warnings.append({ + "code": "MARKET_VALUE_PARTIAL", + "severity": "medium", + "message": "部分持仓缺少可用市值,本次集中度基于已估值持仓计算。", + }) + return { + "status": "ready", + "summary": { + "position_count": len(positions), + "valued_position_count": len(valued), + "total_market_value": str(total.quantize(Decimal("0.01"))), + "industry_coverage_pct": str(coverage_pct), + "metrics_coverage_pct": str( + (Decimal(len(metrics)) / Decimal(len(valued)) * HUNDRED).quantize( + Decimal("0.01") + ) + ), + }, + "product_concentration": { + "hhi": cls._hhi([row["share_pct"] for row in product_rows]), + "rows": product_rows, + }, + "industry_concentration": { + "hhi": industry_hhi, + "rows": industry_rows, + "conclusion_available": industry_hhi is not None, + }, + "warnings": warnings, + "disclaimer": "分析结果仅供参考,不生成交易指令。", + } + + @staticmethod + def _product_rows( + valued: list[tuple[FundHolding, FundProduct, Decimal]], total: Decimal + ) -> list[dict[str, object]]: + rows = [{ + "product_id": str(holding.product_id), + "product_code": product.product_code, + "product_name": product.product_name, + "market_value": str(value.quantize(Decimal("0.01"))), + "share_pct": (value / total * HUNDRED).quantize(Decimal("0.01")), + } for holding, product, value in valued] + return sorted(rows, key=lambda row: -Decimal(str(row["share_pct"]))) + + @staticmethod + def _industry_rows( + valued: list[tuple[FundHolding, FundProduct, Decimal]], + exposures: dict[int, list[AdvisorProductIndustryExposure]], + total: Decimal, + ) -> tuple[list[dict[str, object]], Decimal, set[int]]: + amounts: dict[str, Decimal] = defaultdict(Decimal) + covered = Decimal() + invalid: set[int] = set() + for holding, _product, value in valued: + rows = exposures.get(holding.product_id, []) + weight = sum((row.exposure_weight_pct for row in rows), Decimal()) + if weight <= 0: + continue + if weight > HUNDRED: + invalid.add(holding.product_id) + continue + covered += value * weight / HUNDRED + for row in rows: + amounts[row.industry_name] += value * row.exposure_weight_pct / HUNDRED + result = [{ + "industry_name": name, + "market_value": str(value.quantize(Decimal("0.01"))), + "share_pct": (value / total * HUNDRED).quantize(Decimal("0.01")), + } for name, value in amounts.items()] + return sorted(result, key=lambda row: -Decimal(str(row["share_pct"]))), covered, invalid + + @staticmethod + def _hhi(shares: list[object]) -> str: + value = sum((Decimal(str(share)) ** 2 for share in shares), Decimal()) + return str(value.quantize(Decimal("0.01"))) + + +async def portfolio_analysis_tool( + arguments: PortfolioAnalysisQuery, context: RequestContext +) -> dict[str, object]: + return await PortfolioAnalysisService().analyze_for_agent(arguments, context) diff --git a/docs/21-投顾Agent迁移TODO.md b/docs/21-投顾Agent迁移TODO.md index 67653b5..6d1f169 100644 --- a/docs/21-投顾Agent迁移TODO.md +++ b/docs/21-投顾Agent迁移TODO.md @@ -76,6 +76,17 @@ Agent 暴露客户最新的 `confirmed` 目标,未确认目标不会进入后 阶段七测试结果:专项测试 `5 passed`,全量单元测试 `468 passed, 3 warnings`,Ruff 通过,MyPy(122 个源文件)通过。阶段七提交:待提交。 +### 阶段八:持仓分析(核心完成,图谱增强待补) + +已基于现行 `fin_holding`、`fin_product` 和投顾行业/历史指标表完成只读持仓分析: +支持持仓查询、市值汇总、单产品集中度、行业穿透、产品/行业 HHI、行业与历史指标 +覆盖率,并在市值或行业数据不足时返回明确的降级状态和警告。MySQL 是数值分析的 +唯一事实来源,输出只生成分析结论,不生成交易指令;图谱上下文当前明确标记为 +`neo4j_not_configured`,真实 Neo4j 关系增强留在后续阶段。 + +阶段八测试结果:专项测试 `4 passed`,全量单元测试 `471 passed, 3 warnings`,Ruff +通过,MyPy(127 个源文件)通过。阶段八核心提交:待提交。 + ## 一、迁移准备 - [ ] 确认远程仓库可访问。(当前失败:连接 `47.106.207.27:3000` 被拒绝) @@ -252,28 +263,28 @@ python tools/audit_constraints.py ## 八、持仓分析 -- [ ] 迁移持仓查询。 -- [ ] 迁移持仓市值计算。 -- [ ] 迁移单产品集中度计算。 -- [ ] 迁移行业集中度计算。 -- [ ] 迁移 HHI 指标计算。 -- [ ] 迁移行业穿透计算。 -- [ ] 迁移行情和行业数据覆盖率计算。 +- [x] 迁移持仓查询。 +- [x] 迁移持仓市值计算。 +- [x] 迁移单产品集中度计算。 +- [x] 迁移行业集中度计算。 +- [x] 迁移 HHI 指标计算。 +- [x] 迁移行业穿透计算。 +- [x] 迁移行情和行业数据覆盖率计算。 - [ ] 迁移 Neo4j 图谱增强。 -- [ ] 确认 MySQL 是数值分析权威来源。 -- [ ] 确认 Neo4j 只做关系增强。 -- [ ] 实现图谱不可用时的降级。 -- [ ] 实现关键数据不足时的降级。 -- [ ] 确认分析结果不生成交易指令。 -- [ ] 完成持仓分析提交 `advisor/portfolio-analysis`。 +- [x] 确认 MySQL 是数值分析权威来源。 +- [x] 确认 Neo4j 只做关系增强。(真实驱动接入待补) +- [x] 实现图谱不可用时的降级。(当前返回 `neo4j_not_configured`) +- [x] 实现关键数据不足时的降级。 +- [x] 确认分析结果不生成交易指令。 +- [x] 完成持仓分析提交 `advisor/portfolio-analysis`。(专项 `4 passed`;全量单元 `471 passed`;图谱增强待补) 验收: -- [ ] 无持仓时返回明确状态。 -- [ ] 缺少市值时不计算集中度。 -- [ ] 行业覆盖不足时不输出确定性行业结论。 -- [ ] 图谱不可用时仍可返回可靠的数值分析。 -- [ ] 客户看不到内部数据库查询细节。 +- [x] 无持仓时返回明确状态。 +- [x] 缺少市值时不计算集中度。 +- [x] 行业覆盖不足时不输出确定性行业结论。 +- [x] 图谱不可用时仍可返回可靠的数值分析。(当前图谱未配置,数值分析仍可返回) +- [x] 客户看不到内部数据库查询细节。 ## 九、动态资产配置 diff --git a/tests/unit/service/test_advisor_base_adapter.py b/tests/unit/service/test_advisor_base_adapter.py index 617ea2c..a9d3388 100644 --- a/tests/unit/service/test_advisor_base_adapter.py +++ b/tests/unit/service/test_advisor_base_adapter.py @@ -18,5 +18,9 @@ def test_advisor_is_registered_through_the_new_base_factory() -> None: assert isinstance(agent, BaseAgent) assert isinstance(agent, AdvisorAgent) assert agent.definition == definition - assert definition.allowed_tools == ("query_fund_quote", "query_investment_goal") - assert definition.supported_intents == ("fund_quote", "investment_goal") + assert definition.allowed_tools == ( + "query_fund_quote", "query_investment_goal", "analyze_portfolio" + ) + assert definition.supported_intents == ( + "fund_quote", "investment_goal", "portfolio_analysis" + ) diff --git a/tests/unit/service/test_portfolio_analysis_service.py b/tests/unit/service/test_portfolio_analysis_service.py new file mode 100644 index 0000000..ab3008d --- /dev/null +++ b/tests/unit/service/test_portfolio_analysis_service.py @@ -0,0 +1,63 @@ +from datetime import date +from decimal import Decimal +from types import SimpleNamespace + +from app.service.portfolio_analysis_service import PortfolioAnalysisService + + +def holding(product_id: int, value: str | None) -> SimpleNamespace: + return SimpleNamespace(product_id=product_id, market_value=( + Decimal(value) if value is not None else None + )) + + +def product(product_id: int) -> SimpleNamespace: + return SimpleNamespace( + id=product_id, product_code=f"P{product_id}", product_name=f"产品{product_id}" + ) + + +def exposure(product_id: int, industry: str, weight: str) -> SimpleNamespace: + return SimpleNamespace( + product_id=product_id, industry_name=industry, + exposure_weight_pct=Decimal(weight), as_of_date=date(2026, 9, 10), id=product_id, + ) + + +def test_no_positions_and_missing_valuation_are_explicit() -> None: + empty = PortfolioAnalysisService._analyze([], {}, {}) + assert empty["status"] == "no_positions" + + missing = PortfolioAnalysisService._analyze( + [(holding(1, None), product(1))], {}, {} + ) + assert missing["status"] == "valuation_required" + + +def test_concentration_and_industry_hhi_use_mysql_facts() -> None: + positions = [(holding(1, "70"), product(1)), (holding(2, "30"), product(2))] + exposures = { + 1: [exposure(1, "科技", "100")], + 2: [exposure(2, "消费", "100")], + } + result = PortfolioAnalysisService._analyze(positions, exposures, {}) + assert result["status"] == "ready" + concentration = result["product_concentration"] + assert concentration["hhi"] == "5800.00" + industries = result["industry_concentration"] + assert industries["hhi"] == "5800.00" + assert industries["conclusion_available"] is True + assert result["disclaimer"] == "分析结果仅供参考,不生成交易指令。" + + +def test_incomplete_industry_data_does_not_make_a_deterministic_claim() -> None: + result = PortfolioAnalysisService._analyze( + [(holding(1, "100"), product(1))], + {1: [exposure(1, "科技", "120")]}, + {}, + ) + industries = result["industry_concentration"] + assert industries["hhi"] is None + assert industries["conclusion_available"] is False + codes = {warning["code"] for warning in result["warnings"]} + assert {"INDUSTRY_COVERAGE_INCOMPLETE", "INDUSTRY_EXPOSURE_INVALID"} <= codes diff --git a/tools/audit_constraints.py b/tools/audit_constraints.py index 121eaaf..1ff16fd 100644 --- a/tools/audit_constraints.py +++ b/tools/audit_constraints.py @@ -26,6 +26,7 @@ sys.path.insert(0, str(ROOT)) from app.core.config import get_settings # noqa: E402 from app.model import ( # noqa: E402,F401 + advisor_product, audit, configuration, conversation,