feat: adapt AlphaSift hotspot contract (#1675)

* feat: adapt alphasift hotspot contract

* fix(review-feedback-1675): update sample install spec with new allowed pin

* fix(review-feedback-1675): 补充 “revert 本 PR / 恢复旧 AlphaSift pin” 级别的回滚说明,并明确无关联 issue 或补充 Refs

* fix: harden alphasift hotspot metadata migration

* fix(review-feedback-1675): preserve hotspot route events in the new detail path

* fix(review-feedback-1675): 修正旧 AlphaSift pin 回滚/重装路径说明,避免运维按文档操作时被 install allow-list 拒绝

* fix(review-feedback-1675): src/services/alphasift service.py:当 AlphaSift v2 detail payload

* fix(review-feedback-1675): 补对应回归测试
This commit is contained in:
mumu
2026-06-13 14:46:18 +08:00
committed by GitHub
parent a82ae51ca8
commit ecc370e50b
12 changed files with 577 additions and 25 deletions

View File

@@ -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 下的隔离缓存目录。

View File

@@ -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<string, unknown>;
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<string, unknown>[];
};
export type AlphaSiftHotspotsResponse = {

View File

@@ -734,13 +734,28 @@ const StockScreeningPage: React.FC = () => {
<p className="mt-1 text-xs leading-5 text-secondary-text">
{loadingHotspotDetail ? '正在读取发酵路线与概念股...' : hotspotDetail?.summary || '点击题材查看发酵路线与概念股。'}
</p>
{hotspotDetail?.canonicalTopic && hotspotDetail.canonicalTopic !== selectedHotspotTopic ? (
<p className="mt-1 text-[11px] text-secondary-text">{hotspotDetail.canonicalTopic}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{hotspotDetail?.qualityStatus ? (
<span className="w-fit rounded-full bg-warning/10 px-3 py-1 text-xs font-semibold text-warning">
{hotspotDetail.qualityStatus}
</span>
) : null}
{hotspotDetail?.fallbackUsed || hotspotDetail?.stale ? (
<span className="w-fit rounded-full bg-warning/10 px-3 py-1 text-xs font-semibold text-warning">
{hotspotDetail.staleAgeHours != null ? `缓存回退 ${formatNumber(hotspotDetail.staleAgeHours, 1)}h` : '缓存回退'}
</span>
) : null}
{hotspotDetail?.stockCount != null ? (
<span className="w-fit rounded-full bg-orange-500/10 px-3 py-1 text-xs font-semibold text-orange-500">
{hotspotDetail.stockCount}
</span>
) : null}
</div>
</div>
{hotspotDetailError ? (
<p className="mb-3 rounded-xl border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
@@ -748,6 +763,14 @@ const StockScreeningPage: React.FC = () => {
</p>
) : null}
{hotspotDetail && ((hotspotDetail.missingFields || []).length > 0 || (hotspotDetail.sourceErrors || []).length > 0) ? (
<p className="mb-3 rounded-xl border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
{(hotspotDetail.missingFields || []).length > 0 ? `缺失 ${(hotspotDetail.missingFields || []).join('、')}` : ''}
{(hotspotDetail.sourceErrors || []).length > 0 ? ` ${(hotspotDetail.sourceErrors || []).slice(0, 2).join('')}` : ''}
</p>
) : null}
{hotspotDetail ? (
<div className="grid gap-4 lg:grid-cols-[1fr_1.3fr]">
<div>
@@ -778,6 +801,13 @@ const StockScreeningPage: React.FC = () => {
<p className="mt-2 text-[11px] text-secondary-text">
{formatNumber(stock.changePct)}% · {formatNumber(stock.hotStockScore, 0)}
</p>
{stock.source || stock.sourceConfidence != null || stock.fallbackUsed ? (
<p className="mt-1 text-[11px] text-secondary-text">
{stock.source || '-'}
{stock.sourceConfidence != null ? ` · 置信 ${formatPercent(stock.sourceConfidence)}` : ''}
{stock.fallbackUsed ? ' · 回退' : ''}
</p>
) : null}
</div>
))}
</div>

View File

@@ -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 () => {

View File

@@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 修复运行流 live SSE 事件未复用快照层递归脱敏规则的问题避免本地路径、prompt/raw response、代理头等敏感诊断字段在 refetch 前短暂暴露。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [改进] 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` 配置,默认把大盘摘要注入个股分析并启用保守护栏,仍允许用户显式关闭且保留大盘复盘报告独立运行。

View File

@@ -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_<NAME>_*``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 选股入口和相关配置项即可恢复到集成前流程;默认关闭状态下不会影响原有股票分析、报告生成和通知流程。

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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")