feat: add advisor product evidence intake
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user