Files
group_fqcd_jr/app/repository/fund_query_repository.py
T

839 lines
30 KiB
Python
Raw Normal View History

"""场内基金模拟交易域(fin_*)只读查询仓储。
只读硬保证
----------
1. 本模块只有 SELECT 语义:不 import ``insert``/``update``/``delete``,不调用
``session.add``/``flush``/``commit``,也不提供任何写方法。
2. 返回值是冻结快照 :class:`FundRecord`(``values`` 为 ``MappingProxyType``),
**不是 ORM 实例**,因此调用方即便改动字段也无法触发 flush 回写数据库。
3. Agent 属 Service 层,只能经本仓储查询,不得直接访问 ``app.model.fund`` 的实体;
本层不承载下单/改持仓/改交易能力(项目硬性业务红线)。
4. 客户数据范围 fail-closed:未显式声明 :class:`CustomerScope` 时查询返回空集。
业务组员扩展点(无需修改底座代码)
----------------------------------
* :class:`FundQuerySpec` / :class:`FieldFilter` / :class:`AllOf` / :class:`AnyOf`:
组合式过滤条件集合,任意已映射列都可作为筛选维度;
* :func:`register_filter`:把业务语义名注册为谓词工厂,用 ``spec.named_filter(name, **kw)`` 使用;
* :func:`register_entity`:注册新的 ORM 实体或业务别名,直接走 :meth:`FundQueryRepository.fetch`;
* :func:`register_scope_resolver` / :func:`register_indirect_path`:扩展客户归属解析规则;
* :meth:`FundQueryRepository.fetch`:通用查询入口,新表/新维度不必新增仓储方法。
"""
from __future__ import annotations
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass, replace
from datetime import date, datetime
from enum import StrEnum
from types import MappingProxyType
from typing import Any, Final, Protocol, cast
from sqlalchemy import ColumnElement, Select, Table, and_, false, func, or_, select, true
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import ValidationAgentError
from app.model.fund import (
FundCapitalFlow,
FundCashLedger,
FundCustomerProfile,
FundFeeRule,
FundHolding,
FundMarketPrice,
FundNavHistory,
FundProduct,
FundRiskAlert,
FundRiskAssessment,
FundRiskNotification,
FundSimAccount,
FundSimOrder,
FundTransaction,
)
MAX_PAGE_SIZE: Final[int] = 200
DEFAULT_PAGE_SIZE: Final[int] = 50
__all__ = [
"DEFAULT_PAGE_SIZE",
"MAX_PAGE_SIZE",
"AllOf",
"AnyOf",
"CustomerScope",
"FieldFilter",
"FundFilterOperator",
"FundPage",
"FundQueryRepository",
"FundQuerySpec",
"FundQueryValidationError",
"FundRecord",
"PageRequest",
"register_entity",
"register_filter",
"register_indirect_path",
"register_scope_resolver",
"registered_entities",
"registered_filters",
]
class FundQueryValidationError(ValidationAgentError):
"""只读查询层的参数/注册错误。"""
code = "FUND_QUERY_VALIDATION_ERROR"
# --------------------------------------------------------------------------------------
# 分页
# --------------------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class PageRequest:
"""分页请求;``limit`` 有硬上限,避免业务组员一次拉全量客户数据。"""
limit: int = DEFAULT_PAGE_SIZE
offset: int = 0
def __post_init__(self) -> None:
if self.limit < 1:
raise FundQueryValidationError("分页 limit 必须 >= 1")
if self.limit > MAX_PAGE_SIZE:
raise FundQueryValidationError(f"分页 limit 超过只读查询上限 {MAX_PAGE_SIZE}")
if self.offset < 0:
raise FundQueryValidationError("分页 offset 必须 >= 0")
@dataclass(frozen=True, slots=True)
class FundRecord:
"""不可变行快照;``values`` 是只读视图,无法回写数据库。"""
entity: str
values: Mapping[str, Any]
def __getitem__(self, key: str) -> Any:
return self.values[key]
def get(self, key: str, default: Any = None) -> Any:
return self.values.get(key, default)
def __contains__(self, key: object) -> bool:
return key in self.values
def __iter__(self) -> Iterator[str]:
return iter(self.values)
def to_dict(self) -> dict[str, Any]:
return dict(self.values)
@dataclass(frozen=True, slots=True)
class FundPage:
"""一页只读结果;``next_offset`` 为 ``None`` 表示已到末页。"""
entity: str
items: tuple[FundRecord, ...]
limit: int
offset: int
next_offset: int | None
total: int | None = None
@property
def has_more(self) -> bool:
return self.next_offset is not None
# --------------------------------------------------------------------------------------
# 客户数据范围
# --------------------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class CustomerScope:
"""客户数据范围;``None`` 表示该维度不限定,空集合表示拒绝(返回空集)。"""
customer_ids: frozenset[int] | None = None
trade_accounts: frozenset[str] | None = None
@property
def is_unrestricted(self) -> bool:
return self.customer_ids is None and self.trade_accounts is None
@property
def is_denied(self) -> bool:
return self.customer_ids == frozenset() or self.trade_accounts == frozenset()
@classmethod
def unrestricted(cls) -> CustomerScope:
"""不限定范围,仅供底座内部治理用途;业务 Agent 不应使用。"""
return cls()
@classmethod
def denied(cls) -> CustomerScope:
"""显式拒绝,返回空集。"""
return cls(customer_ids=frozenset())
@classmethod
def for_customers(
cls, customer_ids: Iterable[int], *, trade_accounts: Iterable[str] | None = None
) -> CustomerScope:
return cls(
customer_ids=frozenset(customer_ids),
trade_accounts=None if trade_accounts is None else frozenset(trade_accounts),
)
ScopeResolver = Callable[[type[Any], CustomerScope], "ColumnElement[bool] | None"]
def _global_scope(_entity: type[Any], _scope: CustomerScope) -> ColumnElement[bool] | None:
"""公共数据(行情/净值/费率/产品)无客户归属,不做范围限制。"""
return None
_ENTITIES: dict[str, type[Any]] = {
"product": FundProduct,
"market_price": FundMarketPrice,
"nav_history": FundNavHistory,
"account": FundSimAccount,
"cash_ledger": FundCashLedger,
"capital_flow": FundCapitalFlow,
"fee_rule": FundFeeRule,
"order": FundSimOrder,
"transaction": FundTransaction,
"holding": FundHolding,
"customer_profile": FundCustomerProfile,
"risk_assessment": FundRiskAssessment,
"risk_alert": FundRiskAlert,
"risk_notification": FundRiskNotification,
}
_SCOPE_RESOLVERS: dict[type[Any], ScopeResolver] = {
entity: _global_scope
for entity in (FundProduct, FundMarketPrice, FundNavHistory, FundFeeRule)
}
# (源列名, 归属实体, 归属实体上的客户列):本表无 customer_id 时用子查询间接解析归属。
_INDIRECT_PATHS: list[tuple[str, type[Any], str]] = [
("account_id", FundSimAccount, "customer_id"),
("alert_id", FundRiskAlert, "customer_id"),
]
def register_scope_resolver(
entity: type[Any], resolver: ScopeResolver, *, replace_resolver: bool = False
) -> None:
"""扩展某实体的客户归属解析;``resolver`` 返回 ``None`` 表示该实体无客户归属。"""
if not replace_resolver and entity in _SCOPE_RESOLVERS:
name = getattr(entity, "__tablename__", entity)
raise FundQueryValidationError(f"该实体的数据范围解析器已注册:{name}")
_SCOPE_RESOLVERS[entity] = resolver
def register_indirect_path(
source_column: str, owner_entity: type[Any], owner_customer_column: str
) -> None:
"""注册新的间接归属路径(如新表只有 account_id / alert_id 而没有 customer_id)。"""
path = (source_column, owner_entity, owner_customer_column)
if path not in _INDIRECT_PATHS:
_INDIRECT_PATHS.append(path)
def register_entity(alias: str, entity: type[Any], *, replace_entity: bool = False) -> None:
"""注册 ORM 实体别名,业务组员可用 :meth:`FundQueryRepository.fetch` 按别名查询。"""
if not alias:
raise FundQueryValidationError("实体别名不能为空")
if not replace_entity and alias in _ENTITIES:
raise FundQueryValidationError(f"实体别名已注册:{alias}")
_ENTITIES[alias] = entity
def registered_entities() -> tuple[str, ...]:
return tuple(sorted(_ENTITIES))
def _resolve_entity(entity: type[Any] | str) -> tuple[str, type[Any]]:
if isinstance(entity, str):
resolved = _ENTITIES.get(entity)
if resolved is None:
raise FundQueryValidationError(f"未注册的只读实体:{entity}")
return entity, resolved
for alias, candidate in _ENTITIES.items():
if candidate is entity:
return alias, entity
raise FundQueryValidationError(f"未注册的只读实体:{entity!r}")
def _table_of(entity: type[Any]) -> Table:
table = getattr(entity, "__table__", None)
if not isinstance(table, Table):
raise FundQueryValidationError(f"实体缺少表映射:{entity!r}")
return table
def _column_of(table: Table, field: str) -> ColumnElement[Any]:
if field not in table.columns:
raise FundQueryValidationError(f"未知查询字段:{table.name}.{field}")
return table.columns[field]
def _owned_ids(target: type[Any], target_column: str, scope: CustomerScope) -> Select[Any]:
"""归属实体上命中客户范围的 id 子查询。"""
table = _table_of(target)
statement = select(table.c.id)
if scope.customer_ids is not None:
statement = statement.where(table.c[target_column].in_(sorted(scope.customer_ids)))
if scope.trade_accounts is not None and "trade_account" in table.columns:
statement = statement.where(table.c.trade_account.in_(sorted(scope.trade_accounts)))
return statement
def _scope_condition(entity: type[Any], scope: CustomerScope | None) -> ColumnElement[bool] | None:
"""生成客户数据范围条件;``None`` 表示该实体为公共数据。"""
if scope is None or scope.is_denied:
return false()
if scope.is_unrestricted:
return None
resolver = _SCOPE_RESOLVERS.get(entity)
if resolver is not None:
return resolver(entity, scope)
return _inferred_scope_condition(entity, scope)
def _inferred_scope_condition(
entity: type[Any], scope: CustomerScope
) -> ColumnElement[bool] | None:
table = _table_of(entity)
if "customer_id" in table.columns:
conditions: list[ColumnElement[bool]] = []
if scope.customer_ids is not None:
conditions.append(table.c.customer_id.in_(sorted(scope.customer_ids)))
if scope.trade_accounts is not None and "trade_account" in table.columns:
conditions.append(table.c.trade_account.in_(sorted(scope.trade_accounts)))
if conditions:
return and_(*conditions)
return None
for source_column, owner_entity, owner_column in _INDIRECT_PATHS:
if source_column in table.columns:
return table.c[source_column].in_(_owned_ids(owner_entity, owner_column, scope))
# 未声明归属的实体一律拒绝,避免范围失效导致越权数据外泄。
return false()
# --------------------------------------------------------------------------------------
# 组合式过滤条件
# --------------------------------------------------------------------------------------
class FundFilterOperator(StrEnum):
"""过滤操作符;需要更复杂语义时用 :func:`register_filter` 注册命名过滤器。"""
EQ = "eq"
NE = "ne"
IN = "in"
NOT_IN = "not_in"
GTE = "gte"
LTE = "lte"
BETWEEN = "between"
LIKE = "like"
IS_NULL = "is_null"
class FilterPredicate(Protocol):
"""过滤谓词协议;业务组员可实现自定义谓词对象。"""
def build(self, entity: type[Any]) -> ColumnElement[bool]: ...
def _as_sequence(value: Any, field: str, operator: str) -> list[Any]:
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
raise FundQueryValidationError(f"{field} 的 {operator} 取值必须是序列")
return list(value)
@dataclass(frozen=True, slots=True)
class FieldFilter:
"""单列过滤:``FieldFilter("status", "filled")`` / 范围 ``("trade_date", (a, b), BETWEEN)``。"""
field: str
value: Any = None
op: FundFilterOperator = FundFilterOperator.EQ
def build(self, entity: type[Any]) -> ColumnElement[bool]:
column = _column_of(_table_of(entity), self.field)
operator = self.op
if operator is FundFilterOperator.EQ:
return cast("ColumnElement[bool]", column == self.value)
if operator is FundFilterOperator.NE:
return cast("ColumnElement[bool]", column != self.value)
if operator is FundFilterOperator.IN:
return column.in_(_as_sequence(self.value, self.field, "IN"))
if operator is FundFilterOperator.NOT_IN:
return column.not_in(_as_sequence(self.value, self.field, "NOT_IN"))
if operator is FundFilterOperator.GTE:
return cast("ColumnElement[bool]", column >= self.value)
if operator is FundFilterOperator.LTE:
return cast("ColumnElement[bool]", column <= self.value)
if operator is FundFilterOperator.BETWEEN:
bounds = _as_sequence(self.value, self.field, "BETWEEN")
if len(bounds) != 2:
raise FundQueryValidationError(f"{self.field} 的 BETWEEN 需要 [下界, 上界]")
return column.between(bounds[0], bounds[1])
if operator is FundFilterOperator.LIKE:
return column.like(str(self.value))
return column.is_(None) if self.value else column.is_not(None)
@dataclass(frozen=True, slots=True)
class AllOf:
"""与组合:全部谓词同时成立。"""
predicates: tuple[FilterPredicate, ...] = ()
def build(self, entity: type[Any]) -> ColumnElement[bool]:
if not self.predicates:
return true()
return and_(*(predicate.build(entity) for predicate in self.predicates))
@dataclass(frozen=True, slots=True)
class AnyOf:
"""或组合:任一谓词成立。"""
predicates: tuple[FilterPredicate, ...] = ()
def build(self, entity: type[Any]) -> ColumnElement[bool]:
if not self.predicates:
return false()
return or_(*(predicate.build(entity) for predicate in self.predicates))
NamedFilterFactory = Callable[[Mapping[str, Any]], FilterPredicate]
_NAMED_FILTERS: dict[str, NamedFilterFactory] = {}
def register_filter(
name: str, factory: NamedFilterFactory, *, replace_filter: bool = False
) -> None:
"""业务语义过滤器扩展点:注册后在 :class:`FundQuerySpec` 里按名字使用。"""
if not name:
raise FundQueryValidationError("过滤器名称不能为空")
if not replace_filter and name in _NAMED_FILTERS:
raise FundQueryValidationError(f"过滤器已注册:{name}")
_NAMED_FILTERS[name] = factory
def registered_filters() -> tuple[str, ...]:
return tuple(sorted(_NAMED_FILTERS))
def _field_eq_filter(params: Mapping[str, Any]) -> FilterPredicate:
return FieldFilter(str(params["field"]), params.get("value"))
def _date_range_filter(params: Mapping[str, Any]) -> FilterPredicate:
return AllOf(_range(str(params["field"]), params.get("start"), params.get("end")))
register_filter("field_eq", _field_eq_filter)
register_filter("date_range", _date_range_filter)
@dataclass(frozen=True, slots=True)
class FundQuerySpec:
"""查询规格:谓词集合 + 命名过滤器 + 排序,全部不可变,可组合后复用。"""
predicates: tuple[FilterPredicate, ...] = ()
named: tuple[tuple[str, Mapping[str, Any]], ...] = ()
order_by: tuple[tuple[str, bool], ...] = ()
@classmethod
def where(cls, *predicates: FilterPredicate) -> FundQuerySpec:
return cls(predicates=predicates)
@classmethod
def named_only(cls, name: str, **params: Any) -> FundQuerySpec:
return cls().named_filter(name, **params)
def with_(self, *predicates: FilterPredicate) -> FundQuerySpec:
"""追加谓词(不修改原规格)。"""
return replace(self, predicates=(*self.predicates, *predicates))
def named_filter(self, name: str, **params: Any) -> FundQuerySpec:
"""追加业务语义过滤器(不修改原规格);需先 :func:`register_filter`。"""
if name not in _NAMED_FILTERS:
raise FundQueryValidationError(f"未注册的过滤器:{name}")
return replace(self, named=(*self.named, (name, MappingProxyType(dict(params)))))
def order(self, field: str, *, descending: bool = False) -> FundQuerySpec:
return replace(self, order_by=(*self.order_by, (field, descending)))
# --------------------------------------------------------------------------------------
# 仓储
# --------------------------------------------------------------------------------------
class FundQueryRepository:
"""fin_* 只读查询仓储;``scope`` 缺省即拒绝(返回空集)。"""
def __init__(self, session: AsyncSession, *, scope: CustomerScope | None = None) -> None:
self.session = session
self.scope = scope
async def fetch(
self,
entity: type[Any] | str,
*,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
"""通用只读查询入口:任意已注册实体 + 组合规格 + 分页。"""
alias, orm_entity = _resolve_entity(entity)
request = page if page is not None else PageRequest()
request_spec = spec if spec is not None else FundQuerySpec()
statement = self._statement(orm_entity, request_spec, request)
rows = (await self.session.execute(statement)).mappings().all()
has_more = len(rows) > request.limit
visible = rows[: request.limit]
total = int(
await self.session.scalar(
select(func.count()).select_from(
statement.order_by(None).limit(None).offset(None).subquery()
)
)
or 0
)
records = tuple(
FundRecord(entity=alias, values=MappingProxyType(dict(row))) for row in visible
)
return FundPage(
entity=alias,
items=records,
limit=request.limit,
offset=request.offset,
next_offset=request.offset + request.limit if has_more else None,
total=total,
)
def _statement(
self, entity: type[Any], spec: FundQuerySpec, page: PageRequest
) -> Select[Any]:
table = _table_of(entity)
conditions: list[ColumnElement[bool]] = [
predicate.build(entity) for predicate in spec.predicates
]
for name, params in spec.named:
conditions.append(_NAMED_FILTERS[name](params).build(entity))
scope_condition = _scope_condition(entity, self.scope)
if scope_condition is not None:
conditions.append(scope_condition)
statement = select(table)
if conditions:
statement = statement.where(and_(*conditions))
order: list[ColumnElement[Any]] = [
_column_of(table, field).desc() if descending else _column_of(table, field).asc()
for field, descending in spec.order_by
]
primary_key = next(iter(table.primary_key.columns))
if not any(field == primary_key.name for field, _ in spec.order_by):
order.append(primary_key.desc())
return statement.order_by(*order).limit(page.limit + 1).offset(page.offset)
# -- 产品 / 行情 / 净值 / 费率(公共数据) ------------------------------------------
async def products(
self,
*,
product_code: str | None = None,
status: str | None = None,
exchange_code: str | None = None,
product_category: str | None = None,
risk_level: str | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(
("product_code", product_code),
("status", status),
("exchange_code", exchange_code),
("product_category", product_category),
("risk_level", risk_level),
)
)
return await self.fetch(FundProduct, spec=_merged(spec, base), page=page)
async def market_prices(
self,
*,
product_id: int | None = None,
start: date | None = None,
end: date | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(("product_id", product_id)), *_range("trade_date", start, end)
)
return await self.fetch(FundMarketPrice, spec=_merged(spec, base), page=page)
async def nav_history(
self,
*,
product_id: int | None = None,
start: date | None = None,
end: date | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(("product_id", product_id)), *_range("nav_date", start, end)
).order("nav_date", descending=True)
return await self.fetch(FundNavHistory, spec=_merged(spec, base), page=page)
async def fee_rules(
self,
*,
product_id: int | None = None,
order_side: str | None = None,
status: str | None = None,
as_of: datetime | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
predicates = list(
_equals(("product_id", product_id), ("order_side", order_side), ("status", status))
)
if as_of is not None:
predicates.append(FieldFilter("effective_from", as_of, FundFilterOperator.LTE))
base = FundQuerySpec.where(*predicates)
return await self.fetch(FundFeeRule, spec=_merged(spec, base), page=page)
# -- 账户 / 资金 ----------------------------------------------------------------
async def accounts(
self,
*,
customer_id: int | None = None,
status: str | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(*_equals(("customer_id", customer_id), ("status", status)))
return await self.fetch(FundSimAccount, spec=_merged(spec, base), page=page)
async def cash_ledger(
self,
*,
account_id: int | None = None,
entry_type: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(("account_id", account_id), ("entry_type", entry_type)),
*_range("occurred_at", start, end),
).order("occurred_at", descending=True)
return await self.fetch(FundCashLedger, spec=_merged(spec, base), page=page)
async def capital_flows(
self,
*,
customer_id: int | None = None,
flow_type: str | None = None,
status: str | None = None,
match_status: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(
("customer_id", customer_id),
("flow_type", flow_type),
("status", status),
("match_status", match_status),
),
*_range("occurred_at", start, end),
).order("occurred_at", descending=True)
return await self.fetch(FundCapitalFlow, spec=_merged(spec, base), page=page)
# -- 委托 / 成交 / 持仓 ----------------------------------------------------------
async def orders(
self,
*,
customer_id: int | None = None,
product_id: int | None = None,
order_side: str | None = None,
statuses: Sequence[str] | None = None,
start: datetime | None = None,
end: datetime | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(
("customer_id", customer_id), ("product_id", product_id), ("order_side", order_side)
),
*_in("status", statuses),
*_range("submitted_at", start, end),
).order("submitted_at", descending=True)
return await self.fetch(FundSimOrder, spec=_merged(spec, base), page=page)
async def transactions(
self,
*,
customer_id: int | None = None,
product_id: int | None = None,
transaction_type: str | None = None,
order_side: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(
("customer_id", customer_id),
("product_id", product_id),
("transaction_type", transaction_type),
("order_side", order_side),
),
*_range("executed_at", start, end),
).order("executed_at", descending=True)
return await self.fetch(FundTransaction, spec=_merged(spec, base), page=page)
async def holdings(
self,
*,
customer_id: int | None = None,
product_id: int | None = None,
trade_account: str | None = None,
status: str | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(
("customer_id", customer_id),
("product_id", product_id),
("trade_account", trade_account),
("status", status),
)
)
return await self.fetch(FundHolding, spec=_merged(spec, base), page=page)
# -- 客户画像 / 风测 / 风险事件 ---------------------------------------------------
async def customer_profiles(
self,
*,
customer_id: int | None = None,
trade_account: str | None = None,
investor_type: str | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(
("customer_id", customer_id),
("trade_account", trade_account),
("investor_type", investor_type),
)
)
return await self.fetch(FundCustomerProfile, spec=_merged(spec, base), page=page)
async def risk_assessments(
self,
*,
customer_id: int | None = None,
investor_type: str | None = None,
valid_after: datetime | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
predicates = list(_equals(("customer_id", customer_id), ("investor_type", investor_type)))
if valid_after is not None:
predicates.append(FieldFilter("valid_until", valid_after, FundFilterOperator.GTE))
base = FundQuerySpec.where(*predicates).order("assessed_at", descending=True)
return await self.fetch(FundRiskAssessment, spec=_merged(spec, base), page=page)
async def risk_alerts(
self,
*,
customer_id: int | None = None,
alert_type: str | None = None,
alert_level: str | None = None,
status: str | None = None,
ack_status: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(
("customer_id", customer_id),
("alert_type", alert_type),
("alert_level", alert_level),
("status", status),
("ack_status", ack_status),
),
*_range("created_at", start, end),
).order("priority_score", descending=True)
return await self.fetch(FundRiskAlert, spec=_merged(spec, base), page=page)
async def risk_notifications(
self,
*,
alert_id: int | None = None,
channel: str | None = None,
send_status: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
spec: FundQuerySpec | None = None,
page: PageRequest | None = None,
) -> FundPage:
base = FundQuerySpec.where(
*_equals(("alert_id", alert_id), ("channel", channel), ("send_status", send_status)),
*_range("created_at", start, end),
).order("created_at", descending=True)
return await self.fetch(FundRiskNotification, spec=_merged(spec, base), page=page)
def _merged(spec: FundQuerySpec | None, base: FundQuerySpec) -> FundQuerySpec:
"""合并便捷方法内置条件与业务自定义规格;``spec`` 自带的排序优先。"""
if spec is None:
return base
return replace(
base,
predicates=(*spec.predicates, *base.predicates),
named=(*spec.named, *base.named),
order_by=spec.order_by if spec.order_by else base.order_by,
)
def _equals(*pairs: tuple[str, Any]) -> tuple[FieldFilter, ...]:
return tuple(FieldFilter(field, value) for field, value in pairs if value is not None)
def _in(field: str, values: Sequence[Any] | None) -> tuple[FieldFilter, ...]:
if values is None:
return ()
return (FieldFilter(field, tuple(values), FundFilterOperator.IN),)
def _range(field: str, start: Any, end: Any) -> tuple[FieldFilter, ...]:
if start is not None and end is not None:
return (FieldFilter(field, (start, end), FundFilterOperator.BETWEEN),)
if start is not None:
return (FieldFilter(field, start, FundFilterOperator.GTE),)
if end is not None:
return (FieldFilter(field, end, FundFilterOperator.LTE),)
return ()