diff --git a/.env.example b/.env.example
index 9c6e396ee..65cf33caf 100644
--- a/.env.example
+++ b/.env.example
@@ -31,7 +31,7 @@ ALPHASIFT_ENABLED=false
# - ALPHASIFT_ENABLED 只影响 AlphaSift 选股流程,不会改写/迁移/清理现有 LITELLM_*/LLM_* 配置。
# - 关闭/回退只需将 ALPHASIFT_ENABLED 置 false;现有 provider/base_url/custom headers 与 fallback 语义保持原状。
# - 兼容依据(运维核验):LiteLLM providers 与 OpenAI-compatible 语义说明见下方注释;运行时默认使用 requirements.txt 中固定 litellm 版本。
-ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386
+ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da
# AlphaSift 全市场快照源优先级;未配置时 DSA 调用 AlphaSift 会优先使用更稳的东方财富数据中心源。
# SNAPSHOT_SOURCE_PRIORITY=em_datacenter,tushare,efinance,akshare_em
# AlphaSift 最新版支持 last-good 快照、日线历史和行业/概念 provider 缓存;DSA 运行时默认使用 data/alphasift 下的隔离缓存目录。
diff --git a/apps/dsa-web/src/api/alphasift.ts b/apps/dsa-web/src/api/alphasift.ts
index dc48acbe3..a5692458e 100644
--- a/apps/dsa-web/src/api/alphasift.ts
+++ b/apps/dsa-web/src/api/alphasift.ts
@@ -132,6 +132,9 @@ export type AlphaSiftHotspotStock = {
volumeRatio?: number | null;
role?: string;
hotStockScore?: number | null;
+ source?: string;
+ sourceConfidence?: number | null;
+ fallbackUsed?: boolean;
};
export type AlphaSiftHotspotDetail = {
@@ -139,11 +142,21 @@ export type AlphaSiftHotspotDetail = {
provider: string;
topic: string;
name?: string;
+ canonicalTopic?: string;
+ aliases?: string[];
summary?: string;
+ summaryDetail?: Record;
route: AlphaSiftHotspotRouteItem[];
+ timeline?: AlphaSiftHotspotRouteItem[];
stocks: AlphaSiftHotspotStock[];
stockCount: number;
sourceErrors?: string[];
+ qualityStatus?: 'available' | 'partial' | 'stale' | 'failed' | string;
+ missingFields?: string[];
+ fallbackUsed?: boolean;
+ stale?: boolean;
+ staleAgeHours?: number | null;
+ resolverCandidates?: Record[];
};
export type AlphaSiftHotspotsResponse = {
diff --git a/apps/dsa-web/src/pages/StockScreeningPage.tsx b/apps/dsa-web/src/pages/StockScreeningPage.tsx
index e7cc04911..8810c1edc 100644
--- a/apps/dsa-web/src/pages/StockScreeningPage.tsx
+++ b/apps/dsa-web/src/pages/StockScreeningPage.tsx
@@ -734,12 +734,27 @@ const StockScreeningPage: React.FC = () => {
{loadingHotspotDetail ? '正在读取发酵路线与概念股...' : hotspotDetail?.summary || '点击题材查看发酵路线与概念股。'}
+ {hotspotDetail?.canonicalTopic && hotspotDetail.canonicalTopic !== selectedHotspotTopic ? (
+ 标准题材:{hotspotDetail.canonicalTopic}
+ ) : null}
+
+
+ {hotspotDetail?.qualityStatus ? (
+
+ 质量 {hotspotDetail.qualityStatus}
+
+ ) : null}
+ {hotspotDetail?.fallbackUsed || hotspotDetail?.stale ? (
+
+ {hotspotDetail.staleAgeHours != null ? `缓存回退 ${formatNumber(hotspotDetail.staleAgeHours, 1)}h` : '缓存回退'}
+
+ ) : null}
+ {hotspotDetail?.stockCount != null ? (
+
+ 概念股 {hotspotDetail.stockCount}
+
+ ) : null}
- {hotspotDetail?.stockCount != null ? (
-
- 概念股 {hotspotDetail.stockCount}
-
- ) : null}
{hotspotDetailError ? (
@@ -748,6 +763,14 @@ const StockScreeningPage: React.FC = () => {
) : null}
+ {hotspotDetail && ((hotspotDetail.missingFields || []).length > 0 || (hotspotDetail.sourceErrors || []).length > 0) ? (
+
+ 详情降级:
+ {(hotspotDetail.missingFields || []).length > 0 ? `缺失 ${(hotspotDetail.missingFields || []).join('、')}` : ''}
+ {(hotspotDetail.sourceErrors || []).length > 0 ? ` ${(hotspotDetail.sourceErrors || []).slice(0, 2).join(';')}` : ''}
+
+ ) : null}
+
{hotspotDetail ? (
@@ -778,6 +801,13 @@ const StockScreeningPage: React.FC = () => {
涨跌幅 {formatNumber(stock.changePct)}% · 热度 {formatNumber(stock.hotStockScore, 0)}
+ {stock.source || stock.sourceConfidence != null || stock.fallbackUsed ? (
+
+ 来源 {stock.source || '-'}
+ {stock.sourceConfidence != null ? ` · 置信 ${formatPercent(stock.sourceConfidence)}` : ''}
+ {stock.fallbackUsed ? ' · 回退' : ''}
+
+ ) : null}
))}
diff --git a/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx b/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx
index 5254fb2fd..0272de4fa 100644
--- a/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx
+++ b/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx
@@ -110,9 +110,24 @@ describe('StockScreeningPage', () => {
provider: 'akshare',
topic: 'AI算力',
name: 'AI算力',
+ canonicalTopic: '算力',
summary: 'AI算力 盘中发酵。',
+ qualityStatus: 'stale',
+ missingFields: ['live_stocks'],
+ fallbackUsed: true,
+ stale: true,
+ staleAgeHours: 2.5,
+ sourceErrors: ['akshare timeout'],
route: [{ title: '盘中发酵', description: '出现大笔买入。', source: 'eastmoney_board_change' }],
- stocks: [{ code: '300000', name: '中际旭创', role: '核心龙头', hotStockScore: 88 }],
+ stocks: [{
+ code: '300000',
+ name: '中际旭创',
+ role: '核心龙头',
+ hotStockScore: 88,
+ source: 'last_good_cache.leader_stocks',
+ sourceConfidence: 0.65,
+ fallbackUsed: true,
+ }],
stockCount: 1,
});
getHotspots.mockResolvedValue({ enabled: true, provider: 'akshare', hotspots: [], hotspotCount: 0 });
@@ -195,9 +210,15 @@ describe('StockScreeningPage', () => {
expect(screen.getByText('加速主升')).toBeInTheDocument();
expect(screen.getByText(/中际旭创、工业富联/)).toBeInTheDocument();
expect(await screen.findByText('发酵路线')).toBeInTheDocument();
+ expect(screen.getByText('标准题材:算力')).toBeInTheDocument();
+ expect(screen.getByText('质量 stale')).toBeInTheDocument();
+ expect(screen.getByText('缓存回退 2.5h')).toBeInTheDocument();
+ expect(screen.getByText(/详情降级:/)).toBeInTheDocument();
+ expect(screen.getByText(/缺失 live_stocks/)).toBeInTheDocument();
expect(screen.getByText('盘中发酵')).toBeInTheDocument();
expect(screen.getByText('概念股')).toBeInTheDocument();
expect(screen.getByText('中际旭创')).toBeInTheDocument();
+ expect(screen.getByText(/来源 last_good_cache\.leader_stocks · 置信 65% · 回退/)).toBeInTheDocument();
});
it('loads selected hotspot detail once when switching themes', async () => {
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 599d12ced..f89ebd7ed 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 修复运行流 live SSE 事件未复用快照层递归脱敏规则的问题,避免本地路径、prompt/raw response、代理头等敏感诊断字段在 refetch 前短暂暴露。
-- [改进] AlphaSift 依赖锁定更新到 `de54ea0da367be85770d9589a5bf7ded4f62d386`,并为新版 last-good snapshot、日线历史、行业/概念 provider cache、hotspot 具体题材榜单、题材发酵路线、概念股详情、上次成功热点缓存与 post-analysis 元信息补齐 DSA 运行期和 Web 选股页适配;默认不启用 DSA deep-analysis 回调。
+- [改进] AlphaSift 依赖锁定更新到 `d038c52c468543726fc1fd830b53c27d3f09d6da`,并为新版 last-good snapshot、日线历史、行业/概念 provider cache、hotspot 具体题材榜单、题材发酵路线、概念股详情、上次成功热点缓存与 post-analysis 元信息补齐 DSA 运行期和 Web 选股页适配;默认不启用 DSA deep-analysis 回调。
- [修复] 桌面发布打包改用冻结可执行文件运行时探针校验 `alphasift.dsa_adapter`,避免 macOS PyInstaller 将模块内嵌进可执行文件时被文件系统/zip 扫描误判为缺失。
- [改进] #1381 个股分析新增按当日/市场复用的大盘环境摘要,普通 Pipeline 与 Agent 分析 Prompt 可读取低敏大盘背景,并在高风险/退潮环境下软化激进买入建议。
- [改进] #1381 新增默认开启的 `DAILY_MARKET_CONTEXT_ENABLED` 配置,默认把大盘摘要注入个股分析并启用保守护栏,仍允许用户显式关闭且保留大盘复盘报告独立运行。
diff --git a/docs/alphasift-integration.md b/docs/alphasift-integration.md
index 8f6829e08..12484c702 100644
--- a/docs/alphasift-integration.md
+++ b/docs/alphasift-integration.md
@@ -6,16 +6,33 @@ AlphaSift 作为独立仓库维护的选股引擎接入 DSA。DSA 默认不启
- 默认关闭:`ALPHASIFT_ENABLED=false`。
- 启用入口:设置页或选股页点击开启,或在 `.env` 中配置 `ALPHASIFT_ENABLED=true`。
-- 依赖来源:`requirements.txt` 固定到已验证的 AlphaSift 适配层 commit:`git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386#egg=alphasift`(对应提交 `https://github.com/ZhuLinsen/alphasift/commit/de54ea0da367be85770d9589a5bf7ded4f62d386`)。该来源覆盖 `alphasift.dsa_adapter` 契约与 `screen/list_strategies/get_status` 调用。
+- 依赖来源:`requirements.txt` 固定到已验证的 AlphaSift 适配层 commit:`git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da#egg=alphasift`(对应提交 `https://github.com/ZhuLinsen/alphasift/commit/d038c52c468543726fc1fd830b53c27d3f09d6da`,来源 PR `https://github.com/ZhuLinsen/alphasift/pull/11`)。该来源覆盖 `alphasift.dsa_adapter` 契约与 `screen/list_strategies/get_status` 调用。
- 修复安装来源:`ALPHASIFT_INSTALL_SPEC` 仍保留,默认等于同一个受信任 commit。它不再是策略列表或选股接口的运行时安装主路径,只用于显式调用 `/api/v1/alphasift/install` 时做修复安装和来源校验。
+- 旧配置迁移:如果现有 `.env` 明确保留旧的 `ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386`,该显式环境变量会覆盖代码默认值;升级热点详情缓存契约时应删除该行以使用内置默认值,或同步改为 `d038c52c468543726fc1fd830b53c27d3f09d6da` 后重新安装依赖/重建后端。
+- 回退方式:如新版 AlphaSift contract 运行异常,最小回退是将 `ALPHASIFT_ENABLED=false` 并重启后端;如需回退到旧 alphasift commit,请使用“完整回滚”路径,因为 `/api/v1/alphasift/install` 的 allow-list 与当前代码中的 `DEFAULT_ALPHASIFT_INSTALL_SPEC` 强绑定,单独改 `.env` 中的 `ALPHASIFT_INSTALL_SPEC` 不会通过允许源校验(会返回 `alphasift_install_spec_not_allowed`)。
- 缺失依赖边界:如果运行环境缺少 `alphasift.dsa_adapter`,`status` 返回 `available=false + diagnostics.reason=missing_module`;`strategies` 和 `screen` 返回 `424` 并提示执行 `pip install -r requirements.txt` 或重建 Docker/桌面后端产物,不会在业务请求中自动 `pip install`。
- 运行异常边界:若适配层可导入但 `get_status()` 报错或返回 `available=false`,DSA 返回 `424 + diagnostics`,保留故障诊断,防止用重装掩盖真实运行时错误。
- 策略归属:策略列表、策略参数、全市场快照、初筛、因子评分和 LLM 重排由 AlphaSift 负责;DSA 负责开关、API 壳、数据 provider、展示和错误提示。
+
+## 外部契约来源与迁移边界
+
+- 外部契约依据:本次 AlphaSift 运行契约(含 `schema_version=2` 的热点缓存与题材详情字段)对应 GitHub 提交 `https://github.com/ZhuLinsen/alphasift/commit/d038c52c468543726fc1fd830b53c27d3f09d6da`。
+ 该 commit 在 DSA 中通过以下链路生效:`requirements.txt` 安装 pin、`src/config.py` 默认 `DEFAULT_ALPHASIFT_INSTALL_SPEC`、`.env.example` 默认示例值。
+- 升级路径:
+ - 仅需 `git pull` 后按部署方式重建依赖(`pip install -r requirements.txt`)并重启服务;
+ - 在 Web 端/设置页保持 `ALPHASIFT_ENABLED=true` 或按需开启即可接入新 `schema_version=2` 行为;
+ - 对于已经手动配置的 `ALPHASIFT_INSTALL_SPEC`,DSA 不做静默替换,只影响 `/api/v1/alphasift/install` 的来源校验展示,不会覆盖原配置。
+- 回滚边界(两条路径):
+ - 路径 A(业务临时回退,5 分钟内可执行):将 `ALPHASIFT_ENABLED=false` 并重启服务/进程。核心分析、日报报表与原有 LLM 调用链路不受该开关影响;此路径不影响依赖版本。
+ - 路径 B(适配层版本回滚):恢复到上一个版本的 `requirements.txt` 与 `src/config.py`(`DEFAULT_ALPHASIFT_INSTALL_SPEC`)到旧值,并同步回退 `.env.example` 的默认示例,重建后端镜像/桌面后端产物(等价于完整 revert 本次 PR)后重启。仅改 `.env` 回退 `ALPHASIFT_INSTALL_SPEC` 会被当前 allow-list 拒绝,必须与 `requirements.txt` 与代码 allow-list 一起回退。
+ - 安装入口说明:`/api/v1/alphasift/install` 仅允许当前代码 `ALLOWED_ALPHASIFT_INSTALL_SPECS`(目前为单值集合)中的来源。若确有需要临时接入其他来源,先在环境中手动安装并确认适配层可导入,再重启服务。
+- 兼容说明:`ALPHASIFT_INSTALL_SPEC` 与旧配置同名变量保持“展示可见但不再默认生效”的兼容语义;DSA 只在调用时读取当前值并做 allow-list 校验,不会在运行期自动重写 `.env`。
+
- DSA 增强:AlphaSift 通过 DSA provider context 在 LLM 重排前只补充 Top 候选的轻量实时行情和基本面上下文,不在初筛阶段抓新闻;DSA API 返回阶段会对最终 Top 候选补新闻和辅助摘要,并通过 `dsa_enrichment` 记录复用或补全情况。
- 日 K 线补特征:DSA 调用 AlphaSift 时会优先复用 DSA 历史行情加载链路(数据库缓存、Tushare、Efinance、Akshare、Pytdx、Baostock、Yfinance 等 fallback),仅在 DSA 链路无可用数据时回退到 AlphaSift 原始日线数据源,减少单一上游超时拖垮选股。
- LLM 环境:DSA 调用 AlphaSift 时会桥接 DSA 已解析的 `LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`LLM_CHANNELS`、`LLM__*`、`LITELLM_CONFIG`、渠道额外请求头和各模型密钥;AlphaSift 独立运行时仍使用自己的 `.env`/环境变量。
- 快照源:DSA 调用 AlphaSift 时,未显式配置 `SNAPSHOT_SOURCE_PRIORITY` 会默认优先使用 `em_datacenter`,减少 Tushare/东方财富行情接口在夜间或网络抖动时逐个失败造成的等待;显式配置的源顺序会原样保留。
-- 最新 AlphaSift 能力:锁定 commit `de54ea0da367be85770d9589a5bf7ded4f62d386` 包含选股 pipeline 性能优化、last-good snapshot fallback、日线历史缓存、行业/概念 provider cache、热点/行业热度因子、hotspot 热点题材榜单与本地 scorecard/post-analysis 元信息。DSA 调用时会注入隔离缓存默认路径 `data/alphasift`、`data/alphasift/snapshot.last_good.json`、`data/alphasift/daily_history`、`data/alphasift/industry_provider_cache`;Web 选股页提供“热点题材”手动刷新入口,请求 `/api/v1/alphasift/hotspots` 时会显式使用 `akshare` provider 优先拉取具体概念/题材异动(例如玻璃基板、机器人执行器、减速器),行业板块仅作为兜底;默认打开页面时优先读取上一次成功的热点题材缓存,点击刷新才实时拉取并覆盖缓存,实时拉取失败时会尽量回退旧缓存;点击题材会请求 `/api/v1/alphasift/hotspots/{topic}` 展示发酵路线与概念股;不会默认触发 AlphaSift 的 DSA deep-analysis 回调,避免无提示扩大递归调用面。
+- 最新 AlphaSift 能力:锁定 commit `d038c52c468543726fc1fd830b53c27d3f09d6da` 包含选股 pipeline 性能优化、last-good snapshot fallback、日线历史缓存、行业/概念 provider cache、热点/行业热度因子、hotspot 热点题材榜单与本地 scorecard/post-analysis 元信息。DSA 调用时会注入隔离缓存默认路径 `data/alphasift`、`data/alphasift/snapshot.last_good.json`、`data/alphasift/daily_history`、`data/alphasift/industry_provider_cache`;Web 选股页提供“热点题材”手动刷新入口,请求 `/api/v1/alphasift/hotspots` 时会显式使用 `akshare` provider 优先拉取具体概念/题材异动(例如玻璃基板、机器人执行器、减速器),行业板块仅作为兜底;默认打开页面时优先读取上一次成功的热点题材缓存,点击刷新才实时拉取并覆盖缓存,实时拉取失败时会尽量回退旧缓存;点击题材会请求 `/api/v1/alphasift/hotspots/{topic}` 展示发酵路线与概念股;不会默认触发 AlphaSift 的 DSA deep-analysis 回调,避免无提示扩大递归调用面。
- 风险提示:前端设置页和选股页展示第三方来源与投资风险说明;不会弹窗打断用户。
## AlphaSift 适配层要求
@@ -95,7 +112,7 @@ context = {
AlphaSift 会在 L1 初筛后、LLM 重排前调用 `context["dsa"]` 中的 provider,为有限 Top 候选补充 DSA 行情和基本面轻量上下文,并把 `dsa_context` 随候选返回。新闻搜索、完整摘要和缺失字段补全由 DSA API 在最终 Top 候选阶段执行;若候选已经携带完整新闻上下文,DSA API 返回阶段会复用这些字段,避免重复请求。
-AlphaSift 侧已在 `ZhuLinsen/alphasift@de54ea0da367be85770d9589a5bf7ded4f62d386` 提供 DSA provider context 支持、DSA adapter contract,并支持复用 DSA 的 `LLM_TIMEOUT_SEC`。
+AlphaSift 侧已在 `ZhuLinsen/alphasift@d038c52c468543726fc1fd830b53c27d3f09d6da` 提供 DSA provider context 支持、DSA adapter contract,并支持复用 DSA 的 `LLM_TIMEOUT_SEC`。
## DSA 后端行为
@@ -109,7 +126,7 @@ AlphaSift 侧已在 `ZhuLinsen/alphasift@de54ea0da367be85770d9589a5bf7ded4f62d38
## 配置兼容边界(LLM / LiteLLM / Base URL)
- 兼容语义与版本证据(可追溯):
- - 运行依赖约束:`requirements.txt` 中将 LiteLLM 固定到 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`,并通过 `git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386` 安装 AlphaSift 适配层。
+ - 运行依赖约束:`requirements.txt` 中将 LiteLLM 固定到 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`,并通过 `git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da` 安装 AlphaSift 适配层。
- 文档依据:
- LiteLLM Providers: https://docs.litellm.ai/docs/providers
- LiteLLM OpenAI-compatible: https://docs.litellm.ai/docs/providers/openai_compatible
@@ -142,7 +159,7 @@ AlphaSift 侧已在 `ZhuLinsen/alphasift@de54ea0da367be85770d9589a5bf7ded4f62d38
- 依赖与源码约束核验:`requirements.txt` 中的 `litellm` 约束与 `src/config.py`/`requirements.txt` 一致。
- Hotspot 契约兼容核验:`docs/alphasift-integration.md` 与 `api/v1/endpoints/alphasift.py`、`src/services/alphasift_service.py` 保持 `hotspots`/`hotspots/{topic}` 字段与 `tests/test_alphasift_api.py` 一致,调用前后默认使用 `snapshot.last_good` 缓存兜底。
-- 外部版本来源:本次集成依赖来源为 `https://github.com/ZhuLinsen/alphasift/commit/de54ea0da367be85770d9589a5bf7ded4f62d386`,需在复验时按该 commit pin 回放导入与接口契约。
+- 外部版本来源:本次集成依赖来源为 `https://github.com/ZhuLinsen/alphasift/commit/d038c52c468543726fc1fd830b53c27d3f09d6da`,需在复验时按该 commit pin 回放导入与接口契约。
- 行为核验:`src/services/alphasift_service.py` 的 `_build_alphasift_runtime_env` 与 `_build_alphasift_context` 仅在调用期写入进程环境;`/api/v1/alphasift/screen`、`strategies`、`status` 在运行期不回写 `.env`。
- 回退核验:关闭 `ALPHASIFT_ENABLED` 并重启配置链路后,系统恢复原始 `LITELLM_MODEL/FALLBACK_MODELS`、`LLM_CHANNELS` 与 `LLM_*` 运行语义,不执行迁移清理脚本。
- 语义来源核验:LiteLLM 文档(https://docs.litellm.ai/docs/providers)、OpenAI-compatible 文档(https://docs.litellm.ai/docs/providers/openai_compatible)与 LiteLLM 配置文档(https://docs.litellm.ai/docs/proxy/configs)用于核对 provider/model/base_url/extra_headers 映射链路。
@@ -189,5 +206,6 @@ Docker 镜像与桌面发布包保持一致:`docker/Dockerfile` 会通过 `req
## 回滚
- 关闭功能:设置页关闭 AlphaSift,或配置 `ALPHASIFT_ENABLED=false`。
-- 禁止启用:保持 `ALPHASIFT_ENABLED=false`;如需使用默认来源之外的 AlphaSift 安装包,先在后端 Python 环境完成手动安装并确认 `alphasift.dsa_adapter` 可导入。
+- 版本回退:如需降级 alphasift 适配层,必须同时回退仓库 `requirements.txt` 与 `src/config.py` 中受信任的 pin,否则仅改 `.env` 的 `ALPHASIFT_INSTALL_SPEC` 会被 `alphasift_install_spec_not_allowed` 拒绝;确认后重建依赖与重启服务。
+- 特殊来源:如需使用默认来源之外的 AlphaSift 安装包,先在后端 Python 环境完成手动安装并确认 `alphasift.dsa_adapter` 可导入,随后再重启服务(安装前不要触发 `/api/v1/alphasift/install` 的 allow-list 校验路径)。
- 回滚代码:移除 AlphaSift API 注册、Web 选股入口和相关配置项即可恢复到集成前流程;默认关闭状态下不会影响原有股票分析、报告生成和通知流程。
diff --git a/requirements.txt b/requirements.txt
index f354584e8..8c5b3135e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -19,7 +19,7 @@ yfinance>=0.2.0 # Priority 4: Yahoo Finance (Fallback)
longbridge>=0.2.77 # Priority 5: Longbridge OpenAPI fallback for US/HK stocks; OAuth capability checked at runtime
tickflow>=0.1.0 # TickFlow official SDK (Issue #632, market review enhancement)
# Built-in optional AlphaSift screening engine
-git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386#egg=alphasift
+git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da#egg=alphasift
# Feishu
lark-oapi>=1.0.0 # Feishu API
diff --git a/src/config.py b/src/config.py
index 2e150b845..aa36ee814 100644
--- a/src/config.py
+++ b/src/config.py
@@ -40,7 +40,7 @@ from src.llm import generation_params as llm_generation_params
logger = logging.getLogger(__name__)
DEFAULT_ALPHASIFT_INSTALL_SPEC = (
- "git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386"
+ "git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da"
)
diff --git a/src/services/alphasift_service.py b/src/services/alphasift_service.py
index ea011ecec..3339c30d5 100644
--- a/src/services/alphasift_service.py
+++ b/src/services/alphasift_service.py
@@ -98,7 +98,7 @@ def _load_alphasift_hotspot_cache(*, provider: str, top: int) -> Optional[Dict[s
logger.warning("Failed to read AlphaSift hotspot cache from %s: %s", cache_path, exc)
return None
- payload = raw.get("payload") if isinstance(raw, dict) else None
+ payload = _normalize_alphasift_hotspot_cache_payload(raw)
if not isinstance(payload, dict):
return None
hotspots = payload.get("hotspots")
@@ -119,6 +119,34 @@ def _load_alphasift_hotspot_cache(*, provider: str, top: int) -> Optional[Dict[s
return _remove_non_finite_json_values(cached)
+def _normalize_alphasift_hotspot_cache_payload(raw: Any) -> Optional[Dict[str, Any]]:
+ if not isinstance(raw, dict):
+ return None
+ payload = raw.get("payload")
+ if isinstance(payload, dict):
+ return payload
+ hotspots = raw.get("hotspots")
+ if not isinstance(hotspots, list):
+ return None
+ metadata_raw = raw.get("metadata")
+ metadata: Dict[str, Any] = metadata_raw if isinstance(metadata_raw, dict) else {}
+ cached_at = raw.get("cached_at") or raw.get("generated_at") or metadata.get("generated_at")
+ return {
+ "enabled": True,
+ "provider": _env_text(metadata.get("provider")) or "akshare",
+ "provider_used": _env_text(metadata.get("provider_used")),
+ "fallback_used": False,
+ "cache_used": False,
+ "cached_at": cached_at,
+ "schema_version": raw.get("schema_version") or metadata.get("schema_version"),
+ "source_errors": _list_text_values(raw.get("source_errors") or metadata.get("source_errors")),
+ "stale": bool(raw.get("stale") or metadata.get("stale") or False),
+ "stale_age_hours": raw.get("stale_age_hours") or metadata.get("stale_age_hours"),
+ "hotspots": hotspots,
+ "hotspot_count": len(hotspots),
+ }
+
+
def _write_alphasift_hotspot_cache(payload: Dict[str, Any]) -> None:
cache_path = _alphasift_hotspot_cache_path()
try:
@@ -128,7 +156,25 @@ def _write_alphasift_hotspot_cache(payload: Dict[str, Any]) -> None:
cache_payload["cache_used"] = False
cache_payload["cached_at"] = cached_at
cache_path.write_text(
- json.dumps({"cached_at": cached_at, "payload": cache_payload}, ensure_ascii=False, indent=2),
+ json.dumps(
+ {
+ "schema_version": 2,
+ "generated_at": cached_at,
+ "cached_at": cached_at,
+ "metadata": {
+ "schema_version": 2,
+ "asset_type": "hotspot_cache",
+ "provider": cache_payload.get("provider"),
+ "provider_used": cache_payload.get("provider_used"),
+ "row_count": len(cache_payload.get("hotspots") or []),
+ "source_errors": _list_text_values(cache_payload.get("source_errors")),
+ },
+ "hotspots": cache_payload.get("hotspots") or [],
+ "payload": cache_payload,
+ },
+ ensure_ascii=False,
+ indent=2,
+ ),
encoding="utf-8",
)
except Exception as exc:
@@ -281,17 +327,59 @@ class AlphaSiftService:
provider_name, provider_arg = _resolve_hotspot_provider(provider)
if not isinstance(provider_arg, DsaEastMoneyHotspotProvider):
provider_arg = DsaEastMoneyHotspotProvider()
+ normalized: Dict[str, Any] = {}
+ hotspot_helper_error: str = ""
try:
+ try:
+ hotspot_module = _import_alphasift_hotspot()
+ get_hotspot_detail = getattr(hotspot_module, "get_hotspot_detail", None)
+ except Exception:
+ get_hotspot_detail = None
with _alphasift_runtime_env(self.config):
- detail = provider_arg.hotspot_detail(topic_text)
+ if callable(get_hotspot_detail) and type(provider_arg) is DsaEastMoneyHotspotProvider:
+ try:
+ detail = get_hotspot_detail(
+ topic_text,
+ provider=provider_arg,
+ top_stocks=30,
+ history_path=_alphasift_hotspot_history_path(),
+ fallback_cache_path=_alphasift_hotspot_cache_path(),
+ )
+ normalized = _normalize_alphasift_hotspot_detail(
+ detail,
+ provider=provider_name,
+ requested_topic=topic_text,
+ )
+ normalized = _merge_provider_hotspot_route_fallback(
+ normalized,
+ provider=provider_arg,
+ topic=topic_text,
+ )
+ except Exception as exc:
+ hotspot_helper_error = f"{exc}"
+ logger.warning(
+ "AlphaSift contract hotspot detail fallback to provider for topic=%s: %s",
+ topic_text,
+ hotspot_helper_error,
+ )
+ else:
+ normalized = provider_arg.hotspot_detail(topic_text)
+ if not normalized:
+ normalized = provider_arg.hotspot_detail(topic_text)
except Exception as exc:
raise HTTPException(
status_code=424,
detail={"error": "alphasift_hotspot_detail_failed", "message": f"AlphaSift hotspot detail failed: {exc}"},
) from exc
- detail["enabled"] = True
- detail["provider"] = provider_name
- return _remove_non_finite_json_values(detail)
+ if hotspot_helper_error:
+ source_errors = _list_text_values(normalized.get("source_errors"))
+ source_errors.append(f"alphasift_hotspot_detail_fallback: {hotspot_helper_error}")
+ normalized["source_errors"] = source_errors
+ normalized["fallback_used"] = True
+ normalized["provider"] = provider_name
+ normalized["enabled"] = True
+ normalized["provider"] = provider_name
+ return _remove_non_finite_json_values(normalized)
def screen(self, *, strategy: str, market: str, max_results: int) -> Dict[str, Any]:
_ensure_alphasift_enabled(self.config)
@@ -358,6 +446,168 @@ class AlphaSiftService:
}
+def _normalize_alphasift_hotspot_detail(detail: Any, *, provider: str, requested_topic: str) -> Dict[str, Any]:
+ raw_value = _remove_non_finite_json_values(_to_plain(detail))
+ raw: Dict[str, Any] = raw_value if isinstance(raw_value, dict) else {}
+ summary_value = raw.get("summary")
+ summary: Dict[str, Any] = summary_value if isinstance(summary_value, dict) else {}
+ stocks_value = raw.get("stocks")
+ stocks: List[Any] = stocks_value if isinstance(stocks_value, list) else []
+ timeline_value = raw.get("timeline")
+ timeline: List[Any] = timeline_value if isinstance(timeline_value, list) else []
+ route_value = raw.get("route")
+ route: List[Any] = route_value if isinstance(route_value, list) and route_value else _hotspot_timeline_to_route(timeline)
+ source_errors = _list_text_values(raw.get("source_errors") or summary.get("source_errors"))
+ topic = _env_text(summary.get("topic") or raw.get("topic") or requested_topic)
+ canonical_topic = _env_text(summary.get("canonical_topic") or raw.get("canonical_topic"))
+ name = _env_text(summary.get("name") or raw.get("name") or canonical_topic or topic)
+ quality_status = _env_text(summary.get("quality_status") or raw.get("quality_status"))
+ missing_fields = _list_text_values(summary.get("missing_fields") or raw.get("missing_fields"))
+ summary_text_value = raw.get("summary")
+ summary_text = (
+ summary_text_value
+ if isinstance(summary_text_value, str)
+ else _build_alphasift_hotspot_summary_text(summary, topic=topic, canonical_topic=canonical_topic)
+ )
+ return {
+ "enabled": True,
+ "provider": provider,
+ "topic": topic,
+ "name": name,
+ "canonical_topic": canonical_topic,
+ "aliases": _list_text_values(summary.get("aliases") or raw.get("aliases")),
+ "summary": summary_text,
+ "summary_detail": summary,
+ "route": route,
+ "timeline": timeline,
+ "stocks": stocks,
+ "stock_count": len(stocks),
+ "source_errors": source_errors,
+ "quality_status": quality_status,
+ "missing_fields": missing_fields,
+ "fallback_used": bool(summary.get("fallback_used") or raw.get("fallback_used") or False),
+ "stale": bool(summary.get("stale") or raw.get("stale") or False),
+ "stale_age_hours": summary.get("stale_age_hours") or raw.get("stale_age_hours"),
+ "resolver_candidates": _list_dict_values(summary.get("resolver_candidates") or raw.get("resolver_candidates")),
+ }
+
+
+def _list_text_values(value: Any) -> List[str]:
+ if value is None:
+ return []
+ if isinstance(value, str):
+ text = _env_text(value)
+ return [text] if text else []
+ if not isinstance(value, list):
+ text = _env_text(value)
+ return [text] if text else []
+ return [text for item in value if (text := _env_text(item))]
+
+
+def _list_dict_values(value: Any) -> List[Dict[str, Any]]:
+ if not isinstance(value, list):
+ return []
+ return [item for item in value if isinstance(item, dict)]
+
+
+def _hotspot_timeline_to_route(timeline: List[Any]) -> List[Dict[str, Any]]:
+ route: List[Dict[str, Any]] = []
+ for item in timeline:
+ if not isinstance(item, dict):
+ continue
+ title = _env_text(item.get("title"))
+ if not title:
+ continue
+ date = _env_text(item.get("date") or item.get("published_at"))
+ source = _env_text(item.get("source")) or "alphasift_timeline"
+ route.append({
+ "title": title,
+ "description": f"{date}:{title}" if date else title,
+ "source": source,
+ "url": _env_text(item.get("url")),
+ "published_at": date,
+ })
+ if route:
+ return route
+ return [{
+ "title": "等待发酵",
+ "description": "暂未获取到明确催化事件,可继续观察涨跌幅、成交额和核心个股联动。",
+ "source": "fallback",
+ }]
+
+
+def _merge_provider_hotspot_route_fallback(
+ normalized: Dict[str, Any],
+ *,
+ provider: "DsaEastMoneyHotspotProvider",
+ topic: str,
+) -> Dict[str, Any]:
+ if _has_meaningful_hotspot_route(normalized.get("route")):
+ return normalized
+ try:
+ provider_detail = provider.hotspot_detail(topic)
+ except Exception as exc:
+ logger.warning(
+ "AlphaSift provider route fallback failed for %s; keeping contract detail route: %s",
+ topic,
+ exc,
+ )
+ return normalized
+
+ raw_value = _remove_non_finite_json_values(_to_plain(provider_detail))
+ raw: Dict[str, Any] = raw_value if isinstance(raw_value, dict) else {}
+ provider_route = raw.get("route")
+ if _has_meaningful_hotspot_route(provider_route):
+ normalized["route"] = provider_route
+ provider_timeline = raw.get("timeline")
+ if not normalized.get("timeline") and isinstance(provider_timeline, list):
+ normalized["timeline"] = provider_timeline
+ return normalized
+
+ provider_timeline = raw.get("timeline")
+ if isinstance(provider_timeline, list) and provider_timeline:
+ provider_timeline_route = _hotspot_timeline_to_route(provider_timeline)
+ if _has_meaningful_hotspot_route(provider_timeline_route):
+ normalized["route"] = provider_timeline_route
+ normalized["timeline"] = provider_timeline
+ return normalized
+
+
+def _has_meaningful_hotspot_route(route: Any) -> bool:
+ if not isinstance(route, list):
+ return False
+ for item in route:
+ if not isinstance(item, dict):
+ continue
+ title = _env_text(item.get("title"))
+ description = _env_text(item.get("description"))
+ source = _env_text(item.get("source"))
+ if not title and not description:
+ continue
+ if source == "fallback" and title == "等待发酵":
+ continue
+ return True
+ return False
+
+
+def _build_alphasift_hotspot_summary_text(summary: Dict[str, Any], *, topic: str, canonical_topic: str) -> str:
+ display_topic = canonical_topic or topic
+ quality = _env_text(summary.get("quality_status"))
+ heat = _safe_float(summary.get("heat_score"))
+ stage = _env_text(summary.get("stage"))
+ leaders = summary.get("leaders") if isinstance(summary.get("leaders"), list) else []
+ parts = [f"{display_topic} 当前热点详情"]
+ if heat is not None:
+ parts.append(f"热度 {heat:.1f}")
+ if stage:
+ parts.append(f"阶段 {stage}")
+ if leaders:
+ parts.append("核心股 " + "、".join(_env_text(item) for item in leaders[:3] if _env_text(item)))
+ if quality:
+ parts.append(f"质量状态 {quality}")
+ return ",".join(part for part in parts if part) + "。"
+
+
def _install_alphasift(config: Config) -> Dict[str, Any]:
with _ALPHASIFT_INSTALL_LOCK:
install_spec_is_default = _is_default_alphasift_install_spec(config.alphasift_install_spec)
diff --git a/tests/test_alphasift_api.py b/tests/test_alphasift_api.py
index c4b2657a7..980dde52f 100644
--- a/tests/test_alphasift_api.py
+++ b/tests/test_alphasift_api.py
@@ -463,9 +463,229 @@ class AlphaSiftOpportunitiesApiTestCase(unittest.TestCase):
):
cached = self._hotspots(config=config, provider="akshare", top=1, refresh=False)
+ cache_payload = json.loads(cache_path.read_text(encoding="utf-8"))
+
self.assertEqual(cached["cache_used"], True)
self.assertEqual(cached["hotspots"][0]["topic"], "机器人执行器")
discover_again.assert_not_called()
+ self.assertEqual(cache_payload["schema_version"], 2)
+ self.assertEqual(cache_payload["hotspots"][0]["topic"], "机器人执行器")
+
+ def test_hotspots_reads_alphasift_v2_hotspot_cache(self) -> None:
+ config = self._config(enabled=True)
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_path = Path(tmpdir) / "hotspots.json"
+ cache_path.write_text(
+ json.dumps({
+ "schema_version": 2,
+ "generated_at": "2026-06-13T02:55:00Z",
+ "source_errors": "provider timeout",
+ "metadata": {"schema_version": 2, "provider_used": "last_good_cache"},
+ "hotspots": [
+ {
+ "topic": "算力",
+ "canonical_topic": "算力",
+ "aliases": ["AI算力"],
+ "heat_score": 88.0,
+ "quality_status": "available",
+ }
+ ],
+ }),
+ encoding="utf-8",
+ )
+ discover = MagicMock()
+ with (
+ patch("src.services.alphasift_service.DSA_ALPHASIFT_HOTSPOT_CACHE_PATH", cache_path),
+ patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})),
+ patch("src.services.alphasift_service._import_alphasift_hotspot", return_value=SimpleNamespace(discover_hotspots=discover)),
+ ):
+ cached = self._hotspots(config=config, provider="akshare", top=1, refresh=False)
+
+ self.assertEqual(cached["cache_used"], True)
+ self.assertEqual(cached["cached_at"], "2026-06-13T02:55:00Z")
+ self.assertEqual(cached["schema_version"], 2)
+ self.assertEqual(cached["source_errors"], ["provider timeout"])
+ self.assertEqual(cached["hotspots"][0]["canonical_topic"], "算力")
+ discover.assert_not_called()
+
+ def test_hotspot_detail_uses_alphasift_contract_detail_cache(self) -> None:
+ config = self._config(enabled=True)
+ captured: Dict[str, Any] = {}
+
+ def get_hotspot_detail(topic: str, **kwargs: Any) -> Dict[str, Any]:
+ captured.update({"topic": topic, **kwargs})
+ return {
+ "summary": {
+ "topic": topic,
+ "name": "算力",
+ "canonical_topic": "算力",
+ "aliases": "AI算力",
+ "heat_score": 88.0,
+ "stage": "加速主升",
+ "leaders": ["算力龙头"],
+ "quality_status": "stale",
+ "missing_fields": "live_stocks",
+ "source_errors": "none: no live detail rows",
+ "fallback_used": True,
+ "stale": True,
+ "stale_age_hours": 1.5,
+ "resolver_candidates": [{"topic": "算力", "confidence": 1.0}],
+ },
+ "stocks": [{
+ "code": "300001",
+ "name": "算力龙头",
+ "role": "核心龙头",
+ "source": "last_good_cache.leader_stocks",
+ "source_confidence": 0.65,
+ "fallback_used": True,
+ }],
+ "timeline": [{"date": "2026-06-13", "source": "新闻", "title": "AI算力催化"}],
+ }
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ data_dir = Path(tmpdir) / "alphasift"
+ provider = alphasift_service.DsaEastMoneyHotspotProvider()
+ with (
+ patch.dict(os.environ, {"ALPHASIFT_DATA_DIR": str(data_dir)}, clear=False),
+ patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})),
+ patch("src.services.alphasift_service._resolve_hotspot_provider", return_value=("akshare", provider)),
+ patch(
+ "src.services.alphasift_service._import_alphasift_hotspot",
+ return_value=SimpleNamespace(get_hotspot_detail=get_hotspot_detail),
+ ),
+ ):
+ payload = self._hotspot_detail(config=config, provider="akshare", topic="AI算力")
+
+ self.assertEqual(captured["topic"], "AI算力")
+ self.assertIs(captured["provider"], provider)
+ self.assertEqual(captured["fallback_cache_path"], data_dir / "hotspots.json")
+ self.assertEqual(captured["history_path"], data_dir / "hotspot.history.jsonl")
+ self.assertEqual(payload["enabled"], True)
+ self.assertEqual(payload["provider"], "akshare")
+ self.assertEqual(payload["topic"], "AI算力")
+ self.assertEqual(payload["canonical_topic"], "算力")
+ self.assertEqual(payload["quality_status"], "stale")
+ self.assertEqual(payload["aliases"], ["AI算力"])
+ self.assertEqual(payload["missing_fields"], ["live_stocks"])
+ self.assertEqual(payload["source_errors"], ["none: no live detail rows"])
+ self.assertEqual(payload["stocks"][0]["source"], "last_good_cache.leader_stocks")
+ self.assertEqual(payload["route"][0]["title"], "AI算力催化")
+
+ def test_hotspot_detail_prefers_timeline_when_contract_route_is_empty(self) -> None:
+ config = self._config(enabled=True)
+ provider = alphasift_service.DsaEastMoneyHotspotProvider()
+ provider.hotspot_detail = MagicMock(side_effect=RuntimeError("provider fallback should not be used"))
+
+ def get_hotspot_detail(topic: str, **_kwargs: Any) -> Dict[str, Any]:
+ return {
+ "summary": {
+ "topic": topic,
+ "name": "算力",
+ "canonical_topic": "算力",
+ "quality_status": "available",
+ },
+ "stocks": [{
+ "code": "300001",
+ "name": "算力龙头",
+ }],
+ "timeline": [{"date": "2026-06-13", "source": "新闻", "title": "AI算力催化"}],
+ "route": [],
+ }
+
+ with (
+ patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})),
+ patch("src.services.alphasift_service._resolve_hotspot_provider", return_value=("akshare", provider)),
+ patch(
+ "src.services.alphasift_service._import_alphasift_hotspot",
+ return_value=SimpleNamespace(get_hotspot_detail=get_hotspot_detail),
+ ),
+ ):
+ payload = self._hotspot_detail(config=config, provider="akshare", topic="AI算力")
+
+ self.assertEqual(payload["route"][0]["title"], "AI算力催化")
+ self.assertEqual(payload["route"][0]["source"], "新闻")
+ provider.hotspot_detail.assert_not_called()
+
+ def test_hotspot_detail_falls_back_to_provider_when_contract_helper_fails(self) -> None:
+ config = self._config(enabled=True)
+ provider = alphasift_service.DsaEastMoneyHotspotProvider()
+ provider.hotspot_detail = MagicMock(return_value={
+ "topic": "机器人执行器",
+ "summary": "机器人执行器 盘中发酵。",
+ "route": [{"title": "盘中发酵", "description": "provider fallback route.", "source": "eastmoney_board_change"}],
+ "stocks": [{"code": "002000", "name": "旧路径个股"}],
+ "stock_count": 1,
+ "source_errors": [],
+ })
+
+ def get_hotspot_detail(topic: str, **_kwargs: Any) -> Dict[str, Any]:
+ raise RuntimeError("contract parser broken")
+
+ with (
+ patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})),
+ patch("src.services.alphasift_service._resolve_hotspot_provider", return_value=("akshare", provider)),
+ patch(
+ "src.services.alphasift_service._import_alphasift_hotspot",
+ return_value=SimpleNamespace(get_hotspot_detail=get_hotspot_detail),
+ ),
+ ):
+ payload = self._hotspot_detail(config=config, provider="akshare", topic="机器人执行器")
+
+ self.assertEqual(payload["route"][0]["title"], "盘中发酵")
+ self.assertEqual(payload["route"][0]["source"], "eastmoney_board_change")
+ provider.hotspot_detail.assert_called_once_with("机器人执行器")
+ self.assertEqual(
+ payload["source_errors"][0],
+ "alphasift_hotspot_detail_fallback: contract parser broken",
+ )
+ self.assertTrue(payload["fallback_used"])
+
+ def test_hotspot_detail_preserves_provider_route_when_contract_detail_has_no_timeline(self) -> None:
+ config = self._config(enabled=True)
+ provider = alphasift_service.DsaEastMoneyHotspotProvider()
+ provider.hotspot_detail = MagicMock(return_value={
+ "topic": "机器人执行器",
+ "summary": "机器人执行器 盘中发酵。",
+ "route": [{
+ "title": "盘中发酵",
+ "description": "机器人执行器 当前有异动记录。",
+ "source": "eastmoney_board_change",
+ }],
+ "stocks": [{"code": "002000", "name": "旧路径个股"}],
+ "stock_count": 1,
+ "source_errors": [],
+ })
+
+ def get_hotspot_detail(topic: str, **_kwargs: Any) -> Dict[str, Any]:
+ return {
+ "summary": {
+ "topic": topic,
+ "name": "机器人执行器",
+ "canonical_topic": "机器人执行器",
+ "quality_status": "available",
+ },
+ "stocks": [{
+ "code": "300000",
+ "name": "合约路径个股",
+ "source": "alphasift_contract",
+ }],
+ }
+
+ with (
+ patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})),
+ patch("src.services.alphasift_service._resolve_hotspot_provider", return_value=("akshare", provider)),
+ patch(
+ "src.services.alphasift_service._import_alphasift_hotspot",
+ return_value=SimpleNamespace(get_hotspot_detail=get_hotspot_detail),
+ ),
+ ):
+ payload = self._hotspot_detail(config=config, provider="akshare", topic="机器人执行器")
+
+ self.assertEqual(payload["route"][0]["title"], "盘中发酵")
+ self.assertEqual(payload["route"][0]["source"], "eastmoney_board_change")
+ self.assertEqual(payload["stocks"][0]["name"], "合约路径个股")
+ provider.hotspot_detail.assert_called_once_with("机器人执行器")
def test_hotspot_detail_returns_route_and_concept_stocks(self) -> None:
config = self._config(enabled=True)
diff --git a/tests/test_docker_entrypoint.py b/tests/test_docker_entrypoint.py
index c3b576dd5..58b3e21bf 100644
--- a/tests/test_docker_entrypoint.py
+++ b/tests/test_docker_entrypoint.py
@@ -29,7 +29,7 @@ def test_dockerfile_bundles_default_alphasift_adapter() -> None:
requirements = (REPO_ROOT / "requirements.txt").read_text(encoding="utf-8")
assert "git \\" in dockerfile
- assert "git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386#egg=alphasift" in requirements
+ assert "git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da#egg=alphasift" in requirements
assert "pip install --no-cache-dir -r requirements.txt" in dockerfile
assert "import alphasift.dsa_adapter" in dockerfile
diff --git a/tests/test_system_config_service.py b/tests/test_system_config_service.py
index 3060dc396..af56a483c 100644
--- a/tests/test_system_config_service.py
+++ b/tests/test_system_config_service.py
@@ -732,7 +732,7 @@ class SystemConfigServiceTestCase(unittest.TestCase):
"LLM_OPENAI_MODELS=openai/gpt-4o-mini,openai/gpt-4o",
"LITELLM_FALLBACK_MODELS=openai/gpt-4o-mini,openai/gpt-4o",
"ALPHASIFT_ENABLED=false",
- "ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386",
+ "ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da",
"GEMINI_API_KEY=legacy-secret",
)
@@ -756,7 +756,7 @@ class SystemConfigServiceTestCase(unittest.TestCase):
self.assertEqual(current_map["ALPHASIFT_ENABLED"], "true")
self.assertEqual(
current_map["ALPHASIFT_INSTALL_SPEC"],
- "git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386",
+ "git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da",
)
self.assertEqual(current_map["GEMINI_API_KEY"], "legacy-secret")
self.assertEqual(current_map["LITELLM_MODEL"], "openai/gpt-4o-mini")