From e8a9ca7742e8cb2498c8f491dd76d239b3064e1a Mon Sep 17 00:00:00 2001 From: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:56:21 +0800 Subject: [PATCH] fix: harden release packages and report market context (#1994) * fix: bundle orjson in release packages * fix: remove duplicate decision signal excerpts * feat: add TickFlow sector ranking fallback --- .env.example | 2 +- .../src/components/report/ReportSummary.tsx | 4 - .../__tests__/AnalysisContextSummary.test.tsx | 46 ++++++++- data_provider/tickflow_fetcher.py | 97 +++++++++++++++++++ docs/CHANGELOG.md | 4 + docs/desktop-package.md | 2 +- docs/full-guide.md | 3 +- docs/full-guide_EN.md | 3 +- main.py | 12 ++- requirements.txt | 1 + scripts/build-backend-macos.sh | 20 ++-- scripts/build-backend.ps1 | 24 +++-- src/config.py | 4 +- src/notification.py | 23 ----- src/services/report_renderer.py | 8 -- templates/report_markdown.j2 | 5 - templates/report_wechat.j2 | 5 - tests/test_config_env_compat.py | 2 +- tests/test_desktop_packaging_assets.py | 17 ++++ tests/test_notification.py | 42 +++----- tests/test_packaging_build_scripts.py | 18 ++-- tests/test_report_renderer.py | 22 ++--- tests/test_tickflow_fetcher.py | 50 +++++++++- 23 files changed, 286 insertions(+), 128 deletions(-) diff --git a/.env.example b/.env.example index 397cf2069..c804b8c40 100644 --- a/.env.example +++ b/.env.example @@ -907,7 +907,7 @@ ADMIN_AUTH_ENABLED=false # 基本面聚合性能与稳定性参数(单位:秒) # FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0 -# FUNDAMENTAL_FETCH_TIMEOUT_SECONDS=3.0 +# FUNDAMENTAL_FETCH_TIMEOUT_SECONDS=8.0 # FUNDAMENTAL_RETRY_MAX=1 # FUNDAMENTAL_CACHE_TTL_SECONDS=120 # FUNDAMENTAL_CACHE_MAX_ENTRIES=256 diff --git a/apps/dsa-web/src/components/report/ReportSummary.tsx b/apps/dsa-web/src/components/report/ReportSummary.tsx index f3faf7775..52506d07c 100644 --- a/apps/dsa-web/src/components/report/ReportSummary.tsx +++ b/apps/dsa-web/src/components/report/ReportSummary.tsx @@ -6,7 +6,6 @@ import { ReportNews } from './ReportNews'; import { ReportDetails } from './ReportDetails'; import { ReportDiagnostics } from './ReportDiagnostics'; import { AnalysisContextSummary } from './AnalysisContextSummary'; -import { MarketStructureCard } from './MarketStructureCard'; import { MarketReviewReportView } from './MarketReviewReportView'; import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage'; @@ -69,9 +68,6 @@ export const ReportSummary: React.FC = ({ watchlist={watchlist} /> - {/* 市场结构位置 */} - - {/* 策略点位区 */} diff --git a/apps/dsa-web/src/components/report/__tests__/AnalysisContextSummary.test.tsx b/apps/dsa-web/src/components/report/__tests__/AnalysisContextSummary.test.tsx index 09d0b55fc..a8ba0d32a 100644 --- a/apps/dsa-web/src/components/report/__tests__/AnalysisContextSummary.test.tsx +++ b/apps/dsa-web/src/components/report/__tests__/AnalysisContextSummary.test.tsx @@ -1,7 +1,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { historyApi } from '../../../api/history'; -import type { AnalysisContextPackOverview, AnalysisReport, AnalysisResult } from '../../../types/analysis'; +import type { + AnalysisContextPackOverview, + AnalysisReport, + AnalysisResult, + MarketStructureContext, +} from '../../../types/analysis'; import { AnalysisContextSummary } from '../AnalysisContextSummary'; import { ReportSummary } from '../ReportSummary'; @@ -76,6 +81,42 @@ const overview: AnalysisContextPackOverview = { }, }; +const marketStructure: MarketStructureContext = { + schemaVersion: 'market-structure-v1', + status: 'ok', + market: 'cn', + tradeDate: '2026-07-12', + marketThemeContext: { + schemaVersion: 'market-theme-v1', + status: 'ok', + market: 'cn', + activeThemes: [{ name: 'Robotics', rank: 1, source: 'concept' }], + leadingConcepts: [], + leadingIndustries: [], + laggingThemes: [], + themeBreadth: { + activeCount: 1, + leadingConceptCount: 0, + leadingIndustryCount: 0, + laggingCount: 0, + }, + dataQuality: { status: 'ok', missingFields: [], sources: [], errors: [] }, + }, + stockMarketPosition: { + schemaVersion: 'stock-market-position-v1', + status: 'ok', + stockCode: '600519', + stockName: 'Kweichow Moutai', + market: 'cn', + primaryTheme: { name: 'Robotics', source: 'concept', rank: 1 }, + relatedBoards: [], + stockRole: 'follower', + themePhase: 'accelerating', + riskTags: [], + missingFields: [], + }, +}; + describe('AnalysisContextSummary', () => { beforeEach(() => { vi.clearAllMocks(); @@ -253,6 +294,7 @@ describe('ReportSummary analysis context placement', () => { }, details: { analysisContextPackOverview: overview, + marketStructure, }, }; const result: AnalysisResult = { @@ -293,5 +335,7 @@ describe('ReportSummary analysis context placement', () => { expect(contextSummary.compareDocumentPosition(diagnostics) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(diagnostics.compareDocumentPosition(traceability) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.queryByText('AI 建议 / 决策信号')).not.toBeInTheDocument(); + expect(screen.queryByRole('region', { name: '题材主线与个股位置' })).not.toBeInTheDocument(); + expect(screen.queryByText('Robotics')).not.toBeInTheDocument(); }); }); diff --git a/data_provider/tickflow_fetcher.py b/data_provider/tickflow_fetcher.py index 96064682e..67132b116 100644 --- a/data_provider/tickflow_fetcher.py +++ b/data_provider/tickflow_fetcher.py @@ -54,6 +54,7 @@ _CN_MAIN_INDEX_QUOTES = ( _CN_UNIVERSE_ID = "CN_Equity_A" _MAX_SYMBOLS_PER_QUOTE_REQUEST = 5 _CAPABILITY_NEGATIVE_CACHE_TTL_SECONDS = 900 +_SECTOR_RANKINGS_CACHE_TTL_SECONDS = 300 _MAX_DAILY_PREFETCH_LOOKBACK_DAYS = 730 _MIN_DAILY_KLINE_COUNT = 30 _MAX_DAILY_KLINE_COUNT = 10000 @@ -116,6 +117,10 @@ class TickFlowFetcher(BaseFetcher): self._daily_cache_lock = RLock() self._quote_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} self._quote_cache_lock = RLock() + self._sector_rankings_cache: Optional[ + Tuple[float, List[Dict[str, Any]], List[Dict[str, Any]]] + ] = None + self._sector_rankings_cache_lock = RLock() self._capability_lock = RLock() self._capability_supported: Dict[str, Optional[bool]] = { @@ -1128,3 +1133,95 @@ class TickFlowFetcher(BaseFetcher): return None return stats + + def get_sector_rankings(self, n: int = 5) -> Optional[Tuple[List[Dict], List[Dict]]]: + """Build SW1 industry rankings from TickFlow universes and A-share quotes.""" + try: + limit = max(1, int(n)) + except (TypeError, ValueError): + limit = 5 + + now = monotonic() + with self._sector_rankings_cache_lock: + cached = self._sector_rankings_cache + if cached and cached[0] > now: + return [dict(row) for row in cached[1][:limit]], [dict(row) for row in cached[2][:limit]] + + client = self._get_client() + if client is None or not self._capability_available("universe_quotes"): + return None + + try: + universes = client.universes.list() + sw1_ids = [ + str(item.get("id")) + for item in universes or [] + if isinstance(item, dict) + and str(item.get("id") or "").startswith("CN_Equity_SW1_") + ] + if not sw1_ids: + return None + details = client.universes.batch(sw1_ids) + quotes = client.quotes.get(universes=[_CN_UNIVERSE_ID]) + self._mark_capability("universe_quotes", True) + except Exception as exc: + if self._is_universe_permission_error(exc): + self._mark_capability("universe_quotes", False) + logger.info("[TickFlowFetcher] SW1 sector rankings are unavailable for current plan") + return None + raise + + quote_changes: Dict[str, float] = {} + for quote in quotes or []: + if not isinstance(quote, dict): + continue + symbol = str(quote.get("symbol") or "").strip().upper() + ext = quote.get("ext") or {} + change_pct = self._ratio_to_percent(ext.get("change_pct")) + if change_pct is None: + last_price = self._safe_float(quote.get("last_price")) + prev_close = self._safe_float(quote.get("prev_close")) + if last_price is not None and prev_close and prev_close > 0: + change_pct = (last_price - prev_close) / prev_close * 100 + if symbol and change_pct is not None: + quote_changes[symbol] = change_pct + + industry_symbols: Dict[str, set[str]] = {} + universe_by_id = { + str(item.get("id")): item + for item in universes or [] + if isinstance(item, dict) and item.get("id") + } + for universe_id, detail in (details or {}).items(): + summary = universe_by_id.get(str(universe_id), {}) + name = str(summary.get("name") or "").strip() + if name.startswith("SW1"): + name = name[3:].strip() + if not name: + continue + industry_symbols.setdefault(name, set()).update(self._extract_universe_symbols(detail)) + + rows: List[Dict[str, Any]] = [] + for name, symbols in industry_symbols.items(): + changes = [quote_changes[symbol] for symbol in symbols if symbol in quote_changes] + if changes: + rows.append( + { + "name": name, + "change_pct": round(sum(changes) / len(changes), 4), + "source": "tickflow_sw1", + "constituent_count": len(changes), + } + ) + if not rows: + return None + + descending = sorted(rows, key=lambda row: row["change_pct"], reverse=True) + ascending = sorted(rows, key=lambda row: row["change_pct"]) + with self._sector_rankings_cache_lock: + self._sector_rankings_cache = ( + now + _SECTOR_RANKINGS_CACHE_TTL_SECONDS, + descending, + ascending, + ) + return [dict(row) for row in descending[:limit]], [dict(row) for row in ascending[:limit]] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f2d72d504..84d6da9a8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). > For user-friendly release highlights, see the [GitHub Releases](https://github.com/ZhuLinsen/daily_stock_analysis/releases) page. ## [Unreleased] +- [修复] 桌面与 Docker 发布显式安装 `orjson`,桌面 PyInstaller 产物同时冻结并执行运行时导入探针,避免 LiteLLM 调用时报 `No module named 'orjson'`。 +- [改进] 个股报告不再单独展示“题材主线与个股位置”卡片,相关市场结构数据仍保留在分析上下文、模型 Prompt 与决策信号提取链路中。 +- [改进] 通知推送与完整 Markdown/微信报告不再重复附加“AI 决策信号”摘要,DecisionSignal 的存储、告警和 Web AI 建议页保持不变。 +- [改进] TickFlow 新增基于申万一级行业池的行业涨跌排行 fallback,并将基本面/市场结构单能力默认超时由 3 秒调整为 8 秒,降低正常慢响应被提前降级的概率。 diff --git a/docs/desktop-package.md b/docs/desktop-package.md index 05e55ced4..b8e51366e 100644 --- a/docs/desktop-package.md +++ b/docs/desktop-package.md @@ -295,7 +295,7 @@ win-unpacked/ ### 后端启动报 ModuleNotFoundError -PyInstaller 打包时缺少模块,需要在 `scripts/build-backend.ps1` 中增加 `--hidden-import`。 +PyInstaller 打包时缺少模块,需要在 Windows 与 macOS 后端构建脚本中同步增加 `--hidden-import`,并对冻结产物执行运行时导入校验。当前脚本会显式安装、冻结并探测 LiteLLM 运行路径需要的 `orjson`;若日志包含 `No module named 'orjson'`,请升级到修复版本并重新构建,不能只在已发布目录中手工安装依赖。 如果日志提示缺少 `akshare/file_fold/calendar.json`,说明后端冻结产物没有完整收集 AkShare package data。请使用仓库当前的 `scripts/build-backend.ps1` 或 `scripts/build-backend-macos.sh` 重新构建;脚本会在生成桌面包前检查该文件,缺失时直接终止构建。 diff --git a/docs/full-guide.md b/docs/full-guide.md index aa394ea60..ad752d2d3 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -409,7 +409,7 @@ daily_stock_analysis/ | `REALTIME_SOURCE_PRIORITY` | 实时行情源优先级,逗号分隔,例如 `tencent,akshare_sina,efinance,akshare_em`;需要显式加入 `tickflow` 才会使用 TickFlow 实时行情。 | 见 `.env.example` | 可选 | | `ENABLE_FUNDAMENTAL_PIPELINE` | 基本面聚合总开关;关闭时仅返回 `not_supported` 块,不改变原分析链路 | `true` | 可选 | | `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | 基本面阶段总时延预算(秒) | `8.0` | 可选 | -| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | 单能力源调用超时(秒) | `3.0` | 可选 | +| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | 单能力源调用超时(秒);市场结构行业/概念排行也复用该预算 | `8.0` | 可选 | | `FUNDAMENTAL_RETRY_MAX` | 基本面能力重试次数(含首次) | `1` | 可选 | | `FUNDAMENTAL_CACHE_TTL_SECONDS` | 基本面聚合缓存 TTL(秒),短缓存减轻重复拉取 | `120` | 可选 | | `FUNDAMENTAL_CACHE_MAX_ENTRIES` | 基本面缓存最大条目数(TTL 内按时间淘汰) | `256` | 可选 | @@ -426,6 +426,7 @@ daily_stock_analysis/ > - TickFlow 日 K 区间请求会显式传入 `start_time` / `end_time` / `count`;官方 quickstart 明确说明时间范围查询仍受 `count` 限制。若返回非空但行数打满 `count` 且首个返回交易日晚于请求起始交易日,系统会判定为疑似截断,不写入缓存并让 manager 继续回退。 > - 批量分析时,`prefetch_daily_klines()` 会在逐股 `get_daily_data()` 之前预热进程内缓存,不改变对外调用路径。 > - TickFlow 能力按套餐权限分层:有限权限套餐仍可使用主指数查询;支持 `CN_Equity_A` 标的池查询的套餐才会启用 TickFlow 市场统计。 +> - TickFlow 可通过申万一级行业标的池与全 A 股行情生成行业涨跌排行,并优先参与市场结构行业主线 fallback;概念题材排行仍由现有 AkShare / Tushare / Efinance 链路提供。 > - TickFlow 官方 quickstart 提供了 `quotes.get(universes=["CN_Equity_A"])` 用法,但不同 API Key 不一定拥有对应权限;批量日 K、深度和财务等能力也按权限 fail-open。 > - TickFlow 实际返回的 `change_pct` / `amplitude` 为比例值;系统已在接入层统一转换为百分比值,确保与现有数据源字段语义一致。 > - A 股大盘复盘报告采用盘后工作台式结构:固定包含盘面信号、指数明细、板块 Top 表、近三日市场线索、明日交易计划和风险提示;盘面信号以 `66/100(偏暖,可进攻)` 这类纯文本分数表达,避免色块进度条在不同终端显示不一致;近三日市场线索只列标题、来源和链接,不再展示搜索摘要片段;若部分数据源缺失,则保留可用区块并在对应位置降级展示。 diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index 572c547f4..421eaf5e1 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -337,7 +337,7 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat | `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em`; add `tickflow` explicitly to use TickFlow realtime quotes | See .env.example | Optional | | `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional | | `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional | -| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional | +| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call; market-structure industry/concept rankings share this budget | `8.0` | Optional | | `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional | | `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional | | `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional | @@ -391,6 +391,7 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat > - TickFlow daily K-line range requests pass explicit `start_time` / `end_time` / `count`. Because the official quickstart documents that time-range queries are still limited by `count`, non-empty count-capped responses whose first returned trading date is later than the requested start trading date are rejected before normalization or cache writes, allowing manager fallback to continue. > - Batch analysis can warm the per-process TickFlow daily K-line cache through `prefetch_daily_klines()` before per-stock `get_daily_data()` calls. Only validated frames are cached; batch permission failures are negative-cached and degrade to single-stock requests or existing providers. > - TickFlow behavior is capability-based rather than just key-based: limited plans can still enhance main CN indices, while plans with `CN_Equity_A` universe query support also enhance market breadth and stock-list/name lookups. +> - TickFlow can derive SW1 industry rankings from its industry universes and full A-share quotes, and participates first in the market-structure industry fallback. Concept-theme rankings still use the existing AkShare / Tushare / Efinance chain. > - The official quickstart documents `quotes.get(universes=["CN_Equity_A"])`, but online smoke tests confirmed two additional real-world constraints: universe access depends on plan permissions, and `quotes.get(symbols=[...])` has a per-request symbol limit. > - TickFlow currently returns `change_pct` / `amplitude` / `turnover_rate` as ratio values; this integration normalizes them to the project's percent convention so they match AkShare / Tushare / efinance semantics. > - In scheduler mode, if runtime env explicitly sets `RUN_IMMEDIATELY` but does not set `SCHEDULE_RUN_IMMEDIATELY`, the scheduler keeps inheriting the legacy runtime override instead of being pulled back to a persisted `.env` alias value. diff --git a/main.py b/main.py index 88deb518f..85b9ad992 100644 --- a/main.py +++ b/main.py @@ -45,17 +45,21 @@ if os.getenv("GITHUB_ACTIONS") != "true" and os.getenv("USE_PROXY", "false").low os.environ["http_proxy"] = proxy_url os.environ["https_proxy"] = proxy_url -if os.getenv("DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE") == "1": +_packaged_import_probe = os.getenv("DSA_PACKAGED_IMPORT_PROBE") +if _packaged_import_probe: import importlib import sys try: - importlib.import_module("alphasift.dsa_adapter") + importlib.import_module(_packaged_import_probe) except Exception as exc: - print(f"ERROR: packaged AlphaSift adapter import failed: {exc}", file=sys.stderr) + print( + f"ERROR: packaged import failed for {_packaged_import_probe}: {exc}", + file=sys.stderr, + ) sys.exit(1) - print("OK: packaged AlphaSift adapter import succeeded") + print(f"OK: packaged import succeeded for {_packaged_import_probe}") sys.exit(0) import argparse diff --git a/requirements.txt b/requirements.txt index 31276d261..44f396dcd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,6 +34,7 @@ pypinyin>=0.50.0 # Name-to-code resolver (pinyin matching) openpyxl>=3.1.0 # Excel (.xlsx) parsing for import numpy>=1.24.0 # Numeric computing json-repair>=0.55.1 # JSON repair +orjson>=3.10,<4 # LiteLLM runtime JSON dependency; explicitly bundled in desktop builds # AI analysis # Keep the historical minimum version while excluding quarantined builds and avoiding future major breaks. diff --git a/scripts/build-backend-macos.sh b/scripts/build-backend-macos.sh index 8636c04c8..22d24d2a5 100755 --- a/scripts/build-backend-macos.sh +++ b/scripts/build-backend-macos.sh @@ -47,6 +47,9 @@ log "Checking python-multipart availability..." log "Checking AlphaSift adapter availability..." "${PYTHON_BIN}" -c "import alphasift.dsa_adapter" +log "Checking orjson availability..." +"${PYTHON_BIN}" -c "import orjson" + if [[ -d "${ROOT_DIR}/dist/backend" ]]; then rm -rf "${ROOT_DIR}/dist/backend" fi @@ -92,6 +95,7 @@ hidden_imports=( "src.services.alphasift_service" "alphasift" "alphasift.dsa_adapter" + "orjson" "uvicorn.logging" "uvicorn.loops" "uvicorn.loops.auto" @@ -136,13 +140,15 @@ if ! "${packaged_entry}" --help >/tmp/alphasift-packaged-help.log 2>&1; then exit 1 fi -if DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE=1 "${packaged_entry}" >/tmp/alphasift-packaged-import.log 2>&1; then - cat /tmp/alphasift-packaged-import.log -else - echo "ERROR: packaged backend artifact cannot import alphasift.dsa_adapter." - cat /tmp/alphasift-packaged-import.log - exit 1 -fi +for module in alphasift.dsa_adapter orjson; do + if DSA_PACKAGED_IMPORT_PROBE="${module}" "${packaged_entry}" >/tmp/dsa-packaged-import.log 2>&1; then + cat /tmp/dsa-packaged-import.log + else + echo "ERROR: packaged backend artifact cannot import ${module}." + cat /tmp/dsa-packaged-import.log + exit 1 + fi +done log "Verifying packaged AkShare calendar data..." packaged_akshare_calendar="${packaged_root}/_internal/akshare/file_fold/calendar.json" diff --git a/scripts/build-backend.ps1 b/scripts/build-backend.ps1 index f31b173c4..3ea466049 100644 --- a/scripts/build-backend.ps1 +++ b/scripts/build-backend.ps1 @@ -56,6 +56,11 @@ if (-not (Test-PythonCode -Python $pythonBin -Code "import alphasift.dsa_adapter throw 'alphasift.dsa_adapter is not importable after installing requirements.' } +Write-Host 'Checking orjson availability...' +if (-not (Test-PythonCode -Python $pythonBin -Code "import orjson")) { + throw 'orjson is not importable after installing requirements.' +} + if (Test-Path 'dist\backend') { Remove-Item -Recurse -Force 'dist\backend' } @@ -73,6 +78,7 @@ $hiddenImports = @( 'multipart', 'multipart.multipart', 'json_repair', + 'orjson', 'tiktoken', 'tiktoken_ext', 'tiktoken_ext.openai_public', @@ -142,23 +148,25 @@ if (!(Test-Path 'dist\stock_analysis')) { Copy-Item -Path 'dist\stock_analysis' -Destination 'dist\backend\stock_analysis' -Recurse -Force -Write-Host 'Verifying packaged AlphaSift importability...' +Write-Host 'Verifying packaged runtime imports...' $packagedEntry = Join-Path 'dist\backend\stock_analysis' 'stock_analysis.exe' if (-not (Test-Path $packagedEntry)) { throw "Packaged backend entrypoint not found: $packagedEntry" } -$previousProbe = $env:DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE +$previousProbe = $env:DSA_PACKAGED_IMPORT_PROBE try { - $env:DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE = '1' - $probeProcess = Start-Process -FilePath $packagedEntry -Wait -PassThru - if ($probeProcess.ExitCode -ne 0) { - throw "Packaged backend cannot import alphasift.dsa_adapter; probe exited with code $($probeProcess.ExitCode)." + foreach ($module in @('alphasift.dsa_adapter', 'orjson')) { + $env:DSA_PACKAGED_IMPORT_PROBE = $module + $probeProcess = Start-Process -FilePath $packagedEntry -Wait -PassThru + if ($probeProcess.ExitCode -ne 0) { + throw "Packaged backend cannot import $module; probe exited with code $($probeProcess.ExitCode)." + } } } finally { if ($null -eq $previousProbe) { - Remove-Item Env:DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE -ErrorAction SilentlyContinue + Remove-Item Env:DSA_PACKAGED_IMPORT_PROBE -ErrorAction SilentlyContinue } else { - $env:DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE = $previousProbe + $env:DSA_PACKAGED_IMPORT_PROBE = $previousProbe } } diff --git a/src/config.py b/src/config.py index a9addfb74..07f9dd48f 100644 --- a/src/config.py +++ b/src/config.py @@ -1061,7 +1061,7 @@ class Config: # 基本面阶段总预算(秒) fundamental_stage_timeout_seconds: float = FUNDAMENTAL_STAGE_TIMEOUT_SECONDS_DEFAULT # 单能力源调用超时(秒) - fundamental_fetch_timeout_seconds: float = 3.0 + fundamental_fetch_timeout_seconds: float = 8.0 # 单能力失败重试次数(已包含首次) fundamental_retry_max: int = 1 # 基本面上下文短 TTL(秒) @@ -2036,7 +2036,7 @@ class Config: ), fundamental_fetch_timeout_seconds=parse_env_float( os.getenv('FUNDAMENTAL_FETCH_TIMEOUT_SECONDS'), - 3.0, + 8.0, field_name='FUNDAMENTAL_FETCH_TIMEOUT_SECONDS', minimum=0.0, ), diff --git a/src/notification.py b/src/notification.py index acfadfc11..d357d4693 100644 --- a/src/notification.py +++ b/src/notification.py @@ -26,7 +26,6 @@ from enum import Enum from src.config import Config, get_config from src.enums import ReportType from src.market_phase_summary import format_public_market_status_line, format_public_phase_pack_excerpt -from src.services.decision_signal_summary import format_decision_signal_excerpt from src.notification_routing import ( get_notification_route_config, split_notification_route_channels, @@ -364,12 +363,6 @@ class NotificationService( report_language=report_language, ) - def _decision_signal_excerpt(self, result: AnalysisResult, report_language: str) -> str: - return format_decision_signal_excerpt( - getattr(result, "decision_signal_summary", None), - report_language=report_language, - ) - def _public_market_status_line(self, results: List[AnalysisResult], report_language: str) -> str: for result in results or []: line = format_public_market_status_line( @@ -882,9 +875,6 @@ class NotificationService( f"**Confidence:{confidence_stars}**", "", ]) - signal_excerpt = self._decision_signal_excerpt(result, report_language) - if signal_excerpt: - report_lines.extend([signal_excerpt, ""]) self._append_market_snapshot(report_lines, result) # 核心看点 @@ -1253,10 +1243,6 @@ class NotificationService( f"## {signal_emoji} {stock_name} ({result.code})", "", ]) - signal_excerpt = self._decision_signal_excerpt(result, report_language) - if signal_excerpt: - report_lines.extend([signal_excerpt, ""]) - # ========== 舆情与基本面概览(放在最前面)========== intel = dashboard.get('intelligence', {}) if dashboard else {} if intel: @@ -1586,11 +1572,6 @@ class NotificationService( if one_sentence: lines.append(f"📌 **{one_sentence[:80]}**") lines.append("") - signal_excerpt = self._decision_signal_excerpt(result, report_language) - if signal_excerpt: - lines.append(signal_excerpt) - lines.append("") - # 重要信息区(舆情+基本面) info_lines = [] @@ -1844,10 +1825,6 @@ class NotificationService( if excerpt: lines.extend([excerpt, ""]) - signal_excerpt = self._decision_signal_excerpt(result, report_language) - if signal_excerpt: - lines.extend([signal_excerpt, ""]) - self._append_market_snapshot(lines, result) # 核心决策(一句话) diff --git a/src/services/report_renderer.py b/src/services/report_renderer.py index 260489201..708f422d8 100644 --- a/src/services/report_renderer.py +++ b/src/services/report_renderer.py @@ -16,7 +16,6 @@ from typing import Any, Dict, List, Optional from src.analyzer import AnalysisResult from src.config import get_config from src.market_phase_summary import format_public_market_status_line, format_public_phase_pack_excerpt -from src.services.decision_signal_summary import format_decision_signal_excerpt from src.report_language import ( get_localized_stock_name, get_report_labels, @@ -190,12 +189,6 @@ def render( report_language=report_language, ) - def decision_signal_excerpt(result: AnalysisResult) -> str: - return format_decision_signal_excerpt( - getattr(result, "decision_signal_summary", None), - report_language=report_language, - ) - def market_status_line() -> str: for source_results in (results or [], sorted_results): for result in source_results: @@ -225,7 +218,6 @@ def render( "clean_sniper": _clean_sniper_value, "failed_checks": failed_checks, "phase_pack_excerpt": phase_pack_excerpt, - "decision_signal_excerpt": decision_signal_excerpt, "history_by_code": {}, "get_chip_unavailable_reason": get_chip_unavailable_reason, "is_chip_structure_unavailable": is_chip_structure_unavailable, diff --git a/templates/report_markdown.j2 b/templates/report_markdown.j2 index f5550f31d..92b61e498 100644 --- a/templates/report_markdown.j2 +++ b/templates/report_markdown.j2 @@ -24,11 +24,6 @@ ## {{ e.signal_emoji }} {{ e.stock_name }} ({{ result.code }}) -{% set signal_excerpt = decision_signal_excerpt(result) %} -{% if signal_excerpt %} -{{ signal_excerpt }} -{% endif %} - {% if intel %} ### 📰 {{ labels.info_heading }} diff --git a/templates/report_wechat.j2 b/templates/report_wechat.j2 index e5cf37531..75d214468 100644 --- a/templates/report_wechat.j2 +++ b/templates/report_wechat.j2 @@ -24,11 +24,6 @@ {% if one_sentence %} 📌 **{{ one_sentence[:80] }}** {% endif %} -{% set signal_excerpt = decision_signal_excerpt(result) %} -{% if signal_excerpt %} -{{ signal_excerpt }} -{% endif %} - {% if intel.get('earnings_outlook') %} 📊 {{ labels.earnings_outlook_label }}: {{ intel.earnings_outlook[:60] }} {% endif %} diff --git a/tests/test_config_env_compat.py b/tests/test_config_env_compat.py index d10fe5f87..40fb3a501 100644 --- a/tests/test_config_env_compat.py +++ b/tests/test_config_env_compat.py @@ -251,7 +251,7 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase): config = Config._load_from_env() self.assertEqual(config.fundamental_stage_timeout_seconds, 8.0) - self.assertEqual(config.fundamental_fetch_timeout_seconds, 3.0) + self.assertEqual(config.fundamental_fetch_timeout_seconds, 8.0) @patch("src.config.setup_env") @patch.object(Config, "_parse_litellm_yaml", return_value=[]) diff --git a/tests/test_desktop_packaging_assets.py b/tests/test_desktop_packaging_assets.py index 261779cb5..46be694b6 100644 --- a/tests/test_desktop_packaging_assets.py +++ b/tests/test_desktop_packaging_assets.py @@ -12,6 +12,23 @@ class DesktopPackagingAssetsTestCase(unittest.TestCase): repo_root = Path(__file__).resolve().parent.parent + def test_orjson_is_declared_bundled_and_probed(self) -> None: + requirements = (self.repo_root / "requirements.txt").read_text(encoding="utf-8") + main = (self.repo_root / "main.py").read_text(encoding="utf-8") + macos_script = (self.repo_root / "scripts" / "build-backend-macos.sh").read_text( + encoding="utf-8" + ) + windows_script = (self.repo_root / "scripts" / "build-backend.ps1").read_text( + encoding="utf-8" + ) + + self.assertIn("orjson>=3.10,<4", requirements) + self.assertIn('"orjson"', macos_script) + self.assertIn("'orjson'", windows_script) + self.assertIn('DSA_PACKAGED_IMPORT_PROBE="${module}"', macos_script) + self.assertIn("$env:DSA_PACKAGED_IMPORT_PROBE = $module", windows_script) + self.assertIn('importlib.import_module(_packaged_import_probe)', main) + def test_scripts_collect_and_verify_akshare_calendar_data(self) -> None: macos_script = (self.repo_root / "scripts" / "build-backend-macos.sh").read_text( encoding="utf-8" diff --git a/tests/test_notification.py b/tests/test_notification.py index 77839d0b8..e5ecd3f22 100644 --- a/tests/test_notification.py +++ b/tests/test_notification.py @@ -891,7 +891,7 @@ class TestNotificationServiceReportGeneration(unittest.TestCase): self.assertNotIn("盘中决策护栏", out) @mock.patch("src.notification.get_config") - def test_generate_dashboard_report_appends_decision_signal_excerpt_fallback( + def test_generate_dashboard_report_omits_decision_signal_excerpt_fallback( self, mock_get_config: mock.MagicMock ): mock_get_config.return_value = _make_config(report_renderer_enabled=False) @@ -907,15 +907,11 @@ class TestNotificationServiceReportGeneration(unittest.TestCase): out = service.generate_dashboard_report([result], report_date="2026-02-01") - summary_section, detail_section = out.split("---", 1) - self.assertNotIn("AI 决策信号", summary_section) - self.assertIn("AI 决策信号", detail_section) - self.assertIn("动作: 卖出", detail_section) - self.assertIn("周期: 1d", detail_section) - self.assertIn("理由: 技术面走弱", detail_section) + self.assertNotIn("AI 决策信号", out) + self.assertNotIn("理由: 技术面走弱", out) @mock.patch("src.notification.get_config") - def test_generate_daily_report_appends_decision_signal_excerpt_fallback( + def test_generate_daily_report_omits_decision_signal_excerpt_fallback( self, mock_get_config: mock.MagicMock ): mock_get_config.return_value = _make_config(report_renderer_enabled=False) @@ -932,16 +928,11 @@ class TestNotificationServiceReportGeneration(unittest.TestCase): service = NotificationService() service._report_summary_only = summary_only out = service.generate_daily_report([result], report_date="2026-02-01") - self.assertEqual(out.count("AI 决策信号"), 0 if summary_only else 1) - if summary_only: - self.assertNotIn("动作: 卖出", out) - else: - self.assertIn("动作: 卖出", out) - self.assertIn("周期: 1d", out) - self.assertIn("理由: 技术面走弱", out) + self.assertNotIn("AI 决策信号", out) + self.assertNotIn("理由: 技术面走弱", out) @mock.patch("src.notification.get_config") - def test_generate_wechat_dashboard_appends_decision_signal_excerpt_fallback( + def test_generate_wechat_dashboard_omits_decision_signal_excerpt_fallback( self, mock_get_config: mock.MagicMock ): mock_get_config.return_value = _make_config(report_renderer_enabled=False) @@ -958,13 +949,8 @@ class TestNotificationServiceReportGeneration(unittest.TestCase): service = NotificationService() service._report_summary_only = summary_only out = service.generate_wechat_dashboard([result]) - self.assertEqual(out.count("AI 决策信号"), 0 if summary_only else 1) - if summary_only: - self.assertNotIn("动作: 卖出", out) - else: - self.assertIn("动作: 卖出", out) - self.assertIn("周期: 1d", out) - self.assertIn("理由: 技术面走弱", out) + self.assertNotIn("AI 决策信号", out) + self.assertNotIn("理由: 技术面走弱", out) @mock.patch("src.notification.get_config") def test_generate_wechat_summary_omits_decision_signal_excerpt( @@ -987,7 +973,7 @@ class TestNotificationServiceReportGeneration(unittest.TestCase): self.assertNotIn("动作: 卖出", out) @mock.patch("src.notification.get_config") - def test_generate_dashboard_report_appends_decision_signal_excerpt_with_renderer( + def test_generate_dashboard_report_omits_decision_signal_excerpt_with_renderer( self, mock_get_config: mock.MagicMock ): mock_get_config.return_value = _make_config(report_renderer_enabled=True) @@ -1003,12 +989,8 @@ class TestNotificationServiceReportGeneration(unittest.TestCase): out = service.generate_dashboard_report([result], report_date="2026-02-01") - summary_section, detail_section = out.split("---", 1) - self.assertNotIn("AI 决策信号", summary_section) - self.assertIn("AI 决策信号", detail_section) - self.assertIn("动作: 卖出", detail_section) - self.assertIn("周期: 1d", detail_section) - self.assertIn("理由: 技术面走弱", detail_section) + self.assertNotIn("AI 决策信号", out) + self.assertNotIn("理由: 技术面走弱", out) @mock.patch("src.notification.get_config") def test_aggregate_reports_show_compact_market_status_only(self, mock_get_config: mock.MagicMock): diff --git a/tests/test_packaging_build_scripts.py b/tests/test_packaging_build_scripts.py index 2064aa708..d3a572cea 100644 --- a/tests/test_packaging_build_scripts.py +++ b/tests/test_packaging_build_scripts.py @@ -20,14 +20,14 @@ def test_windows_backend_build_script_collects_alphasift_adapter() -> None: assert "--collect-all" in script assert "alphasift.dsa_adapter" in script assert "hiddenImports" in script - assert "Verifying packaged AlphaSift importability" in script - assert "DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE" in script + assert "Verifying packaged runtime imports" in script + assert "DSA_PACKAGED_IMPORT_PROBE" in script assert "Start-Process -FilePath $packagedEntry -Wait -PassThru" in script assert "$probeProcess.ExitCode" in script assert "& $packagedEntry" not in script - assert "Packaged backend cannot import alphasift.dsa_adapter" in script - assert "DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE" in main_py - assert 'importlib.import_module("alphasift.dsa_adapter")' in main_py + assert "Packaged backend cannot import $module" in script + assert "DSA_PACKAGED_IMPORT_PROBE" in main_py + assert "importlib.import_module(_packaged_import_probe)" in main_py def test_macos_backend_build_script_collects_alphasift_adapter() -> None: @@ -40,10 +40,10 @@ def test_macos_backend_build_script_collects_alphasift_adapter() -> None: assert "cmd+=(\"--collect-all\" \"alphasift\")" in script assert "packaged_entry=\"${packaged_root}/stock_analysis\"" in script assert "--help" in script - assert "DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE=1" in script - assert "alphasift-packaged-import.log" in script + assert 'DSA_PACKAGED_IMPORT_PROBE="${module}"' in script + assert "dsa-packaged-import.log" in script assert "PathFinder.find_spec(" not in script assert "zipfile" not in script assert 'normalized.startswith("alphasift/dsa_adapter.")' not in script - assert "DSA_PACKAGED_ALPHASIFT_IMPORT_PROBE" in main_py - assert 'importlib.import_module("alphasift.dsa_adapter")' in main_py + assert "DSA_PACKAGED_IMPORT_PROBE" in main_py + assert "importlib.import_module(_packaged_import_probe)" in main_py diff --git a/tests/test_report_renderer.py b/tests/test_report_renderer.py index 0f2c8c458..4b04a0ded 100644 --- a/tests/test_report_renderer.py +++ b/tests/test_report_renderer.py @@ -135,8 +135,8 @@ class TestReportRenderer(unittest.TestCase): self.assertIn("作战计划", out) self.assertNotIn("盘中决策护栏", out) - def test_render_markdown_keeps_decision_signal_out_of_summary(self) -> None: - """Markdown summary stays compact while full details keep DecisionSignal excerpts.""" + def test_render_markdown_omits_decision_signal_excerpt(self) -> None: + """Markdown reports omit the duplicated DecisionSignal excerpt.""" r = _with_decision_signal_summary(_make_result()) summary_out = render("markdown", [r], summary_only=True) @@ -145,12 +145,8 @@ class TestReportRenderer(unittest.TestCase): full_out = render("markdown", [r], summary_only=False) self.assertIsNotNone(full_out) - summary_section, detail_section = full_out.split("---", 1) - self.assertNotIn("AI 决策信号", summary_section) - self.assertIn("AI 决策信号", detail_section) - self.assertIn("动作: 卖出", detail_section) - self.assertIn("周期: 1d", detail_section) - self.assertIn("理由: 技术面走弱", detail_section) + self.assertNotIn("AI 决策信号", full_out) + self.assertNotIn("理由: 技术面走弱", full_out) def test_render_markdown_phase_decision_section(self) -> None: """Markdown renders phase_decision when present.""" @@ -209,8 +205,8 @@ class TestReportRenderer(unittest.TestCase): self.assertIsNotNone(out) self.assertIn("贵州茅台", out) - def test_render_wechat_keeps_decision_signal_out_of_summary(self) -> None: - """Wechat summary-only stays compact while full details keep DecisionSignal excerpts.""" + def test_render_wechat_omits_decision_signal_excerpt(self) -> None: + """Wechat reports omit the duplicated DecisionSignal excerpt.""" r = _with_decision_signal_summary(_make_result()) summary_out = render("wechat", [r], summary_only=True) @@ -219,10 +215,8 @@ class TestReportRenderer(unittest.TestCase): full_out = render("wechat", [r], summary_only=False) self.assertIsNotNone(full_out) - self.assertIn("AI 决策信号", full_out) - self.assertIn("动作: 卖出", full_out) - self.assertIn("周期: 1d", full_out) - self.assertIn("理由: 技术面走弱", full_out) + self.assertNotIn("AI 决策信号", full_out) + self.assertNotIn("理由: 技术面走弱", full_out) def test_render_brief(self) -> None: """Brief platform renders 3-5 sentence summary.""" diff --git a/tests/test_tickflow_fetcher.py b/tests/test_tickflow_fetcher.py index 5bbdc6874..a1bb41c7d 100644 --- a/tests/test_tickflow_fetcher.py +++ b/tests/test_tickflow_fetcher.py @@ -81,8 +81,10 @@ class _FakeKlinesResource: class _FakeUniverseResource: - def __init__(self, data=None): + def __init__(self, data=None, list_data=None, batch_data=None): self.data = data if data is not None else {"symbols": []} + self.list_data = list_data or [] + self.batch_data = batch_data or {} self.calls = [] def get(self, universe_id): @@ -91,6 +93,12 @@ class _FakeUniverseResource: raise self.data return self.data + def list(self): + return self.list_data + + def batch(self, ids): + return {universe_id: self.batch_data[universe_id] for universe_id in ids} + class _FakeInstrumentsResource: def get(self, symbol): @@ -98,10 +106,10 @@ class _FakeInstrumentsResource: class _FakeClient: - def __init__(self, symbols_data=None, universe_data=None, daily_data=None, batch_data=None, batch_error=None): + def __init__(self, symbols_data=None, universe_data=None, daily_data=None, batch_data=None, batch_error=None, universe_list=None, universe_batch=None): self.quotes = _FakeQuotesResource(symbols_data, universe_data) self.klines = _FakeKlinesResource(daily_data=daily_data, batch_data=batch_data, batch_error=batch_error) - self.universes = _FakeUniverseResource(universe_data) + self.universes = _FakeUniverseResource(universe_data, universe_list, universe_batch) self.instruments = _FakeInstrumentsResource() self.closed = False @@ -355,6 +363,42 @@ class TestTickFlowFetcher(unittest.TestCase): self.assertIsNone(fetcher.get_market_stats()) self.assertEqual(len(fetcher._client.quotes.calls), 1) + def test_get_sector_rankings_aggregates_sw1_universes_and_caches(self): + universe_list = [ + {"id": "CN_Equity_SW1_A", "name": "SW1轻工制造"}, + {"id": "CN_Equity_SW1_B", "name": "SW1轻工制造"}, + {"id": "CN_Equity_SW1_C", "name": "SW1银行"}, + {"id": "CN_Equity_SW2_D", "name": "SW2造纸"}, + ] + universe_batch = { + "CN_Equity_SW1_A": {"symbols": ["600103.SH"]}, + "CN_Equity_SW1_B": {"symbols": ["600103.SH", "002078.SZ"]}, + "CN_Equity_SW1_C": {"symbols": ["000001.SZ"]}, + } + quotes = [ + _quote("600103.SH", change_pct=0.02), + _quote("002078.SZ", change_pct=0.04), + _quote("000001.SZ", change_pct=-0.01), + ] + fetcher = TickFlowFetcher(api_key="sk-test") + fetcher._client = _FakeClient( + universe_data=quotes, + universe_list=universe_list, + universe_batch=universe_batch, + ) + + top, bottom = fetcher.get_sector_rankings(1) + cached_top, cached_bottom = fetcher.get_sector_rankings(1) + + self.assertEqual(top[0]["name"], "轻工制造") + self.assertAlmostEqual(top[0]["change_pct"], 3.0) + self.assertEqual(top[0]["constituent_count"], 2) + self.assertEqual(bottom[0]["name"], "银行") + self.assertAlmostEqual(bottom[0]["change_pct"], -1.0) + self.assertEqual(cached_top, top) + self.assertEqual(cached_bottom, bottom) + self.assertEqual(len(fetcher._client.quotes.calls), 1) + def test_capability_negative_cache_retries_after_ttl(self): fetcher = TickFlowFetcher(api_key="sk-test") fetcher._client = _FakeClient(universe_data=_PermissionLikeError("universe forbidden"))