相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。
一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
approve→reviews(需 body decision)、activate→activations、
rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
{data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
不再返回 FastAPI 默认的 {"detail": ...}。
二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。
三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
.env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。
四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。
五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。
验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
829 lines
30 KiB
Python
829 lines
30 KiB
Python
"""场内基金模拟交易域(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, 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
|
||
|
||
@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]
|
||
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,
|
||
)
|
||
|
||
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 ()
|