feat: complete advisor product suitability gates

This commit is contained in:
Windows
2026-09-11 14:09:23 +08:00
parent 281e76e4b1
commit 47a5ca498c
3 changed files with 137 additions and 11 deletions
+80 -1
View File
@@ -13,6 +13,7 @@ from app.model.advisor_product import (
AdvisorProductContractSnapshot,
AdvisorProductGovernanceCandidate,
AdvisorProductMarketQuoteSnapshot,
AdvisorProductMetricSnapshot,
AdvisorProductReferenceSnapshot,
AdvisorProductSuitabilityReference,
)
@@ -45,6 +46,13 @@ class ProductContractEvidence:
document_published_at: date | None
@dataclass(frozen=True, slots=True)
class ProductLiquidityEvidence:
average_daily_turnover_amount: Decimal | None
latest_quote_observed_at: datetime | None
status: str
@dataclass(frozen=True, slots=True)
class AuthoritativeProductCandidate:
product: FundProduct
@@ -52,6 +60,7 @@ class AuthoritativeProductCandidate:
contract: ProductContractEvidence
asset_scale_billion: Decimal | None = None
market_quote: AdvisorProductMarketQuoteSnapshot | None = None
liquidity: ProductLiquidityEvidence | None = None
class _ProductRow(Protocol):
@@ -96,6 +105,7 @@ class AdvisorProductRepository:
fund_manager: str | None = None,
quote_max_age_seconds: int = 300,
min_asset_scale_billion: Decimal | None = None,
liquidity_requirement: str | None = None,
limit: int = 50,
) -> list[AuthoritativeProductCandidate]:
"""Apply evidence gates before any product reaches an Agent.
@@ -151,6 +161,15 @@ class AdvisorProductRepository:
AdvisorProductReferenceSnapshot.product_id,
AdvisorProductReferenceSnapshot.as_of_date.desc(),
)))
metric_rows = list(await self.session.scalars(select(
AdvisorProductMetricSnapshot
).where(
AdvisorProductMetricSnapshot.product_id.in_(ids),
AdvisorProductMetricSnapshot.as_of_date <= as_of,
).order_by(
AdvisorProductMetricSnapshot.product_id,
AdvisorProductMetricSnapshot.as_of_date.desc(),
)))
pending_ids = set(await self.session.scalars(select(
AdvisorProductGovernanceCandidate.product_id
).where(
@@ -169,6 +188,7 @@ class AdvisorProductRepository:
suitability_by_product = self._latest_by_product(suitability_rows)
contract_by_product = self._latest_by_product(contract_rows)
reference_by_product = self._latest_by_product(reference_rows)
metric_by_product = self._latest_by_product(metric_rows)
quote_cutoff = now - timedelta(seconds=quote_max_age_seconds)
quote_by_product: dict[int, AdvisorProductMarketQuoteSnapshot] = {}
for quote in quote_rows:
@@ -189,6 +209,11 @@ class AdvisorProductRepository:
and (scale is None or scale < min_asset_scale_billion)
):
continue
metric = metric_by_product.get(product.id)
latest_quote = quote_by_product.get(product.id)
liquidity = self._liquidity_evidence(metric, latest_quote, liquidity_requirement)
if liquidity is not None and liquidity.status == "insufficient":
continue
result.append(AuthoritativeProductCandidate(
product=product,
suitability=ProductSuitabilityEvidence(
@@ -214,10 +239,64 @@ class AdvisorProductRepository:
document_published_at=contract.document_published_at,
),
asset_scale_billion=scale,
market_quote=quote_by_product.get(product.id),
market_quote=latest_quote,
liquidity=liquidity,
))
return result
@staticmethod
def _liquidity_evidence(
metric: AdvisorProductMetricSnapshot | None,
quote: AdvisorProductMarketQuoteSnapshot | None,
requirement: str | None,
) -> ProductLiquidityEvidence | None:
if metric is None and quote is None and requirement is None:
return None
thresholds = {
"daily": Decimal("10000000"),
"within_7_days": Decimal("1000000"),
"within_30_days": Decimal("100000"),
"over_30_days": Decimal("0"),
}
if requirement is not None and requirement not in thresholds:
raise ValueError("unknown liquidity requirement")
average = metric.average_daily_turnover_amount if metric is not None else None
status = "unknown" if average is None else "available"
if requirement is not None and (average is None or average < thresholds[requirement]):
status = "insufficient"
return ProductLiquidityEvidence(
average_daily_turnover_amount=average,
latest_quote_observed_at=quote.observed_at if quote is not None else None,
status=status,
)
@staticmethod
def hard_suitability_filter(
candidates: Sequence[AuthoritativeProductCandidate], customer_risk_level: int
) -> tuple[list[AuthoritativeProductCandidate], list[dict[str, object]]]:
"""Apply R-level hard filtering before ranking or model generation."""
if not 1 <= customer_risk_level <= 5:
raise ValueError("customer risk level must be between 1 and 5")
selected: list[AuthoritativeProductCandidate] = []
excluded: list[dict[str, object]] = []
for candidate in candidates:
product_level = candidate.suitability.risk_level.upper().removeprefix("R")
if not product_level.isdigit() or not 1 <= int(product_level) <= 5:
excluded.append({
"product_code": candidate.product.product_code,
"reason_code": "PRODUCT_RISK_LEVEL_INVALID",
"reason": "产品权威适当性等级无法解析,已失败关闭。",
})
elif int(product_level) > customer_risk_level:
excluded.append({
"product_code": candidate.product.product_code,
"reason_code": "RISK_LEVEL_MISMATCH",
"reason": "产品风险等级高于客户风险承受等级,已排除。",
})
else:
selected.append(candidate)
return selected, excluded
@staticmethod
def _latest_by_product(rows: Sequence[ProductRow]) -> dict[int, ProductRow]:
result: dict[int, ProductRow] = {}