+
---
@@ -687,10 +663,16 @@ PORT=3018 # 服务端口
[MIT](./LICENSE) © tick-stock-panel contributors
+> **分发与二次开发须知**:依据 MIT 协议,分发本软件或其任何部分(包括 fork 后发布、修改再发布、封装为自有产品或服务)时,必须完整保留原始版权声明与许可文本。未经版权所有者书面许可,移除、遮蔽或篡改版权署名、许可声明,或在再分发时隐匿本项目来源,均构成协议违约与著作权侵权,版权所有者保留依法追究责任的权利。
+>
+> 协议全文与第三方组件许可清单亦可见应用内「设置 → 关于 → 开源许可」。
+
本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款
内置数据源插件 [fuyao](https://fuyao.aicubes.cn/docs/api-reference/) 提供同花顺 REST 数据接口(行情 / 财务 / 龙虎榜 / 盘前风向标 / 交易日历等),需自备 API Key,使用前请遵守其服务条款
+内置数据源插件 [TdxQuant](https://help.tdx.com.cn/quant/) 提供通达信量化数据服务(实时行情 / 五档盘口 / 日K / 复权因子 / 分钟K),需自备 API Key,使用前请遵守其服务条款
+
## 社区
本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。
diff --git a/VERSION b/VERSION
index 03f2afa1..d379b578 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-v0.3.5
+v0.3.6
diff --git a/assets/support/wechat-appreciation.jpg b/assets/support/wechat-appreciation.jpg
index ff950eb2..70cb1349 100644
Binary files a/assets/support/wechat-appreciation.jpg and b/assets/support/wechat-appreciation.jpg differ
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
index cf72d223..e074083a 100644
--- a/backend/app/__init__.py
+++ b/backend/app/__init__.py
@@ -8,7 +8,7 @@ from pathlib import Path
# ISCC)、macOS 包元数据 (tsp.spec 读它)、页面版本徽标与更新检查 (本模块) 全部同源,
# 改版本只改那一份文件。dev 模式读仓库文件; PyInstaller 冻结后读 _MEIPASS/package.json
# (tsp.spec 的 datas 会收集)。读取失败回退硬编码值 —— 版本号绝不阻塞应用启动。
-_FALLBACK_VERSION = "0.3.5"
+_FALLBACK_VERSION = "0.3.6"
def _load_version() -> str:
diff --git a/backend/app/api/ext_data.py b/backend/app/api/ext_data.py
index e276734c..90c1e960 100644
--- a/backend/app/api/ext_data.py
+++ b/backend/app/api/ext_data.py
@@ -108,6 +108,8 @@ class PullConfigReq(BaseModel):
auth: PullAuthReq | None = None
# 单次拉取请求超时 (秒), 默认 30 与历史行为一致; 大响应接口可调高
timeout_seconds: int = Field(30, ge=5, le=300)
+ # 两级拉取配方 (目录 → 逐条目明细); 缺省 (None) = 保留现有配置
+ fanout: dict | None = None
class ApiKeyReq(BaseModel):
@@ -407,27 +409,6 @@ def list_configs(request: Request):
return {"items": items}
-@router.post("/presets/{config_id}/fetch")
-async def fetch_preset_data(request: Request, config_id: str):
- """手动触发内置预设 (概念/行业) 的数据拉取。
-
- 注意: 必须在 /{config_id}/... 动态路由之前声明, 否则 'presets' 会被当成 config_id。
- 与通用 pull/run 不同: 走 ext_presets 的结构转换 (接口的 concepts/industries
- 数组 → 拼接成字符串), 保证 schema 与现有数据一致。
- """
- from app.services.ext_presets import fetch_preset
-
- try:
- n = await fetch_preset(config_id, _data_dir(request))
- except ValueError as e:
- raise HTTPException(404, str(e)) from e
- except Exception as e:
- raise HTTPException(400, f"拉取失败: {e}") from e
-
- _refresh_views(request)
- return {"status": "ok", "rows": n}
-
-
@router.post("")
def create_config(request: Request, body: CreateExtReq):
"""创建扩展数据配置。"""
@@ -1016,6 +997,15 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq):
# 保留历史状态字段; auth 缺省时沿用现有配置 (关闭鉴权需显式传 {"type":"none"})
old_pull = config.pull
+ # auth.key_ref / fanout: UI 未暴露的键在保存时沿用旧值, 避免一次普通保存
+ # 就把出厂配方里的凭据引用与两级请求配方抹掉。
+ old_auth = (old_pull.auth or {}) if old_pull else {}
+ if body.auth is not None:
+ new_auth: dict | None = body.auth.model_dump()
+ if new_auth and old_auth.get("key_ref") and not new_auth.get("key_ref"):
+ new_auth["key_ref"] = old_auth["key_ref"]
+ else:
+ new_auth = old_pull.auth if old_pull else None
config.pull = PullConfig(
url=body.url,
method=body.method,
@@ -1030,8 +1020,9 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq):
date_param=body.date_param,
date_format=body.date_format,
time_field=body.time_field,
- auth=body.auth.model_dump() if body.auth else (old_pull.auth if old_pull else None),
+ auth=new_auth,
timeout_seconds=body.timeout_seconds,
+ fanout=body.fanout if body.fanout is not None else (old_pull.fanout if old_pull else None),
last_run=old_pull.last_run if old_pull else None,
last_status=old_pull.last_status if old_pull else None,
last_message=old_pull.last_message if old_pull else None,
diff --git a/backend/app/api/ext_pages.py b/backend/app/api/ext_pages.py
new file mode 100644
index 00000000..c01c2185
--- /dev/null
+++ b/backend/app/api/ext_pages.py
@@ -0,0 +1,136 @@
+"""运行时页面包 API — 上传/清单回填/启停/删除/bundle 分发。
+
+分发端点位于 /api 前缀下, 自动受集中鉴权中间件保护: 浏览器同源动态
+import 会携带登录 cookie, 与页面会话同一访问边界。
+"""
+from __future__ import annotations
+
+import shutil
+import tempfile
+from pathlib import Path
+from typing import Annotated
+
+from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
+from fastapi.responses import FileResponse
+from pydantic import BaseModel
+
+from app.services.ext_pages import PagePackMeta, PagePackStore
+
+router = APIRouter(prefix="/api/ext-pages", tags=["ext-pages"])
+
+# 单文件 ES 模块产物通常 < 1MB, 5MB 上限留足余量; 分块写防 OOM(同 ext_data)
+_MAX_BUNDLE_BYTES = 5 * 1024 * 1024
+_UPLOAD_CHUNK_BYTES = 1024 * 1024
+
+
+async def _write_bundle_capped(file: UploadFile, dest: Path, max_bytes: int) -> None:
+ """分块写入, 累计超限立即 413 并停止读取(防内存放大)。"""
+ total = 0
+ with dest.open("wb") as f:
+ while True:
+ chunk = await file.read(_UPLOAD_CHUNK_BYTES)
+ if not chunk:
+ break
+ total += len(chunk)
+ if total > max_bytes:
+ raise HTTPException(413, f"页面包过大(上限 {max_bytes // (1024 * 1024)}MB)")
+ f.write(chunk)
+
+
+def _store(request: Request) -> PagePackStore:
+ return PagePackStore(request.app.state.repo.store.data_dir)
+
+
+class ManifestBody(BaseModel):
+ label: str = ""
+ path: str = ""
+ error: str = ""
+
+
+class EnabledBody(BaseModel):
+ enabled: bool
+
+
+@router.post("/upload")
+async def upload_page_pack(
+ request: Request,
+ id: Annotated[str, Form()],
+ file: Annotated[UploadFile, File()],
+) -> PagePackMeta:
+ """上传页面包产物(.js/.mjs), 覆盖式: 同 ID 重新上传即更新。"""
+ suffix = Path(file.filename or "").suffix.lower()
+ if suffix not in (".js", ".mjs"):
+ raise HTTPException(400, "仅支持 .js / .mjs 页面包产物")
+ store = _store(request)
+ tmp_dir = Path(tempfile.mkdtemp())
+ try:
+ tmp_path = tmp_dir / "bundle.js"
+ await _write_bundle_capped(file, tmp_path, _MAX_BUNDLE_BYTES)
+ if tmp_path.stat().st_size == 0:
+ raise HTTPException(400, "页面包为空文件")
+ try:
+ return store.save_upload(id, tmp_path)
+ except ValueError as e:
+ raise HTTPException(400, str(e)) from e
+ finally:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+
+
+@router.get("")
+def list_page_packs(request: Request) -> list[PagePackMeta]:
+ return _store(request).list()
+
+
+@router.get("/{pack_id}/bundle.js")
+def get_page_pack_bundle(pack_id: str, request: Request) -> FileResponse:
+ """分发产物。no-store + ETag(sha256): 热更新产物后浏览器立即取新, 未变时可用缓存协商。"""
+ store = _store(request)
+ try:
+ meta = store.get(pack_id)
+ except ValueError as e:
+ raise HTTPException(400, str(e)) from e
+ bundle = store.bundle_path(pack_id) if meta else None
+ if meta is None or bundle is None:
+ raise HTTPException(404, f"页面包 '{pack_id}' 不存在")
+ return FileResponse(
+ bundle,
+ media_type="text/javascript",
+ headers={
+ "Cache-Control": "no-store",
+ "ETag": f'"{meta.sha256}"',
+ },
+ )
+
+
+@router.put("/{pack_id}/manifest")
+def put_page_pack_manifest(pack_id: str, body: ManifestBody, request: Request) -> PagePackMeta:
+ """前端试载结果回填: error 非空 → 停用并记错误, 否则启用并登记摘要。"""
+ store = _store(request)
+ try:
+ return store.update_manifest(pack_id, label=body.label, path=body.path, error=body.error)
+ except KeyError:
+ raise HTTPException(404, f"页面包 '{pack_id}' 不存在") from None
+ except ValueError as e:
+ raise HTTPException(400, str(e)) from e
+
+
+@router.put("/{pack_id}/enabled")
+def put_page_pack_enabled(pack_id: str, body: EnabledBody, request: Request) -> PagePackMeta:
+ store = _store(request)
+ try:
+ return store.set_enabled(pack_id, body.enabled)
+ except KeyError:
+ raise HTTPException(404, f"页面包 '{pack_id}' 不存在") from None
+ except ValueError as e:
+ raise HTTPException(400, str(e)) from e
+
+
+@router.delete("/{pack_id}")
+def delete_page_pack(pack_id: str, request: Request) -> dict:
+ store = _store(request)
+ try:
+ if not store.delete(pack_id):
+ raise HTTPException(404, f"页面包 '{pack_id}' 不存在")
+ except ValueError as e:
+ raise HTTPException(400, str(e)) from e
+ return {"ok": True}
diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py
index 377a8343..6c1c62e9 100644
--- a/backend/app/api/kline.py
+++ b/backend/app/api/kline.py
@@ -1265,19 +1265,19 @@ async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=N
try:
progress("sync_minute", 5, "解析标的池…")
- universe = sorted(set(get_pool("watchlist")) | set(get_pool("CN_Equity_A")))
- # 补充 instruments 全量标的,覆盖北交所、新股等
+ # 标的池 = instruments 维表 + 自选 (单一来源, 不拉 CN_Equity_A/SW1 池)
+ universe: set[str] = set(get_pool("watchlist"))
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
if inst_path.exists():
try:
import polars as pl
inst = pl.read_parquet(inst_path, columns=["symbol"])
- universe = sorted(set(universe) | set(inst["symbol"].to_list()))
+ universe.update(inst["symbol"].to_list())
except Exception: # noqa: BLE001
pass
# 剔除指数 symbol: 指数分钟K无本地存储, 落库会污染 kline_minute
index_set = repo.get_index_symbol_set()
- universe = [s for s in universe if s not in index_set]
+ universe = sorted({s for s in universe if s not in index_set})
progress("sync_minute", 10, f"标的池 {len(universe)} 只")
days = override_days if override_days else get_minute_sync_days()
diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py
index 96659ca7..fded9d46 100644
--- a/backend/app/api/settings.py
+++ b/backend/app/api/settings.py
@@ -60,6 +60,7 @@ def get_settings() -> dict:
current_ai_context_window,
current_ai_max_output_tokens,
current_ai_model,
+ current_ai_protocol,
current_ai_round_checkpoint,
current_codex_command,
current_codex_model,
@@ -81,8 +82,11 @@ def get_settings() -> dict:
"extras_caps": extras_caps(),
# 首次使用引导
"onboarding_completed": preferences.get_onboarding_completed(),
+ # 配置文件位置 (AI 与数据源 Key 统一存放, 支持外部编辑后热加载)
+ "secrets_path": str(secrets_store.file_path()),
# AI 配置
"ai_provider": ai_provider,
+ "ai_protocol": current_ai_protocol(),
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
"has_ai_key": bool(secrets_store.get_ai_key()),
@@ -254,6 +258,7 @@ class AiSettingsIn(BaseModel):
base_url: str = ""
api_key: str | None = None
model: str = ""
+ protocol: str = "" # chat_completions | responses; 空=保持原值
reasoning_effort: str = Field(default="high", max_length=64)
codex_command: str = ""
codex_reasoning_effort: str = ""
@@ -263,16 +268,63 @@ class AiSettingsIn(BaseModel):
round_checkpoint: int | None = None # 助手工具轮次检查点; 0=不检查
+class AiTestIn(BaseModel):
+ """草稿连通性测试入参 — 只读表单值, 与保存完全解耦。"""
+
+ provider: str = "openai_compat"
+ base_url: str = ""
+ api_key: str | None = None # 留空回退已保存 Key (改模型/协议不必重输)
+ model: str = ""
+ protocol: str = ""
+
+
+@router.post("/ai/test")
+async def test_ai_settings(req: AiTestIn) -> dict:
+ """用当前表单值发一次最小请求验证连通性, 不保存/不修改任何配置。
+
+ 与 POST /ai (保存) 解耦: 测试失败不影响已保存配置, 状态栏也不会把
+ 未验证的草稿展示为已生效。错误保留 200 包体 (ok=false), 由前端内联
+ 展示在测试结果徽章, 不触发全局 toast。
+ """
+ from app.services.ai_provider import (
+ RESPONSES_PROTOCOL,
+ _format_openai_error,
+ _is_openai_transport_error,
+ probe_ai_config,
+ )
+
+ provider = req.provider or "openai_compat"
+ api_key = (req.api_key or "").strip() or (secrets_store.get_ai_key() or "")
+ protocol = req.protocol if req.protocol == RESPONSES_PROTOCOL else ""
+ try:
+ text = await probe_ai_config(
+ provider=provider,
+ api_key=api_key,
+ base_url=req.base_url.strip(),
+ model=req.model.strip(),
+ protocol=protocol,
+ )
+ except Exception as exc: # noqa: BLE001 — 连通性测试: 任何异常都转为结果回报
+ detail = str(exc) or repr(exc)
+ if _is_openai_transport_error(exc):
+ detail = _format_openai_error(exc)
+ return {"ok": False, "error": detail, "error_type": type(exc).__name__}
+ return {"ok": True, "model": req.model.strip() or provider, "response": text[:80]}
+
+
@router.post("/ai")
def save_ai_settings(req: AiSettingsIn) -> dict:
"""保存 AI 配置(全部持久化到 secrets.json)"""
from app.config import settings
from app.services.ai_provider import (
+ CHAT_COMPLETIONS_PROTOCOL,
OPENAI_PROVIDER,
+ RESPONSES_PROTOCOL,
ai_configured,
current_ai_context_window,
current_ai_max_output_tokens,
current_ai_model,
+ current_ai_protocol,
current_ai_provider,
current_ai_round_checkpoint,
current_codex_command,
@@ -304,6 +356,12 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
if req.base_url:
updates["ai_base_url"] = req.base_url
settings.ai_base_url = req.base_url
+ if req.protocol:
+ # 协议仅对 openai/openai_compat 有意义; 非法值 fail-loud, 避免静默落错协议。
+ if req.protocol not in (CHAT_COMPLETIONS_PROTOCOL, RESPONSES_PROTOCOL):
+ raise HTTPException(status_code=400, detail=f"不支持的协议: {req.protocol}")
+ updates["ai_protocol"] = req.protocol
+ settings.ai_protocol = req.protocol
if req.api_key is not None:
if req.api_key:
updates["ai_api_key"] = req.api_key
@@ -345,6 +403,7 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
return {
"ok": True,
"ai_provider": provider,
+ "ai_protocol": current_ai_protocol(),
"ai_model": current_ai_model(),
"ai_openai_model": current_openai_model(),
"ai_reasoning_effort": current_openai_reasoning_effort(),
@@ -376,9 +435,10 @@ def clear_ai_settings() -> dict:
"ai_codex_command",
"ai_codex_reasoning_effort",
)
- secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort", "ai_max_output_tokens", "ai_context_window")
+ secrets_store.clear("ai_provider", "ai_protocol", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort", "ai_max_output_tokens", "ai_context_window")
# 同步重置运行时内存(provider 回默认值,其余置空)
settings.ai_provider = "openai_compat"
+ settings.ai_protocol = "chat_completions"
settings.ai_base_url = ""
settings.ai_api_key = ""
settings.ai_model = ""
@@ -396,6 +456,10 @@ async def list_sponsor_models() -> dict:
其网关按 Origin 头过滤: 浏览器跨域请求只会拿到国产模型子集,
服务端请求无 Origin 头可取全量, 故由后端代理转发。
+
+ /v1/models 除 id 外还携带 pricing (discountRate/分层单价) 与
+ context_length; 精简为 items 下发供前端展示折扣徽章与价格,
+ models 保持纯 id 列表向后兼容。
"""
import httpx
@@ -406,11 +470,30 @@ async def list_sponsor_models() -> dict:
data = res.json()
except Exception as exc:
raise HTTPException(status_code=502, detail=f"获取模型列表失败: {exc}") from exc
- models = sorted({
- item.get("id") for item in data.get("data", [])
- if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
- })
- return {"models": models}
+
+ def _amount(pricing: dict, dim: str) -> float | None:
+ value = (pricing.get(dim) or {}).get("amount")
+ return float(value) if isinstance(value, (int, float)) else None
+
+ items: list[dict] = []
+ for raw in data.get("data", []):
+ if not isinstance(raw, dict):
+ continue
+ model_id = raw.get("id")
+ if not isinstance(model_id, str) or not model_id:
+ continue
+ pricing = raw.get("pricing") if isinstance(raw.get("pricing"), dict) else {}
+ rate = pricing.get("discountRate")
+ context_length = raw.get("context_length")
+ items.append({
+ "id": model_id,
+ "context_length": context_length if isinstance(context_length, int) else None,
+ "discount_rate": float(rate) if isinstance(rate, (int, float)) else 1.0,
+ "input_price": _amount(pricing, "input"),
+ "output_price": _amount(pricing, "output"),
+ })
+ items.sort(key=lambda x: x["id"])
+ return {"models": [item["id"] for item in items], "items": items}
# ===== 偏好设置 =====
@@ -1212,6 +1295,48 @@ class MainlineFilterIn(BaseModel):
exclude_st: bool | None = None
+class EmotionScoreConfigIn(BaseModel):
+ """情绪评分口径 (维度权重 / 子项权重与归一化区间 / 档位阈值)。
+
+ config 为整棵树或 null — 传 null 表示恢复出厂口径。不做逐字段白名单:
+ 结构由 emotion_score.merge_config 归一化 (未知 key 丢弃、缺项补默认、
+ 阈值乱序自动重排), 用户改坏权重也不会让看板算不出分。
+ """
+
+ config: dict | None = None
+
+
+@router.get("/emotion-score/config")
+def get_emotion_score_config() -> dict:
+ """情绪评分口径: 当前配置 + 出厂默认 + 维度顺序 + 档位名。
+
+ 看板「情绪雷达」卡片的配置对话框一次拉全 —— 前端不硬编码任何权重/区间/
+ 阈值字面量, 表单全部由本接口返回的结构渲染。
+ """
+ from app.services import preferences
+ from app.services.emotion_score import public_config
+
+ payload = public_config(preferences.get_emotion_score_config())
+ payload["customized"] = preferences.get_emotion_score_config() is not None
+ return payload
+
+
+@router.put("/emotion-score/config")
+def update_emotion_score_config(req: EmotionScoreConfigIn) -> dict:
+ """保存情绪评分口径; config 传 null/{} = 恢复出厂。
+
+ 形状校验在 preferences 层 (经 emotion_score.merge_config), 值的合法性
+ (负权重/区间倒置/阈值乱序) 由计算时归一化兜底, 故此处不做逐字段白名单。
+ """
+ from app.services import preferences
+ from app.services.emotion_score import public_config
+
+ saved = preferences.set_emotion_score_config(req.config)
+ payload = public_config(saved)
+ payload["customized"] = saved is not None
+ return payload
+
+
@router.put("/preferences/mainline-filter")
def update_mainline_filter(req: MainlineFilterIn) -> dict:
"""更新市场主线过滤配置。部分更新; 修改后需重算主线(POST /api/regime/mainline/recompute)生效。
diff --git a/backend/app/api/task_registry.py b/backend/app/api/task_registry.py
new file mode 100644
index 00000000..45844b4c
--- /dev/null
+++ b/backend/app/api/task_registry.py
@@ -0,0 +1,63 @@
+"""进行中任务注册表 API — 前端 fire-and-forget 上报 + 页面挂载时查询。
+
+PUT/DELETE 由任务启动/终态触发 (不阻塞 UI, 失败静默); GET 在任务页挂载时
+最多调用一次。全部是小 JSON 文件读写, 不触碰任务执行路径。
+"""
+from __future__ import annotations
+
+import re
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel, Field
+
+from app.services import task_registry
+
+router = APIRouter(prefix="/api/task-registry", tags=["task-registry"])
+
+# id 由前端按 kind+参数指纹生成 (字母/数字/冒号/下划线/短横)
+_VALID_ID = re.compile(r"^[A-Za-z0-9:_-]{1,128}$")
+
+
+class TaskUpsertRequest(BaseModel):
+ kind: str = Field(max_length=32)
+ status: str = Field(max_length=16)
+ label: str = Field(default="", max_length=120)
+ payload: dict = Field(default_factory=dict)
+
+
+def _data_dir(request: Request):
+ repo = getattr(request.app.state, "repo", None)
+ if repo is None:
+ raise HTTPException(status_code=503, detail="数据目录不可用")
+ return repo.store.data_dir
+
+
+@router.get("")
+def list_tasks(request: Request, kind: str | None = None) -> dict:
+ if kind is not None and kind not in task_registry.TASK_KINDS:
+ raise HTTPException(status_code=400, detail=f"未知的任务类型: {kind}")
+ return {"items": task_registry.list_items(_data_dir(request), kind)}
+
+
+@router.put("/{task_id}")
+def upsert_task(request: Request, task_id: str, req: TaskUpsertRequest) -> dict:
+ if not _VALID_ID.match(task_id):
+ raise HTTPException(status_code=400, detail="非法的任务 id")
+ try:
+ entry = task_registry.upsert(
+ _data_dir(request),
+ task_id,
+ kind=req.kind,
+ status=req.status,
+ label=req.label,
+ payload=req.payload,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+ return {"ok": True, "item": entry}
+
+
+@router.delete("/{task_id}")
+def delete_task(request: Request, task_id: str) -> dict:
+ removed = task_registry.delete(_data_dir(request), task_id)
+ return {"ok": True, "removed": removed}
diff --git a/backend/app/config.py b/backend/app/config.py
index 0554ab5a..49a86a5f 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -84,6 +84,8 @@ class Settings(BaseSettings):
# AI
ai_provider: str = "openai_compat"
+ # OpenAI 系请求协议: chat_completions (兼容面最广) | responses (OpenAI 官方新协议)。
+ ai_protocol: str = "chat_completions"
ai_base_url: str = "https://llm.runninghub.ai/v1"
ai_api_key: str = ""
ai_model: str = "openai/gpt-6-astra-saver"
diff --git a/backend/app/custom/assistant/routes.py b/backend/app/custom/assistant/routes.py
index c3800d85..b455ab7b 100644
--- a/backend/app/custom/assistant/routes.py
+++ b/backend/app/custom/assistant/routes.py
@@ -3,11 +3,14 @@
POST /api/custom/assistant/chat NDJSON 事件流(协议见模块 __init__)
GET /api/custom/assistant/status 供应商配置状态(前端门控展示)
GET /api/custom/assistant/suggests 空会话快捷指令
+GET /api/custom/assistant/sessions 会话历史(服务端, 跨设备)
+PUT /api/custom/assistant/sessions 会话历史整体保存(前端防抖写入)
POST /api/custom/assistant/actions/{call_id}/decision
动作工具确认/拒绝(确认卡按钮回调)
"""
from __future__ import annotations
+from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
@@ -15,6 +18,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.custom.assistant import actions as assistant_actions
+from app.custom.assistant import sessions as assistant_sessions
from app.custom.assistant import tools as assistant_tools
from app.custom.assistant.chat_service import chat_stream
from app.services.ai_provider import (
@@ -45,6 +49,19 @@ class ActionDecisionRequest(BaseModel):
approve: bool
+class AssistantSessionsPayload(BaseModel):
+ """前端 store.ts 的持久化载荷: 会话数组 + 活跃会话 id。
+
+ sessions 用宽松的 list[dict] (消息形状由前端定义, 后端只裁剪条数),
+ 上限与前端一致, 防御异常客户端把文件写爆。字段名对齐前端 (activeId)。
+ """
+
+ sessions: list[dict] = Field(max_length=assistant_sessions.MAX_SESSIONS)
+ active_id: str = Field(default="", max_length=64, alias="activeId")
+
+ model_config = {"populate_by_name": True}
+
+
def build_router() -> APIRouter:
router = APIRouter(prefix="/api/custom/assistant", tags=["custom-assistant"])
@@ -89,6 +106,24 @@ def build_router() -> APIRouter:
def suggests() -> dict:
return {"suggests": assistant_tools.QUICK_SUGGESTS}
+ @router.get("/sessions")
+ def sessions(request: Request) -> dict:
+ repo = getattr(request.app.state, "repo", None)
+ if repo is None:
+ raise HTTPException(status_code=503, detail="数据目录不可用")
+ return assistant_sessions.load_sessions(Path(repo.store.data_dir))
+
+ @router.put("/sessions")
+ def put_sessions(request: Request, req: AssistantSessionsPayload) -> dict:
+ repo = getattr(request.app.state, "repo", None)
+ if repo is None:
+ raise HTTPException(status_code=503, detail="数据目录不可用")
+ n = assistant_sessions.save_sessions(
+ Path(repo.store.data_dir),
+ {"sessions": req.sessions, "activeId": req.active_id},
+ )
+ return {"ok": True, "sessions": n}
+
@router.post("/actions/{call_id}/decision")
async def action_decision(call_id: str, req: ActionDecisionRequest) -> dict:
"""确认卡回调: 唤醒挂起中的动作工具(批准执行或拒绝)。"""
diff --git a/backend/app/custom/assistant/sessions.py b/backend/app/custom/assistant/sessions.py
new file mode 100644
index 00000000..cd11d959
--- /dev/null
+++ b/backend/app/custom/assistant/sessions.py
@@ -0,0 +1,104 @@
+"""AI 助手会话历史服务端存储 — 单用户, 前端为主写入方, 后端只存取与裁剪。
+
+动机: 会话历史是用户产生的内容而非设备习惯, 原先只存浏览器 localStorage,
+换设备即不可见; 2026-10 起同步到服务端, localStorage 降级为启动缓存/离线兜底。
+
+文件: data/user_data/assistant_sessions.json
+ {"sessions": [...], "activeId": "..."} (字段名与前端 store.ts 载荷一致)
+
+容量纪律 (与前端 store.ts 相同): ≤20 会话 × 每会话 ≤200 条消息, 超限裁尾部。
+写走 fs_utils.atomic_write_text 原子替换; 读带 mtime 签名缓存 — GET 只在应用
+加载时调用一次、写经前端防抖, 磁盘 IO 可忽略, 不影响其他任何请求路径。
+"""
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import Any
+
+from app.services.fs_utils import atomic_write_text
+
+logger = logging.getLogger(__name__)
+
+MAX_SESSIONS = 20
+MAX_MESSAGES_PER_SESSION = 200
+_FILENAME = "assistant_sessions.json"
+
+_cache: dict[str, Any] | None = None
+_cache_sig: tuple[int, int] | None = None
+
+
+def _path(data_dir: Path) -> Path:
+ p = data_dir / "user_data" / _FILENAME
+ p.parent.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def _signature(p: Path) -> tuple[int, int] | None:
+ try:
+ st = p.stat()
+ return (st.st_mtime_ns, st.st_size)
+ except OSError:
+ return None
+
+
+def _sanitize_sessions(data: Any) -> list[dict]:
+ """结构校验 + 容量裁剪: 只信 id 为字符串的会话, 消息截尾至 200 条。"""
+ if not isinstance(data, dict):
+ return []
+ sessions = data.get("sessions")
+ if not isinstance(sessions, list):
+ return []
+ out: list[dict] = []
+ for s in sessions:
+ if not isinstance(s, dict) or not isinstance(s.get("id"), str) or not s["id"]:
+ continue
+ msgs = s.get("messages")
+ if not isinstance(msgs, list):
+ msgs = []
+ out.append({**s, "messages": msgs[-MAX_MESSAGES_PER_SESSION:]})
+ return out[:MAX_SESSIONS]
+
+
+def _sanitize_active(data: Any) -> str:
+ if isinstance(data, dict):
+ active = data.get("activeId")
+ if isinstance(active, str):
+ return active
+ return ""
+
+
+def load_sessions(data_dir: Path) -> dict[str, Any]:
+ """读取 {sessions, activeId}; 文件不存在/损坏返回空结构 (不抛错)。"""
+ global _cache, _cache_sig
+ p = _path(data_dir)
+ sig = _signature(p)
+ if sig is None:
+ return {"sessions": [], "activeId": ""}
+ if _cache is not None and sig == _cache_sig:
+ return _cache
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ except Exception as e: # noqa: BLE001 — 损坏文件视为无历史, 不阻断
+ logger.warning("assistant sessions 文件损坏, 视为空: %s", e)
+ return {"sessions": [], "activeId": ""}
+ result = {
+ "sessions": _sanitize_sessions(data),
+ "activeId": _sanitize_active(data),
+ }
+ _cache, _cache_sig = result, sig
+ return result
+
+
+def save_sessions(data_dir: Path, payload: dict[str, Any]) -> int:
+ """校验 + 裁剪 + 原子写盘; 返回保留的会话数。"""
+ global _cache, _cache_sig
+ doc = {
+ "sessions": _sanitize_sessions(payload),
+ "activeId": _sanitize_active(payload),
+ }
+ p = _path(data_dir)
+ atomic_write_text(p, json.dumps(doc, ensure_ascii=False))
+ _cache, _cache_sig = doc, _signature(p)
+ return len(doc["sessions"])
diff --git a/backend/app/custom/assistant/streaming.py b/backend/app/custom/assistant/streaming.py
index 4f419831..04cb8109 100644
--- a/backend/app/custom/assistant/streaming.py
+++ b/backend/app/custom/assistant/streaming.py
@@ -17,6 +17,7 @@ tool_calls 分片, 流结束时统一返回本轮结果, 由 chat_service 决定
from __future__ import annotations
import asyncio
+import inspect
from collections.abc import AsyncIterator, Sequence
from typing import Any
@@ -24,11 +25,13 @@ from openai import AsyncOpenAI
from app import secrets_store
from app.config import settings
+from app.services import ai_responses
from app.services.ai_provider import (
OPENAI_PROVIDER,
current_ai_model,
current_ai_provider,
current_openai_reasoning_effort,
+ is_responses_protocol,
normalize_openai_base_url,
)
@@ -88,6 +91,14 @@ async def stream_openai_round(
timeout: float = 240.0,
) -> AsyncIterator[dict[str, Any]]:
"""执行一轮流式补全: yield 文本 delta, 最后 yield 一个 round_end 事件。"""
+ if is_responses_protocol():
+ # Responses 协议走平行实现, 事件输出与本函数同构, chat_service 无感。
+ async for event in ai_responses.stream_round(
+ messages, tools, temperature=temperature, timeout=timeout,
+ ):
+ yield event
+ return
+
client = _client(timeout)
model = current_ai_model()
kwargs = _build_kwargs(temperature)
@@ -159,10 +170,14 @@ async def stream_openai_round(
if getattr(fn, "arguments", None):
entry["arguments"] += str(fn.arguments)
- # 让 SDK 流对象尽快释放(部分兼容网关要求显式 close)。
+ # 让 SDK 流对象尽快释放(部分兼容网关要求显式 close); SDK 2.x 的
+ # AsyncStream.close 是协程, 必须区分同步/异步两种签名。
close = getattr(stream, "close", None)
if close is not None:
- await asyncio.to_thread(close)
+ if inspect.iscoroutinefunction(close):
+ await close()
+ else:
+ await asyncio.to_thread(close)
if calls:
yield {
diff --git a/backend/app/data_providers/capabilities.py b/backend/app/data_providers/capabilities.py
index ccbb97ce..0a067662 100644
--- a/backend/app/data_providers/capabilities.py
+++ b/backend/app/data_providers/capabilities.py
@@ -198,3 +198,92 @@ def build_capability_matrix(current: dict[str, str], tickflow_tier: str = "none"
"pending": pending,
})
return {"tickflow_tier": tier_base, "capabilities": capabilities}
+
+
+# ---------------------------------------------------------------------------
+# 门禁视角 (面向各服务的启动/调度判断与日志)
+# ---------------------------------------------------------------------------
+
+def _cap_specs_by_id() -> dict[str, dict]:
+ return {c["id"]: c for c in CAPABILITY_REGISTRY}
+
+
+def _current_routing() -> dict[str, str]:
+ """偏好字段 → 当前生效源 (经 preferences getters, 非法值已回退默认)。
+
+ 与 api/settings.py 的 capability-matrix 端点同一组 getter; 显式列全,
+ 不用 getattr 拼函数名 (拼错会静默回退, 难排查)。
+ """
+ from app.services import preferences
+
+ return {
+ "realtime_data_provider": preferences.get_realtime_data_provider(),
+ "daily_data_provider": preferences.get_daily_data_provider(),
+ "minute_data_provider": preferences.get_minute_data_provider(),
+ "full_minute_data_provider": preferences.get_full_minute_data_provider(),
+ "depth5_data_provider": preferences.get_depth5_data_provider(),
+ "adj_factor_provider": preferences.get_adj_factor_provider(),
+ "financial_data_provider": preferences.get_financial_provider(),
+ }
+
+
+def resolve_capability_sources(tickflow_tier: str = "none") -> list[dict]:
+ """每个能力一行: 生效源 + 展示名 + 该源当前能否真正提供 (usable)。
+
+ 与 build_capability_matrix 同口径 (复用同一 candidates 组装), 供服务层
+ 的门禁判断与日志标签使用 —— 门禁必须看「生效源能否提供」, 而不是只看
+ TickFlow 套餐 (路由到插件时 TickFlow 档位无关; 同理路由到 TickFlow 而
+ 档位不足时, 插件声明了也不该被算作可用)。
+
+ 返回 [{id, label, field, source, source_display, usable, tf_available}]。
+ """
+ tier_rank = _TIER_RANK.get(_tier_base(tickflow_tier), -1)
+ sources = _declared_sources()
+ current = _current_routing()
+
+ rows: list[dict] = []
+ for cap in CAPABILITY_REGISTRY:
+ field = cap.get("field")
+ if not field:
+ continue
+ effective = current.get(field, cap["default"])
+ tf_available = tier_rank >= _TIER_RANK[cap["tf_tier"]]
+ provider_names = {"tickflow"} if tf_available else set()
+ provider_names |= {
+ s["name"] for s in sources
+ if cap["id"] in s["datasets"] and s["available"]
+ }
+ rows.append({
+ "id": cap["id"],
+ "label": cap["label"],
+ "field": field,
+ "source": effective,
+ "source_display": _display_of(sources, effective),
+ "usable": effective in provider_names,
+ "tf_available": tf_available,
+ })
+ return rows
+
+
+def capability_gate(cap_id: str, tickflow_tier: str | None = None) -> tuple[bool, str]:
+ """单个能力的门禁: (是否可用, 日志标签)。
+
+ 标签形如 "日K ← fuyao" / "财务数据 ← TickFlow(档位不足)", 让「为什么没跑」
+ 在日志里一眼可读, 而不必回设置页对照路由。
+
+ 可用性以生效源为准 (与设置页 usable 同口径): 路由到已声明的插件即可用,
+ 路由到 TickFlow 但档位不够则不可用。
+ """
+ if tickflow_tier is None:
+ from app.tickflow import policy
+ tickflow_tier = policy.base_tier_name()
+
+ for row in resolve_capability_sources(tickflow_tier):
+ if row["id"] == cap_id:
+ label = f"{row['label']} ← {row['source_display']}"
+ if not row["usable"] and row["source"] == "tickflow" and not row["tf_available"]:
+ label += "(档位不足)"
+ elif not row["usable"]:
+ label += "(当前源不可用)"
+ return row["usable"], label
+ return False, f"{cap_id}(未知能力)"
diff --git a/backend/app/data_providers/custom/loader.py b/backend/app/data_providers/custom/loader.py
index 18a69aa2..ae183dde 100644
--- a/backend/app/data_providers/custom/loader.py
+++ b/backend/app/data_providers/custom/loader.py
@@ -578,6 +578,14 @@ def _register_one_plugin(manifest: dict) -> None:
"homepage": manifest.get("homepage", ""),
"api_key_env": manifest.get("api_key_env", ""),
"api_key_masked": _plugin_key_masked(name, manifest.get("api_key_env", "")),
+ # 计费标识 (可选): 如 "points" → 设置页能力芯片/适配行标注「积分」
+ "billing": manifest.get("billing", ""),
+ # 计费提示 (可选): 积分消耗与轮询频率/拉取范围的关系, 设置页按警示样式展示
+ "billing_note": manifest.get("billing_note", ""),
+ # 使用要点 (可选): 各能力的规模/节奏说明, 设置页详情卡以列表展示
+ "usage_notes": list(manifest.get("usage_notes", []) or []),
+ # Key/账务管理页 (可选): 设置页 Key 配置区展示入口
+ "key_manage_url": manifest.get("key_manage_url", ""),
}
if not available:
return # 依赖没装: 不注册, 但状态已记录供 UI 显示
diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py
index 100e8aef..7775ffb7 100644
--- a/backend/app/jobs/daily_pipeline.py
+++ b/backend/app/jobs/daily_pipeline.py
@@ -26,7 +26,7 @@ from app.market_time import cn_today
from app.services import index_sync, instrument_sync, kline_sync
from app.services import preferences as _prefs
from app.tickflow.capabilities import Cap, CapabilitySet
-from app.tickflow.pools import DEMO_SYMBOLS, get_pool
+from app.tickflow.pools import get_pool
from app.tickflow.repository import KlineRepository
logger = logging.getLogger(__name__)
@@ -139,35 +139,26 @@ def _invalidate(table: str | None = None) -> None:
def _resolve_universe(capset: CapabilitySet, repo=None) -> list[str]:
- """解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。
+ """解析标的池 — 本地 instruments 维表 + 自选 (单一来源, 无池兜底)。
- 有 batch 能力 → 直接拉 CN_Equity_A universe
- 其他用户 → 用 instruments parquet + watchlist 兜底
+ 不再拉 CN_Equity_A 池: 其 universe 匹配曾错配到 Expired 变体、触发
+ SW1 行业聚合兜底, 产出 4224 只的残缺池。维表由盘后管道 Step 0 先同步,
+ 这里只读 parquet; 维表缺失时仅剩自选, 宁缺毋假 (不塞演示标的),
+ 由上游按空池结果如实呈现。
- repo 传入时过滤自选兜底里的指数 symbol (指数日K走独立 kline_index_* 存储,
+ repo 传入时过滤自选里的指数 symbol (指数日K走独立 kline_index_* 存储,
进股票池会污染 kline_daily/kline_minute)。ETF 刻意保留 (既有行为)。
"""
- if capset.has(Cap.KLINE_DAILY_BATCH):
- try:
- all_a = get_pool("CN_Equity_A", refresh=True)
- if all_a:
- return sorted(all_a)
- except Exception as e: # noqa: BLE001
- logger.warning("CN_Equity_A pool unavailable, fallback: %s", e)
-
- # Free 用户兜底: instruments parquet + watchlist + demo
- base: set[str] = set(DEMO_SYMBOLS)
- base.update(get_pool("watchlist"))
- d = Path(settings.data_dir)
- inst_path = d / "instruments" / "instruments.parquet"
+ base: set[str] = set(get_pool("watchlist"))
+ inst_path = Path(settings.data_dir) / "instruments" / "instruments.parquet"
if inst_path.exists():
try:
inst = pl.read_parquet(inst_path, columns=["symbol"])
base.update(inst["symbol"].to_list())
except Exception as e: # noqa: BLE001
- logger.warning("instruments supplement failed: %s", e)
- # 过滤自选兜底里的指数 symbol (指数日K走独立 kline_index_* 存储,
- # 进股票池会污染 kline_daily/kline_minute)。ETF 刻意保留 (既有行为)。
+ logger.warning("instruments 维表读取失败, 标的池仅剩自选: %s", e)
+ else:
+ logger.warning("instruments 维表缺失, 标的池仅剩自选 %d 只", len(base))
if repo is not None:
base -= set(repo.get_index_symbol_set())
return sorted(base)
diff --git a/backend/app/main.py b/backend/app/main.py
index 278245f6..3abd0d28 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -20,6 +20,7 @@ from app.api import (
data,
events,
ext_data,
+ ext_pages,
factors,
financials,
indices,
@@ -39,6 +40,7 @@ from app.api import (
signals,
stock_analysis,
strategy,
+ task_registry,
watchlist,
)
from app.api import auth as auth_api
@@ -160,6 +162,19 @@ async def _application_lifespan(app: FastAPI):
capset = detect_capabilities()
app.state.capabilities = capset
logger.info("ready; %d capabilities active", len(capset.all()))
+ # 各能力的生效源与可用性: 切换数据源后一眼看出路由落点, 无需回设置页对照
+ try:
+ from app.data_providers.capabilities import resolve_capability_sources
+
+ for row in resolve_capability_sources():
+ logger.info(
+ " 能力路由 | %-8s ← %-12s %s",
+ row["label"],
+ row["source_display"],
+ "可用" if row["usable"] else "不可用",
+ )
+ except Exception as e: # noqa: BLE001 — 仅日志, 失败不影响启动
+ logger.warning("能力路由日志输出失败: %s", e)
# 全局行情服务
qs = QuoteService()
@@ -558,6 +573,8 @@ app.include_router(analysis.router)
app.include_router(pipeline.router)
app.include_router(data.router)
app.include_router(ext_data.router)
+app.include_router(ext_pages.router)
+app.include_router(task_registry.router)
app.include_router(financials.router)
app.include_router(stock_analysis.router)
app.include_router(market_recap.router)
diff --git a/backend/app/plugins/fuyao/plugin.yaml b/backend/app/plugins/fuyao/plugin.yaml
index cc063f71..5e8073f2 100644
--- a/backend/app/plugins/fuyao/plugin.yaml
+++ b/backend/app/plugins/fuyao/plugin.yaml
@@ -9,6 +9,6 @@ entry: app.plugins.fuyao.provider:FuyaoProvider
check: app.plugins.fuyao.provider:availability
datasets: [realtime, daily, adj_factor, financial]
api_key_env: FUYAO_API_KEY # 声明后设置页提供 Key 输入框(先探后存, secrets.json 优先)
-description: "同花顺官方 REST 数据 API。A 股实时快照、日K(原始价: 近端走 10d dump, 深窗口走 10 年全量 dump 一次下载秒级筛选, 兜底单标的接口)、除权因子(事件 dump + 本地日K dump 配价推导, 涨跌停自检, 秒级)、财务(利润表/资产负债表/现金流量表/指标, 字段映射至项目口径, bps 由估值反推; 股本无上游接口, 指标历史期建议切回 TickFlow 补齐);分钟未接入,自动回退 TickFlow。"
+description: "同花顺官方金融数据 API。提供实时快照、日K(原始价)、除权因子、财务三表与指标;未接入分钟与股本,自动回退 TickFlow。"
install_hint: "点击卡片中的输入框配置 API Key(https://fuyao.aicubes.cn 申请),或在 .env 中配置 FUYAO_API_KEY"
homepage: "https://fuyao.aicubes.cn"
diff --git a/backend/app/plugins/tdxquant/__init__.py b/backend/app/plugins/tdxquant/__init__.py
new file mode 100644
index 00000000..710f8727
--- /dev/null
+++ b/backend/app/plugins/tdxquant/__init__.py
@@ -0,0 +1 @@
+"""TdxQuant(通达信量化数据服务)内置数据源插件。"""
diff --git a/backend/app/plugins/tdxquant/client.py b/backend/app/plugins/tdxquant/client.py
new file mode 100644
index 00000000..26417684
--- /dev/null
+++ b/backend/app/plugins/tdxquant/client.py
@@ -0,0 +1,260 @@
+"""TdxQuant — tdxaidata (通达信量化数据) 原生库封装。
+
+tdxaidata 是 ctypes 包装的私有 TCP 协议客户端(aihs/aids.tdx.com.cn),
+DLL 与运行时 ini 随 wheel 分发。两个实测约束决定本模块的形状:
+ - 原生层每进程加载一次、token 写在 DLL 旁的 ini 里 → Key 物化到私有
+ lib 目录 + TDX_AI_DATA_LIB 指向, 不污染 site-packages、换 Key 只重写 ini;
+ - 空闲后首次调用会 "Invalid server response" → 统一重试包装。
+"""
+from __future__ import annotations
+
+import hashlib
+import logging
+import os
+import shutil
+import sys
+import time
+from datetime import date
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# 全 A 股票池: 五个市场名实测(2026-10)合计约 5579 只; ETF/指数不在枚举内
+_MARKETS = ("上证主板", "科创板", "深证主板", "创业板", "北交所")
+
+# 快照批量字段(实测有效名; 注意昨收叫 LastClose、高低叫 Max/Min, 快照无 Name)
+SNAPSHOT_FIELDS = (
+ "Now", "Open", "Max", "Min", "LastClose", "Volume", "Amount", "RefreshTime",
+ "Buyp", "Buyv", "Sellp", "Sellv",
+)
+
+_RETRIES = 3
+_NONE_RETRIES = 2 # 空返回(可能是永久性坏代码)只补试一次
+_RETRY_SLEEP_S = 0.6
+_UNIVERSE_TTL_S = 12 * 3600.0
+
+_DLL_BY_PLATFORM = {"win32": "TdxAiData.dll", "darwin": "libTdxAiData.dylib"}
+LIB_DLL_NAME = _DLL_BY_PLATFORM.get(sys.platform, "libTdxAiData.so")
+LIB_ENV = "TDX_AI_DATA_LIB" # tdxaidata 读取: 指向 DLL 文件路径
+
+
+class TdxQuantError(RuntimeError):
+ """tdxaidata 调用失败(网络/参数/协议)。"""
+
+
+def package_lib_dir() -> Path:
+ """tdxaidata 包自带的 lib 目录(平台 DLL + 运行时 ini)。"""
+ import tdxaidata
+
+ return Path(tdxaidata.__file__).resolve().parent / "lib"
+
+
+def write_token_ini(ini: Path, api_key: str) -> None:
+ """把 [Token] token=
写入 ini, 保留其余配置行。"""
+ lines = (
+ ini.read_text(encoding="utf-8", errors="replace").splitlines()
+ if ini.exists()
+ else ["[Token]"]
+ )
+ out: list[str] = []
+ in_token = False
+ replaced = False
+ for line in lines:
+ stripped = line.strip()
+ if stripped.lower() == "[token]":
+ in_token = True
+ out.append(line)
+ continue
+ if stripped.startswith("[") and stripped.endswith("]"):
+ in_token = False
+ if in_token and "=" in line and stripped.lower().split("=", 1)[0].strip() == "token":
+ if not replaced:
+ out.append(f"token={api_key}")
+ replaced = True
+ continue
+ out.append(line)
+ if "[Token]" not in {line.strip() for line in out}:
+ out.extend(["[Token]", f"token={api_key}"])
+ elif not replaced:
+ idx = next(i for i, line in enumerate(out) if line.strip().lower() == "[token]")
+ out.insert(idx + 1, f"token={api_key}")
+ ini.write_text("\n".join(out) + "\n", encoding="utf-8")
+
+
+def materialize_lib(api_key: str, lib_root: Path, source: Path | None = None) -> Path:
+ """复制包内 lib 到私有目录并写入 token, 返回 DLL 路径。
+
+ marker 记 token 指纹 + DLL mtime/size, 未变化时直接复用(每轮 realtime
+ 轮询都会走到这里, 不能反复复制几十 MB 的 DLL)。
+ """
+ src = source or package_lib_dir()
+ dll = src / LIB_DLL_NAME
+ if not dll.exists():
+ raise TdxQuantError(f"tdxaidata lib 缺少 {LIB_DLL_NAME}: {src}")
+ lib_root.mkdir(parents=True, exist_ok=True)
+ marker = lib_root / ".tdxquant_lib_marker"
+ stat = dll.stat()
+ want = f"{hashlib.sha256(api_key.encode()).hexdigest()}:{stat.st_mtime_ns}:{stat.st_size}"
+ copied = lib_root / LIB_DLL_NAME
+ if marker.exists() and marker.read_text(encoding="utf-8") == want and copied.exists():
+ return copied
+ for item in src.iterdir():
+ if item.is_file():
+ shutil.copy2(item, lib_root / item.name)
+ for ini_name in ("TdxAiData.ini", "user.ini"):
+ write_token_ini(lib_root / ini_name, api_key)
+ marker.write_text(want, encoding="utf-8")
+ return copied
+
+
+class TdxQuantClient:
+ """原生 tqs 的薄封装: 重试、股票池枚举、快照/日K/除权三个取数面。"""
+
+ def __init__(self, api_key: str, lib_root: Path | None = None) -> None:
+ from app.config import settings
+
+ self.api_key = api_key
+ self._lib_root = lib_root or (settings.data_dir / "user_data" / "tdxquant_lib")
+ dll = materialize_lib(api_key, self._lib_root)
+ os.environ[LIB_ENV] = str(dll)
+ from tdxaidata import tqs
+ from tdxaidata.tdxaidata import _NativeTdxAiData
+
+ self._tqs = tqs
+ self._native_cls = _NativeTdxAiData
+ self._universe: list[str] = []
+ self._universe_at = 0.0
+ self._warmup()
+
+ def _warmup(self, attempts: int = 5) -> None:
+ """DLL 冷启动后有一段"未就绪"窗口, 业务调用持续失败(实测首批整批丢弃)。
+
+ 用最小快照轮询到就绪; 一直失败不抛 — 后续调用可能自愈, 只留告警。
+ """
+ for _ in range(attempts):
+ try:
+ rows = self._tqs.get_market_snapshot_batch(
+ stock_list=["600519.SH"], field_list=["Now"]
+ )
+ if isinstance(rows, dict) and rows:
+ return
+ except Exception: # noqa: BLE001 — 就绪探测, 任何异常都重试
+ pass
+ time.sleep(1.2)
+ logger.warning("TdxQuant 原生层预热未成功(%d 次), 首批业务调用可能失败重试", attempts)
+
+ def close(self) -> None: # 原生 DLL 常驻进程, 无可回收句柄; 保留接口对齐 loader
+ self._universe.clear()
+
+ # ---- 原生调用(重试包装) ----
+ def _raw(self, method: str, *args):
+ """直接调原生层并 _clean 解包; 异常重试 3 次、空返回补试 1 次。
+
+ 空闲后首连 "Invalid server response" 实测重试即恢复; 坏代码等
+ 永久错误重试也只会快失败(返回 None), 不至于长阻塞。
+ """
+ last: Exception = TdxQuantError(f"{method} 未知失败")
+ for attempt in range(_RETRIES):
+ try:
+ out = self._tqs._clean(getattr(self._native_cls.get_instance(), method)(*args))
+ if out is not None:
+ return out
+ last = TdxQuantError(f"{method} 返回空数据(第 {attempt + 1} 次)")
+ if attempt + 1 >= _NONE_RETRIES:
+ break
+ except Exception as exc: # noqa: BLE001 — 原生 ctypes 层异常类型不可枚举
+ last = TdxQuantError(f"{method}: {exc}")
+ time.sleep(_RETRY_SLEEP_S)
+ raise last
+
+ # ---- 股票池 ----
+ def stock_universe(self) -> list[str]:
+ """五市场全 A 代码(带交易所后缀)。12h 缓存, 避免每轮快照重复枚举。"""
+ now = time.time()
+ if self._universe and now - self._universe_at < _UNIVERSE_TTL_S:
+ return list(self._universe)
+ last: TdxQuantError = TdxQuantError("股票列表为空(五个市场均无返回)")
+ for _attempt in range(_RETRIES):
+ out: list[str] = []
+ try:
+ for market in _MARKETS:
+ lst = self._tqs.get_stock_list(market=market, list_type=0)
+ out.extend(code for code in (lst or []) if isinstance(code, str) and code)
+ except Exception as exc: # noqa: BLE001 — SDK 内部异常类型不可枚举
+ last = TdxQuantError(f"get_stock_list: {exc}")
+ out = []
+ if out:
+ self._universe, self._universe_at = out, now
+ return list(out)
+ time.sleep(_RETRY_SLEEP_S)
+ raise last
+
+ # ---- 快照 ----
+ def snapshot_batch(self, codes: list[str]) -> dict[str, dict]:
+ """批量快照 → {code: row(字段见 SNAPSHOT_FIELDS)}。SDK 内部已做批处理。
+
+ 空闲后首连易 "Invalid server response"(实测重试即恢复), 批级失败必须
+ 重试 — provider 只做批间隔离, 这里不重试会整批丢标的。
+ """
+ if not codes:
+ return {}
+ last: TdxQuantError = TdxQuantError(f"snapshot_batch({len(codes)} 只): 未知失败")
+ for _attempt in range(_RETRIES):
+ try:
+ rows = self._tqs.get_market_snapshot_batch(
+ stock_list=list(codes), field_list=list(SNAPSHOT_FIELDS)
+ )
+ except Exception as exc: # noqa: BLE001 — SDK 内部异常类型不可枚举
+ last = TdxQuantError(f"snapshot_batch({len(codes)} 只): {exc}")
+ time.sleep(_RETRY_SLEEP_S)
+ continue
+ if isinstance(rows, dict) and rows:
+ return rows
+ last = TdxQuantError(f"snapshot_batch({len(codes)} 只): 空返回")
+ time.sleep(_RETRY_SLEEP_S)
+ raise last
+
+ # ---- K 线(count 模式, 原始列) ----
+ def his_dats(self, code: str, end_d: date, count: int, period: str = "1d") -> dict:
+ """单标的 K 线原生 dict: Date 'YYYYMMDD' / Open / High / Low / Close /
+ Volume(股) / Amount(万元) 等。count 模式下 start 恒空(服务端只用 end)。
+ """
+ if count <= 0:
+ return {}
+ end = f"{end_d:%Y-%m-%d} 00:00:00"
+ out = self._raw("get_his_dats", code, "", end, period, "none", int(count))
+ return out if isinstance(out, dict) else {}
+
+ # ---- 除权事件 ----
+ def divid_factors(
+ self, code: str, start_d: date | None = None, end_d: date | None = None
+ ) -> list[dict]:
+ """单标的除权事件, 每行 {Date 'YYYYMMDD', Type, Bonus, ShareBonus,
+ AllotPrice, Allotment} — 金额/比例均为**每 10 股**口径(Bonus=10派X元)。
+ """
+ start = f"{start_d:%Y-%m-%d}" if start_d else ""
+ end = f"{end_d:%Y-%m-%d}" if end_d else ""
+ try:
+ out = self._tqs.get_divid_factors(code, start, end)
+ except Exception as exc: # SDK 内部异常类型不可枚举
+ raise TdxQuantError(f"get_divid_factors({code}): {exc}") from exc
+ if isinstance(out, list):
+ return out
+ # 环境装有 pandas 时 SDK 返回 DataFrame, 归一为 list[dict]。
+ # 注意 Date 会成为 DataFrame 索引(同 get_market_data 的日期索引惯例),
+ # reset_index 后若列名漂移, 把首列恢复为 'Date'。
+ to_dict = getattr(out, "to_dict", None)
+ reset_index = getattr(out, "reset_index", None)
+ if callable(to_dict) and callable(reset_index):
+ try:
+ records = reset_index().to_dict("records")
+ if isinstance(records, list) and records:
+ if "Date" not in records[0]:
+ first_key = next(iter(records[0]))
+ if first_key not in ("Type", "Bonus", "AllotPrice", "ShareBonus", "Allotment"):
+ for row in records:
+ row["Date"] = row.pop(first_key)
+ return records
+ except Exception: # noqa: BLE001 — 转换失败按结构异常处理
+ pass
+ raise TdxQuantError(f"get_divid_factors({code}) 返回异常结构: {type(out)!r}")
diff --git a/backend/app/plugins/tdxquant/plugin.yaml b/backend/app/plugins/tdxquant/plugin.yaml
new file mode 100644
index 00000000..69318651
--- /dev/null
+++ b/backend/app/plugins/tdxquant/plugin.yaml
@@ -0,0 +1,23 @@
+# TdxQuant — 通达信量化数据服务插件 (https://help.tdx.com.cn/quant/)
+# 原生 TCP 私有协议 (tdxaidata 包, ctypes 包装跨平台 DLL), runtime: python。
+# tdxaidata 已是后端默认依赖; 只需配置 TDXQUANT_API_KEY, 未配置时灰显不可切换。
+
+name: tdxquant
+display_name: "TdxQuant (TdxAiData)"
+runtime: python
+entry: app.plugins.tdxquant.provider:TdxQuantProvider
+check: app.plugins.tdxquant.provider:availability
+datasets: [realtime, depth5, daily, adj_factor, minute, full_minute]
+api_key_env: TDXQUANT_API_KEY # 声明后设置页提供 Key 输入框(先探后存, secrets.json 优先)
+billing: points # 积分计费标识: 设置页每个能力芯片/适配行标注「积分」, 消耗以商城积分明细为准
+key_manage_url: "https://vip.tdx.com.cn/site/app/pc-mall/main.html#/aiKey" # Key/积分管理页(通达信商城), 设置页 Key 区提供入口
+description: "通达信 TdxAiData 数据服务(原生 TCP 协议, 依赖已内置): 全市场实时快照、五档盘口、日K、除权因子、分钟K、全量分钟六项能力; 按积分计费, 未接入财务, 自动回退 TickFlow。"
+billing_note: "积分消耗与轮询频率、拉取范围直接相关: 间隔越短、范围越大消耗越高。建议实时轮询间隔 ≥60 秒, 全量分钟按需启用; 具体扣减以商城「积分明细」为准。"
+usage_notes:
+ - "实时快照: 五市场全 A 股票池, 600 只/批, 全市场一轮约 10 批(30-60 秒)"
+ - "五档盘口: 随快照返回, 不产生额外请求"
+ - "日K / 除权因子: count 模式单标的拉取(原始价); 因子按交易所公式推导, 带涨跌停自检"
+ - "分钟K: 1m 逐标的, 240 根/日, 深度约 40 交易日"
+ - "全量分钟: 逐标的拉取, 全市场一轮约 10 分钟量级(上游无批量端点)"
+install_hint: "依赖已内置; 在下方输入框填写数据服务 Key(通达信个人版商城-会员中心-积分和Key管理创建), 或配置环境变量 TDXQUANT_API_KEY"
+homepage: "https://help.tdx.com.cn/quant/"
diff --git a/backend/app/plugins/tdxquant/probe.py b/backend/app/plugins/tdxquant/probe.py
new file mode 100644
index 00000000..16d2ae68
--- /dev/null
+++ b/backend/app/plugins/tdxquant/probe.py
@@ -0,0 +1,162 @@
+"""候选 Key 探测 — 独立子进程「格式 + 连通性」探测。
+
+原生 DLL 每进程加载一次且绑定一个 token: 主进程可能已用旧 Key 加载,
+候选 Key 无法在进程内替换, 因此探测必须另起进程(临时 lib 目录 +
+TDX_AI_DATA_LIB 指向), 结论以 JSON 回传。
+
+可靠性设计(实测教训):
+ - DLL 原生握手固定 ~30s(同 IP 并发会话疑似服务端节流), 超时必须 >45s;
+ - 子进程可能在原生层直接死亡(段错误级, 无任何输出) — 每阶段把进度写入
+ 父进程指定的进度文件, 父进程据此定位死点并附退出码, 不再返回"无输出";
+ - TDX 会话按 IP 复用: 同 IP 有活跃好 Key 会话时(常驻后端必然如此),
+ 错误 Key 的日K也能返回 ⇒ "无效 Key 拒绝"不可靠。本探测只验证
+ 格式 + 连通性, Key 真伪由首次实拉(test_dataset / 日常轮询)暴露。
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+_PROBE_ENV_KEY = "TDX_PROBE_CANDIDATE_KEY"
+_PROBE_ENV_TMP = "TDX_PROBE_TMP_DIR"
+
+# 子进程入口: 复用 client.materialize_lib 保持与主进程同一套物化/写 token 逻辑
+_SCRIPT = """
+import json
+import os
+import time
+from pathlib import Path
+
+key = os.environ.pop("TDX_PROBE_CANDIDATE_KEY", "")
+tmp = Path(os.environ.pop("TDX_PROBE_TMP_DIR", ""))
+progress = tmp / "progress.json" if str(tmp) else None
+
+
+def stage(name, **extra):
+ if progress is None:
+ return
+ try:
+ progress.write_text(json.dumps({"stage": name, **extra}), encoding="utf-8")
+ except Exception:
+ pass
+
+
+try:
+ stage("imports")
+ from app.plugins.tdxquant import client as tc
+
+ stage("materialize")
+ dll = tc.materialize_lib(key, tmp)
+ os.environ[tc.LIB_ENV] = str(dll)
+
+ stage("load_dll")
+ from tdxaidata.tdxaidata import _NativeTdxAiData
+
+ native = _NativeTdxAiData.get_instance()
+
+ # 连通性判定: 快照端点不依赖 token 鉴权, 拿到数据即证明
+ # 服务器可达 + DLL 正常。轮询容忍 DLL 冷启动的未就绪窗口。
+ stage("start")
+ reachable = False
+ for _ in range(3):
+ try:
+ if native.get_market_snapshot_batch("600519.SH", "Now"):
+ reachable = True
+ break
+ except Exception:
+ pass
+ time.sleep(1.2)
+ if not reachable:
+ stage("done", ok=False)
+ print(json.dumps({"ok": False, "reason": "行情服务器连接失败(网络不可达)"}))
+ raise SystemExit(0)
+
+ # 日K仅作辅助信号(会话按 IP 复用时错误 Key 也能通过, 不能作拒绝依据)
+ stage("auth")
+ aux = ""
+ try:
+ raw = native.get_his_dats(
+ "600519.SH", "", "2026-01-01 00:00:00", "1d", "none", 3
+ )
+ from tdxaidata import tqs
+
+ v = tqs._clean(raw)
+ aux = "his_ok" if isinstance(v, dict) and v.get("Date") else "his_empty"
+ except Exception as exc:
+ aux = "his_exc"
+
+ stage("done", ok=True)
+ print(json.dumps({"ok": True, "reason": "", "aux": aux}))
+except Exception as exc:
+ stage("exception", reason=str(exc)[:300])
+ print(json.dumps({"ok": False, "reason": str(exc)[:300]}))
+"""
+
+
+def _read_stage(tmp_dir: str) -> str:
+ try:
+ data = json.loads((Path(tmp_dir) / "progress.json").read_text(encoding="utf-8"))
+ except Exception: # noqa: BLE001 — 进度文件缺失/损坏按未知处理
+ return "未知(无进度文件)"
+ stage = str(data.get("stage", "?"))
+ if stage == "exception":
+ return f"异常: {str(data.get('reason', ''))[:150]}"
+ return stage
+
+
+def _tail(text: str, limit: int = 150) -> str:
+ lines = (text or "").strip().splitlines()
+ return lines[-1][:limit] if lines else "(空)"
+
+
+def run(api_key: str, timeout_s: float = 75.0) -> tuple[bool, str]:
+ """子进程探测候选 Key: 格式由调用方把关, 这里验证连通性(快照可达)。"""
+ if not api_key:
+ return False, "Key 为空"
+ backend_root = Path(__file__).resolve().parents[3]
+ tmp_dir = tempfile.mkdtemp(prefix="tdx_probe_")
+ env = {
+ **os.environ,
+ _PROBE_ENV_KEY: api_key,
+ _PROBE_ENV_TMP: tmp_dir,
+ "PYTHONPATH": str(backend_root),
+ }
+ try:
+ try:
+ proc = subprocess.run(
+ [sys.executable, "-c", _SCRIPT],
+ capture_output=True,
+ text=True,
+ timeout=timeout_s,
+ cwd=str(backend_root),
+ env=env,
+ )
+ except subprocess.TimeoutExpired:
+ stage = _read_stage(tmp_dir)
+ return False, f"探测超时({timeout_s:.0f}s, 停在阶段[{stage}]) — 原生握手约需 30s, 请检查网络后重试"
+ for line in reversed((proc.stdout or "").splitlines()):
+ line = line.strip()
+ if line.startswith("{"):
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError:
+ break
+ if payload.get("ok"):
+ return True, "ok(连通正常; Key 有效性以首次拉取为准)"
+ return False, f"探测失败: {payload.get('reason', '未知原因')}"
+ # 无 JSON 结论: 原生层直接死亡(无输出)或入口即崩 — 用进度文件定位死点
+ stage = _read_stage(tmp_dir)
+ return False, (
+ f"探测失败: 子进程死在阶段[{stage}], exit={proc.returncode}, "
+ f"stderr={_tail(proc.stderr)}"
+ )
+ finally:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
diff --git a/backend/app/plugins/tdxquant/provider.py b/backend/app/plugins/tdxquant/provider.py
new file mode 100644
index 00000000..2dd11fcf
--- /dev/null
+++ b/backend/app/plugins/tdxquant/provider.py
@@ -0,0 +1,782 @@
+"""TdxQuant(通达信量化数据服务)内置数据源 provider。
+
+方法签名对齐 custom.GenericHTTPProvider(service 分流点按这套签名调用),
+插件被 loader 扫描注册后, 各 service 无需改动即可路由。
+
+实现数据集:
+ - realtime A 股全市场快照(五市场股票池, 600 只/批)
+ - depth5 五档盘口(快照 Buyp/Buyv/Sellp/Sellv)
+ - daily A 股日K, 原始价(dividend=none), count 模式单标的拉取
+ - adj_factor 除权因子: 事件接口 + 本地K线配价, 交易所公式推导, 涨跌停自检
+ - minute 分钟K(period=1m count 模式, 单标的 240 根/日, 原始价)
+ - full_minute 全量分钟: 修复轮 get_intraday_batch(当日窗口) +
+ 稳态增量 get_intraday_latest(每股最新 count 根)。
+未声明 financial → provider_has_dataset 为 False, 回退 tickflow。
+
+单位与口径 (CONTRIBUTING §3, 不可凭字段名推断; 2026-10 实测):
+ - 快照 Volume 已是手, 直接透传; Amount 为万元 → ×10000 转元。
+ - 快照无涨跌幅字段 → change_pct = (Now-LastClose)/LastClose, 小数制。
+ - 日K Volume 为股 → floor(/100) 转手; Amount 为万元 → ×10000 转元。
+ - 分钟K与日K同源同口径 (实测茅台全天 240 根 sum(Volume)=3511100 ≈ 日K
+ 3511051 股): Volume 股 → floor(/100) 转手; Amount 万元 → ×10000 转元;
+ datetime = Date+Time 拼北京墙钟 naive (契约 §分钟K)。
+ - 日K取数 dividend="none" 锁定原始价, 前复权由 indicators.pipeline 用本地因子计算。
+ - 除权事件 Bonus/ShareBonus/Allotment 均为每 10 股口径 → ÷10 转每股再进公式。
+ - ex_factor 为单事件比值(非累积), 累积链由 pipeline._apply_adj_factor 构建。
+"""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+import math
+import time
+from collections.abc import Callable, Iterator
+from dataclasses import dataclass, field
+from datetime import date, datetime, timedelta
+from zoneinfo import ZoneInfo
+
+import polars as pl
+
+from app.plugins.tdxquant.client import SNAPSHOT_FIELDS, TdxQuantClient, TdxQuantError
+
+logger = logging.getLogger(__name__)
+
+# 只声明真实提供的数据集; 其余数据集 provider_has_dataset 返回 False → 回退 tickflow
+_DATASETS = ("realtime", "depth5", "daily", "adj_factor", "minute", "full_minute")
+
+API_KEY_ENV = "TDXQUANT_API_KEY"
+SECRETS_FIELD = "tdxquant_api_key" # UI 配置的 Key 存 secrets.json, 优先级高于 .env
+
+_SNAPSHOT_BATCH = 600 # 实测 600 只/批正常返回
+_HIST_INTERVAL_S = 0.08 # 单标的 K 线/事件请求节流
+_HIST_MAX_COUNT = 2600 # count 模式单次上限(≈10.6 年日K, 项目深窗口即 10 年)
+_ADJ_BACKDAYS = 30 # 推导因子时向前找"除权日前收盘"的回看天数(容忍长期停牌)
+_ADJ_FUTURE_DAYS = 15 # 未来已公告事件窗口: 无前收盘自然跳过, 滚动增量轮补上
+_BEIJING = ZoneInfo("Asia/Shanghai")
+
+# 内部指数代码 → TDX 快照代码(实测 2026-10: 上证指数走 TDX 999999 别名)
+_INDEX_CODE_MAP = {"000001.SH": "999999.SH"}
+
+_DAILY_SCHEMA = {
+ "symbol": pl.String, "date": pl.Date,
+ "open": pl.Float64, "high": pl.Float64, "low": pl.Float64, "close": pl.Float64,
+ "volume": pl.Int64, "amount": pl.Float64,
+}
+_ADJ_SCHEMA = {
+ "symbol": pl.String, "trade_date": pl.Date, "ex_factor": pl.Float64,
+ "dividend": pl.Float64, "bonus": pl.Float64, "allot": pl.Float64,
+ "allot_price": pl.Float64, "prev_close": pl.Float64,
+}
+_MINUTE_SCHEMA = {
+ "symbol": pl.String, "datetime": pl.Datetime("us"),
+ "open": pl.Float64, "high": pl.Float64, "low": pl.Float64, "close": pl.Float64,
+ "volume": pl.Float64, "amount": pl.Float64,
+}
+_MINUTE_BARS_PER_DAY = 240 # A 股 1m 一交易日根数(含收盘集合竞价分钟)
+_MINUTE_MAX_COUNT = 9600 # 实测单次 count=9600(40 交易日)正常返回
+_MINUTE_INTERVAL_S = 0.08 # 单标的分钟请求节流(与日K同档)
+
+
+def get_api_key() -> str:
+ from app import secrets_store
+
+ return secrets_store.get_env_backed_secret(SECRETS_FIELD, API_KEY_ENV)
+
+
+def _module_available() -> bool:
+ try:
+ import importlib.util
+
+ return importlib.util.find_spec("tdxaidata") is not None
+ except Exception: # noqa: BLE001 — find_spec 对损坏安装也会抛
+ return False
+
+
+def availability() -> tuple[bool, str]:
+ """loader 启动自检: tdxaidata 已安装 + Key 已配置才注册。不抛异常。"""
+ if not _module_available():
+ return False, "tdxaidata 未安装(默认依赖, 请检查 uv sync)"
+ if not get_api_key():
+ return False, f"缺少 API Key(可在下方输入框直接填写,或配置环境变量 {API_KEY_ENV})"
+ return True, "ok"
+
+
+def probe_api_key(api_key: str) -> tuple[bool, str]:
+ """候选 Key 探测: 格式校验 + 子进程连通性实探。
+
+ TDX 会话按 IP 复用(同 IP 活跃好 Key 会话时错误 Key 也能拉到日K, 实测),
+ "无效 Key 拒绝"不可靠 — 格式明显不对直接拒绝, 连通性通过即保存,
+ Key 真伪由首次实拉(test_dataset / 日常轮询)暴露, 卡片可一键试拉复核。
+ """
+ key = (api_key or "").strip()
+ if not key.startswith("TDX-") or len(key) < 12:
+ return False, "Key 格式应为 TDX- 开头(通达信商城创建的数据服务 Key)"
+ from app.plugins.tdxquant import probe
+
+ return probe.run(key)
+
+
+@dataclass
+class _TdxQuantConfig:
+ """轻量 config shim, 让 custom loader 的 provider_has_dataset 能识别本 provider。"""
+
+ name: str = "tdx"
+ display_name: str = "TdxQuant"
+ datasets: dict = field(default_factory=lambda: dict.fromkeys(_DATASETS))
+ path: None = None
+ builtin: bool = True
+
+
+def _to_float(value) -> float | None:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _compact_date(value) -> date | None:
+ """原生 Date 'YYYYMMDD'(或 DataFrame 归一后的 ISO 'YYYY-MM-DD…')→ date。
+
+ 非法值返回 None, 不伪造。
+ """
+ try:
+ text = str(value).strip()
+ if len(text) >= 10 and text[4] == "-" and text[7] == "-":
+ text = text[:10].replace("-", "")
+ return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
+ except (TypeError, ValueError):
+ return None
+
+
+def _snapshot_ms(refresh_time, fallback_ms: int) -> int:
+ """RefreshTime 'HHMMSS'(服务端北京墙钟) + 今日北京日期 → epoch ms。
+
+ 解析失败退回本地时间; A 股时段无跨日歧义。
+ """
+ try:
+ text = str(refresh_time).strip().zfill(6)
+ hh, mm, ss = int(text[:2]), int(text[2:4]), int(text[4:6])
+ if hh > 23 or mm > 59 or ss > 59:
+ raise ValueError(text)
+ now_bj = datetime.now(_BEIJING)
+ stamp = datetime(now_bj.year, now_bj.month, now_bj.day, hh, mm, ss, tzinfo=_BEIJING)
+ return int(stamp.timestamp() * 1000)
+ except (TypeError, ValueError):
+ return fallback_ms
+
+
+def _map_snapshot_row(symbol: str, row: dict, fallback_ms: int) -> dict | None:
+ """TDX 快照行 → 内部 realtime record。字段缺失按口径推导, 不伪造数据。"""
+ last = _to_float(row.get("Now"))
+ prev = _to_float(row.get("LastClose"))
+ if last is None and prev is None:
+ return None
+
+ change_amount = last - prev if (last is not None and prev is not None) else None
+ # 与 quote_service 推导同口径: 小数制, 不乘 100
+ change_pct = change_amount / prev if (change_amount is not None and prev) else None
+
+ volume = _to_float(row.get("Volume")) # 快照已为手, 零换算
+ amount = _to_float(row.get("Amount")) # 万元 → 元
+
+ return {
+ "symbol": symbol,
+ "name": None, # 快照无名称, 由下游维表关联
+ "last_price": last,
+ "prev_close": prev,
+ "open": _to_float(row.get("Open")),
+ "high": _to_float(row.get("Max")),
+ "low": _to_float(row.get("Min")),
+ "volume": math.floor(volume) if volume is not None else None,
+ "amount": amount * 10_000.0 if amount is not None else None,
+ "change_pct": change_pct,
+ "change_amount": change_amount,
+ "amplitude": None, # 快照未提供, 不启发式计算
+ "turnover_rate": None, # 需股本口径 (§3.4), 交给 enriched 管道
+ "timestamp": _snapshot_ms(row.get("RefreshTime"), fallback_ms),
+ "session": None,
+ }
+
+
+def _ref_price(
+ prev_close: float, dividend: float, bonus: float, allot: float, allot_price: float
+) -> float | None:
+ """交易所除权参考价: (P - D + AR·AP) / (1 + S + AR), 四舍五入(half-up)保留 2 位。"""
+ denom = 1.0 + bonus + allot
+ if denom <= 0:
+ return None
+ x = (prev_close - dividend + allot * allot_price) / denom
+ return math.floor(x * 100 + 0.5) / 100
+
+
+def _price_limit(symbol: str) -> float:
+ """按代码前缀给涨跌停幅度(自检容差用): 创业板/科创板 20%, 北交所 30%, 主板 10%。"""
+ code = symbol.split(".")[0]
+ if code.startswith(("300", "301", "688", "689")):
+ return 0.20
+ if code.startswith(("8", "4", "92")):
+ return 0.30
+ return 0.10
+
+
+def _kline_rows(symbol: str, raw: dict, start_d: date | None, end_d: date) -> list[dict]:
+ """原生 count 模式 K 线 dict → 内部行(窗口过滤, volume 股→手, amount 万元→元)。"""
+
+ def _col(name: str, i: int) -> float | None:
+ values = raw.get(name)
+ return _to_float(values[i]) if values and i < len(values) else None
+
+ dates = raw.get("Date") or []
+ out: list[dict] = []
+ for i, compact in enumerate(dates):
+ d = _compact_date(compact)
+ if d is None or d > end_d:
+ continue
+ if start_d is not None and d < start_d:
+ continue
+ volume = _col("Volume", i)
+ amount = _col("Amount", i)
+ out.append({
+ "symbol": symbol,
+ "date": d,
+ "open": _col("Open", i),
+ "high": _col("High", i),
+ "low": _col("Low", i),
+ "close": _col("Close", i),
+ "volume": math.floor(volume / 100.0) if volume is not None else None,
+ "amount": amount * 10_000.0 if amount is not None else None,
+ })
+ return out
+
+
+def _bars_for_window(span_days: int) -> int:
+ """窗口自然日跨度 → count 模式请求根数(交易日≈自然日×5/7, 加停牌冗余)。"""
+ return max(30, min(_HIST_MAX_COUNT, int(span_days * 5 / 7) + 20))
+
+
+def _minute_dt(compact_date, hhmmss) -> datetime | None:
+ """Date 'YYYYMMDD' + Time 'HHMMSS' → 北京墙钟 naive datetime; 非法返回 None。"""
+ d = _compact_date(compact_date)
+ if d is None:
+ return None
+ t = str(hhmmss or "").strip()
+ if len(t) < 6 or not t[:6].isdigit():
+ return None
+ try:
+ return datetime(d.year, d.month, d.day, int(t[:2]), int(t[2:4]), int(t[4:6]))
+ except ValueError:
+ return None
+
+
+def _minute_rows(
+ symbol: str, raw: dict, start_dt: datetime | None, end_dt: datetime | None,
+ today_only: date | None = None,
+) -> list[dict]:
+ """原生 1m K 线 dict → 内部分钟行(datetime 北京墙钟, volume 股→手, amount 万元→元)。
+
+ today_only 非空时仅保留该交易日的行(修复轮用: count 模式尾部会带前一日余量)。
+ """
+ dates = raw.get("Date") or []
+ times = raw.get("Time") or []
+ out: list[dict] = []
+ for i, compact in enumerate(dates):
+ dt = _minute_dt(compact, times[i] if i < len(times) else None)
+ if dt is None:
+ continue
+ if today_only is not None and dt.date() != today_only:
+ continue
+ if start_dt is not None and dt < start_dt:
+ continue
+ if end_dt is not None and dt > end_dt:
+ continue
+
+ def _col(name: str, idx: int = i) -> float | None:
+ values = raw.get(name)
+ return _to_float(values[idx]) if values and idx < len(values) else None
+
+ close = _col("Close")
+ if close is None:
+ continue # 停牌/无成交分钟, 不伪造
+ volume = _col("Volume")
+ amount = _col("Amount")
+ out.append({
+ "symbol": symbol, "datetime": dt,
+ "open": _col("Open"), "high": _col("High"),
+ "low": _col("Low"), "close": close,
+ "volume": float(math.floor(volume / 100.0)) if volume is not None else None,
+ "amount": amount * 10_000.0 if amount is not None else None,
+ })
+ return out
+
+
+def _minute_count_for_window(start_dt: datetime | None, end_dt: datetime) -> int:
+ """窗口 → 单次 count 请求根数(交易日×240 + 两日余量, 封顶 _MINUTE_MAX_COUNT)。"""
+ span_days = (end_dt.date() - start_dt.date()).days if start_dt else 30
+ return max(240, min(_MINUTE_MAX_COUNT, int(span_days * 5 / 7 * _MINUTE_BARS_PER_DAY) + 480))
+
+
+class TdxQuantProvider:
+ """tdx 数据源。realtime = 五市场全 A 快照批拉; depth5 复用同一快照的五档数组。"""
+
+ name = "tdxquant"
+ builtin = True
+
+ def __init__(self) -> None:
+ self.config = _TdxQuantConfig()
+ self._client: TdxQuantClient | None = None
+
+ def close(self) -> None: # loader.load_all 重建注册表时会对每个 provider 调 close
+ if self._client is not None:
+ with contextlib.suppress(Exception):
+ self._client.close()
+ self._client = None
+
+ def _get_client(self) -> TdxQuantClient:
+ if self._client is None:
+ self._client = TdxQuantClient(api_key=get_api_key())
+ return self._client
+
+ # ---- realtime ----
+ def get_realtime(self) -> list[dict]:
+ """全市场实时快照 → 内部 realtime records。失败软返回空列表(不阻断轮询)。"""
+ client = self._get_client()
+ try:
+ universe = client.stock_universe()
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 股票池枚举失败: %s", e)
+ return []
+
+ fallback_ms = int(time.time() * 1000)
+ records: list[dict] = []
+ dropped = failed_batches = 0
+ for i in range(0, len(universe), _SNAPSHOT_BATCH):
+ codes = universe[i : i + _SNAPSHOT_BATCH]
+ try:
+ rows = client.snapshot_batch(codes)
+ except TdxQuantError as e:
+ failed_batches += 1
+ logger.warning("TdxQuant 快照批次失败(%d 只, 第 %d 批): %s", len(codes), i // _SNAPSHOT_BATCH + 1, e)
+ continue
+ for code, row in rows.items():
+ rec = _map_snapshot_row(code, row, fallback_ms)
+ if rec is not None:
+ records.append(rec)
+ else:
+ dropped += 1
+ if failed_batches:
+ logger.warning("TdxQuant 快照 %d/%d 批失败, 本轮仅返回 %d 条",
+ failed_batches, math.ceil(len(universe) / _SNAPSHOT_BATCH), len(records))
+ if not records and universe:
+ logger.warning("TdxQuant 实时行情本轮 0 条(请求 %d 只), 疑似连接或协议异常", len(universe))
+ elif dropped:
+ logger.info("TdxQuant 实时行情拉取完成: %d 条(丢弃 %d 行无价快照)", len(records), dropped)
+ return records
+
+ def get_realtime_indices(self, symbols: list[str]) -> list[dict] | None:
+ """指数实时快照(可选插件协议, quote_service 鸭子类型调用)。
+
+ TDX 快照直接支持指数代码; 未映射的代码跳过(上层用本地日K兜底)。
+ 失败返回 None 保留上轮有效缓存, 成功但无数据返回 []。
+ """
+ wanted: list[str] = []
+ for s in symbols:
+ if not s:
+ continue
+ code = _INDEX_CODE_MAP.get(s, s)
+ if code not in wanted:
+ wanted.append(code)
+ if not wanted:
+ return []
+ try:
+ rows = self._get_client().snapshot_batch(wanted)
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 指数行情拉取失败: %s", e)
+ return None
+ fallback_ms = int(time.time() * 1000)
+ records = []
+ for code, row in rows.items():
+ rec = _map_snapshot_row(code, row, fallback_ms)
+ if rec is not None:
+ records.append(rec)
+ logger.info("TdxQuant 指数行情拉取完成: %d 条(请求 %d 只)", len(records), len(wanted))
+ return records
+
+ # ---- daily ----
+ def get_daily(
+ self,
+ symbols: list[str],
+ start_time: datetime | None,
+ end_time: datetime | None,
+ asset_type: str = "stock",
+ on_chunk_done: Callable[[int, int], None] | None = None,
+ ) -> pl.DataFrame:
+ chunks = [
+ df
+ for df in self.iter_daily(
+ symbols, start_time=start_time, end_time=end_time,
+ asset_type=asset_type, on_chunk_done=on_chunk_done,
+ )
+ if not df.is_empty()
+ ]
+ return pl.concat(chunks, how="diagonal_relaxed") if chunks else pl.DataFrame(schema=_DAILY_SCHEMA)
+
+ def iter_daily(
+ self,
+ symbols: list[str],
+ start_time: datetime | None,
+ end_time: datetime | None,
+ asset_type: str = "stock",
+ on_chunk_done: Callable[[int, int], None] | None = None,
+ ) -> Iterator[pl.DataFrame]:
+ """逐标的 count 模式拉日K → 内部契约(原始价, volume 手, amount 元)。
+
+ ETF/指数日K未实测覆盖, asset_type 仅接受 stock, 其余空手而归(上层回退)。
+ """
+ if not symbols or asset_type != "stock":
+ if on_chunk_done:
+ on_chunk_done(0, 0)
+ return
+ client = self._get_client()
+ start_d = start_time.date() if start_time else None
+ end_d = end_time.date() if end_time else date.today()
+ span = (end_d - start_d).days if start_d else _HIST_MAX_COUNT * 1.4
+ count = _bars_for_window(int(span))
+ for i, symbol in enumerate(symbols):
+ try:
+ raw = client.his_dats(symbol, end_d, count)
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 日K %s 拉取失败: %s", symbol, e)
+ raw = {}
+ rows = _kline_rows(symbol, raw, start_d, end_d)
+ if rows and rows[0]["date"] is not None and start_d is not None and rows[0]["date"] > start_d:
+ logger.info("TdxQuant 日K %s: count=%d 仅覆盖到 %s(窗口起点 %s 之前的数据不在返回内)",
+ symbol, count, rows[0]["date"], start_d)
+ yield pl.DataFrame(rows, schema=_DAILY_SCHEMA)
+ if on_chunk_done:
+ on_chunk_done(i + 1, len(symbols))
+ time.sleep(_HIST_INTERVAL_S)
+
+ # ---- adj_factor ----
+ def get_adj_factors(
+ self,
+ symbols: list[str],
+ start_time: datetime | None,
+ end_time: datetime | None,
+ asset_type: str = "stock",
+ on_chunk_done: Callable[[int, int], None] | None = None,
+ ) -> pl.DataFrame:
+ """除权因子 → 内部契约(symbol/trade_date/ex_factor, 单事件比值非累积)
+ + 事件明细列(dividend/bonus/allot/allot_price/prev_close, 每股口径)。
+
+ 数据链: divid_factors 事件接口(每 10 股口径 → ÷10 转每股) → 同一标的
+ 一次 count 模式日K取齐窗口内收盘价 → 交易所公式推导 → 涨跌停自检剔除。
+ 未来已公告事件无前收盘, 自然跳过, 滚动增量窗口补上。
+ """
+ if not symbols or asset_type != "stock":
+ return pl.DataFrame(schema=_ADJ_SCHEMA)
+ client = self._get_client()
+ start_d = start_time.date() if start_time else None
+ end_d = end_time.date() if end_time else date.today()
+
+ out_rows: list[dict] = []
+ for i, symbol in enumerate(symbols):
+ try:
+ events = client.divid_factors(symbol, start_d, end_d + timedelta(days=_ADJ_FUTURE_DAYS))
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 除权事件 %s 拉取失败: %s", symbol, e)
+ events = []
+ events = [ev for ev in events if _compact_date(ev.get("Date")) is not None]
+ if events:
+ events.sort(key=lambda ev: str(ev.get("Date")))
+ first_ex = _compact_date(events[0]["Date"])
+ closes = self._close_map(client, symbol, first_ex - timedelta(days=_ADJ_BACKDAYS), end_d)
+ out_rows.extend(self._factor_rows(symbol, events, closes, start_d, end_d))
+ if on_chunk_done:
+ on_chunk_done(i + 1, len(symbols))
+ time.sleep(_HIST_INTERVAL_S)
+ if not out_rows:
+ return pl.DataFrame(schema=_ADJ_SCHEMA)
+ return (
+ pl.DataFrame(out_rows, schema=_ADJ_SCHEMA)
+ .unique(subset=["symbol", "trade_date"], keep="last")
+ .sort(["symbol", "trade_date"])
+ )
+
+ def _close_map(self, client: TdxQuantClient, symbol: str, start_d: date, end_d: date) -> dict[date, float]:
+ """一次 count 模式日K → {交易日: 原始收盘价}(推导前收盘与自检共用)。"""
+ try:
+ raw = client.his_dats(symbol, end_d, _bars_for_window((end_d - start_d).days))
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 除权配价 %s 日K失败: %s", symbol, e)
+ return {}
+ out: dict[date, float] = {}
+ dates = raw.get("Date") or []
+ closes = raw.get("Close") or []
+ for i, compact in enumerate(dates):
+ d = _compact_date(compact)
+ close = _to_float(closes[i]) if i < len(closes) else None
+ if d is not None and close is not None:
+ out[d] = close
+ return out
+
+ def _factor_rows(
+ self, symbol: str, events: list[dict], closes: dict[date, float],
+ start_d: date | None, end_d: date,
+ ) -> list[dict]:
+ days = sorted(closes)
+ rows: list[dict] = []
+ for ev in events:
+ exd = _compact_date(ev.get("Date"))
+ if exd is None or exd > end_d + timedelta(days=_ADJ_FUTURE_DAYS):
+ continue
+ if start_d is not None and exd < start_d:
+ continue
+ # 每 10 股口径 → 每股 (Bonus=10派X元, ShareBonus=10送Y股, Allotment=10配Z股)
+ dividend = (_to_float(ev.get("Bonus")) or 0.0) / 10.0
+ bonus = (_to_float(ev.get("ShareBonus")) or 0.0) / 10.0
+ allot = (_to_float(ev.get("Allotment")) or 0.0) / 10.0
+ allot_price = _to_float(ev.get("AllotPrice"))
+ prev_days = [d for d in days if d < exd]
+ if not prev_days:
+ continue # 日K窗口未覆盖(远古事件或未来已公告事件)
+ p = closes[prev_days[-1]]
+ ref = _ref_price(p, dividend, bonus, allot, allot_price or 0.0)
+ if ref is None or ref <= 0:
+ logger.warning("TdxQuant 除权因子: %s %s 参考价无法计算(P=%s D=%s S=%s AR=%s AP=%s), 跳过",
+ symbol, exd, p, dividend, bonus, allot, allot_price)
+ continue
+ factor = p / ref
+ ex_days = [d for d in days if d >= exd]
+ if ex_days: # 涨跌停自检: 错误因子会令复权后除权日涨跌幅超出板限
+ ret = closes[ex_days[0]] / (p / factor) - 1.0
+ if abs(ret) > _price_limit(symbol) + 0.02:
+ logger.warning("TdxQuant 除权因子自检剔除 %s %s: 复权后除权日涨跌幅 %.1f%% 超出涨跌停",
+ symbol, exd, ret * 100)
+ continue
+ rows.append({
+ "symbol": symbol, "trade_date": exd, "ex_factor": factor,
+ "dividend": dividend, "bonus": bonus, "allot": allot,
+ "allot_price": allot_price, "prev_close": p,
+ })
+ return rows
+
+ # ---- minute / full_minute ----
+ def get_minute(
+ self,
+ symbols: list[str],
+ start_time: datetime | None,
+ end_time: datetime | None,
+ asset_type: str = "stock",
+ freq: str = "1m",
+ on_chunk_done: Callable[[int, int], None] | None = None,
+ ) -> pl.DataFrame:
+ """分钟K → 内部契约(symbol/datetime 北京墙钟 naive/ohlcv/volume 手/amount 元)。
+
+ period=1m count 模式单标的拉取(实测 240 根/日, count≤9600 正常);
+ freq 仅支持 1m(上游有 5m 等档位但项目契约按 1m 路由), 其余空手而归还上层回退;
+ ETF/指数分钟未实测覆盖, asset_type 仅接受 stock。
+ """
+ chunks = list(self.iter_minute(
+ symbols, start_time=start_time, end_time=end_time,
+ asset_type=asset_type, freq=freq, on_chunk_done=on_chunk_done,
+ ))
+ parts = [df for df in chunks if not df.is_empty()]
+ return pl.concat(parts, how="diagonal_relaxed") if parts else pl.DataFrame(schema=_MINUTE_SCHEMA)
+
+ def iter_minute(
+ self,
+ symbols: list[str],
+ start_time: datetime | None,
+ end_time: datetime | None,
+ asset_type: str = "stock",
+ freq: str = "1m",
+ on_chunk_done: Callable[[int, int], None] | None = None,
+ ) -> Iterator[pl.DataFrame]:
+ if not symbols or asset_type != "stock" or freq != "1m":
+ if on_chunk_done:
+ on_chunk_done(0, 0)
+ return
+ client = self._get_client()
+ end_dt = end_time or datetime.now()
+ count = _minute_count_for_window(start_time, end_dt)
+ for i, symbol in enumerate(symbols):
+ try:
+ raw = client.his_dats(symbol, end_dt.date(), count, period="1m")
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 分钟K %s 拉取失败: %s", symbol, e)
+ raw = {}
+ rows = _minute_rows(symbol, raw, start_time, end_dt)
+ yield pl.DataFrame(rows, schema=_MINUTE_SCHEMA)
+ if on_chunk_done:
+ on_chunk_done(i + 1, len(symbols))
+ time.sleep(_MINUTE_INTERVAL_S)
+
+ def get_intraday_batch(
+ self, symbols: list[str], count: int = 300, asset_type: str = "stock",
+ ) -> pl.DataFrame:
+ """全量分钟修复轮: 当日窗口逐标的拉取, 只保留今日 1m 行。
+
+ count 模式尾部天然带前一日余量, 按 today_only 过滤; 单标的请求
+ 240+count 余量根。全市场一轮 5579 次调用(含节流约 10 分钟量级),
+ 由 minute_refresh 以修复轮节奏调度。
+ """
+ if not symbols or asset_type != "stock":
+ return pl.DataFrame(schema=_MINUTE_SCHEMA)
+ client = self._get_client()
+ today = datetime.now().date()
+ fetch = max(_MINUTE_BARS_PER_DAY, min(count, 480)) + _MINUTE_BARS_PER_DAY
+ out: list[pl.DataFrame] = []
+ for symbol in symbols:
+ try:
+ raw = client.his_dats(symbol, today, fetch, period="1m")
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 修复轮 %s 拉取失败: %s", symbol, e)
+ continue
+ rows = _minute_rows(symbol, raw, None, None, today_only=today)
+ if rows:
+ out.append(pl.DataFrame(rows, schema=_MINUTE_SCHEMA))
+ time.sleep(_MINUTE_INTERVAL_S)
+ return pl.concat(out, how="diagonal_relaxed") if out else pl.DataFrame(schema=_MINUTE_SCHEMA)
+
+ def get_intraday_latest(self, symbols: list[str] | None = None, count: int = 3) -> pl.DataFrame:
+ """全量分钟稳态增量轮: 每只标的最新 count 根 1m(symbols 缺省 = 全市场股票池)。
+
+ 与修复轮同为逐标的 count 调用(无批量端点), 差别在根数少、负载轻;
+ 失败标的跳过, 空轮由调用方按空处理。
+ """
+ client = self._get_client()
+ targets = list(symbols) if symbols else []
+ if not targets:
+ try:
+ targets = client.stock_universe()
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 增量轮股票池枚举失败: %s", e)
+ return pl.DataFrame(schema=_MINUTE_SCHEMA)
+ count = max(1, min(int(count), 30))
+ today = datetime.now().date()
+ out: list[pl.DataFrame] = []
+ for j, symbol in enumerate(targets):
+ try:
+ raw = client.his_dats(symbol, today, count, period="1m")
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 增量轮 %s 拉取失败: %s", symbol, e)
+ continue
+ rows = _minute_rows(symbol, raw, None, None)
+ if rows:
+ out.append(pl.DataFrame(rows, schema=_MINUTE_SCHEMA))
+ if j % 1000 == 999:
+ logger.info("TdxQuant 增量轮进度: %d/%d", j + 1, len(targets))
+ time.sleep(_MINUTE_INTERVAL_S)
+ return pl.concat(out, how="diagonal_relaxed") if out else pl.DataFrame(schema=_MINUTE_SCHEMA)
+
+ # ---- depth5 ----
+ def get_depth_batch(self, symbols: list[str]) -> dict[str, dict]:
+ """五档盘口 → {symbol: 标准盘口字典}。数量单位手(透传), 价格转 float。"""
+ client = self._get_client()
+ out: dict[str, dict] = {}
+ fallback_ms = int(time.time() * 1000)
+ for i in range(0, len(symbols), _SNAPSHOT_BATCH):
+ batch = symbols[i : i + _SNAPSHOT_BATCH]
+ try:
+ rows = client.snapshot_batch(batch)
+ except TdxQuantError as e:
+ logger.warning("TdxQuant 五档批次失败(第 %d 批): %s", i // _SNAPSHOT_BATCH + 1, e)
+ continue
+ for code, row in rows.items():
+ entry = self._depth_entry(row, fallback_ms)
+ if entry is not None:
+ out[code] = entry
+ return out
+
+ def _depth_entry(self, row: dict, fallback_ms: int) -> dict | None:
+ def _prices(name: str) -> list[float] | None:
+ values = row.get(name)
+ if not values:
+ return None
+ return [_to_float(v) for v in values]
+
+ def _volumes(name: str) -> list[int] | None:
+ values = row.get(name)
+ if not values:
+ return None
+ out: list[int] = []
+ for v in values:
+ f = _to_float(v)
+ out.append(math.floor(f) if f is not None else 0)
+ return out
+
+ bid_prices = _prices("Buyp")
+ ask_prices = _prices("Sellp")
+ if not bid_prices or not ask_prices:
+ return None
+ return {
+ "bid_prices": bid_prices,
+ "bid_volumes": _volumes("Buyv") or [],
+ "ask_prices": ask_prices,
+ "ask_volumes": _volumes("Sellv") or [],
+ "timestamp": _snapshot_ms(row.get("RefreshTime"), fallback_ms),
+ }
+
+ # ---- 设置页试拉 ----
+ def test_dataset(self, dataset: str, symbols: list[str] | None = None) -> dict:
+ if dataset in ("daily", "adj_factor"):
+ syms = [s for s in (symbols or [])][:3] or ["000001.SZ"]
+ try:
+ if dataset == "daily":
+ df = self.get_daily(syms, datetime.now() - timedelta(days=30), datetime.now())
+ else:
+ df = self.get_adj_factors(syms, datetime.now() - timedelta(days=365), datetime.now())
+ except TdxQuantError as e:
+ return {"provider": self.name, "dataset": dataset, "rows": 0, "error": str(e)}
+ head = df.head(5).to_dicts()
+ for row in head: # date/datetime → ISO 字符串, 保证 JSON 可序列化
+ for k, v in list(row.items()):
+ if isinstance(v, (date, datetime)):
+ row[k] = v.isoformat()
+ return {"provider": self.name, "dataset": dataset, "rows": df.height,
+ "columns": df.columns, "preview": head}
+ if dataset in ("minute", "full_minute"):
+ syms = [s for s in (symbols or [])][:3] or ["600519.SH"]
+ try:
+ end = datetime.now()
+ df = (self.get_intraday_batch(syms) if dataset == "full_minute"
+ else self.get_minute(syms, end - timedelta(days=3), end))
+ except TdxQuantError as e:
+ return {"provider": self.name, "dataset": dataset, "rows": 0, "error": str(e)}
+ head = df.head(5).to_dicts()
+ for row in head:
+ for k, v in list(row.items()):
+ if isinstance(v, (date, datetime)):
+ row[k] = v.isoformat()
+ return {"provider": self.name, "dataset": dataset, "rows": df.height,
+ "columns": df.columns, "preview": head}
+ if dataset in ("realtime", "depth5"):
+ syms = [s for s in (symbols or [])][:5] or ["600519.SH", "000001.SZ"]
+ try:
+ rows = self._get_client().snapshot_batch(syms)
+ except TdxQuantError as e:
+ return {"provider": self.name, "dataset": dataset, "rows": 0, "error": str(e)}
+ fallback_ms = int(time.time() * 1000)
+ if dataset == "realtime":
+ head = [r for r in (_map_snapshot_row(c, row, fallback_ms) for c, row in rows.items()) if r]
+ return {"provider": self.name, "dataset": dataset, "rows": len(rows),
+ "columns": list(head[0].keys()) if head else list(SNAPSHOT_FIELDS),
+ "preview": head[:5]}
+ depths = {c: d for c, d in ((c, self._depth_entry(row, fallback_ms)) for c, row in rows.items()) if d}
+ return {"provider": self.name, "dataset": dataset, "rows": len(depths),
+ "columns": ["symbol", "bid_prices", "bid_volumes", "ask_prices", "ask_volumes", "timestamp"],
+ "preview": [{"symbol": c, **d} for c, d in list(depths.items())[:5]]}
+ return {"provider": self.name, "dataset": dataset, "rows": 0,
+ "error": f"tdx 插件未接入 {dataset} 数据集(自动回退 TickFlow)"}
+
+
+__all__ = [
+ "API_KEY_ENV",
+ "TdxQuantProvider",
+ "availability",
+ "get_api_key",
+ "probe_api_key",
+]
diff --git a/backend/app/secrets_store.py b/backend/app/secrets_store.py
index 5eee014c..bb81da97 100644
--- a/backend/app/secrets_store.py
+++ b/backend/app/secrets_store.py
@@ -30,6 +30,16 @@ def _path() -> Path:
return p
+def file_path() -> Path:
+ """secrets.json 绝对路径 (设置页展示用)。
+
+ 该文件是 AI 与数据源 Key 的统一配置源, 支持用户用编辑器直接修改;
+ load() 按 mtime 签名缓存失效, 外部保存后下一次请求即读取最新内容,
+ 无需重启。
+ """
+ return _path()
+
+
def load() -> dict:
"""读取 secrets.json (带 mtime 签名缓存)。返回深拷贝, 调用方可自由修改。"""
global _cache, _cache_sig
diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py
index 6a5fdcb6..a8841b71 100644
--- a/backend/app/services/ai_provider.py
+++ b/backend/app/services/ai_provider.py
@@ -26,6 +26,10 @@ CODEX_CLI_PROVIDER = "codex_cli"
CODEX_DEFAULT_COMMAND = "codex"
CODEX_SUPPORTED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
OPENAI_DEFAULT_REASONING_EFFORT = "high"
+# OpenAI 系两种请求协议: chat.completions (兼容面最广) 与 Responses (官方新协议)。
+CHAT_COMPLETIONS_PROTOCOL = "chat_completions"
+RESPONSES_PROTOCOL = "responses"
+_SUPPORTED_PROTOCOLS = {CHAT_COMPLETIONS_PROTOCOL, RESPONSES_PROTOCOL}
_CODEX_ENV_ALLOWLIST = (
"PATH",
@@ -124,6 +128,24 @@ def current_ai_provider() -> str:
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
+def current_ai_protocol() -> str:
+ """当前 OpenAI 系请求协议: chat_completions (默认) | responses。
+
+ 仅对 openai/openai_compat 两种 provider 有意义; Codex CLI 走本地子进程,
+ 恒为 chat_completions。非法存储值一律回退默认, 不抛错。
+ """
+ if is_codex_cli_provider():
+ return CHAT_COMPLETIONS_PROTOCOL
+ value = str(
+ secrets_store.get_ai_config("ai_protocol", settings.ai_protocol) or ""
+ ).strip().lower()
+ return value if value in _SUPPORTED_PROTOCOLS else CHAT_COMPLETIONS_PROTOCOL
+
+
+def is_responses_protocol() -> bool:
+ return current_ai_protocol() == RESPONSES_PROTOCOL
+
+
def current_openai_model() -> str:
return secrets_store.get_ai_config("ai_model", settings.ai_model)
@@ -318,6 +340,15 @@ async def generate_ai_text(
_check_input_budget(messages, max_tokens=max_tokens)
if is_codex_cli_provider():
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
+ if is_responses_protocol():
+ from app.services import ai_responses
+
+ return await ai_responses.run_text(
+ messages,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ timeout=timeout,
+ )
return await _run_openai_once(
messages,
temperature=temperature,
@@ -356,13 +387,24 @@ async def generate_ai_text_with_tools(
for _ in range(max_rounds):
_check_input_budget(req_messages, max_tokens=max_tokens)
- message = await _run_openai_message_once(
- req_messages,
- temperature=temperature,
- max_tokens=max_tokens,
- timeout=timeout,
- tools=tool_schemas,
- )
+ if is_responses_protocol():
+ from app.services import ai_responses
+
+ message = await ai_responses.run_message_once(
+ req_messages,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ timeout=timeout,
+ tools=tool_schemas,
+ )
+ else:
+ message = await _run_openai_message_once(
+ req_messages,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ timeout=timeout,
+ tools=tool_schemas,
+ )
if message is None:
return req_messages
@@ -446,6 +488,18 @@ async def stream_ai_text(
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return
+ if is_responses_protocol():
+ from app.services import ai_responses
+
+ async for chunk in ai_responses.stream_text(
+ messages,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ timeout=timeout,
+ ):
+ yield chunk
+ return
+
async for chunk in _stream_openai(
messages,
temperature=temperature,
@@ -631,6 +685,57 @@ def _openai_client(api_key: str, timeout: float):
)
+async def probe_ai_config(
+ *,
+ provider: str,
+ api_key: str,
+ base_url: str,
+ model: str,
+ protocol: str = "",
+ timeout: float = 20.0,
+) -> str:
+ """草稿配置一次性连通性探测 — 不读写任何配置状态。
+
+ 「测试」必须与「保存」解耦: 旧实现先保存草稿再测试, 测试失败时坏配置
+ 已经落盘并被状态栏展示为已连接。本函数用表单值直接建临时客户端发一条
+ 最小消息, 成功与否都不触碰 secrets。
+
+ 与正式调用共用 base URL 规范化与协议分支 (chat.completions / responses);
+ 不传输出上限/温度等可选参数, 规避 max_tokens 与 max_completion_tokens 等
+ 模型差异。返回模型可见文本 (推理模型可能为空串 — 200 即证明鉴权/端点/
+ 模型可用); 传输层异常原样抛出, 由调用方格式化后回报。
+ """
+ if provider == CODEX_CLI_PROVIDER:
+ if not codex_cli_available():
+ raise RuntimeError("Codex CLI 不可用: 未找到可运行的 codex 命令")
+ return ""
+ if not api_key:
+ raise RuntimeError("未配置 API Key")
+ if not base_url:
+ raise ValueError("请先填写 API 地址")
+ if not model:
+ raise ValueError("请先填写模型名称")
+
+ from openai import AsyncOpenAI
+
+ user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
+ client = AsyncOpenAI(
+ api_key=api_key,
+ base_url=normalize_openai_base_url(base_url),
+ timeout=timeout,
+ max_retries=0,
+ default_headers={"User-Agent": user_agent},
+ )
+ prompt = [{"role": "user", "content": "Reply exactly: OK"}]
+ if protocol == RESPONSES_PROTOCOL:
+ resp = await client.responses.create(model=model, input=prompt)
+ return str(getattr(resp, "output_text", "") or "")
+ resp = await client.chat.completions.create(model=model, messages=prompt)
+ if not getattr(resp, "choices", None):
+ return ""
+ return str(resp.choices[0].message.content or "")
+
+
# 不同模型可能拒绝 temperature 或 reasoning_effort。这里不靠模型名猜测,
# 只在 400 明确指出对应参数时移除该参数并重试; 每个参数最多移除一次。
_TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed")
diff --git a/backend/app/services/ai_responses.py b/backend/app/services/ai_responses.py
new file mode 100644
index 00000000..0b28629f
--- /dev/null
+++ b/backend/app/services/ai_responses.py
@@ -0,0 +1,516 @@
+"""OpenAI Responses API 协议适配 — 与 chat.completions 路径平行的第二协议。
+
+设计约束(与 ai_provider._stream_openai / _iter_openai_text 对齐):
+- kwargs 构建与 400 自适应重试同语义, 仅参数名不同:
+ max_tokens→max_output_tokens, reasoning_effort→reasoning={"effort"}。
+- 事件流归一化沿用 chat 版的防御: 长度截断 / 仅推理无正文 / 空响应都显式报错。
+- 工具调用走 function_call output item + function_call_output 回填,
+ 对外仍暴露 chat 形态 (role:tool / tool_calls), 上层 (generate_ai_text_with_tools,
+ assistant chat_service) 零改动; 转换在本模块进出两端完成。
+- 本模块不做 provider 判定, 由 ai_provider.is_responses_protocol() 分流进入。
+
+配置读取全部经 ai_provider 模块限定调用(非 from-import 名字绑定),
+保证测试可 monkeypatch ai_provider.current_ai_provider 等。
+"""
+from __future__ import annotations
+
+import asyncio
+import inspect
+from collections.abc import AsyncIterator, Sequence
+from types import SimpleNamespace
+from typing import Any
+
+from app.services import ai_provider
+from app.services.ai_provider import Message
+
+# Responses 的 incomplete_details.reason 与 status 语义对齐 chat 的 finish_reason。
+_LENGTH_INCOMPLETE_REASONS = {"length", "max_tokens", "max_output_tokens"}
+# 推理事件前缀: reasoning_summary_text.delta / reasoning_text.delta 等。
+_REASONING_EVENT_PREFIXES = (
+ "response.reasoning_summary",
+ "response.reasoning_text",
+ "response.reasoning.",
+)
+
+
+def responses_client(timeout: float):
+ """复用核心的 AsyncOpenAI 客户端 (Key/Base URL/UA/重试策略一处维护)。"""
+ ai_key = ai_provider.secrets_store.get_ai_key()
+ if not ai_key:
+ raise RuntimeError("AI API Key 未配置, 请在设置页配置")
+ return ai_provider._openai_client(ai_key, timeout)
+
+
+# ---------------------------------------------------------------------------
+# 消息与工具的双向转换 (chat 形态 ⇄ Responses input/tools)
+# ---------------------------------------------------------------------------
+
+
+def chat_input(messages: Sequence[Message]) -> list[dict[str, Any]]:
+ """chat messages → Responses input items。
+
+ role:tool → function_call_output; assistant.tool_calls → function_call items;
+ 其余 (system/user/assistant 纯文本) 走 easy-input message 形态。
+ Gemini 兼容层的 extra_content 在 Responses 协议下无意义, 静默丢弃。
+ """
+ items: list[dict[str, Any]] = []
+ for m in messages:
+ role = str(m.get("role") or "user")
+ if role == "tool":
+ items.append({
+ "type": "function_call_output",
+ "call_id": str(m.get("tool_call_id") or ""),
+ "output": str(m.get("content") or ""),
+ })
+ continue
+ tool_calls = m.get("tool_calls")
+ if role == "assistant" and tool_calls:
+ if m.get("content"):
+ items.append({"role": "assistant", "content": str(m["content"])})
+ for tc in tool_calls:
+ fn = tc.get("function") or {}
+ items.append({
+ "type": "function_call",
+ "call_id": str(tc.get("id") or ""),
+ "name": str(fn.get("name") or ""),
+ "arguments": str(fn.get("arguments") or ""),
+ })
+ continue
+ items.append({"role": role, "content": str(m.get("content") or "")})
+ return items
+
+
+def responses_tools(tools: Sequence[dict]) -> list[dict[str, Any]]:
+ """chat 工具 schema → Responses 扁平工具 schema。
+
+ chat: {"type":"function","function":{name,description,parameters}}
+ responses: {"type":"function","name","description","parameters"}
+ 兼容已是扁平形态的输入(部分网关直接吐扁平 schema)。
+ """
+ out: list[dict[str, Any]] = []
+ for t in tools:
+ if not isinstance(t, dict):
+ continue
+ fn = t.get("function")
+ if not isinstance(fn, dict):
+ fn = t if t.get("type") == "function" else None
+ if not fn:
+ continue
+ item: dict[str, Any] = {
+ "type": "function",
+ "name": str(fn.get("name") or ""),
+ }
+ if fn.get("description"):
+ item["description"] = str(fn["description"])
+ if fn.get("parameters") is not None:
+ item["parameters"] = fn["parameters"]
+ if item["name"]:
+ out.append(item)
+ return out
+
+
+def responses_kwargs(
+ *,
+ temperature: float | None,
+ max_tokens: int | None,
+ tools: Sequence[dict] | None = None,
+) -> dict[str, Any]:
+ """构建 responses.create kwargs (与 _openai_kwargs 同语义, 参数名映射)。"""
+ kwargs: dict[str, Any] = {}
+ if max_tokens is not None:
+ kwargs["max_output_tokens"] = max_tokens
+ if temperature is not None:
+ kwargs["temperature"] = temperature
+ if ai_provider.current_ai_provider() == ai_provider.OPENAI_PROVIDER:
+ effort = ai_provider.current_openai_reasoning_effort()
+ if effort:
+ kwargs["reasoning"] = {"effort": effort}
+ if tools:
+ kwargs["tools"] = responses_tools(tools)
+ return kwargs
+
+
+def _is_reasoning_rejected(exc: Exception) -> bool:
+ """True if the upstream 400 specifically rejects the reasoning param."""
+ if getattr(exc, "status_code", None) != 400:
+ return False
+ text = (ai_provider._openai_error_detail(exc) or str(exc)).lower()
+ param = ai_provider._openai_error_param(exc)
+ return param in ("reasoning", "reasoning.effort", "effort") or "reasoning" in text
+
+
+def responses_retry_kwargs(exc: Exception, kwargs: dict[str, Any]) -> dict[str, Any] | None:
+ """Remove one explicitly rejected optional argument for a bounded retry."""
+ retry = dict(kwargs)
+ if "temperature" in retry and ai_provider._is_temperature_rejected(exc):
+ retry.pop("temperature")
+ return retry
+ if "reasoning" in retry and _is_reasoning_rejected(exc):
+ retry.pop("reasoning")
+ return retry
+ return None
+
+
+# ---------------------------------------------------------------------------
+# 终态检查 (completed/failed/incomplete → chat 版等价报错)
+# ---------------------------------------------------------------------------
+
+
+def _incomplete_reason(resp) -> str:
+ details = getattr(resp, "incomplete_details", None)
+ reason = getattr(details, "reason", None) if details is not None else None
+ if reason:
+ return str(reason)
+ # 网关兼容: incomplete_details 为 dict 的情况
+ if isinstance(details, dict):
+ return str(details.get("reason") or "")
+ return ""
+
+
+def _response_error_detail(resp) -> str:
+ err = getattr(resp, "error", None)
+ if err is None:
+ return "上游未返回错误详情"
+ message = getattr(err, "message", None)
+ if message is None and isinstance(err, dict):
+ message = err.get("message")
+ return ai_provider._compact_error_text(str(message or err)) or "上游未返回错误详情"
+
+
+def _check_terminal_response(resp, *, content_seen: bool, reasoning_seen: bool) -> None:
+ """completed/failed/incomplete 终态的防御性检查, 报错文案与 chat 版一致。"""
+ status = str(getattr(resp, "status", "") or "")
+ if status == "failed":
+ raise RuntimeError(f"AI 服务请求失败: {_response_error_detail(resp)}")
+ if status != "incomplete":
+ return
+ reason = _incomplete_reason(resp)
+ if reason not in _LENGTH_INCOMPLETE_REASONS:
+ return
+ if reasoning_seen and not content_seen:
+ raise RuntimeError(
+ "AI 推理达到输出长度上限, 未生成正文; 请提高输出 Token 上限或改用非推理模型"
+ )
+ raise RuntimeError("AI 输出达到长度上限, 内容不完整; 请提高输出 Token 上限后重试")
+
+
+def _raise_no_content(reasoning_seen: bool) -> None:
+ if reasoning_seen:
+ raise RuntimeError("AI 仅返回推理内容, 未生成正文; 请检查模型配置或改用非推理模型")
+ raise RuntimeError("AI 服务未返回正文内容; 请检查模型配置或稍后重试")
+
+
+# ---------------------------------------------------------------------------
+# 非流式: 纯文本 / 工具循环单轮
+# ---------------------------------------------------------------------------
+
+
+def _response_text(resp) -> str:
+ """聚合 Response 的全部 output_text (便利属性缺失时逐 item 提取)。"""
+ text = getattr(resp, "output_text", None)
+ if isinstance(text, str) and text:
+ return text
+ parts: list[str] = []
+ for item in getattr(resp, "output", None) or []:
+ if str(getattr(item, "type", "") or "") != "message":
+ continue
+ for part in getattr(item, "content", None) or []:
+ if str(getattr(part, "type", "") or "") in ("output_text", "text"):
+ value = getattr(part, "text", None)
+ if value:
+ parts.append(str(value))
+ return "".join(parts)
+
+
+def _response_reasoning_seen(resp) -> bool:
+ for item in getattr(resp, "output", None) or []:
+ if str(getattr(item, "type", "") or "").startswith("reasoning"):
+ return True
+ return False
+
+
+def _response_calls(resp) -> list[Any]:
+ """output 里的 function_call item → chat 形态 tool_calls (SimpleNamespace)。"""
+ calls = []
+ for item in getattr(resp, "output", None) or []:
+ if str(getattr(item, "type", "") or "") != "function_call":
+ continue
+ calls.append(SimpleNamespace(
+ id=str(getattr(item, "call_id", None) or getattr(item, "id", "") or ""),
+ type="function",
+ function=SimpleNamespace(
+ name=str(getattr(item, "name", "") or ""),
+ arguments=str(getattr(item, "arguments", "") or ""),
+ ),
+ ))
+ return calls
+
+
+async def _create_with_retry(client, *, payload: dict[str, Any], stream: bool):
+ kwargs = dict(payload)
+ while True:
+ try:
+ return await client.responses.create(**kwargs, stream=stream)
+ except Exception as exc:
+ retry = responses_retry_kwargs(exc, kwargs)
+ if retry is not None:
+ kwargs = retry
+ continue
+ if ai_provider._is_openai_transport_error(exc):
+ raise RuntimeError(ai_provider._format_openai_error(exc)) from exc
+ raise
+
+
+async def run_text(
+ messages: Sequence[Message],
+ *,
+ temperature: float | None,
+ max_tokens: int | None,
+ timeout: float,
+) -> str:
+ """非流式纯文本 (generate_ai_text 的 Responses 分支)。"""
+ client = responses_client(timeout)
+ payload = responses_kwargs(temperature=temperature, max_tokens=max_tokens)
+ payload["model"] = ai_provider.current_ai_model()
+ payload["input"] = chat_input(messages)
+ resp = await _create_with_retry(client, payload=payload, stream=False)
+ text = _response_text(resp).strip()
+ _check_terminal_response(
+ resp, content_seen=bool(text), reasoning_seen=_response_reasoning_seen(resp),
+ )
+ return text
+
+
+async def run_message_once(
+ messages: Sequence[Message],
+ *,
+ temperature: float | None,
+ max_tokens: int | None,
+ timeout: float,
+ tools: Sequence[dict] | None = None,
+):
+ """非流式工具单轮 (generate_ai_text_with_tools 的 Responses 分支)。
+
+ 返回 chat 形态 message 对象 (content + tool_calls), 核心循环零改动。
+ """
+ client = responses_client(timeout)
+ payload = responses_kwargs(temperature=temperature, max_tokens=max_tokens, tools=tools)
+ payload["model"] = ai_provider.current_ai_model()
+ payload["input"] = chat_input(messages)
+ resp = await _create_with_retry(client, payload=payload, stream=False)
+ text = _response_text(resp)
+ calls = _response_calls(resp)
+ if not calls:
+ # 无工具调用时才做终态/空正文防御; 有调用时由上层执行工具进入下一轮。
+ _check_terminal_response(
+ resp, content_seen=bool(text), reasoning_seen=_response_reasoning_seen(resp),
+ )
+ return SimpleNamespace(content=text, tool_calls=calls)
+
+
+# ---------------------------------------------------------------------------
+# 流式: 纯文本 (stream_ai_text 分支)
+# ---------------------------------------------------------------------------
+
+
+async def stream_text(
+ messages: Sequence[Message],
+ *,
+ temperature: float | None,
+ max_tokens: int | None,
+ timeout: float,
+) -> AsyncIterator[str]:
+ client = responses_client(timeout)
+ payload = responses_kwargs(temperature=temperature, max_tokens=max_tokens)
+ payload["model"] = ai_provider.current_ai_model()
+ payload["input"] = chat_input(messages)
+ stream = await _create_with_retry(client, payload=payload, stream=True)
+ try:
+ async for piece in iter_text(stream):
+ yield piece
+ except Exception as exc:
+ if ai_provider._is_openai_transport_error(exc):
+ raise RuntimeError(ai_provider._format_openai_error(exc)) from exc
+ raise
+
+
+def _is_reasoning_event(event_type: str) -> bool:
+ return event_type.startswith(_REASONING_EVENT_PREFIXES)
+
+
+async def iter_text(stream) -> AsyncIterator[str]:
+ """Responses 事件流 → 文本增量, 防御与 _iter_openai_text 等价。"""
+ content_seen = False
+ reasoning_seen = False
+ completed = None
+
+ async for event in stream:
+ event_type = str(getattr(event, "type", "") or "")
+ if event_type == "response.output_text.delta":
+ delta = getattr(event, "delta", None)
+ if delta:
+ content_seen = True
+ yield str(delta)
+ elif event_type == "response.output_text.done" and not content_seen:
+ # 个别网关只发 done 不发 delta: done 携带全量文本, 兜底产出一次。
+ text = getattr(event, "text", None)
+ if text:
+ content_seen = True
+ yield str(text)
+ elif _is_reasoning_event(event_type):
+ reasoning_seen = True
+ elif event_type in ("response.completed", "response.incomplete"):
+ completed = getattr(event, "response", None)
+ elif event_type in ("response.failed", "error"):
+ resp = getattr(event, "response", None)
+ if resp is not None and getattr(resp, "error", None) is not None:
+ raise RuntimeError(f"AI 服务请求失败: {_response_error_detail(resp)}")
+ err = getattr(event, "error", None)
+ if err is not None:
+ raise RuntimeError(f"AI 服务请求失败: {_response_error_detail(SimpleNamespace(error=err))}")
+
+ if completed is not None:
+ _check_terminal_response(completed, content_seen=content_seen, reasoning_seen=reasoning_seen)
+ if not content_seen:
+ _raise_no_content(reasoning_seen)
+
+
+# ---------------------------------------------------------------------------
+# 流式: 工具轮 (assistant stream_openai_round 分支)
+# ---------------------------------------------------------------------------
+
+
+def _event_item_id(event) -> str:
+ """function_call 累积的 key: item_id 优先, 缺失时回退 output_index。"""
+ item_id = getattr(event, "item_id", None)
+ if item_id:
+ return str(item_id)
+ item = getattr(event, "item", None)
+ if item is not None:
+ item_id = getattr(item, "id", None)
+ if item_id:
+ return str(item_id)
+ return f"#{getattr(event, 'output_index', 0)}"
+
+
+def _completed_calls(resp) -> list[dict[str, Any]]:
+ """completed.response.output 是权威工具调用列表 (顺序与完整性优于分片累积)。"""
+ calls = []
+ for item in getattr(resp, "output", None) or []:
+ if str(getattr(item, "type", "") or "") != "function_call":
+ continue
+ calls.append({
+ "id": str(getattr(item, "call_id", None) or getattr(item, "id", "") or ""),
+ "name": str(getattr(item, "name", "") or ""),
+ "arguments": str(getattr(item, "arguments", "") or ""),
+ })
+ return calls
+
+
+async def stream_round(
+ messages: Sequence[dict[str, Any]],
+ tools: Sequence[dict[str, Any]],
+ *,
+ temperature: float | None = 0.3,
+ timeout: float = 240.0,
+) -> AsyncIterator[dict[str, Any]]:
+ """一轮 Responses 流式补全, 输出与 assistant streaming.stream_openai_round 同构。"""
+ client = responses_client(timeout)
+ payload = responses_kwargs(temperature=temperature, max_tokens=None, tools=tools)
+ payload["model"] = ai_provider.current_ai_model()
+ payload["input"] = chat_input(messages)
+ stream = await _create_with_retry(client, payload=payload, stream=True)
+
+ content_seen = False
+ reasoning_seen = False
+ calls: dict[str, dict[str, Any]] = {}
+ ordered: list[dict[str, Any]] = []
+ final_calls: list[dict[str, Any]] | None = None
+ completed = None
+
+ async for event in stream:
+ event_type = str(getattr(event, "type", "") or "")
+ if event_type == "response.output_text.delta":
+ delta = getattr(event, "delta", None)
+ if delta:
+ content_seen = True
+ yield {"type": "text", "delta": str(delta)}
+ elif event_type == "response.output_text.done" and not content_seen:
+ text = getattr(event, "text", None)
+ if text:
+ content_seen = True
+ yield {"type": "text", "delta": str(text)}
+ elif _is_reasoning_event(event_type):
+ reasoning_seen = True
+ elif event_type == "response.output_item.added":
+ item = getattr(event, "item", None)
+ if item is not None and str(getattr(item, "type", "") or "") == "function_call":
+ key = _event_item_id(event)
+ entry = {"id": "", "name": "", "arguments": ""}
+ calls[key] = entry
+ ordered.append(entry)
+ call_id = getattr(item, "call_id", None)
+ if call_id:
+ entry["id"] = str(call_id)
+ if getattr(item, "name", None):
+ entry["name"] = str(item.name)
+ elif event_type == "response.function_call_arguments.delta":
+ key = _event_item_id(event)
+ entry = calls.get(key)
+ if entry is None:
+ entry = {"id": "", "name": "", "arguments": ""}
+ calls[key] = entry
+ ordered.append(entry)
+ delta = getattr(event, "delta", None)
+ if delta:
+ entry["arguments"] += str(delta)
+ elif event_type == "response.output_item.done":
+ item = getattr(event, "item", None)
+ if item is not None and str(getattr(item, "type", "") or "") == "function_call":
+ # done item 是权威值, 整条覆盖累积分片 (网关分片乱序/缺失时兜底)。
+ key = _event_item_id(event)
+ entry = calls.get(key)
+ if entry is None:
+ entry = {"id": "", "name": "", "arguments": ""}
+ calls[key] = entry
+ ordered.append(entry)
+ call_id = getattr(item, "call_id", None)
+ if call_id:
+ entry["id"] = str(call_id)
+ if getattr(item, "name", None):
+ entry["name"] = str(item.name)
+ if getattr(item, "arguments", None):
+ entry["arguments"] = str(item.arguments)
+ elif event_type in ("response.completed", "response.incomplete"):
+ completed = getattr(event, "response", None)
+ if completed is not None:
+ final_calls = _completed_calls(completed)
+ elif event_type in ("response.failed", "error"):
+ resp = getattr(event, "response", None)
+ err = getattr(event, "error", None)
+ if err is None and resp is not None:
+ err = getattr(resp, "error", None)
+ raise RuntimeError(
+ f"AI 服务请求失败: {_response_error_detail(SimpleNamespace(error=err))}"
+ )
+
+ # 让 SDK 流对象尽快释放(部分兼容网关要求显式 close); SDK 2.x 的
+ # AsyncStream.close 是协程, 必须区分同步/异步两种签名。
+ close = getattr(stream, "close", None)
+ if close is not None:
+ if inspect.iscoroutinefunction(close):
+ await close()
+ else:
+ await asyncio.to_thread(close)
+
+ if final_calls is None:
+ final_calls = ordered
+ if final_calls:
+ yield {"type": "round_end", "tool_calls": final_calls, "finish_reason": "tool_calls"}
+ return
+
+ if completed is not None:
+ _check_terminal_response(completed, content_seen=content_seen, reasoning_seen=reasoning_seen)
+ if not content_seen:
+ _raise_no_content(reasoning_seen)
+ yield {"type": "round_end", "tool_calls": [], "finish_reason": "stop"}
diff --git a/backend/app/services/depth_service.py b/backend/app/services/depth_service.py
index a65e9d7e..a6ac606c 100644
--- a/backend/app/services/depth_service.py
+++ b/backend/app/services/depth_service.py
@@ -101,7 +101,7 @@ class DepthService:
def boot_check(self) -> None:
"""启动补跑: 当天 depth5 文件不存在则 finalize 一次; 已存在则恢复内存缓存。"""
if not self._has_capability():
- logger.info("depth sealed: 无 DEPTH5_BATCH 能力, 跳过启动补跑")
+ logger.info("depth sealed: %s 不可用, 跳过启动补跑", self._capability_label())
return
today = cn_today()
if self._persisted_for_date(today):
@@ -190,7 +190,7 @@ class DepthService:
返回 {"ok": bool, "count": int, "msg": str}
"""
if not self._has_capability():
- return {"ok": False, "count": 0, "msg": "无五档盘口能力(需 Pro+)"}
+ return {"ok": False, "count": 0, "msg": f"{self._capability_label()} 不可用"}
try:
self._fetch_and_seal(persist=True) # 落盘, 刷新页面不丢
with self._lock:
@@ -631,9 +631,21 @@ class DepthService:
# ================================================================
def _has_capability(self) -> bool:
- capset = self._get_capset()
- from app.tickflow.capabilities import Cap
- return capset.has(Cap.DEPTH5_BATCH)
+ """五档能力是否可用 — 以【生效源】为准, 与取数路径 (_call_depth_batch) 同口径。
+
+ 不能只看 TickFlow 档位: 五档路由到声明了 depth5 的插件 (如 tdxquant) 时,
+ TickFlow 档位无关; 反之路由到 TickFlow 但档位不足时, 插件声明不该算数。
+ 本轮修复前此处只查 capset, 导致「五档已切到插件但启动补跑被跳过」。
+ """
+ from app.data_providers.capabilities import capability_gate
+ ok, _ = capability_gate("depth5")
+ return ok
+
+ def _capability_label(self) -> str:
+ """门禁日志标签: "五档盘口 ← fuyao" / "…(档位不足)", 便于判断该切哪个源。"""
+ from app.data_providers.capabilities import capability_gate
+ _, label = capability_gate("depth5")
+ return label
def _get_capset(self):
"""获取当前 capset(优先 app.state, 回退 detect)。"""
diff --git a/backend/app/services/emotion_score.py b/backend/app/services/emotion_score.py
new file mode 100644
index 00000000..0cd38d53
--- /dev/null
+++ b/backend/app/services/emotion_score.py
@@ -0,0 +1,243 @@
+"""情绪评分 — 维度 / 权重 / 区间的单一权威定义 + 可配置计算。
+
+口径即配置: 6 个维度各自由若干「指标 → 归一化区间 → 加权」子项合成, 维度再按
+权重加权平均得到总分; 档位阈值把分数映射为 强势/偏暖/震荡/偏冷/冰点。
+
+设计约束:
+ - DEFAULT_CONFIG 复现历史硬编码口径, 逐位一致 (所有子项权重和 = 1, 维度等权);
+ - 子项权重在维度内**按权重和归一化**, 因此用户改动权重不会因和不为 1 而畸变;
+ - 用户配置与默认配置做逐字段合并, 新增维度/子项时老配置自动补默认值, 不会
+ 因缺字段而 KeyError;
+ - 指标值缺失 (None) 的维度取中性 50, 与历史实现「无主线数据取 50」一致。
+
+消费方: app.services.market_overview_builder (看板总览 + 复盘情绪分)。
+"""
+from __future__ import annotations
+
+import math
+
+# 档位: (阈值键, 展示名, 颜色语义) — 从高到低匹配
+LABELS: tuple[tuple[str, str], ...] = (
+ ("strong", "强势"),
+ ("warm", "偏暖"),
+ ("neutral", "震荡"),
+ ("cold", "偏冷"),
+)
+FALLBACK_LABEL = "冰点"
+NEUTRAL = 50
+
+
+def _default_config() -> dict:
+ """出厂口径 — 与改造前的硬编码计算逐位一致。"""
+
+ def dim(label: str, terms: list[dict], **extra) -> dict:
+ return {"label": label, "weight": 1.0, "terms": terms, **extra}
+
+ def term(key: str, label: str, weight: float, low: float, high: float, unit: str = "") -> dict:
+ return {"key": key, "label": label, "weight": weight, "low": low, "high": high, "unit": unit}
+
+ return {
+ "dimensions": {
+ "index": dim("指数", [
+ term("avg_index_pct", "指数平均涨跌", 1.0, -2.5, 2.5, "%"),
+ ]),
+ "profit": dim("赚钱", [
+ term("up_pct", "上涨占比", 0.45, 20, 80, "%"),
+ term("avg_pct", "平均涨跌", 0.25, -0.02, 0.02),
+ term("median_pct", "中位涨跌", 0.20, -0.02, 0.02),
+ term("strong_diff_pct", "强涨-强跌占比差", 0.10, -8, 8, "%"),
+ ]),
+ "money": dim("量能", [
+ term("avg_vol_ratio", "平均量比", 0.70, 0.6, 1.8),
+ term("high_vol_pct", "放量股占比", 0.30, 2, 12, "%"),
+ ]),
+ "speculation": dim("投机", [
+ term("limit_up", "涨停家数", 0.25, 5, 90, "家"),
+ term("seal_rate", "封板率", 0.35, 30, 85, "%"),
+ term("max_boards", "最高连板", 0.25, 1, 8, "板"),
+ term("tier2_count", "2板及以上", 0.15, 0, 30, "家"),
+ ]),
+ # 抗跌为逆向维度: 下跌越多分越低, 故 invert
+ "resilience": dim("抗跌", [
+ term("down_pct", "下跌占比", 0.55, 20, 80, "%"),
+ term("strong_down_pct", "强跌占比", 0.45, 1, 12, "%"),
+ ], invert=True),
+ "mainline": dim("主线", [
+ term("mainline_avg", "主线平均涨跌", 0.65, -0.005, 0.03),
+ term("mainline_cover_pct", "主线覆盖率", 0.35, 1, 12, "%"),
+ ]),
+ },
+ "thresholds": {"strong": 70, "warm": 55, "neutral": 45, "cold": 30},
+ }
+
+
+DEFAULT_CONFIG: dict = _default_config()
+
+# 维度展示顺序 = 雷达图顶点顺序 = 配置面板行序 (三者共用, 避免各处再排一遍)
+DIMENSION_ORDER: tuple[str, ...] = tuple(DEFAULT_CONFIG["dimensions"])
+
+
+def _finite(value) -> float | None:
+ if value is None:
+ return None
+ try:
+ v = float(value)
+ except (TypeError, ValueError):
+ return None
+ return v if math.isfinite(v) else None
+
+
+def score_of(value, low: float, high: float) -> int:
+ """指标 → 0..100 归一化分。与历史 `_score` 完全同语义 (含非有限输入取中性)。"""
+ if high <= low:
+ return NEUTRAL
+ v = _finite(value)
+ if v is None:
+ return NEUTRAL
+ return max(0, min(100, round((v - low) / (high - low) * 100)))
+
+
+def merge_config(raw: dict | None) -> dict:
+ """用户配置 → 完整配置 (逐字段合并, 缺项补默认)。
+
+ 合并粒度到 term: 用户改过的权重/区间生效, 未提及的字段沿用出厂值。
+ 维度/子项数量与 key 以出厂定义为准 (不支持增删指标 —— 指标值由计算方提供,
+ 凭空新增的 key 取不到数)。
+ """
+ merged = _default_config()
+ if not isinstance(raw, dict):
+ return merged
+
+ raw_dims = raw.get("dimensions")
+ if isinstance(raw_dims, dict):
+ for key, dim in merged["dimensions"].items():
+ user_dim = raw_dims.get(key)
+ if not isinstance(user_dim, dict):
+ continue
+ weight = _finite(user_dim.get("weight"))
+ if weight is not None and weight >= 0:
+ dim["weight"] = weight
+ user_terms = user_dim.get("terms")
+ if not isinstance(user_terms, list):
+ continue
+ by_key = {t["key"]: t for t in user_terms if isinstance(t, dict)}
+ for term in dim["terms"]:
+ user_term = by_key.get(term["key"])
+ if not isinstance(user_term, dict):
+ continue
+ for field in ("weight", "low", "high"):
+ val = _finite(user_term.get(field))
+ if val is not None:
+ term[field] = val
+
+ raw_th = raw.get("thresholds")
+ if isinstance(raw_th, dict):
+ for key in merged["thresholds"]:
+ val = _finite(raw_th.get(key))
+ if val is not None:
+ merged["thresholds"][key] = val
+ _normalize_thresholds(merged["thresholds"])
+ return merged
+
+
+def _normalize_thresholds(th: dict) -> None:
+ """阈值夹到 0..100 并保证降序 (强≥暖≥震荡≥冷)。
+
+ 用户可能把 强势 填得比 偏暖 还低, 那样档位区间为空、标签永远错。按数值重排
+ 回填, 既保留用户意图的相对关系, 也保证映射单调。
+ """
+ for key in th:
+ val = _finite(th.get(key))
+ th[key] = NEUTRAL if val is None else max(0.0, min(100.0, val))
+ values = sorted((th[k] for k in th), reverse=True)
+ for (key, _), val in zip(LABELS, values):
+ th[key] = val
+
+
+def label_of(score: float, thresholds: dict) -> str:
+ for key, name in LABELS:
+ if score >= thresholds.get(key, 0):
+ return name
+ return FALLBACK_LABEL
+
+
+def dimension_value(dim: dict, metrics: dict) -> int:
+ """单个维度 → 0..100。子项权重和归一化; 全部子项缺数时取中性。"""
+ total_weight = 0.0
+ acc = 0.0
+ for term in dim["terms"]:
+ if term["key"] not in metrics:
+ continue # 该指标本次不可用 → 跳过, 不当作 0
+ value = score_of(metrics[term["key"]], term["low"], term["high"])
+ weight = max(0.0, float(term.get("weight") or 0))
+ if weight <= 0:
+ continue
+ acc += value * weight
+ total_weight += weight
+ if total_weight <= 0:
+ return NEUTRAL
+ # 权重和已是 1 时不做除法: 出厂口径的 0.45+0.25+0.20+0.10 在浮点下是
+ # 0.9999999999999999, 除一趟会把恰好落在 .5 的值推过舍入边界, 使默认结果
+ # 与历史实现差 1 分。改过权重时才归一化 (此时结果本就该变)。
+ weighted = acc if abs(total_weight - 1.0) < 1e-9 else acc / total_weight
+ if dim.get("invert"):
+ # 先取整再取反 (而非 round(100 - x)): 两者在 x 的小数部分恰为 .5 时
+ # 结果可差 1, 出厂口径必须与历史实现逐位一致。
+ return 100 - round(weighted)
+ return round(weighted)
+
+
+def compute(metrics: dict, config: dict | None = None) -> dict:
+ """指标 + 配置 → {score, label, radar, dimensions}。
+
+ radar 保持历史形状 [{key,label,value}] (前端雷达图与复盘快照依赖),
+ dimensions 额外带权重与命中分, 供配置面板展示「各维度当前实际得分」。
+ """
+ cfg = merge_config(config)
+ radar: list[dict] = []
+ dims: list[dict] = []
+ for key in DIMENSION_ORDER:
+ dim = cfg["dimensions"][key]
+ value = dimension_value(dim, metrics)
+ radar.append({"key": key, "label": dim["label"], "value": value})
+ dims.append({
+ "key": key,
+ "label": dim["label"],
+ "value": value,
+ "weight": dim["weight"],
+ "invert": bool(dim.get("invert")),
+ })
+
+ # 等权时直接取整数均值: 权重在均值里本就约掉, 而 Python 3.12+ 的 sum() 对
+ # 浮点走补偿求和、对整数是精确累加 —— 走 `int * 1.0` 会引入浮点误差, 让恰好
+ # 落在 .5 的总分被舍到另一侧, 与历史实现差 1 分。改过权重时才走加权分支。
+ weights = [d["weight"] for d in dims]
+ if weights and all(abs(w - weights[0]) < 1e-12 for w in weights):
+ score = round(sum(d["value"] for d in dims) / len(dims)) if dims else NEUTRAL
+ else:
+ total_weight = sum(weights)
+ if total_weight > 0:
+ score = round(sum(d["value"] * d["weight"] for d in dims) / total_weight)
+ elif dims:
+ score = round(sum(d["value"] for d in dims) / len(dims))
+ else:
+ score = NEUTRAL
+
+ return {
+ "score": score,
+ "label": label_of(score, cfg["thresholds"]),
+ "radar": radar,
+ "dimensions": dims,
+ "thresholds": cfg["thresholds"],
+ }
+
+
+def public_config(config: dict | None = None) -> dict:
+ """完整配置 + 指标元数据 (供前端渲染配置面板)。"""
+ cfg = merge_config(config)
+ return {
+ "config": cfg,
+ "defaults": DEFAULT_CONFIG,
+ "dimension_order": list(DIMENSION_ORDER),
+ "labels": [{"key": k, "name": n} for k, n in LABELS] + [{"key": "fallback", "name": FALLBACK_LABEL}],
+ }
diff --git a/backend/app/services/ext_data.py b/backend/app/services/ext_data.py
index 86a41a1d..9c62e42b 100644
--- a/backend/app/services/ext_data.py
+++ b/backend/app/services/ext_data.py
@@ -53,6 +53,7 @@ class PullConfig:
"next_run", "time_window_start", "time_window_end", "date_param",
"date_format", "time_field", "auth", "timeout_seconds",
"page_param", "page_size_param", "page_size", "page_start", "max_pages",
+ "fanout",
)
def __init__(
@@ -82,7 +83,9 @@ class PullConfig:
page_size: int = 0,
page_start: int = 1,
max_pages: int = 20,
+ fanout: dict | None = None,
) -> None:
+ self.fanout = fanout or None # 两级拉取配方 (见 fanout.py), None=单请求
self.url = url
self.method = method # GET | POST
self.headers = headers or {}
@@ -163,6 +166,7 @@ class PullConfig:
"page_size": self.page_size,
"page_start": self.page_start,
"max_pages": self.max_pages,
+ "fanout": self.fanout,
}
@classmethod
@@ -195,6 +199,7 @@ class PullConfig:
page_size=d.get("page_size", 0),
page_start=d.get("page_start", 1),
max_pages=d.get("max_pages", 20),
+ fanout=d.get("fanout"),
)
diff --git a/backend/app/services/ext_fanout.py b/backend/app/services/ext_fanout.py
new file mode 100644
index 00000000..d7fda3bb
--- /dev/null
+++ b/backend/app/services/ext_fanout.py
@@ -0,0 +1,155 @@
+"""两级拉取 (fanout) — 「目录 → 逐条目明细」接口的声明式配方。
+
+适用面: 数据源只提供「类别清单」+「单类别成员」两个端点, 没有一次返回
+「成员 → 所属类别」全量映射的接口 (同花顺概念/行业、多数板块类 API)。
+
+通用拉取是单请求 + 字段名映射, 表达不了这种形态; 与其按 config_id 硬编码
+特例, 不如把「第二级怎么发、结果怎么拼」写进 PullConfig.fanout —— 配置本身
+即配方, 在「数据」页展开扩展表 → 拉取 就能看到全貌, 也便于照抄接入自有源
+(见 docs/custom-data-source.md)。
+
+fanout 配方字段:
+ url 第二级请求地址 (与 pull.url 同走标识头 + auth 鉴权)
+ param 第二级请求的查询参数名, 取值为第一级条目的 id_field
+ id_field 第一级条目中作为第二级参数的字段
+ label_field 第一级条目中作展示名的字段 (可选, 缺省用 id_field; 仅用于告警)
+ items_path 第二级响应里成员数组的 dot-path (留空 = 响应本身即数组)
+ member_id_field 成员对象里取标的代码的字段
+ member_label_field 成员对象里取标的名称的字段 (可选)
+ symbol_col 输出列名: 标的代码
+ name_col 输出列名: 标的名称
+ value_col 输出列名: 拼接后的类别
+ separator 多类别拼接符 (概念用 ";", 行业层级用 "-")
+ concurrency 并发上限 (可选, 默认 8)
+ retries 单条目失败重试次数 (可选, 默认 1; 用尽则跳过该条目)
+
+产出: 行方向由「类别 → 成员」反转为「成员 → 类别清单」, 与单请求路径的
+行结构一致, 下游 rows_to_parquet / 分析页零改动。
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+import math
+from typing import Any
+
+from app.services.ext_data import ExtConfig
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_CONCURRENCY = 8
+_DEFAULT_RETRIES = 1
+_REQUIRED_KEYS = (
+ "url", "param", "id_field", "items_path", "member_id_field",
+ "symbol_col", "name_col", "value_col",
+)
+
+
+def clean_label(value: Any) -> str:
+ """类别名归一: 剔除 None / NaN / 字符串 "nan" 等占位值。"""
+ if value is None or (isinstance(value, float) and not math.isfinite(value)):
+ return ""
+ text = str(value).strip()
+ return "" if text.casefold() in {"nan", "none", "null"} else text
+
+
+def _raise_on_envelope_error(data: Any) -> None:
+ """常见信封 {code, message, data}: code 非 0 时抛出带服务端文案的错误。"""
+ if isinstance(data, dict):
+ code = data.get("code")
+ if code not in (0, None, "0"):
+ raise ValueError(f"接口返回错误 code={code}: {data.get('message', '')}")
+
+
+async def fetch_fanout_rows(config: ExtConfig, target_date=None) -> list[dict]:
+ """执行两级拉取并反转为「成员 → 类别清单」行。"""
+ from app.services.ext_pull import _append_query_params, _extract_rows, _request_json
+
+ pull = config.pull
+ if pull is None:
+ raise ValueError("拉取未配置")
+ fanout = pull.fanout or {}
+ missing = [k for k in _REQUIRED_KEYS if not fanout.get(k)]
+ if missing:
+ raise ValueError(f"fanout 配方缺少必填项: {', '.join(missing)}")
+
+ # ---- 第一级: 类别清单 ----
+ catalog = await _request_json(pull, config.id, target_date)
+ _raise_on_envelope_error(catalog)
+ items = _extract_rows(catalog, pull.response_path)
+
+ id_field = fanout["id_field"]
+ label_field = fanout.get("label_field") or id_field
+ entries = [
+ it for it in items
+ if isinstance(it, dict) and str(it.get(id_field) or "").strip()
+ ]
+ if not entries:
+ raise ValueError(f"第一级返回 0 个可用条目 (id_field={id_field})")
+
+ # ---- 第二级: 逐条目成员 (并发 + 单条目失败容忍) ----
+ concurrency = int(fanout.get("concurrency") or _DEFAULT_CONCURRENCY)
+ raw_retries = fanout.get("retries")
+ retries = int(_DEFAULT_RETRIES if raw_retries is None else raw_retries)
+ sem = asyncio.Semaphore(max(1, concurrency))
+
+ async def _members(entry: dict) -> list[dict] | None:
+ member_url = _append_query_params(
+ fanout["url"], {fanout["param"]: str(entry[id_field]).strip()}
+ )
+ async with sem:
+ for attempt in range(retries + 1):
+ try:
+ data = await _request_json(
+ pull, config.id, None, url_override=member_url
+ )
+ _raise_on_envelope_error(data)
+ return _extract_rows(data, fanout.get("items_path") or "")
+ except Exception: # noqa: BLE001 — 单条目失败: 重试后跳过
+ if attempt == retries:
+ return None
+ await asyncio.sleep(0.5)
+ return None
+
+ members_list = await asyncio.gather(*(_members(e) for e in entries))
+
+ # ---- 反转聚合: 成员 → 类别清单 (顺序跟随目录顺序, 保证拼接结果稳定) ----
+ m_id = fanout["member_id_field"]
+ m_name = fanout.get("member_label_field")
+ agg: dict[str, dict] = {}
+ failed: list[str] = []
+ for entry, members in zip(entries, members_list, strict=True):
+ if members is None:
+ failed.append(str(entry.get(label_field) or entry.get(id_field) or ""))
+ continue
+ label = clean_label(entry.get(label_field))
+ for m in members:
+ if not isinstance(m, dict):
+ continue
+ sym = str(m.get(m_id) or "").strip()
+ if not sym:
+ continue
+ row = agg.get(sym)
+ if row is None:
+ row = {
+ fanout["symbol_col"]: sym,
+ fanout["name_col"]: (m.get(m_name) or "") if m_name else "",
+ fanout["value_col"]: [],
+ }
+ agg[sym] = row
+ if label and label not in row[fanout["value_col"]]:
+ row[fanout["value_col"]].append(label)
+
+ if failed:
+ logger.warning(
+ "fanout 第二级失败 %d/%d (已跳过): %s",
+ len(failed), len(entries), "、".join(failed[:5]),
+ )
+ if not agg:
+ raise ValueError("fanout 全部条目第二级拉取失败")
+
+ separator = fanout.get("separator") or ";"
+ return [
+ {**row, fanout["value_col"]: separator.join(row[fanout["value_col"]])}
+ for row in agg.values()
+ ]
diff --git a/backend/app/services/ext_pages.py b/backend/app/services/ext_pages.py
new file mode 100644
index 00000000..8c1ecb94
--- /dev/null
+++ b/backend/app/services/ext_pages.py
@@ -0,0 +1,133 @@
+"""运行时页面包存储 — data/ext_pages/{id}/ 目录式持久化。
+
+页面包是前端扩展的打包产物(单文件 ES 模块): 上传后由前端在启动引导窗口
+动态 import 挂载(见 frontend/src/extensions/runtime.ts)。后端只负责存储、
+元数据与分发, 不解析 JS 内容 —— 清单契约(能否 import、apiVersion、路由
+冲突)由前端注册表校验, 校验结果经 manifest 接口回填到 page.json。
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import time
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+# 与 ExtConfigStore 同口径的 ID 白名单: 防 ../ 路径穿越写删
+_VALID_ID = re.compile(r"^[a-zA-Z0-9_]{1,64}$")
+
+
+@dataclass
+class PagePackMeta:
+ id: str
+ enabled: bool = False
+ # 前端试载成功后回填的清单摘要(展示与排障用, 真实注册以 bundle 为准)
+ label: str = ""
+ path: str = ""
+ size: int = 0
+ sha256: str = ""
+ uploaded_at: float = 0.0
+ # 前端试载/注册失败的错误信息; 空串表示未出错
+ error: str = ""
+
+
+def _sha256_file(path: Path) -> str:
+ h = hashlib.sha256()
+ with path.open("rb") as f:
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+class PagePackStore:
+ """每个页面包一个目录: page.json(元数据) + bundle.js(产物)。
+
+ 元数据写入走 临时文件+rename, 与 ext_data 的原子写同语义, 避免半写
+ 状态被启动扫描读到。
+ """
+
+ def __init__(self, data_dir: Path):
+ self.data_dir = Path(data_dir) / "ext_pages"
+ self.data_dir.mkdir(parents=True, exist_ok=True)
+
+ def _dir(self, pack_id: str) -> Path:
+ if not _VALID_ID.match(pack_id or ""):
+ raise ValueError("页面包 ID 仅允许字母/数字/下划线, 长度 1-64")
+ return self.data_dir / pack_id
+
+ def _write_meta(self, meta: PagePackMeta) -> None:
+ d = self._dir(meta.id)
+ d.mkdir(parents=True, exist_ok=True)
+ tmp = d / "page.json.tmp"
+ tmp.write_text(json.dumps(asdict(meta), ensure_ascii=False, indent=2), encoding="utf-8")
+ tmp.replace(d / "page.json")
+
+ def save_upload(self, pack_id: str, bundle_src: Path) -> PagePackMeta:
+ """把已写好的临时 bundle 文件收编入库(同 ID 重新上传 = 覆盖更新)。"""
+ d = self._dir(pack_id)
+ d.mkdir(parents=True, exist_ok=True)
+ dest = d / "bundle.js"
+ # 临时目录与数据目录可能跨盘(Windows), 用 copy 而非 rename
+ dest.write_bytes(bundle_src.read_bytes())
+ meta = PagePackMeta(
+ id=pack_id,
+ size=dest.stat().st_size,
+ sha256=_sha256_file(dest),
+ uploaded_at=time.time(),
+ )
+ self._write_meta(meta)
+ return meta
+
+ def get(self, pack_id: str) -> PagePackMeta | None:
+ p = self._dir(pack_id) / "page.json"
+ if not p.exists():
+ return None
+ try:
+ return PagePackMeta(**json.loads(p.read_text(encoding="utf-8")))
+ except (json.JSONDecodeError, TypeError):
+ # 半写/损坏的元数据按损坏页处理, 不阻断其他页面包
+ return PagePackMeta(id=pack_id, error="page.json 损坏, 请重新上传")
+
+ def bundle_path(self, pack_id: str) -> Path | None:
+ p = self._dir(pack_id) / "bundle.js"
+ return p if p.exists() else None
+
+ def list(self) -> list[PagePackMeta]:
+ metas: list[PagePackMeta] = []
+ for d in sorted(self.data_dir.iterdir()):
+ if not d.is_dir():
+ continue
+ meta = self.get(d.name)
+ if meta is not None:
+ metas.append(meta)
+ return metas
+
+ def update_manifest(self, pack_id: str, *, label: str, path: str, error: str) -> PagePackMeta:
+ """前端试载结果回填: error 非空 → 停用并记错误; 否则启用。"""
+ meta = self.get(pack_id)
+ if meta is None:
+ raise KeyError(pack_id)
+ meta.label = label
+ meta.path = path
+ meta.error = error
+ meta.enabled = not error
+ self._write_meta(meta)
+ return meta
+
+ def set_enabled(self, pack_id: str, enabled: bool) -> PagePackMeta:
+ meta = self.get(pack_id)
+ if meta is None:
+ raise KeyError(pack_id)
+ meta.enabled = enabled
+ self._write_meta(meta)
+ return meta
+
+ def delete(self, pack_id: str) -> bool:
+ d = self._dir(pack_id)
+ if not d.exists():
+ return False
+ for f in d.iterdir():
+ f.unlink(missing_ok=True)
+ d.rmdir()
+ return True
diff --git a/backend/app/services/ext_presets.py b/backend/app/services/ext_presets.py
index 527753bf..c649b825 100644
--- a/backend/app/services/ext_presets.py
+++ b/backend/app/services/ext_presets.py
@@ -1,22 +1,29 @@
-"""内置扩展数据预设 — 概念/行业启动时只创建配置, 等待用户手动获取 (#199)。
+"""内置扩展数据预设 — 概念/行业。
设计原则:
- - 扩展数据通用逻辑零改动 (ExtConfig / fetch_and_ingest / API / 前端均不动)
- - 仅在本模块做「接口结构 → 本地 schema」的转换
- - 「已存在则跳过」: 绝不覆盖用户已有数据, 老用户零影响
- - 拉取失败只记 warning, 不阻断启动 (保持「没数据也能跑」)
+ - 出厂即写【完整可用的拉取配置】(URL / 鉴权 / 两级请求配方), 不是空壳。
+ 这样「数据」页展开表 → 拉取 就能看到一份真实可跑的配方, 照抄即可接入
+ 自有源; 也免去"点获取按钮才拉"的隐藏路径。
+ - 扩展数据通用逻辑零改动 (ExtConfig / fetch_and_ingest / API / 前端均不动):
+ 概念/行业所需的「目录 → 逐条目」形态由 PullConfig.fanout 声明式表达,
+ 通用引擎 (ext_fanout) 执行, 无按 id 的特例分支。
+ - 「已存在则跳过」: 绝不覆盖用户已有配置与数据, 老用户零影响。
+ - 出厂值变更时定向迁移: 仅当现有配置的 pull 精确命中历代出厂值时整体替换,
+ 用户自定义过的一律不碰 (见 _RETIRED_FACTORY_PULLS)。
+ - 拉取失败只记 warning, 不阻断启动 (保持「没数据也能跑」)。
-种子数据来源 (概念/行业各自独立配置):
- - 概念: https://shy313.com/api/plugins/market_flow/exports/ths-concepts
- - 行业: https://shy313.com/api/plugins/market_flow/exports/ths-industries
-作者更新数据只需改接口上的 JSON, 用户下次拉取自动同步, 无需发版。
+数据来源 (概念/行业共用 fuyao 同花顺指数接口):
+ - 板块清单: /api/a-share-index/catalog/ths-index-list (tag=cn_concept / industry)
+ - 板块成分: /api/a-share-index/constituents/ths-stock-list (单板块一次)
+ 接口方向为「板块→股票」, fanout 反转为「股票→板块名清单」。
+ 鉴权复用 fuyao 行情插件同一凭据 (auth.key_ref = fuyao_api_key), 用户只需
+ 在一处填 Key。全量约 390+320 次成分股调用, 8 并发可承受。
接入点: app.main.lifespan → ensure_builtin_presets(store.data_dir)
"""
from __future__ import annotations
import logging
-import math
from pathlib import Path
from app.services.ext_data import (
@@ -24,25 +31,39 @@ from app.services.ext_data import (
ExtConfigStore,
ExtField,
PullConfig,
- rows_to_parquet,
)
logger = logging.getLogger(__name__)
-# 种子数据源 (概念/行业各自独立配置, 作者维护)
-_CONCEPT_DATA_URL = "https://shy313.com/api/plugins/market_flow/exports/ths-concepts"
-_INDUSTRY_DATA_URL = "https://shy313.com/api/plugins/market_flow/exports/ths-industries"
+# fuyao 同花顺指数接口 (鉴权 X-api-key, 信封 {code, message, data:{item:[...]}})
+_FUYAO_BASE = "https://fuyao.aicubes.cn"
+_FUYAO_CATALOG_PATH = "/api/a-share-index/catalog/ths-index-list"
+_FUYAO_CONSTITUENTS_PATH = "/api/a-share-index/constituents/ths-stock-list"
+# 成分股逐板块拉取: 全量 710 次调用, 8 并发 + 单板块失败重试 1 次 (实测无 429)
+_FUYAO_CONCURRENCY = 8
+_FUYAO_TIMEOUT_S = 20.0
+_FUYAO_BOARD_RETRIES = 1
+# 复用 fuyao 行情插件的凭据 (secrets.json: fuyao_api_key / 环境变量 FUYAO_API_KEY)
+_FUYAO_KEY_REF = "fuyao_api_key"
+
+_PRESET_IDS = ("ext_gn_ths", "ext_hy_ths")
# ---------------------------------------------------------------------------
-# 预设定义: 字段结构 + 拉取配方
+# 预设定义: 字段结构 + 完整拉取配方
# ---------------------------------------------------------------------------
+def _fuyao_auth() -> dict:
+ """fuyao 鉴权: X-api-key 头, 凭据复用行情插件的 fuyao_api_key。"""
+ return {"type": "header", "header": "X-api-key", "key_ref": _FUYAO_KEY_REF}
+
+
def _concept_preset() -> ExtConfig:
"""扩展概念 (ext_gn_ths)。
- 接口结构: [{symbol, name, concepts: [概念1, 概念2, ...]}]
- 本地 schema: 股票代码 / 股票简称 / 所属概念(分号拼接) / symbol / code
+ 两级拉取: 板块目录 (tag=cn_concept) → 逐板块成分股, 反转为
+ 「股票 → 所属概念」, 多个概念以分号拼接。
+ 输出 schema: 股票代码 / 股票简称 / 所属概念 / symbol / code
"""
return ExtConfig(
id="ext_gn_ths",
@@ -55,16 +76,32 @@ def _concept_preset() -> ExtConfig:
ExtField("股票简称", "string", "股票简称"),
ExtField("所属概念", "string", "所属概念"),
],
- description="同花顺概念分类 (启动仅创建配置, 在概念/行业页手动获取)",
+ description="同花顺概念分类 (fuyao 同花顺指数接口; 拉取配方见「拉取」页, 可照抄接自有源)",
symbol_map={"type": "mapped", "col": "股票代码"},
code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"},
pull=PullConfig(
- url=_CONCEPT_DATA_URL,
+ url=f"{_FUYAO_BASE}{_FUYAO_CATALOG_PATH}?tag=cn_concept",
method="GET",
+ response_path="data.item",
+ auth=_fuyao_auth(),
+ timeout_seconds=int(_FUYAO_TIMEOUT_S),
schedule_minutes=1440,
- # enabled=False: ensure_builtin_presets 承诺启动不拉取, PullScheduler
- # 只调度 enabled 配置; 手动获取走 fetch_preset 独立路径不受影响 (#199)
- enabled=False,
+ enabled=True,
+ fanout={
+ "url": f"{_FUYAO_BASE}{_FUYAO_CONSTITUENTS_PATH}",
+ "param": "thscode",
+ "id_field": "thscode",
+ "label_field": "name",
+ "items_path": "data.item",
+ "member_id_field": "thscode",
+ "member_label_field": "name",
+ "symbol_col": "股票代码",
+ "name_col": "股票简称",
+ "value_col": "所属概念",
+ "separator": ";",
+ "concurrency": _FUYAO_CONCURRENCY,
+ "retries": _FUYAO_BOARD_RETRIES,
+ },
),
)
@@ -72,8 +109,8 @@ def _concept_preset() -> ExtConfig:
def _industry_preset() -> ExtConfig:
"""扩展行业 (ext_hy_ths)。
- 接口结构: [{symbol, name, industries: [一级行业, 二级行业, 三级行业]}]
- 本地 schema: 股票代码 / 股票简称 / 所属同花顺行业(横杠拼接) / symbol / code
+ fuyao 为扁平板块清单, 无一/二/三级层级路径, 单级粒度。
+ 输出 schema: 股票代码 / 股票简称 / 所属同花顺行业 / symbol / code
"""
return ExtConfig(
id="ext_hy_ths",
@@ -86,15 +123,32 @@ def _industry_preset() -> ExtConfig:
ExtField("股票简称", "string", "股票简称"),
ExtField("所属同花顺行业", "string", "所属同花顺行业"),
],
- description="同花顺行业分类 (启动仅创建配置, 在概念/行业页手动获取)",
+ description="同花顺行业分类 (fuyao 同花顺指数接口, 扁平板块无层级; 拉取配方见「拉取」页)",
symbol_map={"type": "mapped", "col": "股票代码"},
code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"},
pull=PullConfig(
- url=_INDUSTRY_DATA_URL,
+ url=f"{_FUYAO_BASE}{_FUYAO_CATALOG_PATH}?tag=industry",
method="GET",
+ response_path="data.item",
+ auth=_fuyao_auth(),
+ timeout_seconds=int(_FUYAO_TIMEOUT_S),
schedule_minutes=1440,
- # 同概念 preset: 出厂禁用, 避免启动即网络拉取 (#199)
- enabled=False,
+ enabled=True,
+ fanout={
+ "url": f"{_FUYAO_BASE}{_FUYAO_CONSTITUENTS_PATH}",
+ "param": "thscode",
+ "id_field": "thscode",
+ "label_field": "name",
+ "items_path": "data.item",
+ "member_id_field": "thscode",
+ "member_label_field": "name",
+ "symbol_col": "股票代码",
+ "name_col": "股票简称",
+ "value_col": "所属同花顺行业",
+ "separator": "-",
+ "concurrency": _FUYAO_CONCURRENCY,
+ "retries": _FUYAO_BOARD_RETRIES,
+ },
),
)
@@ -103,129 +157,6 @@ def _presets() -> list[ExtConfig]:
return [_concept_preset(), _industry_preset()]
-# ---------------------------------------------------------------------------
-# 接口结构 → 本地 schema 转换 (仅预设使用)
-# ---------------------------------------------------------------------------
-
-def _symbol_to_code(symbol: str) -> str:
- """symbol (000001.SZ) → code (000001)。"""
- return symbol.split(".", 1)[0] if "." in symbol else symbol
-
-
-def _dimension_label(value: object) -> str:
- if value is None or (isinstance(value, float) and not math.isfinite(value)):
- return ""
- text = str(value).strip()
- return "" if text.casefold() in {"nan", "none", "null"} else text
-
-
-def _flatten_concept_rows(raw_rows: list[dict]) -> list[dict]:
- """概念: concepts 数组 → 分号拼接成「所属概念」字符串。
-
- [{symbol, name, concepts:[...]}] → [{股票代码, 股票简称, 所属概念, symbol, code}]
- 注: code 由 symbol 派生 (000001.SZ → 000001), 因 rows_to_parquet 不执行 code_map。
- """
- out: list[dict] = []
- for r in raw_rows:
- sym = (r.get("symbol") or "").strip()
- if not sym:
- continue
- concepts = r.get("concepts") or []
- labels = [label for c in concepts if (label := _dimension_label(c))]
- out.append({
- "股票代码": sym,
- "股票简称": r.get("name") or "",
- "所属概念": ";".join(labels),
- "symbol": sym,
- "code": _symbol_to_code(sym),
- })
- return out
-
-
-def _flatten_industry_rows(raw_rows: list[dict]) -> list[dict]:
- """行业: industries 数组 → 横杠拼接成「所属同花顺行业」字符串。
-
- [{symbol, name, industries:[...]}] → [{股票代码, 股票简称, 所属同花顺行业, symbol, code}]
- """
- out: list[dict] = []
- for r in raw_rows:
- sym = (r.get("symbol") or "").strip()
- if not sym:
- continue
- inds = r.get("industries") or []
- labels = [label for i in inds if (label := _dimension_label(i))]
- out.append({
- "股票代码": sym,
- "股票简称": r.get("name") or "",
- "所属同花顺行业": "-".join(labels),
- "symbol": sym,
- "code": _symbol_to_code(sym),
- })
- return out
-
-
-# ---------------------------------------------------------------------------
-# 拉取执行 (复用 httpx, 不依赖 fetch_and_ingest 的 PullConfig 路径)
-# ---------------------------------------------------------------------------
-
-# 部分网络环境 (CDN/WAF/网关) 会把数组包成 {data: [...]}/{list: [...]}/{rows: [...]} 信封。
-# 这里做一次兼容解包, 避免误判为「接口返回不是数组」。
-_ENVELOPE_KEYS = ("data", "list", "rows", "result", "results")
-
-
-async def _fetch_json(url: str) -> list[dict]:
- """请求 JSON 接口, 返回行数组。超时 30s, 失败抛异常由调用方兜底。
-
- 兼容两种上游返回形态:
- - 直接是数组: [{...}, ...] → 原样返回
- - 信封包裹: {data: [{...}]} 等 → 自动解包
- """
- import httpx
-
- # 延迟导入避免与 ext_pull 循环依赖; 出站请求带 tsp 标识头
- from app.services.ext_pull import outbound_headers
-
- async with httpx.AsyncClient(timeout=30) as client:
- resp = await client.get(url, headers=outbound_headers())
- resp.raise_for_status()
- data = resp.json()
-
- if isinstance(data, list):
- return data
-
- # 信封解包: 在常见键里找第一个值为数组的
- if isinstance(data, dict):
- for key in _ENVELOPE_KEYS:
- inner = data.get(key)
- if isinstance(inner, list):
- return inner
- # 兜底: 遍历所有值, 取第一个数组
- for v in data.values():
- if isinstance(v, list):
- return v
-
- raise ValueError(
- f"接口返回不是数组 (type={type(data).__name__}), "
- f"响应预览: {str(data)[:200]}"
- )
-
-
-async def _seed_one(config: ExtConfig, flatten, data_dir: Path) -> int:
- """拉取 + 转换 + 写入单个预设。返回写入行数。"""
- from datetime import date
-
- raw = await _fetch_json(config.pull.url)
- rows = flatten(raw)
- if not rows:
- raise ValueError(f"接口返回 0 行: {config.pull.url}")
- n = rows_to_parquet(rows, config, data_dir, snapshot_date=date.today())
- return n
-
-
-# ---------------------------------------------------------------------------
-# 对外入口
-# ---------------------------------------------------------------------------
-
def get_preset(config_id: str) -> ExtConfig | None:
"""按 id 取预设定义 (供 API 层校验 id 合法性)。"""
for c in _presets():
@@ -234,48 +165,77 @@ def get_preset(config_id: str) -> ExtConfig | None:
return None
-async def ensure_builtin_presets(data_dir: Path) -> None:
- """启动时: 为缺失的预设创建 config.json (含 pull 配置), 但【不拉取数据】。
+# 历代出厂 pull 配方指纹 — 仅比对 url + fanout 是否存在, 命中即视为未改过的出厂值。
+# 换源/改配方后, 老用户的出厂配置需要整体替换为新配方; 用户自己填过 URL 的不动。
+_RETIRED_FACTORY_PULLS: dict[str, tuple[dict, ...]] = {
+ "ext_gn_ths": (
+ # 换源前的自维护种子源
+ {"url": "https://shy313.com/api/plugins/market_flow/exports/ths-concepts"},
+ # 过渡期的 fuyao 目录标记 (无 fanout, 拉不到成分股)
+ {"url": f"{_FUYAO_BASE}{_FUYAO_CATALOG_PATH}?tag=cn_concept"},
+ ),
+ "ext_hy_ths": (
+ {"url": "https://shy313.com/api/plugins/market_flow/exports/ths-industries"},
+ {"url": f"{_FUYAO_BASE}{_FUYAO_CATALOG_PATH}?tag=industry"},
+ ),
+}
+# 出厂 description 前缀 (历代文案) — 命中则同步为当前文案, 用户自定义描述不动
+_FACTORY_DESC_PREFIXES = (
+ "同花顺概念分类",
+ "同花顺行业分类",
+)
- 设计: 数据获取改为用户在概念/行业页手动点「获取数据」触发, 避免启动时
- 网络请求阻塞, 也避免「自动拉取」与「用户自主控制」的预期冲突。
+
+def _is_retired_factory_pull(config_id: str, pull: PullConfig | None) -> bool:
+ """现有 pull 是否为历代出厂值 (不含当前配方), 需要补齐/替换为当前配方。
+
+ True 的三种情形:
+ - pull 为 None / 空 URL: 出厂空壳 (#199 之后、本次改动之前的形态)
+ - URL 命中历代出厂源, 且未带 fanout (换源前的种子源 / 只有目录的过渡配方)
+ 用户自定义过 URL 或已带 fanout 的配置一律 False (绝不覆盖)。
+ """
+ if pull is None:
+ return True
+ url = (pull.url or "").strip()
+ if not url:
+ return True # 空 URL 的空壳配置: 由出厂空壳演进而来, 需补齐
+ if pull.fanout:
+ return False # 已带两级配方 (当前形态或用户自建), 不碰
+ return any(url == retired["url"] for retired in _RETIRED_FACTORY_PULLS.get(config_id, ()))
+
+
+async def ensure_builtin_presets(data_dir: Path) -> None:
+ """启动时: 为缺失的预设创建配置, 并把历代出厂空壳/旧配方补齐为当前配方。
安全保证:
- - 已存在则完全跳过 (绝不覆盖用户数据)
- - 只写 config.json, 失败只记 warning 不阻断启动
+ - 已存在且是用户自定义配置 → 跳过, 绝不覆盖用户数据
+ - 仅当 pull 为空 / 命中历代出厂配方指纹时, 用当前完整配方替换
+ - description 仅当命中出厂前缀时同步, 用户自定义描述不动
+ - 只写 config.json (不拉数据), 失败只记 warning 不阻断启动
+
+ 数据获取: 由拉取调度器按 schedule_minutes 执行, 或用户在数据页点「拉取 → 执行」。
"""
store = ExtConfigStore(data_dir)
-
for config in _presets():
- existing = store.get(config.id)
- if existing is not None:
- # 用户已有此表 (老用户 / 自己重建过) → 一律不动
- continue
try:
- store.upsert(config)
- logger.info("内置扩展表 %s 配置已就绪 (待用户手动获取数据)", config.id)
- except Exception as e:
+ existing = store.get(config.id)
+ if existing is None:
+ store.upsert(config)
+ logger.info("内置扩展表 %s 配置已就绪 (含出厂拉取配方)", config.id)
+ continue
+
+ changed = False
+ if _is_retired_factory_pull(config.id, existing.pull):
+ existing.pull = config.pull
+ changed = True
+ if not (existing.description or "").strip() or \
+ (existing.description or "").startswith(_FACTORY_DESC_PREFIXES):
+ if existing.description != config.description:
+ existing.description = config.description
+ changed = True
+
+ if changed:
+ store.upsert(existing)
+ logger.info("内置扩展表 %s 出厂配方已更新为当前版本", config.id)
+ except Exception as e: # noqa: BLE001 — 启动契约: 预设写入失败不阻断
logger.warning("内置扩展表 %s 配置写入失败 (不影响启动): %s", config.id, e)
-
-
-async def fetch_preset(config_id: str, data_dir: Path) -> int:
- """手动触发某个预设的数据拉取 (供 API 调用)。
-
- Raises:
- ValueError: config_id 不是内置预设
- Exception: 网络请求/解析/写入失败 (由 API 层转 HTTP 错误)
- """
- config = get_preset(config_id)
- if config is None:
- raise ValueError(f"未知的内置预设: {config_id}")
-
- flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows
-
- # 确保 config.json 存在 (用户可能从未启动过 ensure_builtin_presets)
- store = ExtConfigStore(data_dir)
- if store.get(config_id) is None:
- store.upsert(config)
-
- n = await _seed_one(config, flatten, data_dir)
- logger.info("内置扩展表 %s 手动拉取成功: %d 行", config_id, n)
- return n
diff --git a/backend/app/services/ext_pull.py b/backend/app/services/ext_pull.py
index 6c08ddb4..a3f78373 100644
--- a/backend/app/services/ext_pull.py
+++ b/backend/app/services/ext_pull.py
@@ -117,19 +117,6 @@ def _apply_field_map(rows: list[dict], field_map: dict[str, str]) -> list[dict]:
# 拉取执行
# ---------------------------------------------------------------------------
-def _apply_preset_flatten(config_id: str, rows: list[dict]) -> list[dict]:
- """对内置预设 (概念/行业) 应用结构转换, 与 fetch_preset 保持一致。
-
- 延迟导入避免与 ext_presets 形成循环依赖。
- 非预设 id 原样返回。
- """
- if config_id not in ("ext_gn_ths", "ext_hy_ths"):
- return rows
- from app.services.ext_presets import _flatten_concept_rows, _flatten_industry_rows
- flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows
- return flatten(rows)
-
-
def _format_date_value(day: date, date_format: str) -> str:
"""按配置格式把交易日序列化为接口参数值。
@@ -160,17 +147,31 @@ def _apply_auth(config_id: str, auth: dict | None, url: str, headers: dict[str,
header → {header: }, query → ?{param}=。Key 只存 secrets.json,
不落 config.json; 配置了鉴权但未设置 Key 时 fail-closed 直接报错,
避免不带凭据请求被服务端记成无效调用。返回 (可能追加了参数的) url。
+
+ auth.key_ref: 复用已有凭据而非本表专属 Key。值为 secrets.json 的字段名
+ (如 "fuyao_api_key"), 环境变量兜底取该名的大写形式。同一数据源被行情插件
+ 与扩展表共用时, 用户只需在一处填 Key。
"""
from urllib.parse import quote
+ from app import secrets_store
+
from app.services.ext_data import get_ext_api_key
auth_type = str((auth or {}).get("type") or "none").lower()
if auth_type == "none":
return url
- key = get_ext_api_key(config_id)
- if not key:
- raise ValueError(f"已配置 {auth_type} 鉴权但未设置 API Key, 请在拉取设置中填写")
+ key_ref = str((auth or {}).get("key_ref") or "").strip()
+ if key_ref:
+ key = secrets_store.get_env_backed_secret(key_ref, key_ref.upper())
+ if not key:
+ raise ValueError(
+ f"已配置鉴权但未找到凭据 {key_ref} — 请在 设置 → 数据源 配置对应 API Key"
+ )
+ else:
+ key = get_ext_api_key(config_id)
+ if not key:
+ raise ValueError(f"已配置 {auth_type} 鉴权但未设置 API Key, 请在拉取设置中填写")
if auth_type == "bearer":
headers[str(auth.get("header") or "Authorization")] = f"Bearer {key}"
elif auth_type == "header":
@@ -208,14 +209,21 @@ def _assert_rows_date(rows: list[dict], day: date) -> None:
async def _request_json(
pull: PullConfig, config_id: str, day: date | None = None,
extra_params: dict[str, str] | None = None,
+ *,
+ url_override: str | None = None,
) -> Any:
"""发起一次拉取请求并返回解析后的 JSON。
正式拉取 (带日期参数) 与设置页"测试" (不带) 共用同一实现,
保证 UA 标识头与 API Key 鉴权注入只有一套口径。
extra_params: 追加的 query 参数 (分页页码/每页条数), 值经 URL 编码。
+ url_override: 直接指定请求地址 (两级拉取的第二级请求用), 不再套用
+ date_param 与 pull.url。
"""
- url = _with_date_param(pull.url, pull.date_param, day, pull.date_format) if day else pull.url
+ if url_override is not None:
+ url = url_override
+ else:
+ url = _with_date_param(pull.url, pull.date_param, day, pull.date_format) if day else pull.url
if extra_params:
url = _append_query_params(url, extra_params)
# 每配置超时 (PullConfig.timeout_seconds, 默认 30 与历史行为一致);
@@ -251,15 +259,12 @@ def _append_query_params(url: str, params: dict[str, str]) -> str:
def _parse_rows_payload(config: ExtConfig, pull: PullConfig, data: Any) -> list[dict]:
- """单次响应 → 行列表 (提取/预设转换/字段映射)。与写入链共用, 不含日期校验。"""
+ """单次响应 → 行列表 (提取/字段映射)。与写入链共用, 不含日期校验。
+
+ 两级拉取 (pull.fanout) 不走这里 —— 它的行由多个响应聚合而来, 见
+ fetch_rows_for_date 的分支。
+ """
rows = _extract_rows(data, pull.response_path)
-
- # 内置预设 (概念/行业): 应用结构转换, 让产出 schema 与分析页一致。
- # 否则 raw 接口列 (concepts/industries 数组、name) 会直接覆盖正确的 part.parquet,
- # 导致分析页因找不到维度字段 (所属概念/所属同花顺行业) 而"数据消失"。
- # 见 ext_presets._flatten_* —— 手动拉取 / 定时拉取都必须走同一套转换。
- rows = _apply_preset_flatten(config.id, rows)
-
# 字段映射
return _apply_field_map(rows, pull.field_map)
@@ -267,15 +272,20 @@ def _parse_rows_payload(config: ExtConfig, pull: PullConfig, data: Any) -> list[
async def fetch_rows_for_date(config: ExtConfig, target_date: date) -> list[dict]:
"""按日期请求外部 API 并解析为行 (不写盘)。空数据返回 []。
- 与 fetch_and_ingest 共用同一解析链 (response_path/预设转换/字段映射/
- 关联字段校验), 历史回补与当日拉取不产生第二套口径。
+ 与 fetch_and_ingest 共用同一解析链 (response_path/字段映射/关联字段校验),
+ 历史回补与当日拉取不产生第二套口径。
配置了 pull.page_param 时按页循环拉取 (仅 GET), 空页/短页/达 max_pages 停。
+ 配置了 pull.fanout 时走两级拉取 (目录 → 逐条目明细), 见 ext_fanout。
"""
pull = config.pull
if not pull or not pull.url:
raise ValueError("拉取未配置或 URL 为空")
- if pull.page_param and pull.method.upper() == "GET":
+ if pull.fanout:
+ from app.services.ext_fanout import fetch_fanout_rows
+
+ rows = await fetch_fanout_rows(config, target_date)
+ elif pull.page_param and pull.method.upper() == "GET":
collected: list[dict] = []
page = pull.page_start
for _ in range(pull.max_pages):
diff --git a/backend/app/services/extend_history.py b/backend/app/services/extend_history.py
index 0249ad4e..9cab0edf 100644
--- a/backend/app/services/extend_history.py
+++ b/backend/app/services/extend_history.py
@@ -37,31 +37,29 @@ def _invalidate(table: str | None = None) -> None:
invalidate_data_cache(table)
-def _resolve_universe(capset: CapabilitySet) -> list[str]:
- """解析标的池 — 与 daily_pipeline 独立的副本。"""
- if capset.has(Cap.KLINE_DAILY_BATCH):
- try:
- from app.tickflow.pools import get_pool
- all_a = get_pool("CN_Equity_A", refresh=True)
- if all_a:
- return sorted(all_a)
- except Exception as e:
- logger.warning("CN_Equity_A pool unavailable: %s", e)
+def _resolve_universe(capset: CapabilitySet, repo=None) -> list[str]:
+ """解析标的池 — 与 daily_pipeline 同口径: instruments 维表 + 自选 (单一来源)。
- from app.tickflow.pools import DEMO_SYMBOLS, get_pool as _get_pool
- from app.config import settings
+ 不再拉 CN_Equity_A 池 (含 SW1 聚合兜底); 维表由盘后管道同步维护。
+ repo 传入时过滤自选里的指数 symbol (指数日K走独立存储)。
+ """
from pathlib import Path
+
import polars as pl
- base: set[str] = set(DEMO_SYMBOLS)
- base.update(_get_pool("watchlist"))
- d = Path(settings.data_dir)
- inst_path = d / "instruments" / "instruments.parquet"
+
+ from app.config import settings
+ from app.tickflow.pools import get_pool
+
+ base: set[str] = set(get_pool("watchlist"))
+ inst_path = Path(settings.data_dir) / "instruments" / "instruments.parquet"
if inst_path.exists():
try:
inst = pl.read_parquet(inst_path, columns=["symbol"])
base.update(inst["symbol"].to_list())
- except Exception as e:
- logger.warning("instruments supplement failed: %s", e)
+ except Exception as e: # noqa: BLE001
+ logger.warning("instruments 维表读取失败, 标的池仅剩自选: %s", e)
+ if repo is not None:
+ base -= set(repo.get_index_symbol_set())
return sorted(base)
@@ -131,7 +129,7 @@ def run_extend_history(
# 2. 解析标的池
emit("extend_history", 5, "解析标的池…")
- universe = _resolve_universe(capset)
+ universe = _resolve_universe(capset, repo)
if not universe:
return {"error": "标的池为空"}
emit("extend_history", 8, f"标的池: {len(universe)} 只")
diff --git a/backend/app/services/financial_sync.py b/backend/app/services/financial_sync.py
index 556b619a..d5fe393b 100644
--- a/backend/app/services/financial_sync.py
+++ b/backend/app/services/financial_sync.py
@@ -53,6 +53,13 @@ def _financial_is_custom() -> bool:
return custom_sources.provider_has_dataset(provider, "financial")
+def _financial_capability_label() -> str:
+ """门禁日志标签: "财务数据 ← fuyao" / "…(档位不足)", 便于判断该切哪个源。"""
+ from app.data_providers.capabilities import capability_gate
+ _, label = capability_gate("financial")
+ return label
+
+
def _fetch_table(
table: str,
symbols: list[str],
@@ -62,7 +69,7 @@ def _fetch_table(
"""通过当前财务数据源拉取一张标准化财务表。"""
is_custom = _financial_is_custom()
if not is_custom and not capset.has(Cap.FINANCIAL):
- logger.info("sync_%s skipped: no FINANCIAL capability", table)
+ logger.info("sync_%s skipped: %s 不可用", table, _financial_capability_label())
return pl.DataFrame()
if not symbols:
logger.warning("sync_%s skipped: no symbols", table)
@@ -251,7 +258,7 @@ def sync_shares(data_dir: Path, capset: CapabilitySet) -> int:
def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]:
"""同步所有财务表。返回 {table: rows}。"""
if not capset.has(Cap.FINANCIAL) and not _financial_is_custom():
- logger.info("sync_all financials skipped: no FINANCIAL capability")
+ logger.info("sync_all financials skipped: %s 不可用", _financial_capability_label())
return {}
symbols = _get_symbols(data_dir)
@@ -332,7 +339,7 @@ class FinancialScheduler:
self._data_dir = data_dir
self._capset = capset
if not capset.has(Cap.FINANCIAL) and not _financial_is_custom():
- logger.info("FinancialScheduler skipped: no FINANCIAL capability")
+ logger.info("FinancialScheduler skipped: %s 不可用", _financial_capability_label())
return
# 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步"
try:
diff --git a/backend/app/services/index_sync.py b/backend/app/services/index_sync.py
index 00640efc..de1c3a1c 100644
--- a/backend/app/services/index_sync.py
+++ b/backend/app/services/index_sync.py
@@ -205,6 +205,7 @@ def sync_and_persist_index_daily(
on_chunk_done(current, total) 每个批次完成后回调。
"""
if not capset.has(Cap.KLINE_DAILY_BATCH):
+ kline_sync._log_cap_unavailable("daily", "指数/ETF 日K同步")
return 0
if symbols_override:
@@ -340,6 +341,7 @@ def sync_and_persist_etf_daily(
on_chunk_done(current, total) 每个批次完成后回调。
"""
if not capset.has(Cap.KLINE_DAILY_BATCH):
+ kline_sync._log_cap_unavailable("daily", "ETF 日K同步")
return 0
if symbols_override:
diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py
index c0a477f1..aa78ca72 100644
--- a/backend/app/services/kline_sync.py
+++ b/backend/app/services/kline_sync.py
@@ -31,6 +31,20 @@ from app.tickflow.repository import KlineRepository, replace_with_retry
logger = logging.getLogger(__name__)
+
+def _log_cap_unavailable(cap_id: str, action: str) -> None:
+ """门禁未通过时记一条带生效源的日志。
+
+ 原实现是静默 return —— 切换数据源后「什么都没发生」且无迹可循。
+ 带源标签后可一眼看出是路由到不可用源, 还是 TickFlow 档位不足。
+ """
+ try:
+ from app.data_providers.capabilities import capability_gate
+ _, label = capability_gate(cap_id)
+ except Exception: # noqa: BLE001 — 仅日志, 失败不应影响主流程
+ label = cap_id
+ logger.info("%s 跳过: %s 不可用", action, label)
+
# ── SDK 批量调用守护 deadline ────────────────────────────────────────────
# TickFlow SDK 自带 timeout=30s×3 重试, 但只保护其覆盖的请求阶段; 真挂死在
# 不可中断的网络读里时调用永不返回 → 持有独占槽的重任务线程被连坐卡死,
@@ -365,6 +379,7 @@ def sync_and_persist_daily_batch(
# 自定义源未配置 daily → 回退 TickFlow
if not capset.has(Cap.KLINE_DAILY_BATCH):
+ _log_cap_unavailable("daily", "日K同步")
return _finalize(0)
limit = resolve_limit(capset, Cap.KLINE_DAILY_BATCH, default_batch=100)
@@ -653,6 +668,7 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
# 自定义源未配置 adj_factor → 回退 TickFlow
if not capset.has(Cap.ADJ_FACTOR):
+ _log_cap_unavailable("adj_factor", "除权因子同步")
return 0, []
tf = get_client()
diff --git a/backend/app/services/market_overview_builder.py b/backend/app/services/market_overview_builder.py
index 4060d0bd..5f95ca56 100644
--- a/backend/app/services/market_overview_builder.py
+++ b/backend/app/services/market_overview_builder.py
@@ -70,14 +70,9 @@ def _board(symbol: str) -> str:
return "其他"
-def _score(value: float, low: float, high: float) -> int:
- if high <= low:
- return 50
- if not math.isfinite(value):
- # 非有限输入不可映射: 取中性 50, 不得让 round(inf)/round(nan) 抛异常打断看板计算
- # (与 app/services/regime_builder.py 的同名实现保持一致)
- return 50
- return max(0, min(100, round((value - low) / (high - low) * 100)))
+# 注: 原 _score(value, low, high) 已迁至 app.services.emotion_score.score_of ——
+# 情绪评分的区间与权重现由用户可配置, 归一化函数须与配置同处一个模块。
+# regime_builder 保留同名实现 (其区间是 A 股分位数校准的另一套口径, 不共用)。
# ================================================================
@@ -568,27 +563,33 @@ def build_market_overview(
mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]]
mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0)
mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0
- mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50
- radar = [
- {"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)},
- {"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)},
- {"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)},
- {"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)},
- {"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)},
- {"key": "mainline", "label": "主线", "value": mainline_score},
- ]
- emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50
- if emotion_score >= 70:
- emotion_label = "强势"
- elif emotion_score >= 55:
- emotion_label = "偏暖"
- elif emotion_score >= 45:
- emotion_label = "震荡"
- elif emotion_score >= 30:
- emotion_label = "偏冷"
- else:
- emotion_label = "冰点"
+ # 情绪评分 (维度/权重/区间/档位) 走可配置口径: 定义在 app.services.emotion_score,
+ # 用户可在看板「情绪雷达」卡片上改权重与区间。指标名即该模块的 term.key。
+ from app.services import preferences
+ from app.services.emotion_score import compute as compute_emotion
+
+ emotion = compute_emotion({
+ "avg_index_pct": avg_index_pct,
+ "up_pct": up_pct,
+ "avg_pct": avg_pct,
+ "median_pct": median_pct,
+ "strong_diff_pct": strong_diff_pct,
+ "avg_vol_ratio": avg_vol_ratio,
+ "high_vol_pct": high_vol_pct,
+ "limit_up": limit_up,
+ "seal_rate": seal_rate,
+ "max_boards": max_boards,
+ "tier2_count": tier2_count,
+ "down_pct": down_pct,
+ "strong_down_pct": strong_down_pct,
+ # 主线仅在有条目时才有意义: 无条目时该维度取中性 50 (与历史实现一致)
+ **({"mainline_avg": mainline_avg, "mainline_cover_pct": mainline_cover_pct} if mainline_items else {}),
+ }, preferences.get_emotion_score_config())
+
+ radar = emotion["radar"]
+ emotion_score = emotion["score"]
+ emotion_label = emotion["label"]
return _json_safe({
"as_of": str(as_of),
diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py
index efd897ab..6e375e57 100644
--- a/backend/app/services/preferences.py
+++ b/backend/app/services/preferences.py
@@ -435,6 +435,38 @@ def get_mainline_filter_config() -> dict:
}
+def get_emotion_score_config() -> dict | None:
+ """情绪评分口径配置 (维度权重/子项权重与区间/档位阈值)。
+
+ 返回原始 dict 或 None (未自定义 = 用出厂口径)。**不在此处做结构校验与补全** ——
+ 合并与归一化统一由 emotion_score.merge_config 负责, 避免两处各写一套默认值而漂移。
+ """
+ v = load().get("emotion_score_config")
+ return v if isinstance(v, dict) else None
+
+
+def set_emotion_score_config(cfg: dict | None) -> dict | None:
+ """保存情绪评分口径。传 None 或 {} = 恢复出厂口径 (清除该键)。
+
+ 只做形状校验, 值的合法性 (负数权重/区间倒置/阈值乱序) 由 emotion_score 层
+ 在每次计算时归一化 —— 存进去什么由用户负责, 算出来一定安全。
+ """
+ from app.services.emotion_score import merge_config
+
+ if not cfg:
+ with _SAVE_LOCK:
+ current = load()
+ current.pop("emotion_score_config", None)
+ atomic_write_text(_path(), json.dumps(current, indent=2, ensure_ascii=False))
+ _invalidate_cache()
+ return None
+
+ # 落盘前过一遍 merge_config: 校验形状并剔除未知字段, 避免脏配置长期驻留
+ normalized = merge_config(cfg)
+ save({"emotion_score_config": normalized})
+ return normalized
+
+
def set_mainline_filter_config(cfg: dict) -> dict:
"""保存主线过滤配置(白名单字段, 部分更新)。修改后需重算主线生效。"""
updates: dict = {}
diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py
index ee7823da..948431f0 100644
--- a/backend/app/services/quote_service.py
+++ b/backend/app/services/quote_service.py
@@ -383,7 +383,10 @@ class QuoteService:
if not self.is_realtime_allowed():
if preferences.get_realtime_quotes_enabled():
self._save_enabled(False)
- logger.info("实时行情未启动:当前档位(none)无实时行情权限")
+ # 带生效源: 便于判断「该切哪个源」, 而不是只看到 TickFlow 档位
+ from app.data_providers.capabilities import capability_gate
+ _, label = capability_gate("realtime")
+ logger.info("实时行情未启动: %s 不可用", label)
return
if preferences.get_realtime_quotes_enabled():
self.start()
diff --git a/backend/app/services/task_registry.py b/backend/app/services/task_registry.py
new file mode 100644
index 00000000..21126585
--- /dev/null
+++ b/backend/app/services/task_registry.py
@@ -0,0 +1,178 @@
+"""进行中任务注册表 — 长任务(回测/挖掘/优化/滚动验证)跨设备接回。
+
+前端在启动任务时 fire-and-forget 上报 (status=running, id=kind+参数指纹),
+终态时按同一 id 关闭; 其他设备打开对应页面时, 本地无 reconnect 键则从注册表
+取最近 running 项回填本地键, 复用既有 tryReconnect 机制 — 任务执行与重连
+逻辑零改动, 上报失败也只是退化为原来的单机行为。
+
+文件: data/user_data/task_registry.json
+ {"items": [{id, kind, label, payload, status, started_at, updated_at}, ...]}
+
+容量与时效: ≤20 条 (超限裁 updated_at 最旧); running 超过 24h 视为孤儿
+(进程重启遗留), 读取时静默剔除并落盘回收。
+"""
+from __future__ import annotations
+
+import json
+import logging
+import time
+from pathlib import Path
+from typing import Any
+
+from app.services.fs_utils import atomic_write_text
+
+logger = logging.getLogger(__name__)
+
+TASK_KINDS = frozenset({"backtest", "mining", "optimizer", "walkforward"})
+TASK_STATUSES = frozenset({"running", "done", "error"})
+MAX_ITEMS = 20
+ORPHAN_RUNNING_SECONDS = 24 * 3600
+_FILENAME = "task_registry.json"
+
+_cache: dict[str, Any] | None = None
+_cache_sig: tuple[int, int] | None = None
+
+
+def _path(data_dir: Path) -> Path:
+ p = data_dir / "user_data" / _FILENAME
+ p.parent.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def _signature(p: Path) -> tuple[int, int] | None:
+ try:
+ st = p.stat()
+ return (st.st_mtime_ns, st.st_size)
+ except OSError:
+ return None
+
+
+def _sanitize_items(data: Any) -> list[dict]:
+ if not isinstance(data, dict):
+ return []
+ items = data.get("items")
+ if not isinstance(items, list):
+ return []
+ out: list[dict] = []
+ for it in items:
+ if not isinstance(it, dict):
+ continue
+ if it.get("kind") not in TASK_KINDS or it.get("status") not in TASK_STATUSES:
+ continue
+ if not isinstance(it.get("id"), str) or not it["id"]:
+ continue
+ out.append({
+ "id": it["id"],
+ "kind": it["kind"],
+ "status": it["status"],
+ "label": it["label"] if isinstance(it.get("label"), str) else "",
+ "payload": it["payload"] if isinstance(it.get("payload"), dict) else {},
+ "started_at": it.get("started_at") or 0,
+ "updated_at": it.get("updated_at") or 0,
+ })
+ return out
+
+
+def _sorted_trimmed(items: list[dict]) -> list[dict]:
+ ordered = sorted(items, key=lambda x: x.get("updated_at") or 0, reverse=True)
+ return ordered[:MAX_ITEMS]
+
+
+def _prune_orphans(items: list[dict]) -> tuple[list[dict], int]:
+ now = time.time()
+ kept: list[dict] = []
+ orphans = 0
+ for it in items:
+ if (
+ it["status"] == "running"
+ and it["updated_at"]
+ and now - it["updated_at"] > ORPHAN_RUNNING_SECONDS
+ ):
+ orphans += 1
+ continue
+ kept.append(it)
+ return kept, orphans
+
+
+def _load_raw(data_dir: Path) -> list[dict] | None:
+ global _cache, _cache_sig
+ p = _path(data_dir)
+ sig = _signature(p)
+ if sig is None:
+ return []
+ if _cache is not None and sig == _cache_sig:
+ return _cache["items"]
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ except Exception as e: # noqa: BLE001 — 损坏文件视为空注册表
+ logger.warning("task registry 文件损坏, 视为空: %s", e)
+ return []
+ items = _sorted_trimmed(_sanitize_items(data))
+ _cache, _cache_sig = {"items": items}, sig
+ return items
+
+
+def _save(data_dir: Path, items: list[dict]) -> None:
+ global _cache, _cache_sig
+ trimmed = _sorted_trimmed(items)
+ p = _path(data_dir)
+ atomic_write_text(p, json.dumps({"items": trimmed}, ensure_ascii=False))
+ _cache, _cache_sig = {"items": trimmed}, _signature(p)
+
+
+def list_items(data_dir: Path, kind: str | None = None) -> list[dict]:
+ """按 updated_at 倒序返回 (可选 kind 过滤); 顺带回收孤儿 running 项。"""
+ items = _load_raw(data_dir)
+ if items is None:
+ return []
+ kept, orphans = _prune_orphans(items)
+ if orphans:
+ _save(data_dir, kept)
+ items = kept
+ if kind is None:
+ return items
+ return [it for it in items if it["kind"] == kind]
+
+
+def upsert(
+ data_dir: Path,
+ task_id: str,
+ *,
+ kind: str,
+ status: str,
+ label: str = "",
+ payload: dict | None = None,
+) -> dict:
+ if kind not in TASK_KINDS:
+ raise ValueError(f"未知的任务类型: {kind}")
+ if status not in TASK_STATUSES:
+ raise ValueError(f"未知的任务状态: {status}")
+ items = _load_raw(data_dir) or []
+ now = time.time()
+ entry = {
+ "id": task_id,
+ "kind": kind,
+ "status": status,
+ "label": label,
+ "payload": payload or {},
+ "started_at": now,
+ "updated_at": now,
+ }
+ for i, it in enumerate(items):
+ if it["id"] == task_id:
+ entry["started_at"] = it.get("started_at") or now
+ items[i] = entry
+ break
+ else:
+ items.append(entry)
+ _save(data_dir, items)
+ return entry
+
+
+def delete(data_dir: Path, task_id: str) -> bool:
+ items = _load_raw(data_dir) or []
+ remaining = [it for it in items if it["id"] != task_id]
+ if len(remaining) == len(items):
+ return False
+ _save(data_dir, remaining)
+ return True
diff --git a/backend/app/strategy/prompts/strategy-guide.md b/backend/app/strategy/prompts/strategy-guide.md
index 0c8800da..74d377d7 100644
--- a/backend/app/strategy/prompts/strategy-guide.md
+++ b/backend/app/strategy/prompts/strategy-guide.md
@@ -140,6 +140,7 @@ def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame:
- `LOOKBACK_DAYS` 决定引擎加载多少天的数据,设为策略逻辑需要的最大回看天数
- 优先使用 Polars 的 `with_columns`、`over("symbol")`、`group_by`、`join`、`filter` 实现历史逻辑,避免把数据转成 Python list/dict 循环
- 只有遇到表达式难以描述的复杂状态机时,才使用 `partition_by("symbol")` + `to_dicts()` 逐股票分析
+- **`to_dicts()` 逐行攒下的命中行重建 DataFrame 必须显式带 schema**:写 `pl.DataFrame(hits, schema=df.schema)`,**不能裸写 `pl.DataFrame(hits)`**。原因:从 dict 列表建表只扫描前 100 行推断列类型(默认 `infer_schema_length=100`),若某列在前 100 个命中行里全为 `None`,该列会被推断成 Null 类型,之后某行这一列出现数值时直接报 `ComputeError: could not append value ... to the builder`——是否触发取决于命中行的排列顺序,数据一变就可能复现,非常隐蔽;显式带 schema 同时保住 date/整型等原始列类型不被重新推断
- **返回所有匹配行,不要过滤 `latest`**;策略引擎会自动取最新日,回测引擎需要全区间命中
- 未声明 `filter_history()` 的策略走普通 `filter()` 路径,不受影响
- **date 类型参数必须先转换再与 `date` 列比较**:params 里的 `"type": "date"` 参数从 JSON 传来是字符串(如 `"2024-01-01"`),而数据中 `date` 列是 Polars Date 类型,**不能直接比较**,否则报错。必须先转换:
@@ -288,7 +289,7 @@ REQUIRED_FEATURES = {"ma20", "momentum_20d", "vol_ratio_5d"}
6. Polars 策略允许 `import polars as pl` 和 `from datetime import date/datetime`;矩阵策略只允许 NumPy 和 `app.backtest.matrix` 协议/算子
7. 禁止使用 `open()`, `exec()`, `eval()`, `os`, `sys`, `subprocess`
8. **贴合用户需求优先**:第3/4节的指标列和信号列仅供参考,能用则用;如果用户需求需要自定义计算(如"前高""上次涨停价""N日内某个事件后X天"),直接在 `filter_history()` 中自行设计和计算,不需要局限于已有列
-9. `filter_history()` 中优先用 Polars 向量化语法;仅在复杂状态机无法清晰表达时,才用 `partition_by("symbol")` 逐股票分析
+9. `filter_history()` 中优先用 Polars 向量化语法;仅在复杂状态机无法清晰表达时,才用 `partition_by("symbol")` 逐股票分析;`to_dicts()` 逐行分析的命中行必须以 `pl.DataFrame(hits, schema=df.schema)` 重建,禁止裸 `pl.DataFrame(hits)`(dict 推断类型漂移,见第 2 节关键要点)
## 7. 策略示例
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 1229247c..fa35ea34 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -26,13 +26,15 @@ dependencies = [
"pypinyin>=0.50", # 股票名拼音首字母搜索 (同花顺式 payh → 平安银行)
# TickFlow 官方 SDK
"tickflow[all]>=0.1.23",
+ # TdxQuant 内置数据源插件的原生 TCP 客户端 (wheel 自带跨平台库, 无需系统依赖)
+ "tdxaidata>=1.2.2",
# Scheduling
"apscheduler>=3.10",
# Config
"pyyaml>=6.0",
"python-dotenv>=1.0",
# AI(可选,但默认装上)
- "openai>=1.40", # OpenAI 兼容适配器复用 openai SDK
+ "openai>=1.66", # OpenAI 兼容适配器复用 openai SDK; 1.66+ 提供 Responses API
"httpx>=0.27",
"platformdirs>=4.0", # 桌面版用户数据目录 (跨平台持久可写)
"winotify>=1.1; sys_platform == 'win32'", # Windows 系统通知 (进操作中心)
diff --git a/backend/tests/test_ai_probe.py b/backend/tests/test_ai_probe.py
new file mode 100644
index 00000000..ed07ad03
--- /dev/null
+++ b/backend/tests/test_ai_probe.py
@@ -0,0 +1,161 @@
+"""AI 草稿连通性测试端点 (POST /api/settings/ai/test) 的契约测试。
+
+核心契约: 测试只读表单值, 与保存完全解耦 —— 测试失败不得写任何配置,
+状态栏也不得把未验证的草稿展示为已生效。失败以 200 + ok=false 回报,
+由前端内联展示, 不触发全局 toast。
+"""
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from app import secrets_store
+from app.api import settings as settings_api
+from app.services import ai_provider
+
+
+def test_probe_requires_base_url_model_and_key(monkeypatch):
+ monkeypatch.setattr(secrets_store, "get_ai_key", lambda: "")
+
+ with pytest.raises(ValueError, match="API 地址"):
+ import asyncio
+ asyncio.run(ai_provider.probe_ai_config(
+ provider="openai_compat", api_key="sk-x", base_url="", model="m",
+ ))
+ with pytest.raises(ValueError, match="模型"):
+ import asyncio
+ asyncio.run(ai_provider.probe_ai_config(
+ provider="openai_compat", api_key="sk-x", base_url="https://x.example/v1", model="",
+ ))
+ with pytest.raises(RuntimeError, match="API Key"):
+ import asyncio
+ asyncio.run(ai_provider.probe_ai_config(
+ provider="openai_compat", api_key="", base_url="https://x.example/v1", model="m",
+ ))
+
+
+def test_probe_codex_unavailable_raises(monkeypatch):
+ import asyncio
+
+ monkeypatch.setattr(ai_provider, "codex_cli_available", lambda: False)
+ with pytest.raises(RuntimeError, match="Codex CLI 不可用"):
+ asyncio.run(ai_provider.probe_ai_config(
+ provider="codex_cli", api_key="", base_url="", model="",
+ ))
+
+
+@pytest.mark.asyncio
+async def test_probe_chat_protocol_uses_given_key_and_base(monkeypatch):
+ calls: list[dict] = []
+
+ class _Completions:
+ async def create(self, **kwargs):
+ calls.append(kwargs)
+ message = SimpleNamespace(content="OK")
+ return SimpleNamespace(choices=[SimpleNamespace(message=message)])
+
+ class _Chat:
+ completions = _Completions()
+
+ captured_client: dict = {}
+
+ class _Client:
+ def __init__(self, **kwargs):
+ captured_client.update(kwargs)
+ self.chat = _Chat()
+
+ import openai
+ monkeypatch.setattr(openai, "AsyncOpenAI", _Client)
+
+ text = await ai_provider.probe_ai_config(
+ provider="openai_compat",
+ api_key="sk-draft",
+ base_url="https://draft.example/v1",
+ model="draft-model",
+ )
+
+ assert text == "OK"
+ assert captured_client["api_key"] == "sk-draft"
+ assert "draft.example" in captured_client["base_url"]
+ assert calls == [{
+ "model": "draft-model",
+ "messages": [{"role": "user", "content": "Reply exactly: OK"}],
+ }]
+
+
+@pytest.mark.asyncio
+async def test_probe_responses_protocol_calls_responses(monkeypatch):
+ calls: list[dict] = []
+
+ class _Responses:
+ async def create(self, **kwargs):
+ calls.append(kwargs)
+ return SimpleNamespace(output_text="ok")
+
+ class _Client:
+ def __init__(self, **kwargs):
+ self.responses = _Responses()
+
+ import openai
+ monkeypatch.setattr(openai, "AsyncOpenAI", _Client)
+
+ text = await ai_provider.probe_ai_config(
+ provider="openai_compat",
+ api_key="sk-draft",
+ base_url="https://draft.example/v1",
+ model="draft-model",
+ protocol="responses",
+ )
+
+ assert text == "ok"
+ assert calls[0]["model"] == "draft-model"
+ assert calls[0]["input"] == [{"role": "user", "content": "Reply exactly: OK"}]
+
+
+@pytest.mark.asyncio
+async def test_test_endpoint_failure_reports_error_without_saving(monkeypatch):
+ """关键回归: 测试失败 → ok=false 回报, 且不调用任何 secrets 写入。"""
+ saved: list = []
+ monkeypatch.setattr(settings_api.secrets_store, "save", lambda updates: saved.append(updates))
+
+ async def boom(**kwargs):
+ raise RuntimeError("模型或接口地址不存在")
+
+ monkeypatch.setattr(ai_provider, "probe_ai_config", boom)
+
+ result = await settings_api.test_ai_settings(settings_api.AiTestIn(
+ provider="openai_compat",
+ base_url="https://bad.example/v1",
+ api_key="sk-bad",
+ model="no-such-model",
+ ))
+
+ assert result["ok"] is False
+ assert "不存在" in result["error"]
+ assert saved == [] # 未写入任何配置
+
+
+@pytest.mark.asyncio
+async def test_test_endpoint_falls_back_to_saved_key(monkeypatch):
+ """表单 Key 留空时回退已保存 Key (改模型/协议不必重输)。"""
+ seen: dict = {}
+
+ async def fake_probe(**kwargs):
+ seen.update(kwargs)
+ return "OK"
+
+ monkeypatch.setattr(ai_provider, "probe_ai_config", fake_probe)
+ monkeypatch.setattr(settings_api.secrets_store, "get_ai_key", lambda: "sk-saved")
+
+ result = await settings_api.test_ai_settings(settings_api.AiTestIn(
+ provider="openai_compat",
+ base_url="https://api.example/v1",
+ api_key=None,
+ model="m1",
+ protocol="responses",
+ ))
+
+ assert result["ok"] is True
+ assert seen["api_key"] == "sk-saved"
+ assert seen["protocol"] == "responses"
diff --git a/backend/tests/test_ai_responses.py b/backend/tests/test_ai_responses.py
new file mode 100644
index 00000000..32e798c2
--- /dev/null
+++ b/backend/tests/test_ai_responses.py
@@ -0,0 +1,613 @@
+"""Responses API 协议适配层 (app/services/ai_responses.py) 的契约测试。
+
+伪事件流/伪响应全部用 SimpleNamespace 鸭子类型, 只对齐 SDK 暴露的字段名
+(event.type / event.delta / response.status / item.call_id 等) — 适配层
+本身也只依赖这些字段, 与 _iter_openai_text 的宽松风格一致。
+"""
+from __future__ import annotations
+
+import json
+from types import SimpleNamespace
+
+import pytest
+
+from app import secrets_store
+from app.api import settings as settings_api
+from app.config import settings as app_settings
+from app.services import ai_provider, ai_responses
+
+# ---------------------------------------------------------------------------
+# 事件 / 响应工厂
+# ---------------------------------------------------------------------------
+
+
+def _evt(type_: str, **kw):
+ return SimpleNamespace(type=type_, **kw)
+
+
+def _text_delta(s: str):
+ return _evt("response.output_text.delta", delta=s)
+
+
+def _reasoning_delta(s: str = "内部推理"):
+ return _evt("response.reasoning_summary_text.delta", delta=s)
+
+
+def _completed(output=None, status="completed", incomplete_reason=None):
+ resp = SimpleNamespace(status=status, output=list(output or []), error=None, output_text="")
+ if incomplete_reason is not None:
+ resp.incomplete_details = SimpleNamespace(reason=incomplete_reason)
+ return _evt("response.completed", response=resp)
+
+
+async def _fake_stream(*events):
+ for e in events:
+ yield e
+
+
+def _message_item(text: str):
+ part = SimpleNamespace(type="output_text", text=text)
+ return SimpleNamespace(type="message", content=[part])
+
+
+def _call_item(call_id: str, name: str, arguments: str):
+ return SimpleNamespace(type="function_call", call_id=call_id, name=name, arguments=arguments)
+
+
+class _FakeApiError(Exception):
+ """带 status_code/body 的伪上游错误, 喂给重试判定逻辑。"""
+
+ def __init__(self, status_code: int, body: dict):
+ super().__init__(json.dumps(body))
+ self.status_code = status_code
+ self.body = body
+
+
+def _fake_client(result=None, stream=None, error=None):
+ """伪 AsyncOpenAI: client.responses.create(**kwargs)。返回 (client, calls)。"""
+ calls: list[dict] = []
+
+ async def create(**kwargs):
+ calls.append(kwargs)
+ if error is not None:
+ raise error
+ if stream is not None:
+ return stream
+ return result
+
+ return SimpleNamespace(responses=SimpleNamespace(create=create)), calls
+
+
+# ---------------------------------------------------------------------------
+# 消息 / 工具转换
+# ---------------------------------------------------------------------------
+
+
+def test_chat_input_converts_tool_round_trip_shapes():
+ messages = [
+ {"role": "system", "content": "你是助手"},
+ {"role": "user", "content": "查一下茅台"},
+ {
+ "role": "assistant",
+ "content": "先查行情",
+ "tool_calls": [
+ {"id": "call_1", "type": "function",
+ "function": {"name": "get_quote", "arguments": '{"symbol": "600519"}'}},
+ ],
+ },
+ {"role": "tool", "tool_call_id": "call_1", "content": '{"ok": true}'},
+ ]
+
+ items = ai_responses.chat_input(messages)
+
+ assert items == [
+ {"role": "system", "content": "你是助手"},
+ {"role": "user", "content": "查一下茅台"},
+ {"role": "assistant", "content": "先查行情"},
+ {"type": "function_call", "call_id": "call_1", "name": "get_quote",
+ "arguments": '{"symbol": "600519"}'},
+ {"type": "function_call_output", "call_id": "call_1", "output": '{"ok": true}'},
+ ]
+
+
+def test_chat_input_plain_assistant_and_missing_role():
+ items = ai_responses.chat_input([
+ {"role": "assistant", "content": "纯文本回复"},
+ {"content": "无角色消息"},
+ ])
+ assert items == [
+ {"role": "assistant", "content": "纯文本回复"},
+ {"role": "user", "content": "无角色消息"},
+ ]
+
+
+def test_responses_tools_flattens_and_passes_through_flat():
+ chat_schema = [{
+ "type": "function",
+ "function": {"name": "get_quote", "description": "查行情", "parameters": {"type": "object"}},
+ }]
+ flat_schema = [{
+ "type": "function",
+ "name": "get_kline",
+ "parameters": {"type": "object"},
+ }]
+
+ assert ai_responses.responses_tools(chat_schema) == [{
+ "type": "function", "name": "get_quote",
+ "description": "查行情", "parameters": {"type": "object"},
+ }]
+ # 已扁平的 schema 原样接受(个别网关直接吐扁平形态); description 缺省可省
+ assert ai_responses.responses_tools(flat_schema) == [{
+ "type": "function", "name": "get_kline", "parameters": {"type": "object"},
+ }]
+ # 非 dict / 无 function 体的条目静默跳过
+ assert ai_responses.responses_tools([None, {"type": "other"}]) == []
+
+
+# ---------------------------------------------------------------------------
+# kwargs 与自适应重试
+# ---------------------------------------------------------------------------
+
+
+def test_responses_kwargs_maps_param_names(monkeypatch):
+ stored = {"ai_provider": "openai", "ai_reasoning_effort": "medium"}
+ monkeypatch.setattr(secrets_store, "load", lambda: stored)
+
+ kwargs = ai_responses.responses_kwargs(temperature=0.3, max_tokens=1200)
+ assert kwargs == {
+ "max_output_tokens": 1200,
+ "temperature": 0.3,
+ "reasoning": {"effort": "medium"},
+ }
+
+ # 兼容 provider 不下发 reasoning (对齐 _openai_kwargs 的 provider 门控)
+ stored["ai_provider"] = "openai_compat"
+ kwargs = ai_responses.responses_kwargs(temperature=None, max_tokens=1200)
+ assert kwargs == {"max_output_tokens": 1200}
+
+
+def test_responses_kwargs_omits_none_max_tokens():
+ assert ai_responses.responses_kwargs(temperature=0.5, max_tokens=None) == {"temperature": 0.5}
+
+
+def test_responses_kwargs_carries_tools():
+ kwargs = ai_responses.responses_kwargs(
+ temperature=None, max_tokens=None,
+ tools=[{"type": "function", "function": {"name": "f", "parameters": {"type": "object"}}}],
+ )
+ assert kwargs["tools"] == [{"type": "function", "name": "f", "parameters": {"type": "object"}}]
+
+
+def test_responses_retry_kwargs_drops_rejected_params():
+ temperature_reject = _FakeApiError(400, {"error": {"message": "temperature only 1 is allowed"}})
+ reasoning_reject = _FakeApiError(400, {"error": {"message": "Unsupported parameter: 'reasoning'"}})
+
+ retry = ai_responses.responses_retry_kwargs(
+ temperature_reject, {"temperature": 0.3, "reasoning": {"effort": "high"}},
+ )
+ assert retry == {"reasoning": {"effort": "high"}}
+
+ retry = ai_responses.responses_retry_kwargs(
+ reasoning_reject, {"temperature": 0.3, "reasoning": {"effort": "high"}},
+ )
+ assert retry == {"temperature": 0.3}
+
+ # 非 400 / 无关 400 不重试
+ assert ai_responses.responses_retry_kwargs(
+ _FakeApiError(500, {"error": {"message": "boom"}}), {"temperature": 0.3},
+ ) is None
+ assert ai_responses.responses_retry_kwargs(
+ _FakeApiError(400, {"error": {"message": "model not found"}}), {"temperature": 0.3},
+ ) is None
+
+
+# ---------------------------------------------------------------------------
+# iter_text: 事件流归一化 (与 _iter_openai_text 的防御等价)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_iter_text_yields_deltas_and_ignores_reasoning():
+ stream = _fake_stream(
+ _reasoning_delta(),
+ _text_delta("完整"),
+ _text_delta("正文"),
+ _completed(),
+ )
+ assert [p async for p in ai_responses.iter_text(stream)] == ["完整", "正文"]
+
+
+@pytest.mark.asyncio
+async def test_iter_text_done_event_fallback_without_deltas():
+ """个别网关只发 output_text.done 不发 delta: done 全量兜底产出一次。"""
+ stream = _fake_stream(
+ _evt("response.output_text.done", text="一次成型"),
+ _completed(),
+ )
+ assert [p async for p in ai_responses.iter_text(stream)] == ["一次成型"]
+
+
+@pytest.mark.asyncio
+async def test_iter_text_rejects_length_truncated_partial():
+ stream = _fake_stream(
+ _text_delta("未完成正文"),
+ _completed(status="incomplete", incomplete_reason="max_output_tokens"),
+ )
+ with pytest.raises(RuntimeError, match="输出达到长度上限"):
+ async for _ in ai_responses.iter_text(stream):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_iter_text_rejects_reasoning_only_length_exhaustion():
+ stream = _fake_stream(
+ _reasoning_delta(),
+ _completed(status="incomplete", incomplete_reason="max_output_tokens"),
+ )
+ with pytest.raises(RuntimeError, match="推理达到输出长度上限"):
+ async for _ in ai_responses.iter_text(stream):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_iter_text_rejects_stream_without_content():
+ with pytest.raises(RuntimeError, match="未返回正文内容"):
+ async for _ in ai_responses.iter_text(_fake_stream(_completed())):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_iter_text_failed_event_raises_with_detail():
+ resp = SimpleNamespace(
+ status="failed", output=[], error=SimpleNamespace(message="quota exceeded"),
+ )
+ stream = _fake_stream(_evt("response.failed", response=resp))
+ with pytest.raises(RuntimeError, match="quota exceeded"):
+ async for _ in ai_responses.iter_text(stream):
+ pass
+
+
+# ---------------------------------------------------------------------------
+# 非流式: run_text / run_message_once
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_run_text_extracts_text_with_and_without_helper_attr(monkeypatch):
+ resp = SimpleNamespace(
+ status="completed",
+ output_text="聚合文本",
+ output=[_message_item("聚合文本")],
+ error=None,
+ )
+ client, calls = _fake_client(result=resp)
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ assert await ai_responses.run_text(
+ [{"role": "user", "content": "hi"}], temperature=0.3, max_tokens=100, timeout=5,
+ ) == "聚合文本"
+ assert calls[0]["input"] == [{"role": "user", "content": "hi"}]
+ assert calls[0]["max_output_tokens"] == 100
+
+ # output_text 便利属性缺失时逐 item 聚合
+ resp2 = SimpleNamespace(status="completed", output=[_message_item("逐条"), _message_item("聚合")])
+ client2, _ = _fake_client(result=resp2)
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client2)
+ assert await ai_responses.run_text(
+ [{"role": "user", "content": "hi"}], temperature=None, max_tokens=None, timeout=5,
+ ) == "逐条聚合"
+
+
+@pytest.mark.asyncio
+async def test_run_text_incomplete_raises(monkeypatch):
+ resp = SimpleNamespace(
+ status="incomplete",
+ output_text="半截",
+ output=[],
+ incomplete_details=SimpleNamespace(reason="max_output_tokens"),
+ )
+ client, _ = _fake_client(result=resp)
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ with pytest.raises(RuntimeError, match="输出达到长度上限"):
+ await ai_responses.run_text(
+ [{"role": "user", "content": "hi"}], temperature=None, max_tokens=10, timeout=5,
+ )
+
+
+@pytest.mark.asyncio
+async def test_run_message_once_returns_chat_shape_calls(monkeypatch):
+ resp = SimpleNamespace(
+ status="completed",
+ output_text="",
+ output=[_call_item("call_1", "get_quote", '{"symbol":"600519"}')],
+ )
+ client, calls = _fake_client(result=resp)
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ message = await ai_responses.run_message_once(
+ [{"role": "user", "content": "查"}],
+ temperature=0.3, max_tokens=None, timeout=5,
+ tools=[{"type": "function", "function": {"name": "get_quote"}}],
+ )
+ tc = message.tool_calls[0]
+ assert (tc.id, tc.function.name, tc.function.arguments) == (
+ "call_1", "get_quote", '{"symbol":"600519"}',
+ )
+ # 工具 schema 以扁平形态下发
+ assert calls[0]["tools"] == [{"type": "function", "name": "get_quote"}]
+
+
+@pytest.mark.asyncio
+async def test_run_message_once_retries_without_rejected_temperature(monkeypatch):
+ ok_resp = SimpleNamespace(status="completed", output_text="OK", output=[_message_item("OK")])
+ error = _FakeApiError(400, {"error": {"message": "temperature only 1 is allowed"}})
+ client, calls = _fake_client(result=ok_resp, error=error)
+ # 首次抛 400, 摘掉 temperature 后重试成功
+ state = {"raised": False}
+
+ async def create(**kwargs):
+ calls.append(kwargs)
+ if not state["raised"]:
+ state["raised"] = True
+ raise error
+ return ok_resp
+
+ client = SimpleNamespace(responses=SimpleNamespace(create=create))
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ text = await ai_responses.run_message_once(
+ [{"role": "user", "content": "hi"}], temperature=0.3, max_tokens=None, timeout=5,
+ )
+ assert text.content == "OK"
+ assert calls[0]["temperature"] == 0.3
+ assert "temperature" not in calls[1]
+
+
+# ---------------------------------------------------------------------------
+# stream_round: 助手工具轮事件流
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_stream_round_accumulates_tool_calls_from_fragments(monkeypatch):
+ stream = _fake_stream(
+ _evt("response.output_item.added",
+ item=SimpleNamespace(id="fc_1", type="function_call", call_id="call_1",
+ name="get_quote", arguments="")),
+ _text_delta("先查一下"),
+ _evt("response.function_call_arguments.delta", item_id="fc_1", delta='{"sym'),
+ _evt("response.function_call_arguments.delta", item_id="fc_1", delta='bol":"600519"}'),
+ _evt("response.output_item.done",
+ item=SimpleNamespace(id="fc_1", type="function_call", call_id="call_1",
+ name="get_quote", arguments='{"symbol":"600519"}')),
+ _completed(output=[_call_item("call_1", "get_quote", '{"symbol":"600519"}')]),
+ )
+ client, _ = _fake_client(stream=stream)
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ events = [e async for e in ai_responses.stream_round(
+ [{"role": "user", "content": "查茅台"}],
+ [{"type": "function", "function": {"name": "get_quote"}}],
+ temperature=0.3, timeout=5,
+ )]
+
+ assert events[0] == {"type": "text", "delta": "先查一下"}
+ assert events[-1] == {
+ "type": "round_end",
+ "tool_calls": [{"id": "call_1", "name": "get_quote", "arguments": '{"symbol":"600519"}'}],
+ "finish_reason": "tool_calls",
+ }
+
+
+@pytest.mark.asyncio
+async def test_stream_round_completed_output_is_authoritative(monkeypatch):
+ """completed.response.output 优先于分片累积(网关乱序/缺 done 时兜底)。"""
+ stream = _fake_stream(
+ _evt("response.output_item.added",
+ item=SimpleNamespace(id="fc_1", type="function_call", call_id="call_1",
+ name="", arguments="")),
+ _evt("response.function_call_arguments.delta", item_id="fc_1", delta="半截参数"),
+ _completed(output=[_call_item("call_x", "get_kline", '{"days":5}')]),
+ )
+ client, _ = _fake_client(stream=stream)
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ events = [e async for e in ai_responses.stream_round(
+ [{"role": "user", "content": "查K线"}], [], temperature=None, timeout=5,
+ )]
+ assert events[-1]["tool_calls"] == [
+ {"id": "call_x", "name": "get_kline", "arguments": '{"days":5}'},
+ ]
+
+
+@pytest.mark.asyncio
+async def test_stream_round_text_only_round_ends_empty(monkeypatch):
+ stream = _fake_stream(
+ _text_delta("回答完毕"),
+ _completed(),
+ )
+ client, _ = _fake_client(stream=stream)
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ events = [e async for e in ai_responses.stream_round(
+ [{"role": "user", "content": "hi"}], [], temperature=None, timeout=5,
+ )]
+ assert events == [
+ {"type": "text", "delta": "回答完毕"},
+ {"type": "round_end", "tool_calls": [], "finish_reason": "stop"},
+ ]
+
+
+@pytest.mark.asyncio
+async def test_stream_round_length_truncation_without_calls_raises(monkeypatch):
+ stream = _fake_stream(
+ _text_delta("半截"),
+ _completed(status="incomplete", incomplete_reason="max_output_tokens"),
+ )
+ client, _ = _fake_client(stream=stream)
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ with pytest.raises(RuntimeError, match="输出达到长度上限"):
+ async for _ in ai_responses.stream_round(
+ [{"role": "user", "content": "hi"}], [], temperature=None, timeout=5,
+ ):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_stream_round_empty_response_raises(monkeypatch):
+ client, _ = _fake_client(stream=_fake_stream(_completed()))
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ with pytest.raises(RuntimeError, match="未返回正文内容"):
+ async for _ in ai_responses.stream_round(
+ [{"role": "user", "content": "hi"}], [], temperature=None, timeout=5,
+ ):
+ pass
+
+
+# ---------------------------------------------------------------------------
+# 协议配置与入口接线
+# ---------------------------------------------------------------------------
+
+
+def test_current_ai_protocol_config_and_fallback(monkeypatch):
+ stored: dict = {}
+ monkeypatch.setattr(secrets_store, "load", lambda: stored)
+
+ assert ai_provider.current_ai_protocol() == "chat_completions"
+
+ stored["ai_protocol"] = "responses"
+ assert ai_provider.current_ai_protocol() == "responses"
+ assert ai_provider.is_responses_protocol() is True
+
+ stored["ai_protocol"] = "bogus"
+ assert ai_provider.current_ai_protocol() == "chat_completions"
+
+ # Codex CLI 恒为 chat_completions, 存储值不生效
+ stored["ai_provider"] = "codex_cli"
+ stored["ai_protocol"] = "responses"
+ assert ai_provider.current_ai_protocol() == "chat_completions"
+
+
+@pytest.mark.asyncio
+async def test_generate_ai_text_routes_to_responses(monkeypatch):
+ async def fake_run_text(messages, *, temperature, max_tokens, timeout):
+ return "responses 回复"
+
+ monkeypatch.setattr(secrets_store, "load", lambda: {"ai_protocol": "responses"})
+ monkeypatch.setattr(ai_responses, "run_text", fake_run_text)
+
+ text = await ai_provider.generate_ai_text(
+ [{"role": "user", "content": "hi"}], temperature=0.3, max_tokens=100, timeout=5,
+ )
+ assert text == "responses 回复"
+
+
+@pytest.mark.asyncio
+async def test_stream_ai_text_routes_to_responses(monkeypatch):
+ async def fake_stream_text(messages, *, temperature, max_tokens, timeout):
+ yield "流式"
+ yield "回复"
+
+ monkeypatch.setattr(secrets_store, "load", lambda: {"ai_protocol": "responses"})
+ monkeypatch.setattr(ai_responses, "stream_text", fake_stream_text)
+
+ chunks = [c async for c in ai_provider.stream_ai_text(
+ [{"role": "user", "content": "hi"}], temperature=0.5, max_tokens=100, timeout=5,
+ )]
+ assert chunks == ["流式", "回复"]
+
+
+@pytest.mark.asyncio
+async def test_assistant_round_routes_to_responses(monkeypatch):
+ from app.custom.assistant import streaming
+
+ async def fake_stream_round(messages, tools, *, temperature, timeout):
+ yield {"type": "text", "delta": "助手回复"}
+ yield {"type": "round_end", "tool_calls": [], "finish_reason": "stop"}
+
+ monkeypatch.setattr(secrets_store, "load", lambda: {"ai_protocol": "responses"})
+ monkeypatch.setattr(streaming, "is_responses_protocol", lambda: True)
+ monkeypatch.setattr(streaming.ai_responses, "stream_round", fake_stream_round)
+
+ events = [e async for e in streaming.stream_openai_round(
+ [{"role": "user", "content": "hi"}], [], temperature=0.3, timeout=5,
+ )]
+ assert events == [
+ {"type": "text", "delta": "助手回复"},
+ {"type": "round_end", "tool_calls": [], "finish_reason": "stop"},
+ ]
+
+
+@pytest.mark.asyncio
+async def test_stream_round_awaits_async_close(monkeypatch):
+ """SDK 2.x 的 AsyncStream.close 是协程: 必须真正 await, 而非 to_thread 丢弃。"""
+ closed: list[bool] = []
+
+ class _ClosableStream:
+ def __init__(self, events):
+ self._inner = _fake_stream(*events)
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ return await self._inner.__anext__()
+
+ async def close(self):
+ closed.append(True)
+
+ stream = _ClosableStream([_text_delta("OK"), _completed()])
+ client, _ = _fake_client(stream=stream)
+ monkeypatch.setattr(ai_responses, "responses_client", lambda timeout: client)
+ monkeypatch.setattr(secrets_store, "load", lambda: {})
+
+ events = [e async for e in ai_responses.stream_round(
+ [{"role": "user", "content": "hi"}], [], temperature=None, timeout=5,
+ )]
+ assert closed == [True]
+ assert events[-1]["finish_reason"] == "stop"
+
+
+def test_save_ai_settings_persists_protocol(monkeypatch):
+ saved: dict = {}
+ monkeypatch.setattr(settings_api.secrets_store, "save", lambda updates: saved.update(updates))
+ monkeypatch.setattr(settings_api.secrets_store, "load", lambda: saved)
+ original = app_settings.ai_protocol
+ try:
+ result = settings_api.save_ai_settings(settings_api.AiSettingsIn(
+ provider="openai_compat",
+ base_url="https://example.com/v1",
+ api_key="sk-test",
+ model="gpt-x",
+ protocol="responses",
+ ))
+ assert saved["ai_protocol"] == "responses"
+ assert result["ai_protocol"] == "responses"
+ assert ai_provider.current_ai_protocol() == "responses"
+ finally:
+ app_settings.ai_protocol = original
+
+
+def test_save_ai_settings_rejects_unknown_protocol(monkeypatch):
+ saved: dict = {}
+ monkeypatch.setattr(settings_api.secrets_store, "save", lambda updates: saved.update(updates))
+ monkeypatch.setattr(settings_api.secrets_store, "load", lambda: saved)
+
+ from fastapi import HTTPException
+ with pytest.raises(HTTPException):
+ settings_api.save_ai_settings(settings_api.AiSettingsIn(
+ provider="openai_compat", protocol="grpc",
+ ))
+ assert "ai_protocol" not in saved
diff --git a/backend/tests/test_custom_assistant_sessions.py b/backend/tests/test_custom_assistant_sessions.py
new file mode 100644
index 00000000..58745351
--- /dev/null
+++ b/backend/tests/test_custom_assistant_sessions.py
@@ -0,0 +1,97 @@
+"""AI 助手会话历史服务端存储 (跨设备同步) 的服务与 API 测试。
+
+契约: GET 空结构、PUT 保存往返、结构裁剪 (无 id 会话剔除 / 消息截尾 200 /
+会话上限 20)、损坏文件按空处理、API 挂载在 assistant 扩展路由下。
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.custom.assistant import sessions as assistant_sessions
+from app.custom.assistant.routes import build_router
+
+
+@pytest.fixture
+def client(tmp_path: Path) -> TestClient:
+ app = FastAPI()
+ app.include_router(build_router())
+ app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
+ return TestClient(app)
+
+
+def _session(sid: str, n_msgs: int = 1) -> dict:
+ return {
+ "id": sid,
+ "title": f"会话{sid}",
+ "createdAt": 1700000000000,
+ "messages": [
+ {"id": f"m{i}", "role": "user", "content": f"hi {i}", "ts": i}
+ for i in range(n_msgs)
+ ],
+ }
+
+
+def test_api_roundtrip_and_empty_state(client: TestClient) -> None:
+ assert client.get("/api/custom/assistant/sessions").json() == {
+ "sessions": [],
+ "activeId": "",
+ }
+
+ r = client.put(
+ "/api/custom/assistant/sessions",
+ json={"sessions": [_session("s1"), _session("s2")], "activeId": "s2"},
+ )
+ assert r.status_code == 200 and r.json() == {"ok": True, "sessions": 2}
+
+ got = client.get("/api/custom/assistant/sessions").json()
+ assert [s["id"] for s in got["sessions"]] == ["s1", "s2"]
+ assert got["activeId"] == "s2"
+
+
+def test_sanitize_trims_and_drops(client: TestClient, tmp_path: Path) -> None:
+ # 无 id/非字符串 id 的会话剔除
+ bad = [{"title": "no id"}, {"id": 123, "messages": []}]
+ # 消息超 200 截尾
+ long = _session("long", n_msgs=250)
+ # 会话超 20 截断 (服务层: API 层 max_length=20 会先拒绝更大的载荷)
+ many = [_session(f"s{i}") for i in range(25)]
+
+ n = assistant_sessions.save_sessions(
+ tmp_path, {"sessions": [*bad, long, *many], "activeId": "s0"}
+ )
+ assert n == assistant_sessions.MAX_SESSIONS
+
+ got = assistant_sessions.load_sessions(tmp_path)
+ ids = [s["id"] for s in got["sessions"]]
+ assert len(ids) == 20
+ assert "long" in ids # 输入顺序靠前, 保留
+ assert len(next(s for s in got["sessions"] if s["id"] == "long")["messages"]) == 200
+
+ # API 层: 结构非法的会话静默剔除, 合法照常保存
+ r = client.put(
+ "/api/custom/assistant/sessions",
+ json={"sessions": [*bad, _session("ok1")], "activeId": "ok1"},
+ )
+ assert r.status_code == 200 and r.json() == {"ok": True, "sessions": 1}
+ ids2 = [s["id"] for s in client.get("/api/custom/assistant/sessions").json()["sessions"]]
+ assert ids2 == ["ok1"]
+
+
+def test_corrupted_file_treated_as_empty(tmp_path: Path) -> None:
+ p = tmp_path / "user_data" / "assistant_sessions.json"
+ p.parent.mkdir(parents=True)
+ p.write_text("{not json", encoding="utf-8")
+ assert assistant_sessions.load_sessions(tmp_path) == {"sessions": [], "activeId": ""}
+
+
+def test_put_rejects_oversized_session_list(client: TestClient) -> None:
+ r = client.put(
+ "/api/custom/assistant/sessions",
+ json={"sessions": [_session(f"s{i}") for i in range(21)]},
+ )
+ assert r.status_code == 422 # 超过 max_length=20, 防御异常客户端
diff --git a/backend/tests/test_emotion_score.py b/backend/tests/test_emotion_score.py
new file mode 100644
index 00000000..f61de9fc
--- /dev/null
+++ b/backend/tests/test_emotion_score.py
@@ -0,0 +1,200 @@
+"""情绪评分口径 — 默认口径还原 + 配置归一化契约。
+
+「默认口径必须与历史硬编码算法逐位一致」是这里的核心约束: 情绪分是看板与
+复盘快照的对外数字, 改动计算模块不能悄悄挪动它。原算法已从
+market_overview_builder 移除, 故本文件内联一份等价实现做对照。
+"""
+from __future__ import annotations
+
+import math
+import random
+
+from app.services.emotion_score import (
+ DEFAULT_CONFIG,
+ DIMENSION_ORDER,
+ compute,
+ dimension_value,
+ label_of,
+ merge_config,
+ public_config,
+ score_of,
+)
+
+
+def _legacy_score(value: float, low: float, high: float) -> int:
+ """改造前 market_overview_builder._score 的逐字复制。"""
+ if high <= low:
+ return 50
+ if not math.isfinite(value):
+ return 50
+ return max(0, min(100, round((value - low) / (high - low) * 100)))
+
+
+def _legacy_radar_and_score(m: dict) -> tuple[list[dict], int]:
+ """改造前看板情绪雷达的逐字复制 (含 mainline 预计算那一版)。"""
+ radar = [
+ {"key": "index", "label": "指数", "value": _legacy_score(m["avg_index_pct"], -2.5, 2.5)},
+ {"key": "profit", "label": "赚钱", "value": round(
+ _legacy_score(m["up_pct"], 20, 80) * 0.45
+ + _legacy_score(m["avg_pct"], -0.02, 0.02) * 0.25
+ + _legacy_score(m["median_pct"], -0.02, 0.02) * 0.20
+ + _legacy_score(m["strong_diff_pct"], -8, 8) * 0.10)},
+ {"key": "money", "label": "量能", "value": round(
+ _legacy_score(m["avg_vol_ratio"], 0.6, 1.8) * 0.70
+ + _legacy_score(m["high_vol_pct"], 2, 12) * 0.30)},
+ {"key": "speculation", "label": "投机", "value": round(
+ _legacy_score(m["limit_up"], 5, 90) * 0.25
+ + _legacy_score(m["seal_rate"], 30, 85) * 0.35
+ + _legacy_score(m["max_boards"], 1, 8) * 0.25
+ + _legacy_score(m["tier2_count"], 0, 30) * 0.15)},
+ {"key": "resilience", "label": "抗跌", "value": 100 - round(
+ _legacy_score(m["down_pct"], 20, 80) * 0.55
+ + _legacy_score(m["strong_down_pct"], 1, 12) * 0.45)},
+ {"key": "mainline", "label": "主线", "value": m["mainline_score"]},
+ ]
+ return radar, round(sum(r["value"] for r in radar) / len(radar))
+
+
+def _sample(rng: random.Random) -> dict:
+ m = {
+ "avg_index_pct": rng.uniform(-6, 6), "up_pct": rng.uniform(0, 100),
+ "avg_pct": rng.uniform(-0.05, 0.05), "median_pct": rng.uniform(-0.05, 0.05),
+ "strong_diff_pct": rng.uniform(-20, 20), "avg_vol_ratio": rng.uniform(0.3, 3),
+ "high_vol_pct": rng.uniform(0, 40), "limit_up": rng.randint(0, 200),
+ "seal_rate": rng.uniform(0, 100), "max_boards": rng.randint(0, 12),
+ "tier2_count": rng.randint(0, 60), "down_pct": rng.uniform(0, 100),
+ "strong_down_pct": rng.uniform(0, 25), "mainline_avg": rng.uniform(-0.03, 0.08),
+ "mainline_cover_pct": rng.uniform(0, 30),
+ }
+ m["mainline_score"] = round(
+ _legacy_score(m["mainline_avg"], -0.005, 0.03) * 0.65
+ + _legacy_score(m["mainline_cover_pct"], 1, 12) * 0.35)
+ return m
+
+
+def test_default_matches_legacy_bit_for_bit() -> None:
+ """出厂口径与历史硬编码算法逐位一致 (随机 5000 组)。
+
+ 取整顺序在国内很敏感: 抗跌维度必须「先取整再取反」(100 - round(x)),
+ 写成 round(100 - x) 会在 x 的小数部分恰为 .5 时差 1 分。
+ """
+ rng = random.Random(7)
+ for _ in range(5000):
+ m = _sample(rng)
+ legacy_radar, legacy_score = _legacy_radar_and_score(m)
+ res = compute(m, None)
+ assert res["radar"] == legacy_radar, f"雷达不一致: {m}"
+ assert res["score"] == legacy_score, f"总分不一致: {m}"
+
+
+def test_label_mapping_matches_legacy_over_full_range() -> None:
+ """档位映射覆盖 0..100 全整数区间, 与历史阈值一致。"""
+ def legacy_label(score: int) -> str:
+ if score >= 70:
+ return "强势"
+ if score >= 55:
+ return "偏暖"
+ if score >= 45:
+ return "震荡"
+ if score >= 30:
+ return "偏冷"
+ return "冰点"
+
+ th = DEFAULT_CONFIG["thresholds"]
+ for score in range(101):
+ assert label_of(score, th) == legacy_label(score), f"{score} 档位不一致"
+
+
+def test_score_of_handles_non_finite() -> None:
+ """非有限输入取中性 50, 不得抛异常打断看板计算。"""
+ for bad in (float("nan"), float("inf"), None, "abc"):
+ assert score_of(bad, 0, 1) == 50
+ assert score_of(0.5, 0, 1) == 50
+ assert score_of(5, 0, 1) == 100 # 超出上界夹到 100
+ assert score_of(-5, 0, 1) == 0 # 低于下界夹到 0
+
+
+def test_merge_config_fills_defaults_and_ignores_unknown() -> None:
+ """部分配置 → 其余字段补默认; 未知 key 丢弃 (不因脏配置崩)。"""
+ merged = merge_config({
+ "dimensions": {
+ "index": {"weight": 3.0, "terms": [{"key": "avg_index_pct", "low": -5}]},
+ "no_such_dim": {"weight": 9},
+ },
+ "thresholds": {"strong": 80},
+ "unknown_top": 1,
+ })
+ assert merged["dimensions"]["index"]["weight"] == 3.0
+ assert merged["dimensions"]["index"]["terms"][0]["low"] == -5
+ assert merged["dimensions"]["index"]["terms"][0]["high"] == 2.5 # 未提及 → 默认
+ assert "no_such_dim" not in merged["dimensions"]
+ assert "unknown_top" not in merged
+ assert merged["thresholds"]["strong"] == 80
+ assert merged["thresholds"]["warm"] == DEFAULT_CONFIG["thresholds"]["warm"]
+
+
+def test_thresholds_normalized_to_descending() -> None:
+ """用户把阈值填乱序 → 自动重排为降序, 保证档位区间非空。"""
+ merged = merge_config({"thresholds": {"strong": 20, "warm": 90, "neutral": 50, "cold": 10}})
+ th = merged["thresholds"]
+ assert th["strong"] >= th["warm"] >= th["neutral"] >= th["cold"]
+
+
+def test_thresholds_clamped_to_range() -> None:
+ """阈值越界 (负数/超 100) 夹回 0..100。"""
+ merged = merge_config({"thresholds": {"strong": 999, "cold": -50}})
+ th = merged["thresholds"]
+ assert 0 <= th["cold"] <= 100
+ assert 0 <= th["strong"] <= 100
+
+
+def test_dimension_weight_affects_score() -> None:
+ """改维度权重必须改变总分 (否则配置形同虚设)。"""
+ m = _sample(random.Random(1))
+ base = compute(m, None)["score"]
+ heavy = merge_config({"dimensions": {"index": {"weight": 10.0}}})
+ assert compute(m, heavy)["score"] != base
+
+
+def test_term_weight_within_dimension_is_normalized() -> None:
+ """子项权重和不为 1 时按比例归一化, 不因总和变化而畸变。
+
+ 把「指数」维度 (单子项) 的权重从 1 改成 7, 该维度得分应不变 —— 单子项
+ 归一化后权重的绝对大小不影响自身得分。
+ """
+ dim = merge_config(None)["dimensions"]["index"]
+ before = dimension_value(dim, {"avg_index_pct": 1.0})
+ scaled = merge_config({"dimensions": {"index": {"terms": [
+ {"key": "avg_index_pct", "weight": 7.0}]}}})["dimensions"]["index"]
+ assert dimension_value(scaled, {"avg_index_pct": 1.0}) == before
+
+
+def test_missing_metric_takes_neutral() -> None:
+ """指标缺失 → 该维度取中性 50 (与历史「无主线数据取 50」一致)。"""
+ dim = merge_config(None)["dimensions"]["mainline"]
+ assert dimension_value(dim, {}) == 50
+
+
+def test_public_config_shape() -> None:
+ """配置接口返回结构稳定 (前端表单完全依赖它渲染)。"""
+ payload = public_config(None)
+ assert payload["dimension_order"] == list(DIMENSION_ORDER)
+ assert set(payload["config"]) == {"dimensions", "thresholds"}
+ assert payload["defaults"] == DEFAULT_CONFIG
+ names = [l["name"] for l in payload["labels"]]
+ assert "强势" in names and "冰点" in names
+ # 每个维度都带 label / weight / terms, 每个 term 带完整六字段
+ for key in DIMENSION_ORDER:
+ dim = payload["config"]["dimensions"][key]
+ assert dim["label"] and "weight" in dim and dim["terms"]
+ for term in dim["terms"]:
+ assert set(term) == {"key", "label", "weight", "low", "high", "unit"}
+
+
+def test_compute_returns_dimension_details() -> None:
+ """compute 额外返回 dimensions (带权重与 invert), 供配置面板展示。"""
+ res = compute(_sample(random.Random(3)), None)
+ assert len(res["dimensions"]) == len(DIMENSION_ORDER)
+ resilience = next(d for d in res["dimensions"] if d["key"] == "resilience")
+ assert resilience["invert"] is True
+ assert res["label"] in ("强势", "偏暖", "震荡", "偏冷", "冰点")
diff --git a/backend/tests/test_ext_fanout.py b/backend/tests/test_ext_fanout.py
new file mode 100644
index 00000000..d620e05c
--- /dev/null
+++ b/backend/tests/test_ext_fanout.py
@@ -0,0 +1,293 @@
+"""通用两级拉取 (ext_fanout) 单测 — 目录 → 逐条目明细 → 反转为「成员 → 类别」。
+
+全程 httpx.MockTransport, 不出网。覆盖: 信封解包报错、反转聚合与目录顺序、
+单条目失败容忍(重试后跳过)、全部条目失败报错、必填项校验、
+以及端到端落盘 (config.json + parquet, 走 fetch_and_ingest 通用路径)。
+"""
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+
+import httpx
+import polars as pl
+import pytest
+
+from app.services.ext_data import (
+ ExtConfig,
+ ExtConfigStore,
+ ExtField,
+ PullConfig,
+ rows_to_parquet,
+)
+from app.services.ext_fanout import clean_label, fetch_fanout_rows
+
+_CATALOG = "/api/a-share-index/catalog/ths-index-list"
+_CONSTITUENTS = "/api/a-share-index/constituents/ths-stock-list"
+_AUTH_HEADER = {"X-api-key": "test-key"}
+
+
+def _ok(items: list[dict]) -> httpx.Response:
+ return httpx.Response(
+ 200, json={"code": 0, "message": "success", "data": {"item": items}}
+ )
+
+
+def _mock_transport(
+ catalog_by_tag: dict[str, list[dict]],
+ constituents_by_code: dict[str, list[dict]],
+ fail_codes: tuple[str, ...] = (),
+) -> httpx.MockTransport:
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path == _CATALOG:
+ tag = request.url.params.get("tag", "cn_concept")
+ return _ok(catalog_by_tag.get(tag, []))
+ if request.url.path == _CONSTITUENTS:
+ code = request.url.params.get("thscode", "")
+ if code in fail_codes:
+ raise httpx.ConnectError(f"injected failure: {code}")
+ return _ok(constituents_by_code.get(code, []))
+ return httpx.Response(404, text="not found")
+
+ return httpx.MockTransport(handler)
+
+
+_CATALOG_CN_CONCEPT = [
+ {"thscode": "B1.TI", "name": "白酒概念"},
+ {"thscode": "B2.TI", "name": "新能源汽车"},
+]
+_CONSTITUENTS_BY_CODE = {
+ "B1.TI": [
+ {"thscode": "600519.SH", "ticker": "600519", "name": "贵州茅台"},
+ {"thscode": "000858.SZ", "ticker": "000858", "name": "五粮液"},
+ ],
+ "B2.TI": [
+ {"thscode": "600519.SH", "ticker": "600519", "name": "贵州茅台"},
+ {"thscode": "300750.SZ", "ticker": "300750", "name": "宁德时代"},
+ ],
+}
+
+
+def _concept_config(url_tag: str = "cn_concept", **fanout_overrides) -> ExtConfig:
+ """测试用概念表配置: 结构与出厂预设同构, 便于断言 schema。"""
+ fanout = {
+ "url": f"https://fuyao.aicubes.cn{_CONSTITUENTS}",
+ "param": "thscode",
+ "id_field": "thscode",
+ "label_field": "name",
+ "items_path": "data.item",
+ "member_id_field": "thscode",
+ "member_label_field": "name",
+ "symbol_col": "股票代码",
+ "name_col": "股票简称",
+ "value_col": "所属概念",
+ "separator": ";",
+ "concurrency": 8,
+ "retries": 1,
+ }
+ fanout.update(fanout_overrides)
+ return ExtConfig(
+ id="ext_gn_ths",
+ label="扩展概念",
+ mode="snapshot",
+ fields=[
+ ExtField("symbol", "string", "标的代码"),
+ ExtField("code", "string", "代码"),
+ ExtField("股票代码", "string", "股票代码"),
+ ExtField("股票简称", "string", "股票简称"),
+ ExtField("所属概念", "string", "所属概念"),
+ ],
+ symbol_map={"type": "mapped", "col": "股票代码"},
+ code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"},
+ pull=PullConfig(
+ url=f"https://fuyao.aicubes.cn{_CATALOG}?tag={url_tag}",
+ response_path="data.item",
+ auth={"type": "header", "header": "X-api-key"},
+ fanout=fanout,
+ ),
+ )
+
+
+@pytest.fixture
+def mock_http(monkeypatch: pytest.MonkeyPatch):
+ """把 ext_pull 的出站请求替换为 MockTransport (鉴权与标识头口径保持不变)。
+
+ 同时注入测试 Key: _apply_auth 在缺凭据时 fail-closed 报错, 这是生产要的
+ 行为, 测 fanout 逻辑时需补上凭据。
+ """
+ import app.services.ext_pull as ext_pull
+ import app.services.ext_data as ext_data
+
+ monkeypatch.setattr(
+ ext_data, "get_ext_api_key", lambda config_id: "test-key"
+ )
+
+ def install(transport: httpx.MockTransport):
+ real_request = ext_pull._request_json
+
+ async def patched(pull, config_id, day=None, extra_params=None, *, url_override=None):
+ # 复用真实实现的 URL/鉴权拼装, 只把 transport 换成 Mock
+ from app.services.ext_pull import _apply_auth, _append_query_params, _with_date_param
+ url = url_override or (
+ _with_date_param(pull.url, pull.date_param, day, pull.date_format) if day else pull.url
+ )
+ if extra_params:
+ url = _append_query_params(url, extra_params)
+ headers = {"User-Agent": "test", "X-TSP-Client": "tick-stock-panel"}
+ url = _apply_auth(config_id, pull.auth, url, headers)
+ async with httpx.AsyncClient(timeout=30, transport=transport) as client:
+ resp = await client.request(pull.method.upper(), url, headers=headers)
+ resp.raise_for_status()
+ return resp.json()
+
+ monkeypatch.setattr(ext_pull, "_request_json", patched)
+ return real_request
+
+ return install
+
+
+# ---- 值清洗 ----
+
+
+def test_clean_label_drops_missing_value_placeholders() -> None:
+ for placeholder in (None, float("nan"), "nan", "None", " null ", ""):
+ assert clean_label(placeholder) == ""
+ assert clean_label(" 银行 ") == "银行"
+
+
+# ---- 必填项校验 ----
+
+
+def test_fanout_missing_required_keys_raises() -> None:
+ config = _concept_config()
+ config.pull.fanout.pop("member_id_field")
+ with pytest.raises(ValueError, match="缺少必填项"):
+ asyncio.run(fetch_fanout_rows(config))
+
+
+# ---- 信封解包 ----
+
+
+def test_fanout_envelope_error_raises(mock_http) -> None:
+ mock_http(
+ httpx.MockTransport(
+ lambda r: httpx.Response(200, json={"code": 2001, "message": "invalid key"})
+ )
+ )
+ with pytest.raises(ValueError, match="code=2001"):
+ asyncio.run(fetch_fanout_rows(_concept_config()))
+
+
+# ---- 反转聚合 ----
+
+
+def test_fanout_inverts_by_catalog_order(mock_http) -> None:
+ mock_http(_mock_transport({"cn_concept": _CATALOG_CN_CONCEPT}, _CONSTITUENTS_BY_CODE))
+
+ rows = asyncio.run(fetch_fanout_rows(_concept_config()))
+ by_symbol = {r["股票代码"]: r for r in rows}
+ assert len(by_symbol) == 3
+ # 类别名跟随目录顺序拼接 (B1 在前 B2 在后), 与请求完成顺序无关
+ assert by_symbol["600519.SH"]["所属概念"] == "白酒概念;新能源汽车"
+ assert by_symbol["600519.SH"]["股票简称"] == "贵州茅台"
+ assert by_symbol["000858.SZ"]["所属概念"] == "白酒概念"
+ assert by_symbol["300750.SZ"]["所属概念"] == "新能源汽车"
+
+
+def test_fanout_tolerates_entry_failure(mock_http) -> None:
+ """单条目重试后仍失败 → 跳过该条目, 其余正常返回。"""
+ mock_http(
+ _mock_transport(
+ {"cn_concept": _CATALOG_CN_CONCEPT},
+ _CONSTITUENTS_BY_CODE,
+ fail_codes=("B2.TI",),
+ )
+ )
+ rows = asyncio.run(fetch_fanout_rows(_concept_config()))
+ by_symbol = {r["股票代码"]: r for r in rows}
+ assert set(by_symbol) == {"600519.SH", "000858.SZ"} # B2 成员被跳过
+ assert by_symbol["600519.SH"]["所属概念"] == "白酒概念"
+
+
+def test_fanout_all_entries_failed_raises(mock_http) -> None:
+ mock_http(
+ _mock_transport(
+ {"cn_concept": _CATALOG_CN_CONCEPT},
+ _CONSTITUENTS_BY_CODE,
+ fail_codes=("B1.TI", "B2.TI"),
+ )
+ )
+ with pytest.raises(ValueError, match="全部条目第二级拉取失败"):
+ asyncio.run(fetch_fanout_rows(_concept_config()))
+
+
+def test_fanout_empty_catalog_raises(mock_http) -> None:
+ mock_http(_mock_transport({"cn_concept": []}, {}))
+ with pytest.raises(ValueError, match="第一级返回 0 个可用条目"):
+ asyncio.run(fetch_fanout_rows(_concept_config()))
+
+
+def test_fanout_dedupes_repeated_labels(mock_http) -> None:
+ """同一标的在同一类别下重复出现 → 只记一次。"""
+ mock_http(
+ _mock_transport(
+ {"cn_concept": [{"thscode": "B1.TI", "name": "白酒概念"}]},
+ {
+ "B1.TI": [
+ {"thscode": "600519.SH", "name": "贵州茅台"},
+ {"thscode": "600519.SH", "name": "贵州茅台"},
+ ]
+ },
+ )
+ )
+ rows = asyncio.run(fetch_fanout_rows(_concept_config()))
+ assert rows[0]["所属概念"] == "白酒概念"
+
+
+# ---- 端到端落盘 (通用写入链) ----
+
+
+def test_fanout_rows_persist_via_generic_writer(
+ tmp_path: Path, mock_http
+) -> None:
+ mock_http(_mock_transport({"cn_concept": _CATALOG_CN_CONCEPT}, _CONSTITUENTS_BY_CODE))
+ config = _concept_config()
+
+ rows = asyncio.run(fetch_fanout_rows(config))
+ n = rows_to_parquet(rows, config, tmp_path)
+ assert n == 3
+
+ out = tmp_path / "ext_data" / "ext_gn_ths" / "part.parquet"
+ assert out.exists(), "快照 parquet 未落盘"
+
+ df = pl.read_parquet(out)
+ row = df.filter(pl.col("symbol") == "600519.SH").to_dicts()[0]
+ assert row["所属概念"] == "白酒概念;新能源汽车"
+ assert row["股票简称"] == "贵州茅台"
+ assert row["code"] == "600519"
+
+
+def test_fanout_industry_separator(mock_http) -> None:
+ """行业配方用 "-" 拼接层级, 概念用 ";": 同一引擎, 差别只在配方。"""
+ mock_http(
+ _mock_transport(
+ {"industry": [{"thscode": "H1.TI", "name": "金融"}]},
+ {"H1.TI": [{"thscode": "600519.SH", "name": "贵州茅台"}]},
+ )
+ )
+ config = _concept_config(
+ url_tag="industry", value_col="所属同花顺行业", separator="-"
+ )
+ rows = asyncio.run(fetch_fanout_rows(config))
+ assert rows[0]["所属同花顺行业"] == "金融"
+
+
+def test_pull_config_roundtrip_preserves_fanout(tmp_path: Path) -> None:
+ """fanout 随 config.json 持久化与读回, 不丢字段。"""
+ config = _concept_config()
+ store = ExtConfigStore(tmp_path)
+ store.upsert(config)
+
+ loaded = ExtConfigStore(tmp_path).get("ext_gn_ths")
+ assert loaded is not None and loaded.pull is not None
+ assert loaded.pull.fanout == config.pull.fanout
diff --git a/backend/tests/test_ext_pages.py b/backend/tests/test_ext_pages.py
new file mode 100644
index 00000000..6e0350bf
--- /dev/null
+++ b/backend/tests/test_ext_pages.py
@@ -0,0 +1,138 @@
+"""运行时页面包 API 测试 — 上传校验/生命周期/bundle 分发。
+
+TestClient 最小 app 风格(同 test_ext_market_level): 不起完整后端,
+app.state.repo 只提供 store.data_dir。
+"""
+from __future__ import annotations
+
+import io
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from starlette.datastructures import UploadFile
+
+from app.api.ext_pages import router
+
+
+@pytest.fixture
+def client(tmp_path: Path) -> TestClient:
+ app = FastAPI()
+ app.include_router(router)
+ app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
+ return TestClient(app)
+
+
+def _bundle_js() -> bytes:
+ # 最小合法 ES 模块产物: default 导出 FrontendExtension 形状
+ return (
+ "const extension = { id: 'demo.page', apiVersion: 1, "
+ "routes: [{ id: 'demo-page', path: '/demo-page', component: () => null }], "
+ "navigation: [{ id: 'demo-page', routeId: 'demo-page', label: '演示页', icon: null }] };\n"
+ "export default extension;\n"
+ ).encode()
+
+
+def _upload(client: TestClient, pack_id: str, data: bytes, filename: str = "extension.js"):
+ return client.post(
+ "/api/ext-pages/upload",
+ data={"id": pack_id},
+ files={"file": (filename, io.BytesIO(data), "text/javascript")},
+ )
+
+
+def test_upload_and_lifecycle(client: TestClient, tmp_path: Path):
+ r = _upload(client, "demo_page", _bundle_js())
+ assert r.status_code == 200, r.text
+ meta = r.json()
+ assert meta["id"] == "demo_page"
+ assert meta["enabled"] is False # 上传后未验证, 默认停用
+ assert meta["size"] == len(_bundle_js()) and len(meta["sha256"]) == 64
+ assert (tmp_path / "ext_pages" / "demo_page" / "bundle.js").exists()
+
+ # 清单回填 → 启用
+ r = client.put(
+ "/api/ext-pages/demo_page/manifest",
+ json={"label": "演示页", "path": "/demo-page", "error": ""},
+ )
+ assert r.status_code == 200 and r.json()["enabled"] is True
+
+ # 列表
+ r = client.get("/api/ext-pages")
+ assert [m["id"] for m in r.json()] == ["demo_page"]
+
+ # 分发: MIME + no-store + ETag
+ r = client.get("/api/ext-pages/demo_page/bundle.js")
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("text/javascript")
+ assert r.headers["cache-control"] == "no-store"
+ assert "etag" in r.headers
+ assert b"export default" in r.content
+
+ # 停用 / 删除
+ assert client.put("/api/ext-pages/demo_page/enabled", json={"enabled": False}).json()["enabled"] is False
+ assert client.delete("/api/ext-pages/demo_page").json() == {"ok": True}
+ assert client.get("/api/ext-pages/demo_page/bundle.js").status_code == 404
+
+
+def test_upload_rejects_bad_suffix_and_id(client: TestClient):
+ assert _upload(client, "x", b"a,b", "data.csv").status_code == 400
+ # 路径穿越/非法 ID 一律拒绝(空 ID 由 FastAPI 表单校验 422), 不得落盘
+ for bad in ("../evil", "a/b", "bad id", "x" * 0, ""):
+ assert _upload(client, bad, _bundle_js()).status_code in (400, 422)
+
+
+def test_upload_size_cap_413(client: TestClient):
+ big = b"//" + b"x" * (5 * 1024 * 1024 + 10)
+ assert _upload(client, "big", big).status_code == 413
+
+
+def test_upload_empty_file_400(client: TestClient):
+ assert _upload(client, "empty", b"").status_code == 400
+
+
+def test_upload_overwrites_same_id(client: TestClient):
+ _upload(client, "same", _bundle_js())
+ v2 = _bundle_js() + b"// v2\n"
+ _upload(client, "same", v2)
+ r = client.get("/api/ext-pages/same/bundle.js")
+ assert r.content == v2 # 覆盖式更新
+
+
+def test_manifest_error_disables(client: TestClient):
+ _upload(client, "bad", _bundle_js())
+ r = client.put(
+ "/api/ext-pages/bad/manifest",
+ json={"label": "", "path": "", "error": "apiVersion 不匹配"},
+ )
+ assert r.json()["enabled"] is False and r.json()["error"] == "apiVersion 不匹配"
+
+
+def test_missing_pack_404(client: TestClient):
+ assert client.get("/api/ext-pages/none/bundle.js").status_code == 404
+ assert client.put("/api/ext-pages/none/enabled", json={"enabled": True}).status_code == 404
+
+
+def test_write_bundle_capped_stops_reading(tmp_path: Path):
+ """越限立即停止读取: 计数流在 413 后不应被继续消费。"""
+ import asyncio
+
+ from app.api.ext_pages import _write_bundle_capped
+
+ class CountingStream:
+ def __init__(self, data: bytes):
+ self._buf = io.BytesIO(data)
+ self.reads = 0
+
+ # UploadFile 经 run_in_threadpool 同步调用 file.read, 这里保持同步签名
+ def read(self, n: int = -1) -> bytes:
+ self.reads += 1
+ return self._buf.read(n)
+
+ stream = CountingStream(b"x" * 200)
+ with pytest.raises(Exception) as exc:
+ asyncio.run(_write_bundle_capped(UploadFile(stream), tmp_path / "dummy.bin", max_bytes=64))
+ assert getattr(exc.value, "status_code", None) == 413
+ assert stream.reads == 1 # 超限块读出后不再继续读
diff --git a/backend/tests/test_ext_preset_dimension_values.py b/backend/tests/test_ext_preset_dimension_values.py
index 82133a31..a18ccf20 100644
--- a/backend/tests/test_ext_preset_dimension_values.py
+++ b/backend/tests/test_ext_preset_dimension_values.py
@@ -1,28 +1,12 @@
+"""维度值清洗口径。
+
+类别名里的 NaN/None 占位值清洗已随 fanout 迁移到 ext_fanout.clean_label
+(见 test_ext_fanout.py); 此处保留消费侧的维度值清洗测试。
+"""
from __future__ import annotations
-from app.services.ext_presets import _flatten_concept_rows, _flatten_industry_rows
from app.services.market_overview_builder import _dimension_values
-def test_concept_flatten_drops_missing_value_placeholders():
- rows = _flatten_concept_rows([{
- "symbol": "600000.SH",
- "name": "浦发银行",
- "concepts": ["银行", "nan", None, float("nan"), " null ", "金融科技"],
- }])
-
- assert rows[0]["所属概念"] == "银行;金融科技"
-
-
-def test_industry_flatten_drops_missing_value_placeholders():
- rows = _flatten_industry_rows([{
- "symbol": "600000.SH",
- "name": "浦发银行",
- "industries": ["金融", "None", "银行"],
- }])
-
- assert rows[0]["所属同花顺行业"] == "金融-银行"
-
-
def test_overview_dimension_values_ignore_legacy_nan_group():
assert _dimension_values("人工智能;nan;芯片;NULL") == ["人工智能", "芯片"]
diff --git a/backend/tests/test_ext_presets_startup.py b/backend/tests/test_ext_presets_startup.py
index 49a142a5..7da5cc3f 100644
--- a/backend/tests/test_ext_presets_startup.py
+++ b/backend/tests/test_ext_presets_startup.py
@@ -1,20 +1,22 @@
-"""内置概念/行业 preset 启动不得自动拉取 (#199)。
+"""内置概念/行业 preset 启动契约。
-ensure_builtin_presets 的契约是「只创建 config.json, 不拉取数据, 等待用户手动获取」;
-但 preset 出厂 PullConfig.enabled=True 会让 PullScheduler.refresh 在启动时立即调度
-_run_loop 并马上执行一次网络拉取 (启用后立即执行一次), 与契约矛盾。
+出厂即带【完整可用的拉取配方】(URL / 鉴权 / 两级请求 fanout), 不是空壳 —
+「数据」页展开表 → 拉取 就能看到一份真实可跑的配方, 供接入自有源照抄。
-回归断言: 内置 preset 出厂 pull.enabled 必须为 False —— scheduler 的 enabled 过滤
-(ext_pull.refresh) 会因此跳过它们; 手动获取走 fetch_preset 独立路径, 不经过本开关
-(该路径行为由 test_ext_preset_dimension_values / test_ext_pull_refresh 覆盖)。
+ensure_builtin_presets 的契约:
+ - 只创建/补齐 config.json, 不拉取数据 (数据由调度器或用户手动触发)
+ - 空壳 / 历代出厂配方 → 补齐/替换为当前配方
+ - 用户自定义过 pull (URL 非出厂值) 或 description → 一律不碰
+ - 失败只记 warning, 不阻断启动
"""
from __future__ import annotations
import asyncio
from pathlib import Path
-from app.services.ext_data import ExtConfigStore
+from app.services.ext_data import ExtConfigStore, PullConfig
from app.services.ext_presets import (
+ _RETIRED_FACTORY_PULLS,
_concept_preset,
_industry_preset,
ensure_builtin_presets,
@@ -23,37 +25,117 @@ from app.services.ext_presets import (
_PRESET_IDS = ("ext_gn_ths", "ext_hy_ths")
-def test_builtin_presets_ship_disabled() -> None:
- """出厂 preset 的 pull.enabled 必须为 False, 否则启动即网络拉取 (#199)。"""
+def test_builtin_presets_ship_with_complete_pull() -> None:
+ """出厂 preset 自带完整拉取配方: URL / 鉴权 / 两级请求一应俱全。"""
for preset in (_concept_preset(), _industry_preset()):
- assert preset.pull is not None
- assert preset.pull.url, "禁用归禁用, 手动获取仍需 url 配方"
- assert preset.pull.enabled is False, f"{preset.id} 启动即自动拉取, 违反启动契约"
+ pull = preset.pull
+ assert pull is not None, f"{preset.id} 出厂未带拉取配方"
+ assert pull.url.startswith("https://"), f"{preset.id} 缺少第一级 URL"
+ assert pull.response_path, f"{preset.id} 缺少响应路径"
+ assert pull.auth and pull.auth.get("type") == "header", f"{preset.id} 缺少鉴权配置"
+ # 凭据复用 fuyao 行情插件, 用户只需在一处填 Key
+ assert pull.auth.get("key_ref") == "fuyao_api_key", f"{preset.id} 未复用 fuyao 凭据"
+
+ fanout = pull.fanout
+ assert fanout, f"{preset.id} 缺少两级拉取配方"
+ assert fanout["url"].startswith("https://"), f"{preset.id} fanout 缺少第二级 URL"
+ for key in ("param", "id_field", "member_id_field",
+ "symbol_col", "name_col", "value_col"):
+ assert fanout.get(key), f"{preset.id} fanout 缺少 {key}"
+ assert fanout.get("separator"), f"{preset.id} fanout 缺少拼接符"
-def test_ensure_builtin_presets_writes_disabled_configs(tmp_path: Path) -> None:
- """全新数据目录: 启动只落禁用的 pull 配置, scheduler 扫描后无任务可建。"""
+def test_ensure_builtin_presets_writes_complete_configs(tmp_path: Path) -> None:
+ """全新数据目录: 启动即落完整配方, 两张表都带 fanout。"""
asyncio.run(ensure_builtin_presets(tmp_path))
store = ExtConfigStore(tmp_path)
for cid in _PRESET_IDS:
config = store.get(cid)
assert config is not None, f"{cid} 配置未创建"
- assert config.pull is not None
- assert config.pull.enabled is False
+ assert config.pull is not None, f"{cid} 出厂配方未写入"
+ assert config.pull.url, f"{cid} 第一级 URL 为空"
+ assert config.pull.fanout, f"{cid} 两级配方未写入"
-def test_ensure_builtin_presets_keeps_existing_user_config(tmp_path: Path) -> None:
- """老用户/已存在的配置一律不动: 即使保留 enabled=True 也不被覆盖。"""
+def test_ensure_builtin_presets_fills_empty_shell(tmp_path: Path) -> None:
+ """空壳配置 (无 pull, #199 之后的形态) → 补齐为完整配方。"""
asyncio.run(ensure_builtin_presets(tmp_path))
store = ExtConfigStore(tmp_path)
config = store.get("ext_gn_ths")
- assert config is not None and config.pull is not None
- config.pull.enabled = True
+ assert config is not None
+ config.pull = None
store.upsert(config)
asyncio.run(ensure_builtin_presets(tmp_path))
refreshed = ExtConfigStore(tmp_path).get("ext_gn_ths")
assert refreshed is not None and refreshed.pull is not None
- assert refreshed.pull.enabled is True, "已存在配置被静默改写, 违反「绝不覆盖」原则"
+ assert refreshed.pull.fanout, "空壳配置未被补齐为完整配方"
+
+
+def test_ensure_builtin_presets_keeps_custom_user_pull(tmp_path: Path) -> None:
+ """用户自定义过 pull (URL 不等于历代出厂值) → 一律不动, 即使 enabled=True。"""
+ asyncio.run(ensure_builtin_presets(tmp_path))
+ store = ExtConfigStore(tmp_path)
+ config = store.get("ext_gn_ths")
+ assert config is not None
+
+ config.pull = PullConfig(
+ url="https://example.com/my-own-concepts", enabled=True
+ )
+ store.upsert(config)
+
+ asyncio.run(ensure_builtin_presets(tmp_path))
+
+ refreshed = ExtConfigStore(tmp_path).get("ext_gn_ths")
+ assert refreshed is not None and refreshed.pull is not None
+ assert refreshed.pull.url == "https://example.com/my-own-concepts"
+ assert refreshed.pull.enabled is True, "自定义拉取配置被静默改写, 违反「绝不覆盖」原则"
+
+
+def test_ensure_builtin_presets_replaces_retired_factory_pulls(tmp_path: Path) -> None:
+ """pull.url 命中历代废弃出厂值 → 替换为带 fanout 的当前配方。"""
+ asyncio.run(ensure_builtin_presets(tmp_path))
+ store = ExtConfigStore(tmp_path)
+
+ for cid in _PRESET_IDS:
+ config = store.get(cid)
+ assert config is not None
+ for retired in _RETIRED_FACTORY_PULLS[cid]:
+ config.pull = PullConfig(url=retired["url"], enabled=True)
+ store.upsert(config)
+
+ asyncio.run(ensure_builtin_presets(tmp_path))
+
+ refreshed = ExtConfigStore(tmp_path).get(cid)
+ assert refreshed is not None and refreshed.pull is not None
+ assert refreshed.pull.fanout, (
+ f"{cid} 旧出厂配方 {retired['url']} 未替换为带 fanout 的当前配方"
+ )
+
+
+def test_ensure_builtin_presets_syncs_retired_factory_description(tmp_path: Path) -> None:
+ """description 命中出厂前缀 → 同步为当前文案; 用户自定义描述不动。"""
+ asyncio.run(ensure_builtin_presets(tmp_path))
+ store = ExtConfigStore(tmp_path)
+
+ config = store.get("ext_gn_ths")
+ assert config is not None
+ old_factory_desc = "同花顺概念分类 (首次启动自动拉取, 可在扩展数据页手动更新)"
+ config.description = old_factory_desc
+ store.upsert(config)
+
+ asyncio.run(ensure_builtin_presets(tmp_path))
+
+ refreshed = ExtConfigStore(tmp_path).get("ext_gn_ths")
+ assert refreshed is not None
+ assert refreshed.description == _concept_preset().description, "旧出厂文案未同步"
+
+ # 用户自定义描述 (非出厂前缀) → 不动
+ refreshed.description = "我自己的概念表, 别动"
+ store.upsert(refreshed)
+ asyncio.run(ensure_builtin_presets(tmp_path))
+ final = ExtConfigStore(tmp_path).get("ext_gn_ths")
+ assert final is not None
+ assert final.description == "我自己的概念表, 别动"
diff --git a/backend/tests/test_packaging_spec.py b/backend/tests/test_packaging_spec.py
index 2f867135..2f80a05f 100644
--- a/backend/tests/test_packaging_spec.py
+++ b/backend/tests/test_packaging_spec.py
@@ -29,3 +29,11 @@ def test_spec_declares_builtin_plugin_fuyao():
"""fuyao 插件双声明不被回归 (同类动态发现问题, 见 spec 内注释)。"""
assert "app.plugins.fuyao.provider" in SPEC_CODE
assert "app/plugins/fuyao" in SPEC_CODE
+
+
+def test_spec_declares_builtin_plugin_tdx():
+ """tdx 插件双声明不被回归 (与 fuyao 同一处理方式; probe 子进程入口也要可导入)。"""
+ assert "app.plugins.tdxquant.provider" in SPEC_CODE
+ assert "app.plugins.tdxquant.client" in SPEC_CODE
+ assert "app.plugins.tdxquant.probe" in SPEC_CODE
+ assert '"plugins" / "tdxquant" / "plugin.yaml"' in SPEC_CODE # 源路径必须指向现存目录
diff --git a/backend/tests/test_resolve_universe_index.py b/backend/tests/test_resolve_universe_index.py
index ef27d0e2..a64492e3 100644
--- a/backend/tests/test_resolve_universe_index.py
+++ b/backend/tests/test_resolve_universe_index.py
@@ -1,4 +1,5 @@
-"""_resolve_universe 指数过滤测试。"""
+"""_resolve_universe 维表单一来源 + 指数过滤测试。"""
+import polars as pl
import pytest
from app.jobs import daily_pipeline
@@ -10,20 +11,59 @@ def repo(tmp_path):
return KlineRepository(DataStore(tmp_path))
+class _Capset:
+ def __init__(self, has_batch: bool = False):
+ self._has_batch = has_batch
+
+ def has(self, cap):
+ return self._has_batch
+
+
+def _write_instruments(tmp_path, symbols):
+ out = tmp_path / "instruments" / "instruments.parquet"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ pl.DataFrame({"symbol": symbols}).write_parquet(out)
+
+
def test_resolve_universe_excludes_index_symbols(repo, monkeypatch, tmp_path):
"""自选里的指数不进入股票日K/分钟K同步池。"""
- class _Capset:
- def has(self, cap):
- return False
-
+ _write_instruments(tmp_path, ["600000.SH"])
monkeypatch.setattr(
daily_pipeline, "get_pool",
lambda name, refresh=False: ["600000.SH", "000001.SH"] if name == "watchlist" else [],
)
- monkeypatch.setattr(daily_pipeline, "DEMO_SYMBOLS", [])
monkeypatch.setattr(daily_pipeline.settings, "data_dir", tmp_path)
monkeypatch.setattr(repo, "get_index_symbol_set", lambda: {"000001.SH"})
universe = daily_pipeline._resolve_universe(_Capset(), repo)
assert "600000.SH" in universe
assert "000001.SH" not in universe
+
+
+def test_resolve_universe_never_fetches_pools(repo, monkeypatch, tmp_path):
+ """维表单一来源: 即使有 batch 能力也不拉 CN_Equity_A 池 (SW1 兜底随之消失)。"""
+ def _guard(name, refresh=False):
+ if name != "watchlist":
+ raise AssertionError(f"不得拉取标的池: {name}")
+ return ["600000.SH"]
+
+ _write_instruments(tmp_path, ["000001.SZ"])
+ monkeypatch.setattr(daily_pipeline, "get_pool", _guard)
+ monkeypatch.setattr(daily_pipeline.settings, "data_dir", tmp_path)
+ monkeypatch.setattr(repo, "get_index_symbol_set", lambda: set())
+
+ universe = daily_pipeline._resolve_universe(_Capset(has_batch=True), repo)
+ assert universe == ["000001.SZ", "600000.SH"]
+
+
+def test_resolve_universe_watchlist_only_when_no_table(repo, monkeypatch, tmp_path):
+ """维表缺失时不伪造标的: 池=自选, 宁缺毋假。"""
+ monkeypatch.setattr(
+ daily_pipeline, "get_pool",
+ lambda name, refresh=False: ["600000.SH"] if name == "watchlist" else [],
+ )
+ monkeypatch.setattr(daily_pipeline.settings, "data_dir", tmp_path)
+ monkeypatch.setattr(repo, "get_index_symbol_set", lambda: set())
+
+ universe = daily_pipeline._resolve_universe(_Capset(), repo)
+ assert universe == ["600000.SH"]
diff --git a/backend/tests/test_secrets_external_edit.py b/backend/tests/test_secrets_external_edit.py
new file mode 100644
index 00000000..2e3a13d8
--- /dev/null
+++ b/backend/tests/test_secrets_external_edit.py
@@ -0,0 +1,72 @@
+"""secrets.json 作为统一配置源的外部编辑契约。
+
+用户可以直接用编辑器修改 secrets.json (AI + 数据源 Key 同文件), 保存后
+下一次请求即读取最新内容 —— 不需要重启服务。这里锁定三条:
+① file_path() 指向 data/user_data/secrets.json;
+② 外部改写文件后 load()/get_ai_config() 立刻反映新值 (mtime+size 签名缓存失效);
+③ GET /api/settings 暴露 secrets_path 供设置页展示。
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from app import secrets_store
+
+
+@pytest.fixture()
+def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ from app.config import settings
+
+ monkeypatch.setattr(settings, "data_dir", tmp_path, raising=False)
+ yield tmp_path
+
+
+def test_file_path_points_to_user_data_secrets(data_dir: Path) -> None:
+ assert secrets_store.file_path() == data_dir / "user_data" / "secrets.json"
+
+
+def test_external_edit_is_picked_up_without_restart(data_dir: Path) -> None:
+ secrets_store.save({"ai_model": "old-model", "ai_base_url": "https://old.example/v1"})
+ assert secrets_store.get_ai_config("ai_model") == "old-model"
+
+ # 模拟用户编辑器直接覆写文件 (绕过 secrets_store.save)。
+ # 内容长度与 mtime 都变化, 签名缓存必然失效。
+ path = data_dir / "user_data" / "secrets.json"
+ path.write_text(json.dumps({
+ "ai_model": "brand-new-model",
+ "ai_base_url": "https://new.example/v1",
+ "ai_api_key": "sk-new",
+ }, indent=2), encoding="utf-8")
+
+ assert secrets_store.get_ai_config("ai_model") == "brand-new-model"
+ assert secrets_store.get_ai_config("ai_base_url") == "https://new.example/v1"
+ assert secrets_store.get_ai_key() == "sk-new"
+
+
+def test_external_edit_with_same_size_still_reloads(data_dir: Path) -> None:
+ """同长度改写: 签名含 mtime_ns, 不能因 size 相同而吃到旧缓存。"""
+ import os
+ import time
+
+ path = data_dir / "user_data" / "secrets.json"
+ secrets_store.save({"ai_model": "model-aaaa"})
+ assert secrets_store.get_ai_config("ai_model") == "model-aaaa"
+
+ path.write_text(json.dumps({"ai_model": "model-bbbb"}), encoding="utf-8")
+ # 强制 mtime 前进 (文件系统时间戳粒度可能吞掉快速连续写入)
+ future = time.time() + 5
+ os.utime(path, (future, future))
+
+ assert secrets_store.get_ai_config("ai_model") == "model-bbbb"
+
+
+def test_get_settings_exposes_secrets_path(data_dir: Path) -> None:
+ from app.api import settings as settings_api
+
+ payload = settings_api.get_settings()
+
+ assert payload["secrets_path"].endswith("secrets.json")
+ assert str(data_dir) in payload["secrets_path"] or Path(payload["secrets_path"]).name == "secrets.json"
diff --git a/backend/tests/test_sponsor_models.py b/backend/tests/test_sponsor_models.py
new file mode 100644
index 00000000..7c490db7
--- /dev/null
+++ b/backend/tests/test_sponsor_models.py
@@ -0,0 +1,134 @@
+"""sponsor-models 代理端点的精简字段契约测试。
+
+上游 /v1/models 原始体量大 (144KB, 含分层 tiers/dimensions 等细粒度定价);
+端点只透传前端展示需要的 id / context_length / discount_rate / 首档单价,
+并以 models 纯 id 列表保持向后兼容。
+"""
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from app.api import settings as settings_api
+
+
+def _fake_upstream(payload: dict):
+ """把 httpx.AsyncClient 换成返回固定 /models 响应的伪实现。"""
+
+ class _Resp:
+ status_code = 200
+
+ def raise_for_status(self):
+ return None
+
+ def json(self):
+ return payload
+
+ class _Client:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ async def get(self, url):
+ assert url == "https://llm.runninghub.ai/v1/models"
+ return _Resp()
+
+ return _Client
+
+
+@pytest.mark.asyncio
+async def test_sponsor_models_returns_trimmed_pricing(monkeypatch):
+ payload = {
+ "object": "list",
+ "data": [
+ {
+ "id": "deepseek/deepseek-v4.1-flash",
+ "object": "model",
+ "owned_by": "runninghub",
+ "context_length": 1048576,
+ "pricing": {
+ "billingType": "TOKEN_INPUT_TIERED",
+ "discountRate": 0.6,
+ "input": {"amount": 0.000295, "unit": "USD/1K", "tiers": []},
+ "output": {"amount": 0.001177, "unit": "USD/1K", "tiers": []},
+ "dimensions": {"cache_read": {"amount": 0.00001}},
+ },
+ },
+ {
+ "id": "openai/gpt-6.1-sol",
+ "object": "model",
+ "context_length": 1050000,
+ "pricing": {"discountRate": 1.0, "input": {"amount": 0.002}, "output": {"amount": 0.01}},
+ },
+ {"id": "", "object": "model"}, # 空 id 剔除
+ {"object": "model"}, # 无 id 剔除
+ "garbage", # 非 dict 剔除
+ ],
+ }
+ monkeypatch.setattr(httpx, "AsyncClient", _fake_upstream(payload))
+
+ result = await settings_api.list_sponsor_models()
+
+ assert result["models"] == ["deepseek/deepseek-v4.1-flash", "openai/gpt-6.1-sol"]
+ assert result["items"] == [
+ {
+ "id": "deepseek/deepseek-v4.1-flash",
+ "context_length": 1048576,
+ "discount_rate": 0.6,
+ "input_price": 0.000295,
+ "output_price": 0.001177,
+ },
+ {
+ "id": "openai/gpt-6.1-sol",
+ "context_length": 1050000,
+ "discount_rate": 1.0,
+ "input_price": 0.002,
+ "output_price": 0.01,
+ },
+ ]
+
+
+@pytest.mark.asyncio
+async def test_sponsor_models_tolerates_missing_pricing(monkeypatch):
+ """个别模型无 pricing 块: discount_rate 回退 1.0(无折扣), 价格为 None。"""
+ payload = {"data": [{"id": "vendor/plain-model", "context_length": None}]}
+ monkeypatch.setattr(httpx, "AsyncClient", _fake_upstream(payload))
+
+ result = await settings_api.list_sponsor_models()
+
+ assert result["items"] == [{
+ "id": "vendor/plain-model",
+ "context_length": None,
+ "discount_rate": 1.0,
+ "input_price": None,
+ "output_price": None,
+ }]
+
+
+@pytest.mark.asyncio
+async def test_sponsor_models_maps_upstream_error_to_502(monkeypatch):
+ from fastapi import HTTPException
+
+ class _Broken:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ async def get(self, url):
+ raise httpx.ConnectError("boom")
+
+ monkeypatch.setattr(httpx, "AsyncClient", _Broken)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await settings_api.list_sponsor_models()
+ assert exc_info.value.status_code == 502
diff --git a/backend/tests/test_task_registry.py b/backend/tests/test_task_registry.py
new file mode 100644
index 00000000..e3e67754
--- /dev/null
+++ b/backend/tests/test_task_registry.py
@@ -0,0 +1,106 @@
+"""进行中任务注册表 (跨设备接回) 的服务与 API 测试。
+
+契约: upsert 幂等 (同 id 更新不新增)、容量上限、孤儿 running 回收、
+API 生命周期 (PUT/GET/DELETE) 与非法输入拒绝。存取均为小文件原子写。
+"""
+from __future__ import annotations
+
+import time
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.api import task_registry as task_registry_api
+from app.services import task_registry
+
+
+@pytest.fixture
+def client(tmp_path: Path) -> TestClient:
+ app = FastAPI()
+ app.include_router(task_registry_api.router)
+ app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
+ return TestClient(app)
+
+
+def test_upsert_idempotent_and_list_sorted(tmp_path: Path) -> None:
+ task_registry.upsert(tmp_path, "backtest:abc", kind="backtest", status="running", payload={"qs": "x=1"})
+ task_registry.upsert(
+ tmp_path, "backtest:abc", kind="backtest", status="done", payload={"qs": "x=1"}
+ )
+ task_registry.upsert(tmp_path, "mining:m1", kind="mining", status="running", payload={"run_id": "r1"})
+
+ items = task_registry.list_items(tmp_path)
+ assert [it["id"] for it in items] == ["mining:m1", "backtest:abc"] # updated_at 倒序
+ assert items[1]["status"] == "done" # 同 id 更新而非新增
+ assert items[1]["started_at"] > 0
+
+ only_bt = task_registry.list_items(tmp_path, kind="backtest")
+ assert [it["id"] for it in only_bt] == ["backtest:abc"]
+
+
+def test_capacity_trim_keeps_newest(tmp_path: Path) -> None:
+ for i in range(task_registry.MAX_ITEMS + 5):
+ task_registry.upsert(
+ tmp_path, f"backtest:t{i}", kind="backtest", status="done"
+ )
+ time.sleep(0.001) # 保证 updated_at 严格递增
+ items = task_registry.list_items(tmp_path)
+ assert len(items) == task_registry.MAX_ITEMS
+ assert items[-1]["id"] == "backtest:t5" # t0..t4 最旧的被裁掉
+ assert items[0]["id"] == f"backtest:t{task_registry.MAX_ITEMS + 4}"
+
+
+def test_orphan_running_pruned_on_read(tmp_path: Path) -> None:
+ import json
+
+ task_registry.upsert(tmp_path, "backtest:old", kind="backtest", status="running")
+ # 直接改文件把 updated_at 拨到 25h 前 (模拟进程重启遗留的孤儿 running)
+ p = tmp_path / "user_data" / task_registry._FILENAME
+ doc = json.loads(p.read_text(encoding="utf-8"))
+ doc["items"][0]["updated_at"] = time.time() - (task_registry.ORPHAN_RUNNING_SECONDS + 3600)
+ p.write_text(json.dumps(doc), encoding="utf-8")
+
+ items = task_registry.list_items(tmp_path)
+ assert items == [], "孤儿 running 项应在读取时被回收"
+ # 回收已落盘: 重复读不再触发
+ assert task_registry.list_items(tmp_path) == []
+
+
+def test_api_lifecycle(client: TestClient) -> None:
+ assert client.get("/api/task-registry").json() == {"items": []}
+
+ r = client.put(
+ "/api/task-registry/backtest:k1",
+ json={"kind": "backtest", "status": "running", "label": "demo", "payload": {"qs": "a=1"}},
+ )
+ assert r.status_code == 200 and r.json()["item"]["id"] == "backtest:k1"
+
+ listed = client.get("/api/task-registry", params={"kind": "backtest"}).json()["items"]
+ assert len(listed) == 1 and listed[0]["label"] == "demo"
+
+ r = client.put(
+ "/api/task-registry/backtest:k1",
+ json={"kind": "backtest", "status": "done", "payload": {"qs": "a=1"}},
+ )
+ assert r.status_code == 200
+ assert client.get("/api/task-registry").json()["items"][0]["status"] == "done"
+
+ assert client.delete("/api/task-registry/backtest:k1").json()["removed"] is True
+ assert client.get("/api/task-registry").json()["items"] == []
+ assert client.delete("/api/task-registry/backtest:k1").json()["removed"] is False
+
+
+def test_api_rejects_bad_input(client: TestClient) -> None:
+ assert client.put(
+ "/api/task-registry/bad id!", json={"kind": "backtest", "status": "running"}
+ ).status_code == 400 # id 含非法字符
+ assert client.put(
+ "/api/task-registry/backtest:x", json={"kind": "nope", "status": "running"}
+ ).status_code == 400 # 未知 kind
+ assert client.put(
+ "/api/task-registry/backtest:x", json={"kind": "backtest", "status": "weird"}
+ ).status_code == 400 # 未知 status
+ assert client.get("/api/task-registry", params={"kind": "nope"}).status_code == 400
diff --git a/backend/tests/test_tdxquant_provider.py b/backend/tests/test_tdxquant_provider.py
new file mode 100644
index 00000000..744398a5
--- /dev/null
+++ b/backend/tests/test_tdxquant_provider.py
@@ -0,0 +1,625 @@
+"""TdxQuantProvider 契约与单位标准化测试。
+
+不依赖真实网络与 tdxaidata 安装: 用假 TdxQuantClient 返回样例快照/K线/除权事件,
+验证字段映射、单位口径 (CONTRIBUTING §3: 万元→元、股→手、每10股→每股、
+小数制涨跌幅)、lib 物化与 token 注入、重试语义、能力声明与设置页试拉。
+"""
+
+from __future__ import annotations
+
+import math
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from types import SimpleNamespace
+from zoneinfo import ZoneInfo
+
+import pytest
+
+from app.plugins.tdxquant import client as tc
+from app.plugins.tdxquant import provider as tp
+from app.plugins.tdxquant.provider import TdxQuantProvider
+
+_BEIJING = ZoneInfo("Asia/Shanghai")
+
+
+# ---------------------------------------------------------------- fakes
+class _FakeClient:
+ """按测试需要预置返回值; 记录调用供批大小/参数断言。"""
+
+ def __init__(self):
+ self.universe: list[str] = []
+ self.universe_error: Exception | None = None
+ self.snapshot_results: list[object] = [] # 每次 snapshot_batch 弹出一个(dict 或 Exception)
+ self.snapshot_calls: list[list[str]] = []
+ self.his: dict[str, object] = {} # code -> raw dict | Exception
+ self.his_default: object = {}
+ self.his_calls: list[dict] = []
+ self.events: dict[str, object] = {} # code -> list[dict] | Exception
+ self.event_calls: list[dict] = []
+
+ def close(self) -> None:
+ pass
+
+ def stock_universe(self) -> list[str]:
+ if self.universe_error:
+ raise self.universe_error
+ return list(self.universe)
+
+ def snapshot_batch(self, codes):
+ self.snapshot_calls.append(list(codes))
+ if not self.snapshot_results:
+ return {}
+ item = self.snapshot_results.pop(0)
+ if isinstance(item, Exception):
+ raise item
+ return item
+
+ def his_dats(self, code, end_d, count, period="1d"):
+ self.his_calls.append({"code": code, "end": end_d, "count": count, "period": period})
+ item = self.his.get(code, self.his_default)
+ if isinstance(item, Exception):
+ raise item
+ return item
+
+ def divid_factors(self, code, start_d=None, end_d=None):
+ self.event_calls.append({"code": code, "start": start_d, "end": end_d})
+ item = self.events.get(code, [])
+ if isinstance(item, Exception):
+ raise item
+ return item
+
+
+def _provider(fake: _FakeClient) -> TdxQuantProvider:
+ p = TdxQuantProvider()
+ p._client = fake
+ return p
+
+
+def _snap_row(**over) -> dict:
+ row = {
+ "Now": "1263.00", "LastClose": "1255.79", "Open": "1255.42",
+ "Max": "1282.00", "Min": "1255.42", "Volume": "35110",
+ "Amount": "445322.34", "RefreshTime": "153017",
+ "Buyp": ["1263.00", "1262.99"], "Buyv": ["73", "2"],
+ "Sellp": ["1263.06", "1263.11"], "Sellv": ["1", "3"],
+ }
+ row.update(over)
+ return row
+
+
+# ---------------------------------------------------------------- client: lib 物化
+def _make_source_lib(root: Path) -> Path:
+ src = root / "src_lib"
+ src.mkdir()
+ (src / "TdxAiData.dll").write_bytes(b"FAKE_DLL_BYTES")
+ (src / "libTdxAiData.so").write_bytes(b"FAKE_SO_BYTES")
+ (src / "libTdxAiData.dylib").write_bytes(b"FAKE_DYLIB_BYTES") # macOS
+ (src / "TdxAiData.ini").write_text(
+ "[TcHqHost]\nHostNum=2\nPrimaryHost=0\n\n[Token]\ntoken=OLD\n", encoding="utf-8"
+ )
+ (src / "user.ini").write_text("[Token]\ntoken=OLD\n", encoding="utf-8")
+ return src
+
+
+def test_materialize_lib_writes_token_into_both_ini(tmp_path):
+ src = _make_source_lib(tmp_path)
+ lib_root = tmp_path / "tdxquant_lib"
+ dll = tc.materialize_lib("TDX-NEW-KEY", lib_root, source=src)
+ assert dll.name == tc.LIB_DLL_NAME
+ assert (lib_root / tc.LIB_DLL_NAME).exists()
+ for ini_name in ("TdxAiData.ini", "user.ini"):
+ text = (lib_root / ini_name).read_text(encoding="utf-8")
+ assert "token=TDX-NEW-KEY" in text
+ assert "token=OLD" not in text
+ # 其它配置段保留
+ assert "[TcHqHost]" in (lib_root / "TdxAiData.ini").read_text(encoding="utf-8")
+ assert "HostNum=2" in (lib_root / "TdxAiData.ini").read_text(encoding="utf-8")
+
+
+def test_materialize_lib_marker_skips_recopy(tmp_path, monkeypatch):
+ src = _make_source_lib(tmp_path)
+ lib_root = tmp_path / "tdxquant_lib"
+ tc.materialize_lib("TDX-KEY-A", lib_root, source=src)
+
+ copies = []
+ real_copy = tc.shutil.copy2
+
+ def _spy(src_p, dst_p):
+ copies.append(str(src_p))
+ return real_copy(src_p, dst_p)
+
+ monkeypatch.setattr(tc.shutil, "copy2", _spy)
+ tc.materialize_lib("TDX-KEY-A", lib_root, source=src) # 同 Key 同 DLL → 复用
+ assert copies == []
+ tc.materialize_lib("TDX-KEY-B", lib_root, source=src) # 换 Key → 重物化
+ assert copies
+ assert "token=TDX-KEY-B" in (lib_root / "user.ini").read_text(encoding="utf-8")
+
+
+def test_write_token_ini_adds_missing_section(tmp_path):
+ ini = tmp_path / "user.ini"
+ ini.write_text("[Other]\nfoo=1\n", encoding="utf-8")
+ tc.write_token_ini(ini, "K1")
+ text = ini.read_text(encoding="utf-8")
+ assert "token=K1" in text and "[Other]" in text and "foo=1" in text
+
+
+# ---------------------------------------------------------------- client: 重试语义
+def _client_with_raw(monkeypatch, returns: list[object], sleep_log: list[float]):
+ c = object.__new__(tc.TdxQuantClient)
+ c.api_key = "k"
+ state = {"i": 0}
+
+ def _raw_value(_method, *args):
+ item = returns[min(state["i"], len(returns) - 1)]
+ state["i"] += 1
+ if isinstance(item, Exception):
+ raise item
+ return item
+
+ c._tqs = SimpleNamespace(_clean=lambda raw: raw)
+ c._native_cls = SimpleNamespace(get_instance=lambda: SimpleNamespace(get_his_dats=_raw_value))
+ c._universe, c._universe_at = [], 0.0
+ monkeypatch.setattr(tc.time, "sleep", lambda s: sleep_log.append(s))
+ return c
+
+
+def test_raw_retries_transient_exception(monkeypatch):
+ sleeps: list[float] = []
+ c = _client_with_raw(
+ monkeypatch,
+ [RuntimeError("Invalid server response"), {"Date": ["20260101"]}],
+ sleeps,
+ )
+ out = c.his_dats("600519.SH", date(2026, 1, 10), 5)
+ assert out["Date"] == ["20260101"]
+ assert sleeps # 失败后有退避
+
+
+def test_raw_gives_up_on_persistent_none(monkeypatch):
+ sleeps: list[float] = []
+ c = _client_with_raw(monkeypatch, [None, None], sleeps)
+ with pytest.raises(tc.TdxQuantError):
+ c.his_dats("600519.SH", date(2026, 1, 10), 5)
+
+
+def test_snapshot_batch_retries_transient_failure(monkeypatch):
+ sleeps: list[float] = []
+ c = _client_with_raw(monkeypatch, [], sleeps)
+ state = {"i": 0}
+
+ def _snap(stock_list=None, field_list=None):
+ state["i"] += 1
+ if state["i"] == 1:
+ raise RuntimeError("Invalid server response")
+ return {"600519.SH": {"Now": "10.0"}}
+
+ c._tqs = type("T", (), {"get_market_snapshot_batch": staticmethod(_snap)})()
+ out = c.snapshot_batch(["600519.SH"])
+ assert out == {"600519.SH": {"Now": "10.0"}}
+ assert state["i"] == 2 and sleeps
+
+
+def test_raw_retries_none_then_data(monkeypatch):
+ sleeps: list[float] = []
+ c = _client_with_raw(monkeypatch, [None, {"Date": ["20260101"]}], sleeps)
+ assert c.his_dats("600519.SH", date(2026, 1, 10), 5)["Date"] == ["20260101"]
+
+
+# ---------------------------------------------------------------- provider: 快照映射
+class _FixedDT(datetime):
+ @classmethod
+ def now(cls, tz=None):
+ return cls(2026, 10, 11, 15, 0, 0, tzinfo=tz)
+
+
+def _map_row(monkeypatch, **over) -> dict:
+ monkeypatch.setattr(tp, "datetime", _FixedDT)
+ return tp._map_snapshot_row("600519.SH", _snap_row(**over), 999)
+
+
+def test_snapshot_row_units(monkeypatch):
+ rec = _map_row(monkeypatch)
+ assert rec["symbol"] == "600519.SH"
+ assert rec["last_price"] == 1263.00
+ assert rec["prev_close"] == 1255.79
+ assert rec["high"] == 1282.00 and rec["low"] == 1255.42 # Max/Min → high/low
+ assert rec["volume"] == 35110 # 快照已为手, 零换算
+ assert rec["amount"] == pytest.approx(445322.34 * 10_000) # 万元 → 元
+ # 涨跌幅小数制: (1263.00-1255.79)/1255.79
+ assert rec["change_pct"] == pytest.approx((1263.00 - 1255.79) / 1255.79)
+ assert rec["change_amount"] == pytest.approx(1263.00 - 1255.79)
+ assert rec["name"] is None and rec["turnover_rate"] is None # 不伪造
+ # RefreshTime 北京墙钟 → epoch ms
+ expect = datetime(2026, 10, 11, 15, 30, 17, tzinfo=_BEIJING)
+ assert rec["timestamp"] == int(expect.timestamp() * 1000)
+
+
+def test_snapshot_row_bad_refresh_time_falls_back(monkeypatch):
+ monkeypatch.setattr(tp, "datetime", _FixedDT)
+ rec = tp._map_snapshot_row("600519.SH", _snap_row(RefreshTime="99:99"), 123456)
+ assert rec["timestamp"] == 123456
+
+
+def test_compact_date_accepts_iso_and_compact():
+ assert tp._compact_date("20260924") == date(2026, 9, 24)
+ assert tp._compact_date("2024-12-20 00:00:00") == date(2024, 12, 20) # DataFrame 归一形态
+ assert tp._compact_date(None) is None
+ assert tp._compact_date("bad") is None
+
+
+def test_snapshot_row_dropped_without_prices(monkeypatch):
+ monkeypatch.setattr(tp, "datetime", _FixedDT)
+ assert tp._map_snapshot_row("600519.SH", _snap_row(Now=None, LastClose=None), 1) is None
+
+
+# ---------------------------------------------------------------- provider: realtime
+def test_realtime_batches_and_merges(monkeypatch):
+ monkeypatch.setattr(tp, "datetime", _FixedDT)
+ fake = _FakeClient()
+ fake.universe = [f"60000{i}.SH" for i in range(tp._SNAPSHOT_BATCH)] + ["000001.SZ", "000002.SZ"]
+ fake.snapshot_results = [
+ {"600000.SH": _snap_row()},
+ {"000001.SZ": _snap_row(Now="11.59", LastClose="11.78")},
+ ]
+ records = _provider(fake).get_realtime()
+ assert [len(c) for c in fake.snapshot_calls] == [tp._SNAPSHOT_BATCH, 2] # 600 只/批
+ assert len(records) == 2
+ assert {r["symbol"] for r in records} == {"600000.SH", "000001.SZ"}
+
+
+def test_realtime_soft_fail_on_universe_error():
+ fake = _FakeClient()
+ fake.universe_error = tc.TdxQuantError("get_stock_list(上证主板): boom")
+ assert _provider(fake).get_realtime() == []
+
+
+def test_realtime_isolates_failed_batch(monkeypatch):
+ monkeypatch.setattr(tp, "datetime", _FixedDT)
+ fake = _FakeClient()
+ fake.universe = [f"6000{i:03d}.SH" for i in range(tp._SNAPSHOT_BATCH)] + ["000001.SZ"]
+ fake.snapshot_results = [
+ tc.TdxQuantError("snapshot_batch(600 只): invalid"), # 首批失败被隔离
+ {"000001.SZ": _snap_row(Now="11.59", LastClose="11.78")},
+ ]
+ records = _provider(fake).get_realtime()
+ assert [r["symbol"] for r in records] == ["000001.SZ"]
+
+
+def test_realtime_indices_maps_sh_index_code(monkeypatch):
+ monkeypatch.setattr(tp, "datetime", _FixedDT)
+ fake = _FakeClient()
+ fake.snapshot_results = [{"999999.SH": _snap_row(Now="3813.79", LastClose="3811.90")}]
+ records = _provider(fake).get_realtime_indices(["000001.SH", "399001.SZ"])
+ assert fake.snapshot_calls == [["999999.SH", "399001.SZ"]] # 000001.SH → 999999.SH
+ assert records and records[0]["last_price"] == 3813.79
+
+
+def test_realtime_indices_none_on_failure():
+ fake = _FakeClient()
+ fake.snapshot_results = [tc.TdxQuantError("snapshot_batch(1 只): down")]
+ assert _provider(fake).get_realtime_indices(["000001.SH"]) is None
+
+
+# ---------------------------------------------------------------- provider: daily
+def _his_raw(*, dates=("20260102", "20260105"), volume="3123935.00", amount="386731.09") -> dict:
+ return {
+ "Date": list(dates), "Time": ["0"] * len(dates),
+ "Open": ["10.0"] * len(dates), "High": ["11.0"] * len(dates),
+ "Low": ["9.5"] * len(dates), "Close": ["10.5"] * len(dates),
+ "Volume": [volume] * len(dates), "Amount": [amount] * len(dates),
+ }
+
+
+def test_daily_units_and_window_filter():
+ fake = _FakeClient()
+ fake.his["600519.SH"] = _his_raw(dates=("20251231", "20260102", "20260105", "20260106"))
+ df = _provider(fake).get_daily(
+ ["600519.SH"],
+ datetime(2026, 1, 1), datetime(2026, 1, 5),
+ )
+ assert df["date"].to_list() == [date(2026, 1, 2), date(2026, 1, 5)] # 窗口过滤
+ row = df.row(0, named=True)
+ assert row["close"] == 10.5
+ assert row["volume"] == math.floor(3123935.0 / 100) # 股 → 手
+ assert row["amount"] == pytest.approx(386731.09 * 10_000) # 万元 → 元
+ call = fake.his_calls[0]
+ assert call["period"] == "1d"
+ assert call["count"] >= 30 # count 模式根数下限
+
+
+def test_daily_etf_asset_type_unsupported_reports_empty():
+ fake = _FakeClient()
+ chunks = list(
+ _provider(fake).iter_daily(
+ ["510300.SH"], datetime(2026, 1, 1), datetime(2026, 1, 31), asset_type="etf"
+ )
+ )
+ assert chunks == []
+
+
+def test_daily_symbol_error_isolated_but_iteration_continues():
+ fake = _FakeClient()
+ fake.his["600000.SH"] = tc.TdxQuantError("get_his_dats: invalid")
+ fake.his["600001.SH"] = _his_raw()
+ done: list[tuple[int, int]] = []
+ df = _provider(fake).get_daily(
+ ["600000.SH", "600001.SH"], datetime(2025, 12, 1), datetime(2026, 1, 31),
+ on_chunk_done=lambda cur, total: done.append((cur, total)),
+ )
+ assert df.height == 2 # 只有 600001 的两根
+ assert done == [(1, 2), (2, 2)] # 空批次也推进度
+
+
+# ---------------------------------------------------------------- provider: adj_factor
+def _closes_raw(pairs: list[tuple[str, float]]) -> dict:
+ return {
+ "Date": [d for d, _ in pairs], "Close": [str(c) for _, c in pairs],
+ "Open": ["1"] * len(pairs), "High": ["1"] * len(pairs), "Low": ["1"] * len(pairs),
+ "Volume": ["100"] * len(pairs), "Amount": ["1"] * len(pairs),
+ }
+
+
+def test_adj_factor_per10_to_per_share_and_formula():
+ # 10派30.876元 → 每股 D=3.0876; P=30.876 → ref=(30.876-3.0876)/1=27.7884 → 27.79
+ fake = _FakeClient()
+ fake.events["600519.SH"] = [
+ {"Date": "20240619", "Type": "1", "Bonus": 30.876, "AllotPrice": 0.0,
+ "ShareBonus": 0.0, "Allotment": 0.0},
+ ]
+ fake.his["600519.SH"] = _closes_raw([("20240618", 30.876), ("20240619", 28.0), ("20240620", 28.5)])
+ df = _provider(fake).get_adj_factors(
+ ["600519.SH"], datetime(2024, 1, 1), datetime(2024, 12, 31)
+ )
+ assert df.height == 1
+ row = df.row(0, named=True)
+ assert row["trade_date"] == date(2024, 6, 19)
+ assert row["dividend"] == pytest.approx(3.0876) # 每 10 股 → 每股
+ assert row["prev_close"] == 30.876
+ ref = math.floor((30.876 - 3.0876) * 100 + 0.5) / 100 # half-up 两位
+ assert row["ex_factor"] == pytest.approx(30.876 / ref)
+ # 涨跌停自检: 复权后除权日涨跌 28.0/(30.876/factor)-1 ≈ 0.1% 在板限内 → 保留
+
+
+def test_adj_factor_limit_check_drops_bad_factor():
+ # 异常事件: 分红与价格严重不匹配 → 复权后除权日涨跌幅超板限 → 自检剔除
+ fake = _FakeClient()
+ fake.events["600519.SH"] = [
+ {"Date": "20240619", "Type": "1", "Bonus": 200.0, "AllotPrice": 0.0,
+ "ShareBonus": 0.0, "Allotment": 0.0},
+ ]
+ fake.his["600519.SH"] = _closes_raw([("20240618", 30.876), ("20240619", 28.0)])
+ df = _provider(fake).get_adj_factors(
+ ["600519.SH"], datetime(2024, 1, 1), datetime(2024, 12, 31)
+ )
+ assert df.is_empty()
+
+
+def test_adj_factor_future_event_without_prev_close_skipped():
+ fake = _FakeClient()
+ fake.events["600519.SH"] = [
+ {"Date": "20991231", "Type": "1", "Bonus": 30.876, "AllotPrice": 0.0,
+ "ShareBonus": 0.0, "Allotment": 0.0},
+ ]
+ fake.his["600519.SH"] = _closes_raw([("20240618", 30.876)])
+ df = _provider(fake).get_adj_factors(
+ ["600519.SH"], datetime(2024, 1, 1), datetime(2026, 12, 31)
+ )
+ assert df.is_empty() # 无除权日前收盘 → 留给滚动增量
+
+
+def test_adj_factor_schema_columns():
+ # 真实量级事件: P=10, 10派2 + 10送1 + 10配2(配股价5) → 参考价 8.31, 除权日收盘 8.35
+ fake = _FakeClient()
+ fake.events["600000.SH"] = [
+ {"Date": "20240619", "Type": "1", "Bonus": 2.0, "AllotPrice": 5.0,
+ "ShareBonus": 1.0, "Allotment": 2.0},
+ ]
+ fake.his["600000.SH"] = _closes_raw([("20240618", 10.0), ("20240619", 8.35)])
+ df = _provider(fake).get_adj_factors(
+ ["600000.SH"], datetime(2024, 1, 1), datetime(2024, 12, 31)
+ )
+ assert df.columns == list(tp._ADJ_SCHEMA)
+ row = df.row(0, named=True)
+ assert row["dividend"] == pytest.approx(0.2) # 10派2 → 每股 0.2
+ assert row["bonus"] == pytest.approx(0.1) # 10送1 → 每股 0.1
+ assert row["allot"] == pytest.approx(0.2) # 10配2 → 每股 0.2
+ assert row["allot_price"] == 5.0
+
+
+# ---------------------------------------------------------------- provider: depth5
+def test_depth_batch_maps_five_levels(monkeypatch):
+ monkeypatch.setattr(tp, "datetime", _FixedDT)
+ fake = _FakeClient()
+ fake.snapshot_results = [{
+ "600519.SH": _snap_row(),
+ "600000.SH": _snap_row(Buyp=None, Sellp=None), # 无五档 → 整标的省略
+ }]
+ out = _provider(fake).get_depth_batch(["600519.SH", "600000.SH"])
+ assert set(out) == {"600519.SH"}
+ entry = out["600519.SH"]
+ assert entry["bid_prices"] == [1263.00, 1262.99]
+ assert entry["bid_volumes"] == [73, 2] # 手, 透传为 int
+ assert entry["ask_prices"] == [1263.06, 1263.11]
+ assert entry["ask_volumes"] == [1, 3]
+ expect = datetime(2026, 10, 11, 15, 30, 17, tzinfo=_BEIJING)
+ assert entry["timestamp"] == int(expect.timestamp() * 1000)
+
+
+# ---------------------------------------------------------------- availability / 能力
+def test_availability_missing_module(monkeypatch):
+ monkeypatch.setattr(tp, "_module_available", lambda: False)
+ ok, reason = tp.availability()
+ assert not ok and "tdxaidata" in reason
+
+
+def test_availability_missing_key(monkeypatch):
+ monkeypatch.setattr(tp, "_module_available", lambda: True)
+ monkeypatch.setattr(tp, "get_api_key", lambda: "")
+ ok, reason = tp.availability()
+ assert not ok and tp.API_KEY_ENV in reason
+
+
+def test_availability_ok(monkeypatch):
+ monkeypatch.setattr(tp, "_module_available", lambda: True)
+ monkeypatch.setattr(tp, "get_api_key", lambda: "TDX-xxx")
+ assert tp.availability() == (True, "ok")
+
+
+def test_datasets_declaration():
+ p = TdxQuantProvider()
+ assert set(p.config.datasets) == {"realtime", "depth5", "daily", "adj_factor", "minute", "full_minute"}
+ assert p.name == "tdxquant" and p.config.builtin is True
+ for absent in ("financial",):
+ assert absent not in p.config.datasets # 未声明 → provider_has_dataset False → 回退 tickflow
+
+
+# ---------------------------------------------------------------- minute / full_minute
+def _minute_raw(rows: list[tuple[str, str, float, float, float, float, float, float]]) -> dict:
+ """(Date, Time, O, H, L, C, Volume股, Amount万元) → 原生 1m 列式 dict。"""
+ return {
+ "Date": [r[0] for r in rows], "Time": [r[1] for r in rows],
+ "Open": [r[2] for r in rows], "High": [r[3] for r in rows],
+ "Low": [r[4] for r in rows], "Close": [r[5] for r in rows],
+ "Volume": [r[6] for r in rows], "Amount": [r[7] for r in rows],
+ }
+
+
+def test_minute_units_and_wallclock_datetime():
+ fake = _FakeClient()
+ fake.his = {"600519.SH": _minute_raw([
+ ("20261009", "093100", 1255.0, 1256.0, 1254.9, 1255.5, 123_400.0, 15_500.0),
+ ("20261009", "150000", 1263.0, 1263.0, 1263.0, 1263.0, 38_600.00, 4869.62),
+ ])}
+ df = _provider(fake).get_minute(
+ ["600519.SH"], datetime(2026, 10, 8), datetime(2026, 10, 9, 23, 59)
+ )
+ assert fake.his_calls[0]["period"] == "1m"
+ assert df.height == 2
+ rows = df.sort("datetime").to_dicts()
+ assert rows[0]["datetime"] == datetime(2026, 10, 9, 9, 31, 0) # 北京墙钟 naive
+ assert rows[1]["datetime"] == datetime(2026, 10, 9, 15, 0, 0)
+ assert rows[0]["volume"] == 1234.0 # 股 → floor(/100) 手
+ assert rows[1]["volume"] == 386.0
+ assert rows[1]["amount"] == pytest.approx(48_696_200.0) # 万元 → 元
+
+
+def test_minute_window_filter_and_gap_skip():
+ fake = _FakeClient()
+ fake.his = {"600519.SH": _minute_raw([
+ ("20261008", "150000", 1250.0, 1250.0, 1250.0, 1250.0, 100_000.0, 12_500.0), # 窗口外
+ ("20261009", "093100", 1255.0, 1256.0, 1254.9, 1255.5, 123_400.0, 15_500.0),
+ ("20261009", "093200", None, None, None, None, None, None), # 停牌缺口
+ ])}
+ df = _provider(fake).get_minute(
+ ["600519.SH"], datetime(2026, 10, 9), datetime(2026, 10, 9, 23, 59)
+ )
+ assert df.height == 1
+ assert df.to_dicts()[0]["datetime"] == datetime(2026, 10, 9, 9, 31, 0)
+
+
+def test_minute_freq_and_asset_guards():
+ fake = _FakeClient()
+ p = _provider(fake)
+ assert p.get_minute(["600519.SH"], None, datetime.now(), freq="5m").is_empty()
+ assert p.get_minute(["510300.SH"], None, datetime.now(), asset_type="etf").is_empty()
+ assert fake.his_calls == [] # 未发起取数
+
+
+def test_minute_transport_error_soft():
+ fake = _FakeClient()
+ fake.his = {"600519.SH": tc.TdxQuantError("boom")}
+ df = _provider(fake).get_minute(["600519.SH"], None, datetime.now())
+ assert df.is_empty() # 软失败不阻断
+
+
+def test_get_intraday_batch_today_only():
+ fake = _FakeClient()
+ today = date.today().strftime("%Y%m%d")
+ yday = (date.today() - timedelta(days=1)).strftime("%Y%m%d")
+ fake.his = {"600519.SH": _minute_raw([
+ (yday, "150000", 1250.0, 1250.0, 1250.0, 1250.0, 100_000.0, 12_500.0), # 前日余量 → 过滤
+ (today, "093100", 1255.0, 1256.0, 1254.9, 1255.5, 123_400.0, 15_500.0),
+ (today, "093200", 1255.5, 1257.0, 1255.0, 1256.0, 88_800.0, 11_160.0),
+ ])}
+ df = _provider(fake).get_intraday_batch(["600519.SH"])
+ assert df.height == 2 # 只保留今日行
+ assert fake.his_calls[0]["period"] == "1m"
+
+
+def test_get_intraday_latest_universe_default():
+ fake = _FakeClient()
+ fake.universe = ["600000.SH", "000001.SZ"]
+ today = date.today().strftime("%Y%m%d")
+ for sym in fake.universe:
+ fake.his[sym] = _minute_raw([
+ (today, "093100", 10.0, 10.1, 9.9, 10.0, 50_000.0, 500.0),
+ (today, "093200", 10.0, 10.1, 9.9, 10.0, 50_000.0, 500.0),
+ ])
+ df = _provider(fake).get_intraday_latest(count=3)
+ assert df.height == 4 # 每只 2 根
+ assert set(df["symbol"].to_list()) == {"600000.SH", "000001.SZ"}
+ assert all(c["count"] <= 30 for c in fake.his_calls)
+
+
+def test_test_dataset_minute():
+ fake = _FakeClient()
+ today = date.today().strftime("%Y%m%d")
+ fake.his = {"600519.SH": _minute_raw([
+ (today, "093100", 1255.0, 1256.0, 1254.9, 1255.5, 123_400.0, 15_500.0),
+ ])}
+ out = _provider(fake).test_dataset("minute", ["600519.SH"])
+ assert out["rows"] == 1
+ assert "datetime" in out["columns"]
+
+
+# ---------------------------------------------------------------- provider: 试拉
+def test_test_dataset_realtime(monkeypatch):
+ monkeypatch.setattr(tp, "datetime", _FixedDT)
+ fake = _FakeClient()
+ fake.snapshot_results = [{"600519.SH": _snap_row()}]
+ out = _provider(fake).test_dataset("realtime")
+ assert out["rows"] == 1 and out["preview"][0]["symbol"] == "600519.SH"
+
+
+def test_test_dataset_unsupported_dataset():
+ out = _provider(_FakeClient()).test_dataset("financial")
+ assert "error" in out and "回退" in out["error"]
+
+
+# ---------------------------------------------------------------- loader 集成
+def test_loader_manifest_parses_and_entry_importable():
+ from importlib import import_module
+
+ from app.data_providers.custom import loader
+
+ manifest = loader.plugin_manifest("tdxquant")
+ assert manifest is not None
+ assert manifest["name"] == "tdxquant"
+ assert manifest["display_name"] == "TdxQuant (TdxAiData)"
+ assert set(manifest["datasets"]) == {"realtime", "depth5", "daily", "adj_factor", "minute", "full_minute"}
+ # 计费标识与 Key/积分管理页 (设置页积分角标 + 商城入口的契约来源)
+ assert manifest["billing"] == "points"
+ assert manifest["key_manage_url"].startswith("https://vip.tdx.com.cn/")
+ # 计费提示与使用要点 (设置页警示框 + 详情列表的契约来源)
+ assert "积分" in manifest["billing_note"] and "间隔" in manifest["billing_note"]
+ assert len(manifest["usage_notes"]) >= 4
+ assert any("快照" in n for n in manifest["usage_notes"])
+ module_path, _, class_name = manifest["entry"].partition(":")
+ provider_cls = getattr(import_module(module_path), class_name)
+ assert provider_cls.name == "tdxquant"
+
+
+def test_loader_probe_plugin_key_dispatch(monkeypatch):
+ from app.data_providers.custom import loader
+
+ monkeypatch.setattr(tp, "probe_api_key", lambda key: (key == "good", "ok" if key == "good" else "bad"))
+ assert loader.probe_plugin_key("tdxquant", "good") == (True, "ok")
+ assert loader.probe_plugin_key("tdxquant", "bad") == (False, "bad")
+
+
+def test_close_resets_client():
+ fake = _FakeClient()
+ p = _provider(fake)
+ p.close()
+ assert p._client is None
diff --git a/backend/uv.lock b/backend/uv.lock
index d28384b7..5acd6df2 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -2482,6 +2482,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" },
]
+[[package]]
+name = "tdxaidata"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/a8/3b1a4341769c081b3bdbc4e6c9991ac05c49585e256965cd9402f066daa6/tdxaidata-1.2.2.tar.gz", hash = "sha256:794011131704a7827746b89da490e43345ddd442644d6100890d39063485171e", size = 4430612, upload-time = "2026-09-30T10:08:12.158Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/c1/29b840db422aa32c87baedf2d402ccb8cbba8964d33caff3b20e5b3ddad4/tdxaidata-1.2.2-py3-none-any.whl", hash = "sha256:ceaf0be18cb54cd197e79cca5590d4ba7d7c3620b899e818d472d032f2c33849", size = 4443080, upload-time = "2026-09-30T10:08:07.807Z" },
+]
+
[[package]]
name = "threadpoolctl"
version = "3.6.0"
@@ -2538,6 +2547,7 @@ dependencies = [
{ name = "python-multipart" },
{ name = "pyyaml" },
{ name = "sse-starlette" },
+ { name = "tdxaidata" },
{ name = "tickflow", extra = ["all"] },
{ name = "uvicorn", extra = ["standard"] },
{ name = "winotify", marker = "sys_platform == 'win32'" },
@@ -2590,6 +2600,7 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" },
{ name = "sse-starlette", specifier = ">=2.0" },
+ { name = "tdxaidata", specifier = ">=1.2.2" },
{ name = "tickflow", extras = ["all"], specifier = ">=0.1.23" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
{ name = "vectorbt", marker = "extra == 'backtest'", specifier = ">=0.26" },
diff --git a/docs/configuration.md b/docs/configuration.md
index 13f52708..d34b7715 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -64,6 +64,7 @@ AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限
| 配置项 | 说明 |
| :--- | :--- |
| `AI_PROVIDER` | `openai_compat`(OpenAI 兼容,支持 DeepSeek / 通义 / OpenAI 等)或 `ollama`(本地模型) |
+| `AI_PROTOCOL` | 请求协议: `chat_completions`(默认, 兼容面最广)或 `responses`(OpenAI 官方新协议, DeepSeek 官方亦已支持); 仅 openai 系 provider 生效, 也可在设置页切换 |
| `AI_BASE_URL` | 接口地址,如 DeepSeek `https://api.deepseek.com/v1` |
| `AI_API_KEY` | 留空则关闭 AI 功能 |
| `AI_MODEL` | 模型名,如 `deepseek-chat` |
diff --git a/docs/factor-system-design.md b/docs/factor-system-design.md
index 1791df3a..10d16efe 100644
--- a/docs/factor-system-design.md
+++ b/docs/factor-system-design.md
@@ -804,7 +804,7 @@ researchUniverses()
| price_limits.py:87-101 | numpy_limit_pct_vectors(当前名推断) | ✓ |
| capabilities.py:44-45 | 复权口径一致性"不做路由耦合"注释 | ✓ |
| index_const.py:12-15 | 核心四只代码(000001.SH/399001.SZ/399006.SZ/000680.SH) | ✓ |
-| daily_pipeline.py:92-125 | _resolve_universe(CN_Equity_A 当前池) | ✓ |
+| daily_pipeline.py | _resolve_universe(instruments 维表 + 自选,无池兜底) | ✓ |
| kline_sync.py:358-367 | (symbol, trade_date) 去重 keep=last 原子合并 | ✓ |
| scoring.py:13-51 / 53-66 / 91-104 / 108+ | VIRTUAL 依赖 35 项 / 预热表 / 依赖展开与预热推导 / scoring_value_expr | ✓(全文读取,附录A 由其逐字推导) |
| monitor.py:1314-1322 | score_min/max 消费 result.scores | ✓ |
diff --git a/docs/features.md b/docs/features.md
index 3cdab61c..757f6400 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -161,6 +161,10 @@
## 🧰 数据与扩展
+### 页面包(运行时扩展页面)
+
+页面可以打包成**单文件 ES 模块产物**(`frontend/page-pack-template` 一键构建),在「设置 → 页面包」上传即挂载为完整页面 — 路由、侧栏入口、菜单管理自动注册,**宿主无需重新构建**;本地 dev / 生产 / 桌面端一致生效。上传时即时试载校验契约(清单格式、路由冲突),坏包停用并显示原因;启停删除随时管理,刷新后生效。源码级扩展(`frontend/src/custom/`)与声明式分析页面仍是随构建的另外两条扩展路径。契约与安全边界见 [page-sdk.md](./page-sdk.md)。
+
### 数据源插件化
内置 [TickFlow](https://tickflow.org) 官方 SDK(日 K / 分钟 K / 指数 / 财务 / 实时行情),同时支持接入第三方数据源:YAML 声明自有 HTTP 接口,或开发插件源。多个数据集(日K / 除权因子 / 实时行情 / 分钟K / 五档盘口 / 财务,注册表持续扩展)按源声明自由组合,在「设置 → 数据源」切换。
@@ -195,6 +199,14 @@ timeseries 模式的表 (如人气排行) 默认只能从开启拉取之日起
数据量大的接口 (全市场排行、批量快照) 单次响应装不下时, 拉取配置可声明分页 (仅 GET): **页码参数名**(`page_param`, 如 `page`)、**每页条数参数名**(`page_size_param`, 如 `pageSize`)与**每页条数**(`page_size`)。配置后单次拉取按页循环请求并合并结果, 空页/不足一页自动判停; **最多页数**(`max_pages`, 默认 20, 上限 200)防止接口永远返回数据拖死循环。POST body 分页不支持 (请求体是固定模板)。
+##### 两级拉取协议 (目录 → 逐条目)
+
+有些数据源只提供「类别清单」+「单类别成员」两个端点 (同花顺概念/行业、多数板块类 API), 没有一次返回「标的 → 所属类别」全量映射的接口。这类源在拉取配置里声明 `fanout` 配方即可, 无需任何按表硬编码: 先请求 `pull.url` 拿第一级目录, 再按 `fanout.param` 并发拉每个条目的成员, 最后**反转为「标的 → 类别清单」**并与普通单请求路径产出同构的行。
+
+配方字段 (除注明外均为字符串): `url` 第二级地址、`param` 第二级查询参数名、`id_field` 第一级条目中作为该参数值的字段、`label_field` 展示名字段 (可选, 仅用于告警)、`items_path` 第二级响应的成员数组 dot-path、`member_id_field` / `member_label_field` 成员里的代码与名称字段、`symbol_col` / `name_col` / `value_col` 输出列名、`separator` 多类别拼接符 (概念用 `;`, 行业层级用 `-`)、`concurrency` 并发上限 (默认 8)、`retries` 单条目失败重试次数 (默认 1, 用尽则跳过该条目并记 warning; 全部条目失败才报错)。鉴权与标识头沿用 `pull.auth` / 通用出站口径。
+
+概念/行业两张内置表出厂即带完整配方, 在「数据」页展开本表 → **拉取** 可查看全部字段, 接入自有板块类源时照抄即可。
+
##### `/rows` 查询接口 (面向二次开发)
每张扩展表天然是标准 HTTP 接口 `GET /api/ext-data/{id}/rows`, 供页面之外的任意消费方调用:
@@ -303,4 +315,4 @@ AI 客户端 ⇄ MCP stdio (薄桥) ⇄ HTTP + Bearer Token ⇄ 开放网关
### 桌面客户端版本清单
-发布流水线会在每个 Release 附带 `latest.json`(版本号、三平台下载地址、sha256),设置 → 系统设置 → 检查更新即基于它(优先 GitHub API)提示新版本。
+发布流水线会在每个 Release 附带 `latest.json`(版本号、三平台下载地址、sha256),设置 → 关于 → 检查更新即基于它(优先 GitHub API)提示新版本。
diff --git a/docs/page-sdk.md b/docs/page-sdk.md
new file mode 100644
index 00000000..66e494d4
--- /dev/null
+++ b/docs/page-sdk.md
@@ -0,0 +1,72 @@
+# 页面包开发指南 (Page Pack SDK v1)
+
+页面包是**运行时可挂载的页面产物**:把页面打包成单文件 ES 模块(`extension.js`),在「设置 → 页面包」上传,刷新后即成为完整页面——自动注册路由、侧栏入口,可在菜单设置里排序/隐藏。本地 dev、生产构建、桌面端三种运行形态一致生效,宿主无需重新构建。
+
+## 快速开始
+
+```bash
+cp -r frontend/page-pack-template my-pack && cd my-pack
+pnpm install && pnpm build # → dist/extension.js
+```
+
+打开宿主「设置 → 页面包」→ 填 ID(字母/数字/下划线)→ 选择 `dist/extension.js` → 上传并校验 → 刷新页面。
+
+## 产物契约 (apiVersion 1)
+
+产物必须 `export default` 一个清单对象:
+
+```tsx
+const extension = {
+ id: 'demo.hello', // 带命名空间且全局唯一; 冲突时整个包被拒
+ apiVersion: 1 as const,
+ routes: [{
+ id: 'hello',
+ path: '/demo-hello', // 非根绝对路径、静态(不支持 :param/通配), 不得覆盖核心或已有页面
+ mount: (el, ctx) => { // 渲染进容器; 返回清理函数(可选)
+ const root = createRoot(el)
+ root.render()
+ return () => root.unmount()
+ },
+ }],
+ navigation: [{
+ id: 'hello', routeId: 'hello', label: '页面包演示', icon: 'Sparkles',
+ }],
+}
+export default extension
+```
+
+校验与冲突规则由宿主注册表统一执行(与源码级扩展 `frontend/src/custom/` 同一机制);上传时即时试载校验,启动时再次校验,失败会停用该包并在面板显示原因。
+
+## 能力桥 ctx
+
+页面包**自包含**(React 等依赖由模板打进产物,与宿主不共享模块实例),宿主能力经 `ctx` 暴露:
+
+| 能力 | 说明 |
+| --- | --- |
+| `ctx.api` | 宿主完整 API 封装(同源 fetch、自动携带登录态、统一错误处理) |
+| `ctx.QK` | 集中查询键(若你自行使用 TanStack Query 可作 key 规范) |
+| `ctx.cn` | 样式类合并工具(clsx + tailwind-merge) |
+| `ctx.format` | 数值/日期格式化工具集 |
+
+**禁止** import 宿主内部模块(`@/lib/...` 等别名在产物构建中不可解析);数据请求一律经 `ctx.api`,不得自行拼后端 URL 绕过封装。
+
+## 图标白名单
+
+`navigation[].icon` 按名称解析,白名单:`Activity BarChart3 Bell BookOpen Brain Calendar Database Globe LineChart PieChart Shield Sparkles Star Table TrendingUp Zap`(缺省 `Sparkles`)。需要扩充时在 `frontend/src/extensions/runtime.tsx` 的 `ICON_ALLOWLIST` 增补。
+
+## 约束与边界
+
+- **样式**:宿主 Tailwind 按需编译,任意类名不保证存在——布局关键样式用内联样式;主题色用宿主 CSS 变量(如 `var(--accent)`)。
+- **路由集成**:页面挂在宿主路由容器内(`/你的路径` 可直达、可刷新),但包内不共享宿主 Router 上下文——需要跳转宿主页面时用 `window.location` 或自行包 `MemoryRouter`。
+- **生效方式**:上传/启停后**刷新页面生效**(挂载发生在启动引导窗口),不做热插拔。
+- **大小**:上限 5MB/包;自包含 React 后典型产物 60–150KB(minify + 可自行加 brotli/gzip 由后端协商)。
+- **安全**:页面包是任意代码,与登录会话同权运行——只上传自己或可信来源的产物;这是单用户产品的既定边界,第三方插件沙箱不在 v1 范围。
+- **版本**:宿主升级破坏契约时提升 `apiVersion` 并保留旧版兼容期;产物内 `apiVersion` 不匹配会被明确拒绝。
+
+## 与其他扩展方式的取舍
+
+| 方式 | 适用 | 见 |
+| --- | --- | --- |
+| 页面包(运行时产物) | 免重建挂载、独立分发 | 本文档 |
+| 源码级扩展 `frontend/src/custom//extension.tsx` | 随宿主一起构建维护、可用全部核心模块 | `docs/secondary-development.md` §3 |
+| 声明式分析页面(零代码) | 扩展数据 + 模板化列表/分组页 | 设置 → 扩展页面 |
diff --git a/docs/plugin-development.md b/docs/plugin-development.md
index ce53134f..e394b012 100644
--- a/docs/plugin-development.md
+++ b/docs/plugin-development.md
@@ -34,6 +34,11 @@ hidden: false # (可选)true = 已加载但对设置
description: "数据源描述"
install_hint: "pip install xxx" # 未装依赖时显示的安装提示
homepage: "https://example.com" # (可选)官网/申请地址, 显示在设置页 Key 配置说明中
+billing: points # (可选)计费标识: "points" → 设置页能力芯片/适配行标注「积分」
+billing_note: "消耗与频率/范围相关,间隔越短越高" # (可选)计费提示: 设置页按警示框展示
+usage_notes: # (可选)使用要点: 各能力规模/节奏, 详情卡以列表展示
+ - "实时快照: 600 只/批, 全市场一轮约 10 批"
+key_manage_url: "https://example.com/me" # (可选)Key/账务管理页, 显示在设置页 Key 配置区(如积分明细入口)
```
只声明真实提供的数据集;未声明的数据集 `provider_has_dataset` 返回 False,自动回退
@@ -313,6 +318,11 @@ uv run --extra dev python -m ruff check app/plugins// tests/test_= 0.8 && z <= 1.5) {
- document.documentElement.style.zoom = String(z)
- }
- } catch (e) { /* ignore */ }
+ var raw = localStorage.getItem('tf-page-zoom')
+ var z = raw == null ? 0.9 : JSON.parse(raw)
+ if (typeof z !== 'number' || z < 0.8 || z > 1.5) z = 0.9
+ document.documentElement.style.zoom = String(z)
+ } catch (e) {
+ document.documentElement.style.zoom = '0.9'
+ }
})()
diff --git a/frontend/package.json b/frontend/package.json
index 490915e3..add8f574 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
- "version": "0.3.5",
+ "version": "0.3.6",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/page-pack-template/README.md b/frontend/page-pack-template/README.md
new file mode 100644
index 00000000..d5cfa723
--- /dev/null
+++ b/frontend/page-pack-template/README.md
@@ -0,0 +1,25 @@
+# 页面包模板 (Page Pack Template)
+
+把一个页面打包成**单文件 ES 模块产物**(`dist/extension.js`),上传到运行中的面板(设置 → 页面包),刷新后即成为完整页面——自动注册路由与侧栏入口,可在菜单设置里排序/隐藏。本地 dev、Docker、桌面端一致生效,**宿主无需重新构建**。
+
+## 打包
+
+```bash
+pnpm install
+pnpm build # → dist/extension.js
+```
+
+产物自包含(React 等依赖由本模板打进 bundle,不与宿主共享模块实例),典型体积 60–150KB,上限 5MB。
+
+## 上传
+
+面板 **设置 → 页面包**:填 ID(字母/数字/下划线)→ 选择 `dist/extension.js` → 上传并校验(即时试载,清单不合法或路由冲突会被拒绝并显示原因)→ 启用 → 刷新页面。
+
+## 开发要点
+
+- `src/extension.tsx` — 产物清单(id / routes / navigation)与示例页面;`mount(el, ctx)` 渲染进容器,返回清理函数。
+- `src/tsp.ts` — 从 `window.__TSP_EXT__` 取宿主能力桥 `ctx`:`ctx.api`(宿主完整 API 封装)、`ctx.QK`、`ctx.cn`、`ctx.format`。
+- **禁止** import 宿主内部模块(`@/lib/...` 在产物构建中不可解析);数据请求一律经 `ctx.api`。
+- 样式:宿主 Tailwind 按需编译,任意类名不保证存在——布局关键样式用内联样式,主题色用宿主 CSS 变量(如 `var(--accent)`)。
+
+完整契约(apiVersion、图标白名单、路由规则、安全边界)见 [`docs/page-sdk.md`](../../docs/page-sdk.md)。
diff --git a/frontend/page-pack-template/package.json b/frontend/page-pack-template/package.json
new file mode 100644
index 00000000..08008568
--- /dev/null
+++ b/frontend/page-pack-template/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "tsp-page-pack",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "build": "vite build"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "^5.6.3",
+ "vite": "^5.4.11"
+ }
+}
diff --git a/frontend/page-pack-template/src/extension.tsx b/frontend/page-pack-template/src/extension.tsx
new file mode 100644
index 00000000..2cb67f01
--- /dev/null
+++ b/frontend/page-pack-template/src/extension.tsx
@@ -0,0 +1,86 @@
+/**
+ * 页面包示例 — 复制本模板改造你的页面。
+ *
+ * 产物契约(v1):
+ * - default 导出清单对象; apiVersion 固定 1
+ * - id 带命名空间 (如 demo.hello), 全局唯一, 与核心/其他扩展冲突会被拒
+ * - routes[].path 非根绝对路径且静态 (不支持 :param / 通配)
+ * - mount(el, ctx): 把页面渲染进容器, 返回清理函数(可选)
+ * - navigation[].icon 用白名单图标名 (Sparkles/Zap/Star/... 见 docs/page-sdk.md)
+ *
+ * 样式约束: 宿主 Tailwind 按需编译, 不保证任意类名存在 — 布局关键样式
+ * 用内联样式; 主题色可用宿主 CSS 变量 (如 var(--accent))。
+ */
+import { useEffect, useRef, useState } from 'react'
+import { createRoot } from 'react-dom/client'
+import { ctx } from './tsp'
+
+function HelloPage() {
+ const [count, setCount] = useState(0)
+ const [serverTime, setServerTime] = useState('')
+ const alive = useRef(true)
+
+ useEffect(() => {
+ // 经能力桥调用宿主 API (同源、自带登录态); 这里取服务端时间做演示
+ ctx.api?.health?.().then((h: { status?: string } | undefined) => {
+ if (alive.current) setServerTime(h?.status ? `服务状态: ${h.status}` : '')
+ }).catch(() => {})
+ return () => { alive.current = false }
+ }, [])
+
+ const card: React.CSSProperties = {
+ maxWidth: 640,
+ margin: '3rem auto',
+ padding: '2rem',
+ borderRadius: 16,
+ border: '1px solid rgba(125,125,125,0.25)',
+ background: 'rgba(125,125,125,0.06)',
+ fontFamily: 'inherit',
+ }
+ const button: React.CSSProperties = {
+ height: 36,
+ padding: '0 14px',
+ borderRadius: 10,
+ border: 'none',
+ background: 'var(--accent, #3b82f6)',
+ color: '#fff',
+ fontSize: 13,
+ cursor: 'pointer',
+ }
+
+ return (
+
+
+
页面包演示页
+
+ 这是一个自包含的运行时页面: 打包成单文件 ES 模块后上传到「设置 → 页面包」,
+ 刷新即出现在侧栏。{serverTime}
+
+
+
+
+ )
+}
+
+const extension = {
+ id: 'demo.hello',
+ apiVersion: 1 as const,
+ routes: [
+ {
+ id: 'hello',
+ path: '/demo-hello',
+ mount: (el: HTMLElement) => {
+ const root = createRoot(el)
+ root.render()
+ return () => root.unmount()
+ },
+ },
+ ],
+ navigation: [
+ { id: 'hello', routeId: 'hello', label: '页面包演示', icon: 'Sparkles' },
+ ],
+}
+
+export default extension
diff --git a/frontend/page-pack-template/src/tsp.ts b/frontend/page-pack-template/src/tsp.ts
new file mode 100644
index 00000000..d9ba8903
--- /dev/null
+++ b/frontend/page-pack-template/src/tsp.ts
@@ -0,0 +1,18 @@
+/**
+ * tsp.ts — 宿主能力桥读取。
+ *
+ * window.__TSP_EXT__ 由宿主 main.tsx 在引导时安装; 页面包经 mount(el, ctx)
+ * 收到的 ctx 即本对象。api 为宿主完整 API 封装(同源 fetch, 自带登录态),
+ * QK 为集中查询键, cn 为样式合并工具, format 为格式化工具集。
+ *
+ * 自包含说明: 页面包产物自带 React/ReactDOM (由本模板的 vite 构建打入),
+ * 与宿主不共享模块实例 —— 请勿 import 宿主内部模块, 只经由 ctx 取能力。
+ */
+type Bridge = {
+ api: any
+ QK: any
+ cn: (...inputs: any[]) => string
+ format: any
+}
+
+export const ctx = (window as unknown as { __TSP_EXT__: Bridge }).__TSP_EXT__
diff --git a/frontend/page-pack-template/vite.config.ts b/frontend/page-pack-template/vite.config.ts
new file mode 100644
index 00000000..949c907c
--- /dev/null
+++ b/frontend/page-pack-template/vite.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// 页面包构建: 单文件自包含 ES 模块 (react 等全部打入), 产物 dist/extension.js
+// 直接上传到宿主「设置 → 页面包」。不做代码分割 — 宿主按整个文件加载挂载。
+export default defineConfig({
+ plugins: [react()],
+ // lib 模式不会自动替换依赖里的 process.env.NODE_ENV, 浏览器无 process 全局
+ define: {
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ },
+ build: {
+ outDir: 'dist',
+ lib: {
+ entry: 'src/extension.tsx',
+ formats: ['es'],
+ },
+ rollupOptions: {
+ output: {
+ entryFileNames: 'extension.js',
+ chunkFileNames: '[name].js',
+ assetFileNames: '[name][extname]',
+ },
+ },
+ },
+})
diff --git a/frontend/public/donate-wechat.jpg b/frontend/public/donate-wechat.jpg
new file mode 100644
index 00000000..70cb1349
Binary files /dev/null and b/frontend/public/donate-wechat.jpg differ
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index d1b689ae..c910ad8c 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -21,6 +21,7 @@ import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
+import { compareByDefaultNav, defaultNavIndex, seedHiddenForNewUser } from '@/lib/navOrder'
import {
Settings,
DatabaseZap,
@@ -40,6 +41,7 @@ import {
PanelLeftClose,
PanelLeftOpen,
Download,
+ HardDriveDownload,
} from 'lucide-react'
import {
IconDashboard,
@@ -47,18 +49,7 @@ import {
IconStrategy,
IconFactors,
IconBacktest,
- IconStockFocus,
- IconLadder,
- IconConcept,
- IconIndustry,
- IconFinancials,
- IconMonitor,
IconRegime,
- IconAlert,
- IconLots,
- IconPaper,
- IconSignals,
- IconReview,
IconIndices,
IconData,
type BrandIconProps,
@@ -96,18 +87,7 @@ const nav = [
{ to: '/screener', label: '策略', icon: IconStrategy },
{ to: '/factors', label: '因子', icon: IconFactors },
{ to: '/backtest', label: '回测', icon: IconBacktest },
- { to: '/stock-analysis', label: '个股分析', icon: IconStockFocus },
- { to: '/limit-ladder', label: '连板梯队', icon: IconLadder },
- { to: '/concept-analysis', label: '概念分析', icon: IconConcept },
- { to: '/industry-analysis', label: '行业分析', icon: IconIndustry },
- { to: '/financials', label: '财务分析', icon: IconFinancials },
- { to: '/monitor', label: '监控中心', icon: IconMonitor },
{ to: '/regime', label: '市场环境', icon: IconRegime },
- { to: '/abnormal', label: '异动监控', icon: IconAlert },
- { to: '/lots', label: '持仓提醒', icon: IconLots },
- { to: '/paper', label: '模拟盘', icon: IconPaper },
- { to: '/signals', label: '信号库', icon: IconSignals },
- { to: '/review', label: '复盘', icon: IconReview },
{ to: '/indices', label: '指数', icon: IconIndices },
{ to: '/data', label: '数据', icon: IconData },
] as const
@@ -172,12 +152,15 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
+ {/* 名称不换行 + 涨跌幅不压缩: 两列布局下单卡内容区仅 85px,
+ 「上证指数」41px + 涨跌幅 36px 已接近占满, 任其收缩或换行会把
+ 指数名折成两行 (曾出现「上证指/数」)。px-1.5 是为此留出的余量。 */}
- {item.name}
- {fmtIndexPct(pct)}
+ {item.name}
+ {fmtIndexPct(pct)}
{fmtIndexValue(value)}
@@ -189,6 +172,30 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
)
}
+// ===== 头部快捷行: 数据同步入口 (描边分段按钮, 与扩展快捷方式并排成组) =====
+// 点击直达 /data; 同步中转圈 / 刚完成绿勾, 空闲无角标保持安静。
+function QuickDataEntry({ syncing, justDone }: { syncing: boolean; justDone: boolean }) {
+ return (
+
+ {syncing
+ ?
+ : }
+ 数据
+ {!syncing && justDone && }
+
+ )
+}
+
// ===== 数据源能力健康卡 =====
// 能力路由架构下的侧栏状态: 不再展示「主数据源 + TickFlow 档位」(单源时代遗留 —
// 五个能力各自路由, 拿日K的源代表全局是随意的), 改为回答「各能力当前是否都有源在供」。
@@ -250,8 +257,7 @@ function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefine
onKeyDown={e => { if (e.key === 'Escape') setPopPos(null) }}
className="group relative flex items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2.5 pr-2 transition-colors duration-150 hover:bg-elevated/70"
>
-
-
+
{/* 能力方格 (按注册顺序: 实时/日K/分钟/除权/财务), 与悬浮卡逐格同色对应 */}
{loading
@@ -334,8 +340,7 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st
className="group relative flex items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2.5 pr-2 transition-colors duration-150 hover:bg-elevated/70"
title={`AI 配置 — ${descText}`}
>
-
-
+
{configured ? (
{model || '已接入模型'}
@@ -395,8 +400,9 @@ export function Layout() {
// 持久化到 localStorage; 迁移旧两态键 tf-nav-collapsed (收起 → 图标条)。
const [navState, setNavState] = useState(() => {
try {
+ // 两态导航: expanded(14rem) / rail(3.5rem 图标条)。历史三态版本的 'hidden' 归一为展开
const v = localStorage.getItem('tf-nav-state')
- if (v === 'expanded' || v === 'rail' || v === 'hidden') return v
+ if (v === 'expanded' || v === 'rail') return v
return localStorage.getItem('tf-nav-collapsed') === '1' ? 'rail' : 'expanded'
} catch { return 'expanded' }
})
@@ -478,9 +484,9 @@ export function Layout() {
// 开启实时行情时若存在排队中的挖掘任务 → 确认弹窗 (实时落盘会让排队任务开跑即失败)
const [miningQueuedWarning, setMiningQueuedWarning] = useState(null)
const miningWarnBackdrop = useDialogBackdrop(() => setMiningQueuedWarning(null))
- // 三态循环切换 (仅桌面): 展开 → 图标条 → 隐藏 → 展开
+ // 两态切换 (仅桌面): 展开 <-> 图标条
const toggleNavCollapsed = () => {
- setNavStatePersist(navState === 'expanded' ? 'rail' : navState === 'rail' ? 'hidden' : 'expanded')
+ setNavStatePersist(navState === 'expanded' ? 'rail' : 'expanded')
}
// 指数条: 固定核心四只 (产品契约, 不再可配置), 常驻显示
const sidebarIndexes = CORE_INDEXES
@@ -556,15 +562,18 @@ export function Layout() {
// 合并内置页面 + 可见的扩展分析菜单
type NavIcon = (props: BrandIconProps) => ReactNode
- type NavItem = { to: string; label: string; icon: NavIcon; badge?: string }
+ // arch: 'ext' = 扩展域页面 (官方扩展/自定义分析), 核心域 nav 常量不带标。
+ // 与菜单设置 ARCH_CLASS 同口径: nav 常量 8 项全部核心, 其余两个来源全部扩展。
+ type NavItem = { to: string; label: string; icon: NavIcon; badge?: string; arch?: 'core' | 'ext' }
const analysisNav: NavItem[] = (analysisMenus?.items ?? [])
.filter(m => m.visible)
- .map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 }))
+ .map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3, arch: 'ext' as const }))
const extensionNav: NavItem[] = getFrontendExtensionNavigation().map(item => ({
to: item.route.path,
label: item.label,
icon: item.icon,
badge: item.badge,
+ arch: 'ext' as const,
}))
const allNav: NavItem[] = [...nav, ...analysisNav, ...extensionNav]
@@ -580,26 +589,51 @@ export function Layout() {
const merged = [...ordered]
for (const item of allNav) {
if (seen.has(item.to)) continue
- // 未保存过排序的新条目: 内置页插回默认位置(排在已保存的默认前驱之后),
- // 分析/扩展菜单仍追加到末尾
- const defaultIndex = nav.findIndex(n => n.to === item.to)
+ // 未保存过排序的新条目: 默认基准内的路径插回默认位置(排在已出现的
+ // 最近默认前驱之后), 分析菜单/未知扩展追加到末尾。基准含迁移到
+ // custom/ 的扩展域页面, 保证老用户升级无感 (lib/navOrder)。
+ const di = defaultNavIndex(item.to)
let anchor = -1
- if (defaultIndex > 0) {
- for (let i = defaultIndex - 1; i >= 0 && anchor < 0; i -= 1) {
- anchor = merged.findIndex(n => n.to === nav[i].to)
+ if (Number.isFinite(di) && di > 0) {
+ for (let i = di - 1; i >= 0 && anchor < 0; i -= 1) {
+ anchor = merged.findIndex(n => defaultNavIndex(n.to) === i)
}
}
if (anchor >= 0) merged.splice(anchor + 1, 0, item)
- else if (defaultIndex >= 0) merged.unshift(item)
+ else if (di === 0) merged.unshift(item)
else merged.push(item)
}
return merged
})()
- : allNav
+ // 无保存排序 (新装/从未调整过菜单): 按迁移前默认基准排序, 扩展域页面
+ // 不沉底 — 老用户升级侧栏顺序与迁移前一致; 未知路径稳定追加在后。
+ : [...allNav].sort((a, b) => compareByDefaultNav(a.to, b.to))
const hiddenIds = new Set(prefs?.nav_hidden ?? [])
const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, '')))
+ // 新装用户一次性种子: 扩展域页面默认隐藏, 菜单设置里可逐个开启。
+ // 老用户 (升级前已完成 onboarding) 只写标记不动偏好, 升级前后侧栏一致。
+ // 种子标记存 localStorage (每设备一次, 保存成功后才写); 隐藏偏好本体持久化在后端 nav_hidden。
+ const extSeedRef = useRef(false)
+ useEffect(() => {
+ if (!prefs || !settingsState || extSeedRef.current) return
+ let seeded = false
+ try { seeded = localStorage.getItem('tf-ext-pages-seed') === '1' } catch { return }
+ if (seeded) { extSeedRef.current = true; return }
+ const next = seedHiddenForNewUser(settingsState.onboarding_completed, prefs.nav_hidden)
+ if (!next) {
+ extSeedRef.current = true
+ try { localStorage.setItem('tf-ext-pages-seed', '1') } catch {}
+ return
+ }
+ extSeedRef.current = true
+ api.saveNavHidden(next)
+ .then(() => { try { localStorage.setItem('tf-ext-pages-seed', '1') } catch {} })
+ .then(() => qc.invalidateQueries({ queryKey: QK.preferences }))
+ .catch(() => { /* 保存失败不写标记, 下次启动重试; 结果集幂等, 重复触发无害 */ })
+ }, [prefs, settingsState, qc])
+
const doEnableRealtime = async () => {
await toggleQuote.mutateAsync(true)
// 仅在交易时段立即获取一次行情
@@ -704,10 +738,7 @@ export function Layout() {
style={{ color: BRAND }}
/>
{!railMode && (
-
+
Tick Stock Panel
)}
@@ -719,7 +750,7 @@ export function Layout() {
'flex items-center rounded-btn text-muted hover:text-foreground hover:bg-elevated/60 transition-colors duration-150 ease-smooth',
railMode ? 'justify-center p-1.5' : 'ml-auto p-1.5',
)}
- title={railMode ? '隐藏菜单 (再点击左缘按钮可唤出)' : '收起菜单'}
+ title={railMode ? '展开菜单' : '收起菜单'}
>
{railMode
?
@@ -741,17 +772,22 @@ export function Layout() {
{!railMode && (
-
+
+ {/* 快捷行: 数据 + 扩展快捷方式 (AI 助手) 一行两个半宽 chip */}
+
+
+
+
)}
-