mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
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:
@@ -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 下的隔离缓存目录。
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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` 配置,默认把大盘摘要注入个股分析并启用保守护栏,仍允许用户显式关闭且保留大盘复盘报告独立运行。
|
||||
|
||||
@@ -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 选股入口和相关配置项即可恢复到集成前流程;默认关闭状态下不会影响原有股票分析、报告生成和通知流程。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user