2026-09-10 15:55:54 +08:00
|
|
|
|
"""场内基金模拟交易域(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
|
|
|
|
|
|
|
2026-09-13 23:51:45 +08:00
|
|
|
|
from sqlalchemy import ColumnElement, Select, Table, and_, false, func, or_, select, true
|
2026-09-10 15:55:54 +08:00
|
|
|
|
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
|
2026-09-13 23:51:45 +08:00
|
|
|
|
total: int | None = None
|
2026-09-10 15:55:54 +08:00
|
|
|
|
|
|
|
|
|
|
@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]
|
2026-09-13 23:51:45 +08:00
|
|
|
|
total = int(
|
|
|
|
|
|
await self.session.scalar(
|
|
|
|
|
|
select(func.count()).select_from(
|
|
|
|
|
|
statement.order_by(None).limit(None).offset(None).subquery()
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
or 0
|
|
|
|
|
|
)
|
2026-09-10 15:55:54 +08:00
|
|
|
|
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,
|
2026-09-13 23:51:45 +08:00
|
|
|
|
total=total,
|
2026-09-10 15:55:54 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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 ()
|