相对第一版 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(含失败关闭反证)。
271 lines
11 KiB
Python
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,
|
|
)
|