feat: add advisor product evidence intake

This commit is contained in:
Windows
2026-09-11 12:47:01 +08:00
parent 9ed536e206
commit 291cb7b58f
17 changed files with 2909 additions and 14 deletions
@@ -0,0 +1,226 @@
"""Read-only product evidence repository for the advisory service."""
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Protocol, TypeVar
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.model.advisor_product import (
AdvisorProductContractSnapshot,
AdvisorProductGovernanceCandidate,
AdvisorProductMarketQuoteSnapshot,
AdvisorProductReferenceSnapshot,
AdvisorProductSuitabilityReference,
)
from app.model.fund import FundProduct
@dataclass(frozen=True, slots=True)
class ProductSuitabilityEvidence:
risk_level: str
sales_institution: str
source_url: str
document_title: str
document_sha256: str
effective_from: date
@dataclass(frozen=True, slots=True)
class ProductContractEvidence:
fund_type: str
investment_scope: str
performance_benchmark: str | None
risk_return_characteristics: str
custodian_name: str | None
management_fee_rate_pct: Decimal | None
custodian_fee_rate_pct: Decimal | None
inception_date: date | None
source_url: str
document_title: str
document_sha256: str
document_published_at: date | None
@dataclass(frozen=True, slots=True)
class AuthoritativeProductCandidate:
product: FundProduct
suitability: ProductSuitabilityEvidence
contract: ProductContractEvidence
asset_scale_billion: Decimal | None = None
market_quote: AdvisorProductMarketQuoteSnapshot | None = None
class _ProductRow(Protocol):
product_id: int
ProductRow = TypeVar("ProductRow", bound=_ProductRow)
class AdvisorProductRepository:
"""Read verified, current, exchange-traded product facts only."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def tradable_products(
self,
now: datetime,
*,
fund_manager: str | None = None,
product_codes: tuple[str, ...] | None = None,
limit: int = 50,
) -> list[FundProduct]:
statement = select(FundProduct).where(
FundProduct.status == "上市",
FundProduct.exchange_code.in_(("SSE", "SZSE")),
or_(FundProduct.open_start_at.is_(None), FundProduct.open_start_at <= now),
or_(FundProduct.open_end_at.is_(None), FundProduct.open_end_at > now),
)
if fund_manager is not None:
statement = statement.where(FundProduct.fund_manager == fund_manager)
if product_codes is not None:
statement = statement.where(FundProduct.product_code.in_(product_codes))
statement = statement.order_by(FundProduct.id.asc()).limit(limit)
return list(await self.session.scalars(statement))
async def authoritative_tradable_products(
self,
now: datetime,
*,
sales_institution: str,
fund_manager: str | None = None,
quote_max_age_seconds: int = 300,
min_asset_scale_billion: Decimal | None = None,
limit: int = 50,
) -> list[AuthoritativeProductCandidate]:
"""Apply evidence gates before any product reaches an Agent.
The baseline ``fin_product.risk_level`` is never treated as the
sales-institution suitability rating. Missing or unverified evidence
excludes a product (fail closed).
"""
products = await self.tradable_products(now, fund_manager=fund_manager, limit=limit)
if not products:
return []
ids = [product.id for product in products]
as_of = now.date()
suitability_rows = list(await self.session.scalars(select(
AdvisorProductSuitabilityReference
).where(
AdvisorProductSuitabilityReference.product_id.in_(ids),
AdvisorProductSuitabilityReference.sales_institution == sales_institution,
AdvisorProductSuitabilityReference.review_status == "verified",
AdvisorProductSuitabilityReference.effective_from <= as_of,
or_(
AdvisorProductSuitabilityReference.effective_until.is_(None),
AdvisorProductSuitabilityReference.effective_until >= as_of,
),
AdvisorProductSuitabilityReference.source_url != "",
AdvisorProductSuitabilityReference.document_sha256 != "",
).order_by(
AdvisorProductSuitabilityReference.product_id,
AdvisorProductSuitabilityReference.effective_from.desc(),
)))
contract_rows = list(await self.session.scalars(select(
AdvisorProductContractSnapshot
).where(
AdvisorProductContractSnapshot.product_id.in_(ids),
AdvisorProductContractSnapshot.review_status == "verified",
AdvisorProductContractSnapshot.effective_from <= as_of,
or_(
AdvisorProductContractSnapshot.effective_until.is_(None),
AdvisorProductContractSnapshot.effective_until >= as_of,
),
AdvisorProductContractSnapshot.source_url != "",
AdvisorProductContractSnapshot.document_sha256 != "",
).order_by(
AdvisorProductContractSnapshot.product_id,
AdvisorProductContractSnapshot.effective_from.desc(),
)))
reference_rows = list(await self.session.scalars(select(
AdvisorProductReferenceSnapshot
).where(
AdvisorProductReferenceSnapshot.product_id.in_(ids),
AdvisorProductReferenceSnapshot.as_of_date <= as_of,
).order_by(
AdvisorProductReferenceSnapshot.product_id,
AdvisorProductReferenceSnapshot.as_of_date.desc(),
)))
pending_ids = set(await self.session.scalars(select(
AdvisorProductGovernanceCandidate.product_id
).where(
AdvisorProductGovernanceCandidate.product_id.in_(ids),
AdvisorProductGovernanceCandidate.review_status == "pending_review",
)))
quote_rows = list(await self.session.scalars(select(
AdvisorProductMarketQuoteSnapshot
).where(
AdvisorProductMarketQuoteSnapshot.product_id.in_(ids),
AdvisorProductMarketQuoteSnapshot.quote_status == "active",
).order_by(
AdvisorProductMarketQuoteSnapshot.product_id,
AdvisorProductMarketQuoteSnapshot.observed_at.desc(),
)))
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)
quote_cutoff = now - timedelta(seconds=quote_max_age_seconds)
quote_by_product: dict[int, AdvisorProductMarketQuoteSnapshot] = {}
for quote in quote_rows:
if quote.observed_at >= quote_cutoff:
quote_by_product.setdefault(quote.product_id, quote)
result: list[AuthoritativeProductCandidate] = []
for product in products:
suitability = suitability_by_product.get(product.id)
contract = contract_by_product.get(product.id)
if product.id in pending_ids or suitability is None or contract is None:
continue
reference = reference_by_product.get(product.id)
scale = reference.fund_asset_scale_billion if reference else None
if (
min_asset_scale_billion is not None
and suitability.risk_level != "R1"
and (scale is None or scale < min_asset_scale_billion)
):
continue
result.append(AuthoritativeProductCandidate(
product=product,
suitability=ProductSuitabilityEvidence(
risk_level=suitability.risk_level,
sales_institution=suitability.sales_institution,
source_url=suitability.source_url,
document_title=suitability.document_title,
document_sha256=suitability.document_sha256,
effective_from=suitability.effective_from,
),
contract=ProductContractEvidence(
fund_type=contract.fund_type,
investment_scope=contract.investment_scope,
performance_benchmark=contract.performance_benchmark,
risk_return_characteristics=contract.risk_return_characteristics,
custodian_name=contract.custodian_name,
management_fee_rate_pct=contract.management_fee_rate_pct,
custodian_fee_rate_pct=contract.custodian_fee_rate_pct,
inception_date=contract.inception_date,
source_url=contract.source_url,
document_title=contract.document_title,
document_sha256=contract.document_sha256,
document_published_at=contract.document_published_at,
),
asset_scale_billion=scale,
market_quote=quote_by_product.get(product.id),
))
return result
@staticmethod
def _latest_by_product(rows: Sequence[ProductRow]) -> dict[int, ProductRow]:
result: dict[int, ProductRow] = {}
for row in rows:
result.setdefault(int(row.product_id), row)
return result