Files
group_fqcd_jr/app/service/fund_quote_service.py
lzf_0626 6516ccb385 feat: 第二版——接口契约对齐 docs/05,修复静默故障与数据库基线
相对第一版 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(含失败关闭反证)。
2026-09-10 15:55:54 +08:00

271 lines
11 KiB
Python

"""基金行情业务服务:合并外部实时行情和历史净值,向上层提供稳定结果。"""
import asyncio
import json
from datetime import UTC, date, datetime, time
from typing import Any, Literal
from zoneinfo import ZoneInfo
from app.core.contracts import RequestContext
from app.core.fund_contracts import FundQuote, FundQuoteQuery
from app.infrastructure.fund_market_adapter import FundMarketAdapter
from app.infrastructure.fund_quote_cache import FundQuoteCache
FUND_TYPE_GROUPS: dict[str, tuple[str, ...]] = {
"货币型": ("202308", "020480"),
"债券型": ("007161", "003776", "020281"),
"混合型": ("018019", "014189", "018020"),
"股票型": (
"020553", "016449", "008854", "008264", "008736", "010592", "160127",
"588890", "020839", "589700", "159382", "159511", "002900", "021958",
"159948", "009059", "001421",
),
}
SOUTHERN_FUND_CODES: tuple[str, ...] = tuple(
dict.fromkeys(code for codes in FUND_TYPE_GROUPS.values() for code in codes)
)
FUND_TYPE_BY_CODE = {
code: fund_type for fund_type, codes in FUND_TYPE_GROUPS.items() for code in codes
}
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
class FundQuoteRuntimeConfig:
"""行情运行参数;缺失或非法配置时使用安全默认值。"""
def __init__(
self,
*,
allowed_codes: tuple[str, ...] = SOUTHERN_FUND_CODES,
intraday_cache_ttl: int = 60,
closing_cache_ttl: int = 900,
) -> None:
self.allowed_codes = allowed_codes
self.intraday_cache_ttl = intraday_cache_ttl
self.closing_cache_ttl = closing_cache_ttl
@classmethod
def from_mapping(cls, value: object) -> "FundQuoteRuntimeConfig":
if not isinstance(value, dict):
return cls()
raw_codes = value.get("allowed_codes", SOUTHERN_FUND_CODES)
if isinstance(raw_codes, list):
codes = tuple(
code for code in raw_codes
if isinstance(code, str) and len(code) == 6 and code.isdigit()
)
else:
codes = SOUTHERN_FUND_CODES
if not codes:
codes = SOUTHERN_FUND_CODES
def ttl(name: str, default: int) -> int:
raw = value.get(name, default)
return raw if isinstance(raw, int) and 1 <= raw <= 86400 else default
return cls(
allowed_codes=codes,
intraday_cache_ttl=ttl("intraday_cache_ttl", 60),
closing_cache_ttl=ttl("closing_cache_ttl", 900),
)
class FundQuoteService:
def __init__(
self,
adapter: FundMarketAdapter,
cache: FundQuoteCache | None = None,
runtime_config: FundQuoteRuntimeConfig | None = None,
) -> None:
self.adapter = adapter
self.cache = cache
self.runtime_config = runtime_config or FundQuoteRuntimeConfig()
async def query(
self,
query: FundQuoteQuery,
*,
now: datetime | None = None,
) -> list[FundQuote]:
if query.fund_type is not None and query.fund_type not in FUND_TYPE_GROUPS:
raise ValueError("基金类型必须是货币型、债券型、混合型或股票型")
configured = set(self.runtime_config.allowed_codes)
source_codes = (
FUND_TYPE_GROUPS[query.fund_type]
if query.fund_type is not None
else SOUTHERN_FUND_CODES
)
available = tuple(code for code in source_codes if code in configured)
selected = [code for code in query.fund_codes if code in available]
if not query.fund_codes:
selected = list(available)
selected = selected[: query.limit]
if not selected:
return []
current = (now or datetime.now(UTC)).astimezone(SHANGHAI_TZ)
target_date = query.target_date or current.date()
intraday = self.is_market_trading_time(current)
cache_key = self._cache_key(query, target_date, intraday)
if self.cache is not None:
cached = await self.cache.get(cache_key)
if cached.value:
try:
values = [FundQuote.model_validate(item) for item in json.loads(cached.value)]
return [item.model_copy(update={"quote_source": "cache"}) for item in values]
except (TypeError, ValueError):
pass
names, live, history = await asyncio.gather(
self._fetch_names(selected),
self._fetch_live(selected, intraday),
self._fetch_history(selected, target_date),
)
quote_time = current
result = [
self._merge(code, names, live, history, quote_time, intraday)
for code in selected
]
if self.cache is not None:
ttl = (self.runtime_config.intraday_cache_ttl
if intraday else self.runtime_config.closing_cache_ttl)
await self.cache.set(
cache_key,
"[" + ",".join(item.model_dump_json() for item in result) + "]",
ttl,
)
return result
async def _fetch_names(self, codes: list[str]) -> dict[str, str]:
"""按代码并发取名。
适配器的 `fetch_names` 内部是逐代码串行循环,所以只能逐个并发调用,
否则延迟会随代码数量线性叠加。
"""
results = await asyncio.gather(
*(self.adapter.fetch_names([code]) for code in codes), return_exceptions=True
)
names: dict[str, str] = {}
for code, item in zip(codes, results, strict=True):
if isinstance(item, dict):
names.update(item)
else:
names[code] = f"基金 {code}"
return names
async def _fetch_live(
self, codes: list[str], intraday: bool
) -> dict[str, dict[str, Any]]:
if not intraday:
return {}
return await self.adapter.fetch_quotes(codes)
async def _fetch_history(
self, codes: list[str], target_date: date
) -> dict[str, dict[str, Any]]:
"""并发取历史净值;单个代码失败只降级该代码,不影响其它代码。"""
results = await asyncio.gather(
*(self.adapter.fetch_history(code, target_date) for code in codes),
return_exceptions=True,
)
return {
code: (item if isinstance(item, dict) else {"degraded": True})
for code, item in zip(codes, results, strict=True)
}
@staticmethod
def _cache_key(query: FundQuoteQuery, target_date: date, intraday: bool) -> str:
codes = ",".join(query.fund_codes) if query.fund_codes else "all"
return (
f"fund_quote:v1:{codes}:{query.fund_type or 'all'}:"
f"{target_date}:{int(intraday)}:{query.limit}"
)
@staticmethod
def is_market_trading_time(current: datetime) -> bool:
local = current.astimezone(SHANGHAI_TZ)
if local.weekday() >= 5:
return False
value = local.time()
return time(9, 30) <= value <= time(11, 30) or time(13) <= value <= time(15)
@staticmethod
def _merge(
code: str,
names: dict[str, str],
live: dict[str, dict[str, Any]],
history: dict[str, dict[str, Any]],
quote_time: datetime,
intraday: bool,
) -> FundQuote:
current = live.get(code, {})
previous = history.get(code, {})
use_live = current.get("nav") is not None
degraded = bool(previous.get("degraded")) or (intraday and not use_live)
source: Literal["eastmoney", "cache", "degraded"] = (
"eastmoney" if (use_live or not degraded) else "degraded"
)
return FundQuote(
fund_code=code,
fund_name=names.get(code, f"基金 {code}"),
fund_type=FUND_TYPE_BY_CODE.get(code),
nav=current.get("nav") if use_live else previous.get("nav"),
nav_date=previous.get("nav_date"),
daily_change=current.get("daily_change") if use_live else previous.get("daily_change"),
half_year_return=previous.get("half_year_return"),
one_year_return=previous.get("one_year_return"),
year_to_date_return=previous.get("year_to_date_return"),
since_inception_return=previous.get("since_inception_return"),
quote_time=quote_time,
quote_source=source,
is_intraday=intraday,
degraded=degraded,
)
async def query_fund_quote_tool(
arguments: FundQuoteQuery, context: RequestContext
) -> list[dict[str, Any]]:
"""ToolExecutor 使用的公共只读行情工具处理器。"""
del context # 权限、审计和来源由 ToolExecutor 统一处理。
# 组装入口统一放在 bootstrap:缓存适配器与 Agent 工具共用同一套 Redis 配置。
from app.service.agent.bootstrap import get_fund_quote_cache
from app.service.runtime_config_service import load_fund_quote_config
try:
mapping = await load_fund_quote_config()
except Exception:
# 配置中心不可用时退回安全默认值,行情查询本身不应因此失败。
mapping = {}
try:
cache = get_fund_quote_cache()
except Exception:
# 缓存只是优化层:Redis 不可用、或适配器构造失败都必须降级而非阻塞。
cache = None
config = FundQuoteRuntimeConfig.from_mapping(mapping)
result = await FundQuoteService(
EastmoneyAdapterFactory.create(), cache=cache, runtime_config=config
).query(arguments)
return [item.model_dump(mode="json") for item in result]
class EastmoneyAdapterFactory:
"""延迟创建外部 Adapter,避免导入底座时建立网络连接。"""
# 请求预算依据(C3:工具超时 ↔ 适配器预算必须自洽):
# * 适配器默认预算为单次 12s × 3 次尝试(含首次),单代码最坏
# 12*3 + 0.2 + 0.4 = 36.6s;代码表上限 25 个,历史净值串行取时最坏
# 900s+,必然先撞工具级超时——功能等于不可用。
# * 东方财富行情接口正常响应在百毫秒级,4s 已远超实际 P99;重试 1 次
# (共 2 次尝试,退避 0.2s)足以吸收偶发抖动:
# 单代码最坏 4.0 + 0.2 + 4.0 = 8.2s。
# * FundQuoteService 已按代码并发,因此 8.2s 是与代码数量无关的上界,
# 工具级超时只需覆盖这一个上界。
REQUEST_TIMEOUT_SECONDS = 4.0
REQUEST_RETRIES = 1
@staticmethod
def create() -> FundMarketAdapter:
from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter
return EastmoneyFundAdapter(
timeout_seconds=EastmoneyAdapterFactory.REQUEST_TIMEOUT_SECONDS,
retries=EastmoneyAdapterFactory.REQUEST_RETRIES,
)