feat: complete advisor product suitability gates
This commit is contained in:
@@ -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] = {}
|
||||
|
||||
Reference in New Issue
Block a user