mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* fix: enforce strict news freshness windows (#697) * fix: normalize timezone-aware publish date parsing (#697) * fix: align analyzer news window with runtime pipeline config (#697)
This commit is contained in:
@@ -110,6 +110,9 @@ SEARXNG_BASE_URLS=
|
|||||||
# ===================================
|
# ===================================
|
||||||
# 新闻时效与分析筛选配置
|
# 新闻时效与分析筛选配置
|
||||||
# ===================================
|
# ===================================
|
||||||
|
# 新闻策略窗口档位:ultra_short(1天) / short(3天) / medium(7天) / long(30天)
|
||||||
|
# 实际窗口 = min(策略窗口, NEWS_MAX_AGE_DAYS)
|
||||||
|
# NEWS_STRATEGY_PROFILE=short
|
||||||
# 新闻最大时效(天),搜索时限制结果在近期内,避免使用过时信息
|
# 新闻最大时效(天),搜索时限制结果在近期内,避免使用过时信息
|
||||||
# NEWS_MAX_AGE_DAYS=3
|
# NEWS_MAX_AGE_DAYS=3
|
||||||
# 乖离率阈值(%),偏离 MA5 超过此值提示不追高;强势趋势股自动放宽到 1.5 倍
|
# 乖离率阈值(%),偏离 MA5 超过此值提示不追高;强势趋势股自动放宽到 1.5 倍
|
||||||
|
|||||||
@@ -164,6 +164,7 @@
|
|||||||
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638 ) Token | 可选 |
|
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638 ) Token | 可选 |
|
||||||
| `PREFETCH_REALTIME_QUOTES` | 实时行情预取开关:设为 `false` 可禁用全市场预取(默认 `true`) | 可选 |
|
| `PREFETCH_REALTIME_QUOTES` | 实时行情预取开关:设为 `false` 可禁用全市场预取(默认 `true`) | 可选 |
|
||||||
| `WECHAT_MSG_TYPE` | 企微消息类型,默认 markdown,支持配置 text 类型,发送纯 markdown 文本 | 可选 |
|
| `WECHAT_MSG_TYPE` | 企微消息类型,默认 markdown,支持配置 text 类型,发送纯 markdown 文本 | 可选 |
|
||||||
|
| `NEWS_STRATEGY_PROFILE` | 新闻策略窗口档位:`ultra_short`(1天) / `short`(3天) / `medium`(7天) / `long`(30天),默认 `short` | 可选 |
|
||||||
| `NEWS_MAX_AGE_DAYS` | 新闻最大时效(天),默认 3,避免使用过时信息 | 可选 |
|
| `NEWS_MAX_AGE_DAYS` | 新闻最大时效(天),默认 3,避免使用过时信息 | 可选 |
|
||||||
| `BIAS_THRESHOLD` | 乖离率阈值(%),默认 5.0,超过提示不追高;强势趋势股自动放宽 | 可选 |
|
| `BIAS_THRESHOLD` | 乖离率阈值(%),默认 5.0,超过提示不追高;强势趋势股自动放宽 | 可选 |
|
||||||
| `AGENT_MODE` | 开启 Agent 策略问股模式(`true`/`false`,默认 false) | 可选 |
|
| `AGENT_MODE` | 开启 Agent 策略问股模式(`true`/`false`,默认 false) | 可选 |
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
- 🐛 **港股 Agent 实时行情去重与快速路由** — 统一 `HK01810` / `1810.HK` / `01810` 等港股代码归一规则;港股实时行情改为直接走单次 `akshare_hk` 路径,避免按 A 股 source priority 重复触发同一失败接口;Agent 运行期对显式 `retriable=false` 的工具失败增加短路缓存,减少同轮分析中的重复失败调用。
|
- 🐛 **港股 Agent 实时行情去重与快速路由** — 统一 `HK01810` / `1810.HK` / `01810` 等港股代码归一规则;港股实时行情改为直接走单次 `akshare_hk` 路径,避免按 A 股 source priority 重复触发同一失败接口;Agent 运行期对显式 `retriable=false` 的工具失败增加短路缓存,减少同轮分析中的重复失败调用。
|
||||||
|
- 📰 **新闻时效硬过滤与策略分窗**(#697)— 新增 `NEWS_STRATEGY_PROFILE`(`ultra_short/short/medium/long`)并与 `NEWS_MAX_AGE_DAYS` 统一计算有效窗口;搜索结果在返回后执行发布时间硬过滤(时间未知剔除、超窗剔除、未来仅容忍 1 天),并在历史 fallback 链路追加相同约束,避免旧闻再次进入“最新动态/风险警报”。
|
||||||
|
|
||||||
## [3.7.0] - 2026-03-15
|
## [3.7.0] - 2026-03-15
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ daily_stock_analysis/
|
|||||||
| `BRAVE_API_KEYS` | Brave Search API Key(美股优化) | 可选 |
|
| `BRAVE_API_KEYS` | Brave Search API Key(美股优化) | 可选 |
|
||||||
| `SERPAPI_API_KEYS` | SerpAPI 备用搜索 | 可选 |
|
| `SERPAPI_API_KEYS` | SerpAPI 备用搜索 | 可选 |
|
||||||
| `SEARXNG_BASE_URLS` | SearXNG 自建实例(无配额兜底,需在 settings.yml 启用 format: json) | 可选 |
|
| `SEARXNG_BASE_URLS` | SearXNG 自建实例(无配额兜底,需在 settings.yml 启用 format: json) | 可选 |
|
||||||
|
| `NEWS_STRATEGY_PROFILE` | 新闻策略窗口档位:`ultra_short`(1天)/`short`(3天)/`medium`(7天)/`long`(30天);实际窗口取与 `NEWS_MAX_AGE_DAYS` 的最小值 | 默认 `short` |
|
||||||
| `NEWS_MAX_AGE_DAYS` | 新闻最大时效(天),搜索时限制结果在近期内 | 默认 `3` |
|
| `NEWS_MAX_AGE_DAYS` | 新闻最大时效(天),搜索时限制结果在近期内 | 默认 `3` |
|
||||||
| `BIAS_THRESHOLD` | 乖离率阈值(%),超过提示不追高;强势趋势股自动放宽到 1.5 倍 | 默认 `5.0` |
|
| `BIAS_THRESHOLD` | 乖离率阈值(%),超过提示不追高;强势趋势股自动放宽到 1.5 倍 | 默认 `5.0` |
|
||||||
|
|
||||||
|
|||||||
1
main.py
1
main.py
@@ -642,6 +642,7 @@ def main() -> int:
|
|||||||
minimax_keys=config.minimax_api_keys,
|
minimax_keys=config.minimax_api_keys,
|
||||||
searxng_base_urls=config.searxng_base_urls,
|
searxng_base_urls=config.searxng_base_urls,
|
||||||
news_max_age_days=config.news_max_age_days,
|
news_max_age_days=config.news_max_age_days,
|
||||||
|
news_strategy_profile=getattr(config, "news_strategy_profile", "short"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if config.gemini_api_key or config.openai_api_key:
|
if config.gemini_api_key or config.openai_api_key:
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ from json_repair import repair_json
|
|||||||
from litellm import Router
|
from litellm import Router
|
||||||
|
|
||||||
from src.agent.llm_adapter import get_thinking_extra_body
|
from src.agent.llm_adapter import get_thinking_extra_body
|
||||||
from src.config import Config, get_config, get_api_keys_for_model, extra_litellm_params, get_configured_llm_models
|
from src.config import (
|
||||||
|
Config,
|
||||||
|
extra_litellm_params,
|
||||||
|
get_api_keys_for_model,
|
||||||
|
get_config,
|
||||||
|
get_configured_llm_models,
|
||||||
|
resolve_news_window_days,
|
||||||
|
)
|
||||||
from src.storage import persist_llm_usage
|
from src.storage import persist_llm_usage
|
||||||
from src.data.stock_mapping import STOCK_NAME_MAP
|
from src.data.stock_mapping import STOCK_NAME_MAP
|
||||||
from src.schemas.report_schema import AnalysisReportSchema
|
from src.schemas.report_schema import AnalysisReportSchema
|
||||||
@@ -1161,6 +1168,22 @@ class GeminiAnalyzer:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# 添加新闻搜索结果(重点区域)
|
# 添加新闻搜索结果(重点区域)
|
||||||
|
news_window_days: Optional[int] = None
|
||||||
|
context_window = context.get("news_window_days")
|
||||||
|
try:
|
||||||
|
if context_window is not None:
|
||||||
|
parsed_window = int(context_window)
|
||||||
|
if parsed_window > 0:
|
||||||
|
news_window_days = parsed_window
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
news_window_days = None
|
||||||
|
|
||||||
|
if news_window_days is None:
|
||||||
|
prompt_config = get_config()
|
||||||
|
news_window_days = resolve_news_window_days(
|
||||||
|
news_max_age_days=getattr(prompt_config, "news_max_age_days", 3),
|
||||||
|
news_strategy_profile=getattr(prompt_config, "news_strategy_profile", "short"),
|
||||||
|
)
|
||||||
prompt += """
|
prompt += """
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1168,10 +1191,14 @@ class GeminiAnalyzer:
|
|||||||
"""
|
"""
|
||||||
if news_context:
|
if news_context:
|
||||||
prompt += f"""
|
prompt += f"""
|
||||||
以下是 **{stock_name}({code})** 近7日的新闻搜索结果,请重点提取:
|
以下是 **{stock_name}({code})** 近{news_window_days}日的新闻搜索结果,请重点提取:
|
||||||
1. 🚨 **风险警报**:减持、处罚、利空
|
1. 🚨 **风险警报**:减持、处罚、利空
|
||||||
2. 🎯 **利好催化**:业绩、合同、政策
|
2. 🎯 **利好催化**:业绩、合同、政策
|
||||||
3. 📊 **业绩预期**:年报预告、业绩快报
|
3. 📊 **业绩预期**:年报预告、业绩快报
|
||||||
|
4. 🕒 **时间规则(强制)**:
|
||||||
|
- 输出到 `risk_alerts` / `positive_catalysts` / `latest_news` 的每一条都必须带具体日期(YYYY-MM-DD)
|
||||||
|
- 超出近{news_window_days}日窗口的新闻一律忽略
|
||||||
|
- 时间未知、无法确定发布日期的新闻一律忽略
|
||||||
|
|
||||||
```
|
```
|
||||||
{news_context}
|
{news_context}
|
||||||
@@ -1226,6 +1253,7 @@ class GeminiAnalyzer:
|
|||||||
- **持仓分类建议**:空仓者怎么做 vs 持仓者怎么做
|
- **持仓分类建议**:空仓者怎么做 vs 持仓者怎么做
|
||||||
- **具体狙击点位**:买入价、止损价、目标价(精确到分)
|
- **具体狙击点位**:买入价、止损价、目标价(精确到分)
|
||||||
- **检查清单**:每项用 ✅/⚠️/❌ 标记
|
- **检查清单**:每项用 ✅/⚠️/❌ 标记
|
||||||
|
- **消息面时间合规**:`latest_news`、`risk_alerts`、`positive_catalysts` 不得包含超出近{news_window_days}日或时间未知的信息
|
||||||
|
|
||||||
请输出完整的 JSON 格式决策仪表盘。"""
|
请输出完整的 JSON 格式决策仪表盘。"""
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ class ConfigIssue:
|
|||||||
_MANAGED_LITELLM_KEY_PROVIDERS = {"gemini", "vertex_ai", "anthropic", "openai", "deepseek"}
|
_MANAGED_LITELLM_KEY_PROVIDERS = {"gemini", "vertex_ai", "anthropic", "openai", "deepseek"}
|
||||||
SUPPORTED_LLM_CHANNEL_PROTOCOLS = ("openai", "anthropic", "gemini", "vertex_ai", "deepseek", "ollama")
|
SUPPORTED_LLM_CHANNEL_PROTOCOLS = ("openai", "anthropic", "gemini", "vertex_ai", "deepseek", "ollama")
|
||||||
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
|
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
|
||||||
|
NEWS_STRATEGY_WINDOWS: Dict[str, int] = {
|
||||||
|
"ultra_short": 1,
|
||||||
|
"short": 3,
|
||||||
|
"medium": 7,
|
||||||
|
"long": 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_env_bool(value: Optional[str], default: bool = False) -> bool:
|
def parse_env_bool(value: Optional[str], default: bool = False) -> bool:
|
||||||
@@ -55,6 +61,19 @@ def parse_env_bool(value: Optional[str], default: bool = False) -> bool:
|
|||||||
return normalized not in _FALSEY_ENV_VALUES
|
return normalized not in _FALSEY_ENV_VALUES
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_news_strategy_profile(value: Optional[str]) -> str:
|
||||||
|
"""Normalize news strategy profile to known values."""
|
||||||
|
candidate = (value or "short").strip().lower()
|
||||||
|
return candidate if candidate in NEWS_STRATEGY_WINDOWS else "short"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_news_window_days(news_max_age_days: int, news_strategy_profile: Optional[str]) -> int:
|
||||||
|
"""Resolve effective news window days from profile and global max-age."""
|
||||||
|
profile = normalize_news_strategy_profile(news_strategy_profile)
|
||||||
|
profile_days = NEWS_STRATEGY_WINDOWS.get(profile, NEWS_STRATEGY_WINDOWS["short"])
|
||||||
|
return max(1, min(max(1, int(news_max_age_days)), profile_days))
|
||||||
|
|
||||||
|
|
||||||
def canonicalize_llm_channel_protocol(value: Optional[str]) -> str:
|
def canonicalize_llm_channel_protocol(value: Optional[str]) -> str:
|
||||||
"""Normalize a protocol label into a LiteLLM provider identifier."""
|
"""Normalize a protocol label into a LiteLLM provider identifier."""
|
||||||
candidate = (value or "").strip().lower().replace("-", "_")
|
candidate = (value or "").strip().lower().replace("-", "_")
|
||||||
@@ -335,6 +354,7 @@ class Config:
|
|||||||
|
|
||||||
# === 新闻与分析筛选配置 ===
|
# === 新闻与分析筛选配置 ===
|
||||||
news_max_age_days: int = 3 # 新闻最大时效(天)
|
news_max_age_days: int = 3 # 新闻最大时效(天)
|
||||||
|
news_strategy_profile: str = "short" # 新闻窗口策略档位:ultra_short/short/medium/long
|
||||||
bias_threshold: float = 5.0 # 乖离率阈值(%),超过此值提示不追高
|
bias_threshold: float = 5.0 # 乖离率阈值(%),超过此值提示不追高
|
||||||
|
|
||||||
# === Agent 模式配置 ===
|
# === Agent 模式配置 ===
|
||||||
@@ -896,6 +916,9 @@ class Config:
|
|||||||
social_sentiment_api_key=os.getenv('SOCIAL_SENTIMENT_API_KEY') or None,
|
social_sentiment_api_key=os.getenv('SOCIAL_SENTIMENT_API_KEY') or None,
|
||||||
social_sentiment_api_url=os.getenv('SOCIAL_SENTIMENT_API_URL', 'https://api.adanos.org').rstrip('/'),
|
social_sentiment_api_url=os.getenv('SOCIAL_SENTIMENT_API_URL', 'https://api.adanos.org').rstrip('/'),
|
||||||
news_max_age_days=max(1, int(os.getenv('NEWS_MAX_AGE_DAYS', '3'))),
|
news_max_age_days=max(1, int(os.getenv('NEWS_MAX_AGE_DAYS', '3'))),
|
||||||
|
news_strategy_profile=cls._parse_news_strategy_profile(
|
||||||
|
os.getenv('NEWS_STRATEGY_PROFILE', 'short')
|
||||||
|
),
|
||||||
bias_threshold=max(1.0, float(os.getenv('BIAS_THRESHOLD', '5.0'))),
|
bias_threshold=max(1.0, float(os.getenv('BIAS_THRESHOLD', '5.0'))),
|
||||||
agent_mode=os.getenv('AGENT_MODE', 'false').lower() == 'true',
|
agent_mode=os.getenv('AGENT_MODE', 'false').lower() == 'true',
|
||||||
_agent_mode_explicit=os.getenv('AGENT_MODE') is not None,
|
_agent_mode_explicit=os.getenv('AGENT_MODE') is not None,
|
||||||
@@ -1310,6 +1333,26 @@ class Config:
|
|||||||
)
|
)
|
||||||
return 'simple'
|
return 'simple'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_news_strategy_profile(cls, value: Optional[str]) -> str:
|
||||||
|
"""Parse NEWS_STRATEGY_PROFILE, fallback to short for invalid values."""
|
||||||
|
normalized = normalize_news_strategy_profile(value)
|
||||||
|
raw = (value or "short").strip().lower()
|
||||||
|
if raw != normalized:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"NEWS_STRATEGY_PROFILE '%s' invalid, fallback to 'short' "
|
||||||
|
"(valid: ultra_short/short/medium/long)",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def get_effective_news_window_days(self) -> int:
|
||||||
|
"""Return effective news window days after profile + max-age merge."""
|
||||||
|
return resolve_news_window_days(
|
||||||
|
news_max_age_days=self.news_max_age_days,
|
||||||
|
news_strategy_profile=self.news_strategy_profile,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_market_review_region(cls, value: str) -> str:
|
def _parse_market_review_region(cls, value: str) -> str:
|
||||||
"""解析大盘复盘市场区域,非法值记录警告后回退为 cn"""
|
"""解析大盘复盘市场区域,非法值记录警告后回退为 cn"""
|
||||||
|
|||||||
@@ -372,6 +372,20 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||||||
"validation": {"min": 1, "max": 30},
|
"validation": {"min": 1, "max": 30},
|
||||||
"display_order": 60,
|
"display_order": 60,
|
||||||
},
|
},
|
||||||
|
"NEWS_STRATEGY_PROFILE": {
|
||||||
|
"title": "News Strategy Profile",
|
||||||
|
"description": "News window profile: ultra_short(1d), short(3d), medium(7d), long(30d). Effective window = min(profile, NEWS_MAX_AGE_DAYS).",
|
||||||
|
"category": "data_source",
|
||||||
|
"data_type": "string",
|
||||||
|
"ui_control": "select",
|
||||||
|
"is_sensitive": False,
|
||||||
|
"is_required": False,
|
||||||
|
"is_editable": True,
|
||||||
|
"default_value": "short",
|
||||||
|
"options": ["ultra_short", "short", "medium", "long"],
|
||||||
|
"validation": {"enum": ["ultra_short", "short", "medium", "long"]},
|
||||||
|
"display_order": 61,
|
||||||
|
},
|
||||||
"BIAS_THRESHOLD": {
|
"BIAS_THRESHOLD": {
|
||||||
"title": "Bias Threshold (%)",
|
"title": "Bias Threshold (%)",
|
||||||
"description": "Deviation threshold from MA5 (%). Exceeding this triggers 'do not chase' warning. Strong trend stocks auto-widen to 1.5x.",
|
"description": "Deviation threshold from MA5 (%). Exceeding this triggers 'do not chase' warning. Strong trend stocks auto-widen to 1.5x.",
|
||||||
@@ -384,7 +398,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||||||
"default_value": "5.0",
|
"default_value": "5.0",
|
||||||
"options": [],
|
"options": [],
|
||||||
"validation": {"min": 0.0, "max": 50.0},
|
"validation": {"min": 0.0, "max": 50.0},
|
||||||
"display_order": 61,
|
"display_order": 62,
|
||||||
},
|
},
|
||||||
"PYTDX_HOST": {
|
"PYTDX_HOST": {
|
||||||
"title": "Pytdx Host",
|
"title": "Pytdx Host",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class StockAnalysisPipeline:
|
|||||||
serpapi_keys=self.config.serpapi_keys,
|
serpapi_keys=self.config.serpapi_keys,
|
||||||
minimax_keys=self.config.minimax_api_keys,
|
minimax_keys=self.config.minimax_api_keys,
|
||||||
news_max_age_days=self.config.news_max_age_days,
|
news_max_age_days=self.config.news_max_age_days,
|
||||||
|
news_strategy_profile=getattr(self.config, "news_strategy_profile", "short"),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"调度器初始化完成,最大并发数: {self.max_workers}")
|
logger.info(f"调度器初始化完成,最大并发数: {self.max_workers}")
|
||||||
@@ -450,6 +451,9 @@ class StockAnalysisPipeline:
|
|||||||
enhanced['stock_name'] = stock_name
|
enhanced['stock_name'] = stock_name
|
||||||
elif realtime_quote and getattr(realtime_quote, 'name', None):
|
elif realtime_quote and getattr(realtime_quote, 'name', None):
|
||||||
enhanced['stock_name'] = realtime_quote.name
|
enhanced['stock_name'] = realtime_quote.name
|
||||||
|
|
||||||
|
# 将运行时搜索窗口透传给 analyzer,避免与全局配置重新读取产生窗口不一致
|
||||||
|
enhanced['news_window_days'] = getattr(self.search_service, "news_window_days", 3)
|
||||||
|
|
||||||
# 添加实时行情(兼容不同数据源的字段差异)
|
# 添加实时行情(兼容不同数据源的字段差异)
|
||||||
if realtime_quote:
|
if realtime_quote:
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ A股自选股智能分析系统 - 搜索服务模块
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
from itertools import cycle
|
from itertools import cycle
|
||||||
import requests
|
import requests
|
||||||
@@ -30,6 +32,11 @@ from tenacity import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from data_provider.us_index_mapping import is_us_index_code
|
from data_provider.us_index_mapping import is_us_index_code
|
||||||
|
from src.config import (
|
||||||
|
NEWS_STRATEGY_WINDOWS,
|
||||||
|
normalize_news_strategy_profile,
|
||||||
|
resolve_news_window_days,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1338,6 +1345,9 @@ class SearchService:
|
|||||||
"{name} technical analysis",
|
"{name} technical analysis",
|
||||||
"{name} {code} performance volume",
|
"{name} {code} performance volume",
|
||||||
]
|
]
|
||||||
|
NEWS_OVERSAMPLE_FACTOR = 2
|
||||||
|
NEWS_OVERSAMPLE_MAX = 10
|
||||||
|
FUTURE_TOLERANCE_DAYS = 1
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -1348,6 +1358,7 @@ class SearchService:
|
|||||||
minimax_keys: Optional[List[str]] = None,
|
minimax_keys: Optional[List[str]] = None,
|
||||||
searxng_base_urls: Optional[List[str]] = None,
|
searxng_base_urls: Optional[List[str]] = None,
|
||||||
news_max_age_days: int = 3,
|
news_max_age_days: int = 3,
|
||||||
|
news_strategy_profile: str = "short",
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
初始化搜索服务
|
初始化搜索服务
|
||||||
@@ -1360,9 +1371,25 @@ class SearchService:
|
|||||||
minimax_keys: MiniMax API Key 列表
|
minimax_keys: MiniMax API Key 列表
|
||||||
searxng_base_urls: SearXNG 实例地址列表(自建无配额兜底)
|
searxng_base_urls: SearXNG 实例地址列表(自建无配额兜底)
|
||||||
news_max_age_days: 新闻最大时效(天)
|
news_max_age_days: 新闻最大时效(天)
|
||||||
|
news_strategy_profile: 新闻窗口策略档位(ultra_short/short/medium/long)
|
||||||
"""
|
"""
|
||||||
self._providers: List[BaseSearchProvider] = []
|
self._providers: List[BaseSearchProvider] = []
|
||||||
self.news_max_age_days = max(1, news_max_age_days)
|
self.news_max_age_days = max(1, news_max_age_days)
|
||||||
|
raw_profile = (news_strategy_profile or "short").strip().lower()
|
||||||
|
self.news_strategy_profile = normalize_news_strategy_profile(news_strategy_profile)
|
||||||
|
if raw_profile != self.news_strategy_profile:
|
||||||
|
logger.warning(
|
||||||
|
"NEWS_STRATEGY_PROFILE '%s' 无效,已回退为 'short'",
|
||||||
|
news_strategy_profile,
|
||||||
|
)
|
||||||
|
self.news_window_days = resolve_news_window_days(
|
||||||
|
news_max_age_days=self.news_max_age_days,
|
||||||
|
news_strategy_profile=self.news_strategy_profile,
|
||||||
|
)
|
||||||
|
self.news_profile_days = NEWS_STRATEGY_WINDOWS.get(
|
||||||
|
self.news_strategy_profile,
|
||||||
|
NEWS_STRATEGY_WINDOWS["short"],
|
||||||
|
)
|
||||||
|
|
||||||
# 初始化搜索引擎(按优先级排序)
|
# 初始化搜索引擎(按优先级排序)
|
||||||
# 1. Bocha 优先(中文搜索优化,AI摘要)
|
# 1. Bocha 优先(中文搜索优化,AI摘要)
|
||||||
@@ -1402,6 +1429,13 @@ class SearchService:
|
|||||||
self._cache: Dict[str, Tuple[float, 'SearchResponse']] = {}
|
self._cache: Dict[str, Tuple[float, 'SearchResponse']] = {}
|
||||||
# Default cache TTL in seconds (10 minutes)
|
# Default cache TTL in seconds (10 minutes)
|
||||||
self._cache_ttl: int = 600
|
self._cache_ttl: int = 600
|
||||||
|
logger.info(
|
||||||
|
"新闻时效策略已启用: profile=%s, profile_days=%s, NEWS_MAX_AGE_DAYS=%s, effective_window=%s",
|
||||||
|
self.news_strategy_profile,
|
||||||
|
self.news_profile_days,
|
||||||
|
self.news_max_age_days,
|
||||||
|
self.news_window_days,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_foreign_stock(stock_code: str) -> bool:
|
def _is_foreign_stock(stock_code: str) -> bool:
|
||||||
@@ -1482,6 +1516,228 @@ class SearchService:
|
|||||||
for k in oldest:
|
for k in oldest:
|
||||||
del self._cache[k]
|
del self._cache[k]
|
||||||
self._cache[key] = (time.time(), response)
|
self._cache[key] = (time.time(), response)
|
||||||
|
|
||||||
|
def _effective_news_window_days(self) -> int:
|
||||||
|
"""Resolve effective news window from strategy profile and global max-age."""
|
||||||
|
return resolve_news_window_days(
|
||||||
|
news_max_age_days=self.news_max_age_days,
|
||||||
|
news_strategy_profile=self.news_strategy_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _provider_request_size(cls, max_results: int) -> int:
|
||||||
|
"""Apply light overfetch before time filtering to avoid sparse outputs."""
|
||||||
|
target = max(1, int(max_results))
|
||||||
|
return max(target, min(target * cls.NEWS_OVERSAMPLE_FACTOR, cls.NEWS_OVERSAMPLE_MAX))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_relative_news_date(text: str, now: datetime) -> Optional[date]:
|
||||||
|
"""Parse common Chinese/English relative-time strings."""
|
||||||
|
raw = (text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lower = raw.lower()
|
||||||
|
if raw in {"今天", "今日", "刚刚"} or lower in {"today", "just now", "now"}:
|
||||||
|
return now.date()
|
||||||
|
if raw == "昨天" or lower == "yesterday":
|
||||||
|
return (now - timedelta(days=1)).date()
|
||||||
|
if raw == "前天":
|
||||||
|
return (now - timedelta(days=2)).date()
|
||||||
|
|
||||||
|
zh = re.match(r"^\s*(\d+)\s*(分钟|小时|天|周|个月|月|年)\s*前\s*$", raw)
|
||||||
|
if zh:
|
||||||
|
amount = int(zh.group(1))
|
||||||
|
unit = zh.group(2)
|
||||||
|
if unit == "分钟":
|
||||||
|
return (now - timedelta(minutes=amount)).date()
|
||||||
|
if unit == "小时":
|
||||||
|
return (now - timedelta(hours=amount)).date()
|
||||||
|
if unit == "天":
|
||||||
|
return (now - timedelta(days=amount)).date()
|
||||||
|
if unit == "周":
|
||||||
|
return (now - timedelta(weeks=amount)).date()
|
||||||
|
if unit in {"个月", "月"}:
|
||||||
|
return (now - timedelta(days=amount * 30)).date()
|
||||||
|
if unit == "年":
|
||||||
|
return (now - timedelta(days=amount * 365)).date()
|
||||||
|
|
||||||
|
en = re.match(
|
||||||
|
r"^\s*(\d+)\s*(minute|minutes|min|mins|hour|hours|day|days|week|weeks|month|months|year|years)\s*ago\s*$",
|
||||||
|
lower,
|
||||||
|
)
|
||||||
|
if en:
|
||||||
|
amount = int(en.group(1))
|
||||||
|
unit = en.group(2)
|
||||||
|
if unit in {"minute", "minutes", "min", "mins"}:
|
||||||
|
return (now - timedelta(minutes=amount)).date()
|
||||||
|
if unit in {"hour", "hours"}:
|
||||||
|
return (now - timedelta(hours=amount)).date()
|
||||||
|
if unit in {"day", "days"}:
|
||||||
|
return (now - timedelta(days=amount)).date()
|
||||||
|
if unit in {"week", "weeks"}:
|
||||||
|
return (now - timedelta(weeks=amount)).date()
|
||||||
|
if unit in {"month", "months"}:
|
||||||
|
return (now - timedelta(days=amount * 30)).date()
|
||||||
|
if unit in {"year", "years"}:
|
||||||
|
return (now - timedelta(days=amount * 365)).date()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_news_publish_date(cls, value: Any) -> Optional[date]:
|
||||||
|
"""Normalize provider date value into a date object."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is not None:
|
||||||
|
local_tz = datetime.now().astimezone().tzinfo or timezone.utc
|
||||||
|
return value.astimezone(local_tz).date()
|
||||||
|
return value.date()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
now = datetime.now()
|
||||||
|
local_tz = now.astimezone().tzinfo or timezone.utc
|
||||||
|
|
||||||
|
relative_date = cls._parse_relative_news_date(text, now)
|
||||||
|
if relative_date:
|
||||||
|
return relative_date
|
||||||
|
|
||||||
|
# Unix timestamp fallback
|
||||||
|
if text.isdigit() and len(text) in (10, 13):
|
||||||
|
try:
|
||||||
|
ts = int(text[:10]) if len(text) == 13 else int(text)
|
||||||
|
# Provider timestamps are typically UTC epoch seconds.
|
||||||
|
# Normalize to local date to keep window checks aligned with local "today".
|
||||||
|
return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(local_tz).date()
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
iso_candidate = text.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
parsed_iso = datetime.fromisoformat(iso_candidate)
|
||||||
|
if parsed_iso.tzinfo is not None:
|
||||||
|
return parsed_iso.astimezone(local_tz).date()
|
||||||
|
return parsed_iso.date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
normalized = re.sub(r"(\d+)(st|nd|rd|th)", r"\1", text, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_rfc = parsedate_to_datetime(normalized)
|
||||||
|
if parsed_rfc:
|
||||||
|
if parsed_rfc.tzinfo is not None:
|
||||||
|
return parsed_rfc.astimezone(local_tz).date()
|
||||||
|
return parsed_rfc.date()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
zh_match = re.search(r"(\d{4})\s*[年/\-.]\s*(\d{1,2})\s*[月/\-.]\s*(\d{1,2})\s*日?", text)
|
||||||
|
if zh_match:
|
||||||
|
try:
|
||||||
|
return date(int(zh_match.group(1)), int(zh_match.group(2)), int(zh_match.group(3)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for fmt in (
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
"%Y-%m-%d %H:%M",
|
||||||
|
"%Y-%m-%d",
|
||||||
|
"%Y/%m/%d %H:%M:%S",
|
||||||
|
"%Y/%m/%d %H:%M",
|
||||||
|
"%Y/%m/%d",
|
||||||
|
"%Y.%m.%d %H:%M:%S",
|
||||||
|
"%Y.%m.%d %H:%M",
|
||||||
|
"%Y.%m.%d",
|
||||||
|
"%Y%m%d",
|
||||||
|
"%b %d, %Y",
|
||||||
|
"%B %d, %Y",
|
||||||
|
"%d %b %Y",
|
||||||
|
"%d %B %Y",
|
||||||
|
"%a, %d %b %Y %H:%M:%S %z",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
parsed_dt = datetime.strptime(normalized, fmt)
|
||||||
|
if parsed_dt.tzinfo is not None:
|
||||||
|
return parsed_dt.astimezone(local_tz).date()
|
||||||
|
return parsed_dt.date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _filter_news_response(
|
||||||
|
self,
|
||||||
|
response: SearchResponse,
|
||||||
|
*,
|
||||||
|
search_days: int,
|
||||||
|
max_results: int,
|
||||||
|
log_scope: str,
|
||||||
|
) -> SearchResponse:
|
||||||
|
"""Hard-filter results by published_date recency and normalize date strings."""
|
||||||
|
if not response.success or not response.results:
|
||||||
|
return response
|
||||||
|
|
||||||
|
today = datetime.now().date()
|
||||||
|
earliest = today - timedelta(days=max(0, int(search_days) - 1))
|
||||||
|
latest = today + timedelta(days=self.FUTURE_TOLERANCE_DAYS)
|
||||||
|
|
||||||
|
filtered: List[SearchResult] = []
|
||||||
|
dropped_unknown = 0
|
||||||
|
dropped_old = 0
|
||||||
|
dropped_future = 0
|
||||||
|
|
||||||
|
for item in response.results:
|
||||||
|
published = self._normalize_news_publish_date(item.published_date)
|
||||||
|
if published is None:
|
||||||
|
dropped_unknown += 1
|
||||||
|
continue
|
||||||
|
if published < earliest:
|
||||||
|
dropped_old += 1
|
||||||
|
continue
|
||||||
|
if published > latest:
|
||||||
|
dropped_future += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered.append(
|
||||||
|
SearchResult(
|
||||||
|
title=item.title,
|
||||||
|
snippet=item.snippet,
|
||||||
|
url=item.url,
|
||||||
|
source=item.source,
|
||||||
|
published_date=published.isoformat(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(filtered) >= max_results:
|
||||||
|
break
|
||||||
|
|
||||||
|
if dropped_unknown or dropped_old or dropped_future:
|
||||||
|
logger.info(
|
||||||
|
"[新闻过滤] %s: provider=%s, total=%s, kept=%s, drop_unknown=%s, drop_old=%s, drop_future=%s, window=[%s,%s]",
|
||||||
|
log_scope,
|
||||||
|
response.provider,
|
||||||
|
len(response.results),
|
||||||
|
len(filtered),
|
||||||
|
dropped_unknown,
|
||||||
|
dropped_old,
|
||||||
|
dropped_future,
|
||||||
|
earliest.isoformat(),
|
||||||
|
latest.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResponse(
|
||||||
|
query=response.query,
|
||||||
|
results=filtered,
|
||||||
|
provider=response.provider,
|
||||||
|
success=response.success,
|
||||||
|
error_message=response.error_message,
|
||||||
|
search_time=response.search_time,
|
||||||
|
)
|
||||||
|
|
||||||
def search_stock_news(
|
def search_stock_news(
|
||||||
self,
|
self,
|
||||||
@@ -1502,20 +1758,10 @@ class SearchService:
|
|||||||
Returns:
|
Returns:
|
||||||
SearchResponse 对象
|
SearchResponse 对象
|
||||||
"""
|
"""
|
||||||
# 智能确定搜索时间范围
|
# 策略窗口优先:ultra_short/short/medium/long = 1/3/7/30 天,
|
||||||
# 策略:
|
# 并统一受 NEWS_MAX_AGE_DAYS 上限约束。
|
||||||
# 1. 周二至周五:搜索近1天(24小时)
|
search_days = self._effective_news_window_days()
|
||||||
# 2. 周六、周日:搜索近2-3天(覆盖周末)
|
provider_max_results = self._provider_request_size(max_results)
|
||||||
# 3. 周一:搜索近3天(覆盖周末)
|
|
||||||
# 4. 用 NEWS_MAX_AGE_DAYS 限制上限
|
|
||||||
today_weekday = datetime.now().weekday()
|
|
||||||
if today_weekday == 0: # 周一
|
|
||||||
weekday_days = 3
|
|
||||||
elif today_weekday >= 5: # 周六(5)、周日(6)
|
|
||||||
weekday_days = 2
|
|
||||||
else: # 周二(1) - 周五(4)
|
|
||||||
weekday_days = 1
|
|
||||||
search_days = min(weekday_days, self.news_max_age_days)
|
|
||||||
|
|
||||||
# 构建搜索查询(优化搜索效果)
|
# 构建搜索查询(优化搜索效果)
|
||||||
is_foreign = self._is_foreign_stock(stock_code)
|
is_foreign = self._is_foreign_stock(stock_code)
|
||||||
@@ -1529,7 +1775,15 @@ class SearchService:
|
|||||||
# 默认主查询:股票名称 + 核心关键词
|
# 默认主查询:股票名称 + 核心关键词
|
||||||
query = f"{stock_name} {stock_code} 股票 最新消息"
|
query = f"{stock_name} {stock_code} 股票 最新消息"
|
||||||
|
|
||||||
logger.info(f"搜索股票新闻: {stock_name}({stock_code}), query='{query}', 时间范围: 近{search_days}天")
|
logger.info(
|
||||||
|
"搜索股票新闻: %s(%s), query='%s', 时间范围: 近%s天, 目标条数=%s, provider请求条数=%s",
|
||||||
|
stock_name,
|
||||||
|
stock_code,
|
||||||
|
query,
|
||||||
|
search_days,
|
||||||
|
max_results,
|
||||||
|
provider_max_results,
|
||||||
|
)
|
||||||
|
|
||||||
# Check cache first
|
# Check cache first
|
||||||
cache_key = self._cache_key(query, max_results, search_days)
|
cache_key = self._cache_key(query, max_results, search_days)
|
||||||
@@ -1538,19 +1792,46 @@ class SearchService:
|
|||||||
logger.info(f"使用缓存搜索结果: {stock_name}({stock_code})")
|
logger.info(f"使用缓存搜索结果: {stock_name}({stock_code})")
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# 依次尝试各个搜索引擎
|
# 依次尝试各个搜索引擎(若过滤后为空,继续尝试下一引擎)
|
||||||
|
had_provider_success = False
|
||||||
for provider in self._providers:
|
for provider in self._providers:
|
||||||
if not provider.is_available:
|
if not provider.is_available:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
response = provider.search(query, max_results, days=search_days)
|
response = provider.search(query, provider_max_results, days=search_days)
|
||||||
|
filtered_response = self._filter_news_response(
|
||||||
if response.success and response.results:
|
response,
|
||||||
|
search_days=search_days,
|
||||||
|
max_results=max_results,
|
||||||
|
log_scope=f"{stock_code}:{provider.name}:stock_news",
|
||||||
|
)
|
||||||
|
had_provider_success = had_provider_success or bool(response.success)
|
||||||
|
|
||||||
|
if filtered_response.success and filtered_response.results:
|
||||||
logger.info(f"使用 {provider.name} 搜索成功")
|
logger.info(f"使用 {provider.name} 搜索成功")
|
||||||
self._put_cache(cache_key, response)
|
self._put_cache(cache_key, filtered_response)
|
||||||
return response
|
return filtered_response
|
||||||
else:
|
else:
|
||||||
logger.warning(f"{provider.name} 搜索失败: {response.error_message},尝试下一个引擎")
|
if response.success and not filtered_response.results:
|
||||||
|
logger.info(
|
||||||
|
"%s 搜索成功但过滤后无有效新闻,继续尝试下一引擎",
|
||||||
|
provider.name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"%s 搜索失败: %s,尝试下一个引擎",
|
||||||
|
provider.name,
|
||||||
|
response.error_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
if had_provider_success:
|
||||||
|
return SearchResponse(
|
||||||
|
query=query,
|
||||||
|
results=[],
|
||||||
|
provider="Filtered",
|
||||||
|
success=True,
|
||||||
|
error_message=None,
|
||||||
|
)
|
||||||
|
|
||||||
# 所有引擎都失败
|
# 所有引擎都失败
|
||||||
return SearchResponse(
|
return SearchResponse(
|
||||||
@@ -1673,7 +1954,18 @@ class SearchService:
|
|||||||
), 'desc': '行业分析'},
|
), 'desc': '行业分析'},
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info(f"开始多维度情报搜索: {stock_name}({stock_code})")
|
search_days = self._effective_news_window_days()
|
||||||
|
target_per_dimension = 3
|
||||||
|
provider_max_results = self._provider_request_size(target_per_dimension)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"开始多维度情报搜索: %s(%s), 时间范围: 近%s天, 目标条数=%s, provider请求条数=%s",
|
||||||
|
stock_name,
|
||||||
|
stock_code,
|
||||||
|
search_days,
|
||||||
|
target_per_dimension,
|
||||||
|
provider_max_results,
|
||||||
|
)
|
||||||
|
|
||||||
# 轮流使用不同的搜索引擎
|
# 轮流使用不同的搜索引擎
|
||||||
provider_index = 0
|
provider_index = 0
|
||||||
@@ -1692,12 +1984,27 @@ class SearchService:
|
|||||||
|
|
||||||
logger.info(f"[情报搜索] {dim['desc']}: 使用 {provider.name}")
|
logger.info(f"[情报搜索] {dim['desc']}: 使用 {provider.name}")
|
||||||
|
|
||||||
response = provider.search(dim['query'], max_results=3, days=self.news_max_age_days)
|
response = provider.search(
|
||||||
results[dim['name']] = response
|
dim['query'],
|
||||||
|
max_results=provider_max_results,
|
||||||
|
days=search_days,
|
||||||
|
)
|
||||||
|
filtered_response = self._filter_news_response(
|
||||||
|
response,
|
||||||
|
search_days=search_days,
|
||||||
|
max_results=target_per_dimension,
|
||||||
|
log_scope=f"{stock_code}:{provider.name}:{dim['name']}",
|
||||||
|
)
|
||||||
|
results[dim['name']] = filtered_response
|
||||||
search_count += 1
|
search_count += 1
|
||||||
|
|
||||||
if response.success:
|
if response.success:
|
||||||
logger.info(f"[情报搜索] {dim['desc']}: 获取 {len(response.results)} 条结果")
|
logger.info(
|
||||||
|
"[情报搜索] %s: 原始=%s条, 过滤后=%s条",
|
||||||
|
dim['desc'],
|
||||||
|
len(response.results),
|
||||||
|
len(filtered_response.results),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[情报搜索] {dim['desc']}: 搜索失败 - {response.error_message}")
|
logger.warning(f"[情报搜索] {dim['desc']}: 搜索失败 - {response.error_message}")
|
||||||
|
|
||||||
@@ -1978,6 +2285,7 @@ def get_search_service() -> SearchService:
|
|||||||
minimax_keys=config.minimax_api_keys,
|
minimax_keys=config.minimax_api_keys,
|
||||||
searxng_base_urls=config.searxng_base_urls,
|
searxng_base_urls=config.searxng_base_urls,
|
||||||
news_max_age_days=config.news_max_age_days,
|
news_max_age_days=config.news_max_age_days,
|
||||||
|
news_strategy_profile=getattr(config, "news_strategy_profile", "short"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return _search_service
|
return _search_service
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ Responsibilities:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Optional, Dict, Any, List, Tuple, TYPE_CHECKING
|
from typing import Optional, Dict, Any, List, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
|
from src.config import get_config, resolve_news_window_days
|
||||||
from src.storage import DatabaseManager
|
from src.storage import DatabaseManager
|
||||||
from src.utils.data_processing import normalize_model_used, parse_json_field
|
from src.utils.data_processing import normalize_model_used, parse_json_field
|
||||||
|
|
||||||
@@ -381,7 +382,31 @@ class HistoryService:
|
|||||||
if item.fetched_at and start_time <= item.fetched_at <= end_time
|
if item.fetched_at and start_time <= item.fetched_at <= end_time
|
||||||
]
|
]
|
||||||
|
|
||||||
return matched[:limit]
|
# 历史兜底链路也做发布时间硬过滤,避免旧库脏数据重新冒出。
|
||||||
|
cfg = get_config()
|
||||||
|
window_days = resolve_news_window_days(
|
||||||
|
news_max_age_days=getattr(cfg, "news_max_age_days", 3),
|
||||||
|
news_strategy_profile=getattr(cfg, "news_strategy_profile", "short"),
|
||||||
|
)
|
||||||
|
# Anchor to analysis date instead of "today" to preserve historical context.
|
||||||
|
anchor_date = analysis.created_at.date()
|
||||||
|
latest_allowed = anchor_date + timedelta(days=1)
|
||||||
|
earliest_allowed = anchor_date - timedelta(days=max(0, window_days - 1))
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for item in matched:
|
||||||
|
if not item.published_date:
|
||||||
|
continue
|
||||||
|
if isinstance(item.published_date, datetime):
|
||||||
|
published = item.published_date.date()
|
||||||
|
elif isinstance(item.published_date, date):
|
||||||
|
published = item.published_date
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if earliest_allowed <= published <= latest_allowed:
|
||||||
|
filtered.append(item)
|
||||||
|
|
||||||
|
return filtered[:limit]
|
||||||
|
|
||||||
def _get_sentiment_label(self, score: int) -> str:
|
def _get_sentiment_label(self, score: int) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -86,6 +86,19 @@ def _get_akshare_name_to_code() -> Optional[Dict[str, str]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_single_char_typo(input_name: str, candidate_name: str) -> bool:
|
||||||
|
"""Return True when two names only differ by one character position."""
|
||||||
|
if not input_name or not candidate_name:
|
||||||
|
return False
|
||||||
|
if len(input_name) != len(candidate_name):
|
||||||
|
return False
|
||||||
|
# Keep typo fallback conservative: only for names with enough signal.
|
||||||
|
if len(input_name) < 3:
|
||||||
|
return False
|
||||||
|
diff = sum(1 for a, b in zip(input_name, candidate_name) if a != b)
|
||||||
|
return diff == 1
|
||||||
|
|
||||||
|
|
||||||
def resolve_name_to_code(name: str) -> Optional[str]:
|
def resolve_name_to_code(name: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Resolve stock name to code.
|
Resolve stock name to code.
|
||||||
@@ -147,10 +160,19 @@ def resolve_name_to_code(name: str) -> Optional[str]:
|
|||||||
# e.g. '中国' matching arbitrary company names in a pool of 5000+ stocks.
|
# e.g. '中国' matching arbitrary company names in a pool of 5000+ stocks.
|
||||||
# Use a higher cutoff (0.8) to reduce mis-hits on longer inputs as well.
|
# Use a higher cutoff (0.8) to reduce mis-hits on longer inputs as well.
|
||||||
if len(s) > 2:
|
if len(s) > 2:
|
||||||
matches = difflib.get_close_matches(s, list(all_name_to_code.keys()), n=1, cutoff=0.8)
|
names = list(all_name_to_code.keys())
|
||||||
|
matches = difflib.get_close_matches(s, names, n=1, cutoff=0.8)
|
||||||
if matches:
|
if matches:
|
||||||
logger.debug(f"[NameResolver] 命中模糊匹配: input={s}, matched={matches[0]}")
|
logger.debug(f"[NameResolver] 命中模糊匹配: input={s}, matched={matches[0]}")
|
||||||
return all_name_to_code[matches[0]]
|
return all_name_to_code[matches[0]]
|
||||||
|
|
||||||
|
# Conservative fallback for one-character typo in medium/long names.
|
||||||
|
# This keeps the strict default threshold while fixing obvious misspellings
|
||||||
|
# such as "贵州茅苔" -> "贵州茅台".
|
||||||
|
typo_matches = difflib.get_close_matches(s, names, n=1, cutoff=0.7)
|
||||||
|
if typo_matches and _is_single_char_typo(s, typo_matches[0]):
|
||||||
|
logger.debug(f"[NameResolver] 命中单字误写兜底: input={s}, matched={typo_matches[0]}")
|
||||||
|
return all_name_to_code[typo_matches[0]]
|
||||||
|
|
||||||
logger.debug(f"[NameResolver] 解析失败: {s}")
|
logger.debug(f"[NameResolver] 解析失败: {s}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
63
tests/test_analyzer_news_prompt.py
Normal file
63
tests/test_analyzer_news_prompt.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for analyzer news prompt hard constraints (Issue #697)."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
try:
|
||||||
|
import litellm # noqa: F401
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
sys.modules["litellm"] = MagicMock()
|
||||||
|
|
||||||
|
from src.analyzer import GeminiAnalyzer
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzerNewsPromptTestCase(unittest.TestCase):
|
||||||
|
def test_prompt_contains_time_constraints(self) -> None:
|
||||||
|
with patch.object(GeminiAnalyzer, "_init_litellm", return_value=None):
|
||||||
|
analyzer = GeminiAnalyzer()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"code": "600519",
|
||||||
|
"stock_name": "贵州茅台",
|
||||||
|
"date": "2026-03-16",
|
||||||
|
"today": {},
|
||||||
|
}
|
||||||
|
fake_cfg = SimpleNamespace(
|
||||||
|
news_max_age_days=30,
|
||||||
|
news_strategy_profile="medium", # 7 days
|
||||||
|
)
|
||||||
|
with patch("src.analyzer.get_config", return_value=fake_cfg):
|
||||||
|
prompt = analyzer._format_prompt(context, "贵州茅台", news_context="news")
|
||||||
|
|
||||||
|
self.assertIn("近7日的新闻搜索结果", prompt)
|
||||||
|
self.assertIn("每一条都必须带具体日期(YYYY-MM-DD)", prompt)
|
||||||
|
self.assertIn("超出近7日窗口的新闻一律忽略", prompt)
|
||||||
|
self.assertIn("时间未知、无法确定发布日期的新闻一律忽略", prompt)
|
||||||
|
|
||||||
|
def test_prompt_prefers_context_news_window_days(self) -> None:
|
||||||
|
with patch.object(GeminiAnalyzer, "_init_litellm", return_value=None):
|
||||||
|
analyzer = GeminiAnalyzer()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"code": "600519",
|
||||||
|
"stock_name": "贵州茅台",
|
||||||
|
"date": "2026-03-16",
|
||||||
|
"today": {},
|
||||||
|
"news_window_days": 1,
|
||||||
|
}
|
||||||
|
fake_cfg = SimpleNamespace(
|
||||||
|
news_max_age_days=30,
|
||||||
|
news_strategy_profile="long", # 30 days if fallback is used
|
||||||
|
)
|
||||||
|
with patch("src.analyzer.get_config", return_value=fake_cfg):
|
||||||
|
prompt = analyzer._format_prompt(context, "贵州茅台", news_context="news")
|
||||||
|
|
||||||
|
self.assertIn("近1日的新闻搜索结果", prompt)
|
||||||
|
self.assertIn("超出近1日窗口的新闻一律忽略", prompt)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
77
tests/test_history_news_fallback.py
Normal file
77
tests/test_history_news_fallback.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for history fallback published_date hard filtering (Issue #697)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from src.services.history_service import HistoryService
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryNewsFallbackTestCase(unittest.TestCase):
|
||||||
|
def test_fallback_filters_by_published_date_window(self) -> None:
|
||||||
|
now = datetime.now()
|
||||||
|
analysis = SimpleNamespace(code="600519", created_at=now)
|
||||||
|
|
||||||
|
# All entries are within fetched_at window; only one should pass published_date window.
|
||||||
|
candidates = [
|
||||||
|
SimpleNamespace(
|
||||||
|
fetched_at=now,
|
||||||
|
published_date=now - timedelta(days=20), # too old
|
||||||
|
title="old",
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
fetched_at=now,
|
||||||
|
published_date=None, # unknown -> drop
|
||||||
|
title="unknown",
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
fetched_at=now,
|
||||||
|
published_date=now - timedelta(days=1), # valid
|
||||||
|
title="fresh",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.get_analysis_history.return_value = [analysis]
|
||||||
|
mock_db.get_recent_news.return_value = candidates
|
||||||
|
|
||||||
|
svc = HistoryService(db_manager=mock_db)
|
||||||
|
fake_cfg = SimpleNamespace(news_max_age_days=30, news_strategy_profile="short")
|
||||||
|
with patch("src.services.history_service.get_config", return_value=fake_cfg):
|
||||||
|
result = svc._fallback_news_by_analysis_context("q-1", limit=20)
|
||||||
|
|
||||||
|
self.assertEqual([item.title for item in result], ["fresh"])
|
||||||
|
|
||||||
|
def test_fallback_uses_analysis_date_as_window_anchor(self) -> None:
|
||||||
|
analysis_time = datetime.now() - timedelta(days=40)
|
||||||
|
analysis = SimpleNamespace(code="600519", created_at=analysis_time)
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
SimpleNamespace(
|
||||||
|
fetched_at=analysis_time,
|
||||||
|
published_date=analysis_time - timedelta(days=10), # too old for short profile
|
||||||
|
title="too_old_for_analysis_window",
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
fetched_at=analysis_time,
|
||||||
|
published_date=analysis_time - timedelta(days=1), # valid around analysis date
|
||||||
|
title="valid_near_analysis_date",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.get_analysis_history.return_value = [analysis]
|
||||||
|
mock_db.get_recent_news.return_value = candidates
|
||||||
|
|
||||||
|
svc = HistoryService(db_manager=mock_db)
|
||||||
|
fake_cfg = SimpleNamespace(news_max_age_days=30, news_strategy_profile="short")
|
||||||
|
with patch("src.services.history_service.get_config", return_value=fake_cfg):
|
||||||
|
result = svc._fallback_news_by_analysis_context("q-1", limit=20)
|
||||||
|
|
||||||
|
self.assertEqual([item.title for item in result], ["valid_near_analysis_date"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
23
tests/test_news_strategy_config.py
Normal file
23
tests/test_news_strategy_config.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for NEWS_STRATEGY_PROFILE parsing and effective window calculation."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from src.config import Config, resolve_news_window_days
|
||||||
|
|
||||||
|
|
||||||
|
class NewsStrategyConfigTestCase(unittest.TestCase):
|
||||||
|
def test_invalid_profile_fallback_to_short(self) -> None:
|
||||||
|
self.assertEqual(Config._parse_news_strategy_profile("bad_value"), "short")
|
||||||
|
|
||||||
|
def test_window_respects_news_max_age_days(self) -> None:
|
||||||
|
# medium=7 but max-age=3 -> effective=3
|
||||||
|
self.assertEqual(resolve_news_window_days(3, "medium"), 3)
|
||||||
|
# long=30 with max-age=30 -> effective=30
|
||||||
|
self.assertEqual(resolve_news_window_days(30, "long"), 30)
|
||||||
|
# ultra_short=1 with max-age=30 -> effective=1
|
||||||
|
self.assertEqual(resolve_news_window_days(30, "ultra_short"), 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -197,6 +197,16 @@ class TestEnhanceContextRealtimeOverride(unittest.TestCase):
|
|||||||
self.assertIn("price_change_ratio", enhanced)
|
self.assertIn("price_change_ratio", enhanced)
|
||||||
self.assertIn("volume_change_ratio", enhanced)
|
self.assertIn("volume_change_ratio", enhanced)
|
||||||
|
|
||||||
|
def test_enhance_context_injects_runtime_news_window_days(self) -> None:
|
||||||
|
context = {"code": "600519", "today": {"close": 15.0}}
|
||||||
|
enhanced = self.pipeline._enhance_context(
|
||||||
|
context, None, None, None, "贵州茅台"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
enhanced["news_window_days"],
|
||||||
|
self.pipeline.search_service.news_window_days,
|
||||||
|
)
|
||||||
|
|
||||||
def test_today_not_overridden_when_trend_missing(self) -> None:
|
def test_today_not_overridden_when_trend_missing(self) -> None:
|
||||||
context = {"code": "600519", "today": {"close": 15.0}}
|
context = {"code": "600519", "today": {"close": 15.0}}
|
||||||
quote = _make_realtime_quote(price=15.72)
|
quote = _make_realtime_quote(price=15.72)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Unit tests for search_stock_news and search_comprehensive_intel news_max_age_days logic (Issue #296).
|
Unit tests for strict news freshness filtering and strategy window logic (Issue #697).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
# Mock newspaper before search_service import (optional dependency)
|
# Mock newspaper before search_service import (optional dependency)
|
||||||
@@ -17,107 +19,199 @@ if "newspaper" not in sys.modules:
|
|||||||
from src.search_service import SearchResponse, SearchResult, SearchService
|
from src.search_service import SearchResponse, SearchResult, SearchService
|
||||||
|
|
||||||
|
|
||||||
def _fake_search_response() -> SearchResponse:
|
def _result(title: str, published_date: str | None) -> SearchResult:
|
||||||
"""Return a successful SearchResponse for mocking."""
|
return SearchResult(
|
||||||
|
title=title,
|
||||||
|
snippet="snippet",
|
||||||
|
url=f"https://example.com/{title}",
|
||||||
|
source="example.com",
|
||||||
|
published_date=published_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _response(results) -> SearchResponse:
|
||||||
return SearchResponse(
|
return SearchResponse(
|
||||||
query="test",
|
query="test",
|
||||||
results=[
|
results=results,
|
||||||
SearchResult(
|
|
||||||
title="Test",
|
|
||||||
snippet="snippet",
|
|
||||||
url="https://example.com/1",
|
|
||||||
source="example.com",
|
|
||||||
published_date=None,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
provider="Mock",
|
provider="Mock",
|
||||||
success=True,
|
success=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SearchNewsFreshnessTestCase(unittest.TestCase):
|
class SearchNewsFreshnessTestCase(unittest.TestCase):
|
||||||
"""Tests for news_max_age_days in search_stock_news and search_comprehensive_intel."""
|
"""Tests for strategy window and strict published_date filtering."""
|
||||||
|
|
||||||
def _create_service_with_mock_provider(self, news_max_age_days: int = 3):
|
def _create_service_with_mock_provider(
|
||||||
"""Create SearchService with a mock provider that records search() calls."""
|
self,
|
||||||
|
*,
|
||||||
|
news_max_age_days: int = 3,
|
||||||
|
news_strategy_profile: str = "short",
|
||||||
|
response: SearchResponse | None = None,
|
||||||
|
):
|
||||||
service = SearchService(
|
service = SearchService(
|
||||||
bocha_keys=["dummy_key"],
|
bocha_keys=["dummy_key"],
|
||||||
news_max_age_days=news_max_age_days,
|
news_max_age_days=news_max_age_days,
|
||||||
|
news_strategy_profile=news_strategy_profile,
|
||||||
|
)
|
||||||
|
mock_search = MagicMock(
|
||||||
|
return_value=response
|
||||||
|
or _response([_result("default", datetime.now().date().isoformat())])
|
||||||
)
|
)
|
||||||
mock_search = MagicMock(return_value=_fake_search_response())
|
|
||||||
service._providers[0].search = mock_search
|
service._providers[0].search = mock_search
|
||||||
return service, mock_search
|
return service, mock_search
|
||||||
|
|
||||||
@patch("src.search_service.datetime")
|
def test_effective_window_uses_profile_and_news_max_age(self) -> None:
|
||||||
def test_search_stock_news_days_monday_limit_by_news_max_age(
|
"""window = min(profile_days, NEWS_MAX_AGE_DAYS)."""
|
||||||
self, mock_dt: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Monday + news_max_age_days=1 -> search_days=1 (min(3,1)=1)."""
|
|
||||||
mock_dt.now.return_value.weekday.return_value = 0 # Monday -> weekday_days=3
|
|
||||||
service, mock_search = self._create_service_with_mock_provider(
|
service, mock_search = self._create_service_with_mock_provider(
|
||||||
news_max_age_days=1
|
news_max_age_days=3,
|
||||||
|
news_strategy_profile="medium", # 7
|
||||||
)
|
)
|
||||||
service.search_stock_news("600519", "贵州茅台")
|
service.search_stock_news("600519", "贵州茅台", max_results=5)
|
||||||
mock_search.assert_called_once()
|
kwargs = mock_search.call_args[1]
|
||||||
call_kwargs = mock_search.call_args[1]
|
self.assertEqual(kwargs["days"], 3)
|
||||||
self.assertEqual(call_kwargs["days"], 1)
|
|
||||||
|
|
||||||
@patch("src.search_service.datetime")
|
def test_invalid_profile_falls_back_to_short(self) -> None:
|
||||||
def test_search_stock_news_days_tuesday_weekday_dominates(
|
"""Invalid profile should fallback to short (3 days)."""
|
||||||
self, mock_dt: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Tuesday + news_max_age_days=3 -> search_days=1 (min(1,3)=1)."""
|
|
||||||
mock_dt.now.return_value.weekday.return_value = 1 # Tuesday -> weekday_days=1
|
|
||||||
service, mock_search = self._create_service_with_mock_provider(
|
service, mock_search = self._create_service_with_mock_provider(
|
||||||
news_max_age_days=3
|
news_max_age_days=30,
|
||||||
|
news_strategy_profile="invalid_profile",
|
||||||
)
|
)
|
||||||
service.search_stock_news("600519", "贵州茅台")
|
service.search_stock_news("600519", "贵州茅台", max_results=5)
|
||||||
mock_search.assert_called_once()
|
kwargs = mock_search.call_args[1]
|
||||||
call_kwargs = mock_search.call_args[1]
|
self.assertEqual(kwargs["days"], 3)
|
||||||
self.assertEqual(call_kwargs["days"], 1)
|
|
||||||
|
|
||||||
@patch("src.search_service.datetime")
|
def test_search_stock_news_strict_filters(self) -> None:
|
||||||
def test_search_stock_news_days_monday_news_max_age_dominates(
|
"""Drop old/unknown/future+2, keep future+1 and within-window dates."""
|
||||||
self, mock_dt: MagicMock
|
today = datetime.now().date()
|
||||||
) -> None:
|
fresh = today.isoformat()
|
||||||
"""Monday + news_max_age_days=5 -> search_days=3 (min(3,5)=3)."""
|
old = (today - timedelta(days=30)).isoformat()
|
||||||
mock_dt.now.return_value.weekday.return_value = 0 # Monday -> weekday_days=3
|
future_1 = (today + timedelta(days=1)).isoformat()
|
||||||
service, mock_search = self._create_service_with_mock_provider(
|
future_2 = (today + timedelta(days=2)).isoformat()
|
||||||
news_max_age_days=5
|
|
||||||
)
|
|
||||||
service.search_stock_news("600519", "贵州茅台")
|
|
||||||
mock_search.assert_called_once()
|
|
||||||
call_kwargs = mock_search.call_args[1]
|
|
||||||
self.assertEqual(call_kwargs["days"], 3)
|
|
||||||
|
|
||||||
@patch("src.search_service.datetime")
|
service, _ = self._create_service_with_mock_provider(
|
||||||
def test_search_stock_news_days_weekend(self, mock_dt: MagicMock) -> None:
|
news_max_age_days=7,
|
||||||
"""Saturday + news_max_age_days=5 -> search_days=2 (min(2,5)=2)."""
|
news_strategy_profile="medium",
|
||||||
mock_dt.now.return_value.weekday.return_value = 5 # Saturday -> weekday_days=2
|
response=_response(
|
||||||
service, mock_search = self._create_service_with_mock_provider(
|
[
|
||||||
news_max_age_days=5
|
_result("old", old),
|
||||||
|
_result("unknown", None),
|
||||||
|
_result("future_2", future_2),
|
||||||
|
_result("future_1", future_1),
|
||||||
|
_result("fresh", fresh),
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
service.search_stock_news("600519", "贵州茅台")
|
|
||||||
mock_search.assert_called_once()
|
|
||||||
call_kwargs = mock_search.call_args[1]
|
|
||||||
self.assertEqual(call_kwargs["days"], 2)
|
|
||||||
|
|
||||||
def test_search_comprehensive_intel_uses_news_max_age_days(self) -> None:
|
resp = service.search_stock_news("600519", "贵州茅台", max_results=5)
|
||||||
"""search_comprehensive_intel passes news_max_age_days directly to provider.search."""
|
titles = [r.title for r in resp.results]
|
||||||
|
self.assertEqual(titles, ["future_1", "fresh"])
|
||||||
|
for item in resp.results:
|
||||||
|
self.assertRegex(item.published_date or "", r"^\d{4}-\d{2}-\d{2}$")
|
||||||
|
|
||||||
|
def test_search_stock_news_overfetch_before_filter(self) -> None:
|
||||||
|
"""Provider request size should be increased before filtering."""
|
||||||
service, mock_search = self._create_service_with_mock_provider(
|
service, mock_search = self._create_service_with_mock_provider(
|
||||||
news_max_age_days=2
|
news_max_age_days=3,
|
||||||
|
news_strategy_profile="short",
|
||||||
)
|
)
|
||||||
with patch("src.search_service.time.sleep"): # avoid delay in tests
|
service.search_stock_news("600519", "贵州茅台", max_results=4)
|
||||||
service.search_comprehensive_intel(
|
args, kwargs = mock_search.call_args
|
||||||
|
requested = kwargs.get("max_results")
|
||||||
|
if requested is None:
|
||||||
|
requested = args[1]
|
||||||
|
self.assertEqual(requested, 8)
|
||||||
|
|
||||||
|
def test_search_stock_news_try_next_provider_when_filtered_empty(self) -> None:
|
||||||
|
"""If provider-A passes API call but all results are filtered, continue to provider-B."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
old = (today - timedelta(days=90)).isoformat()
|
||||||
|
fresh = today.isoformat()
|
||||||
|
|
||||||
|
service = SearchService(
|
||||||
|
bocha_keys=["dummy_key"],
|
||||||
|
news_max_age_days=3,
|
||||||
|
news_strategy_profile="short",
|
||||||
|
)
|
||||||
|
|
||||||
|
p1 = SimpleNamespace(
|
||||||
|
is_available=True,
|
||||||
|
name="P1",
|
||||||
|
search=MagicMock(return_value=_response([_result("too_old", old)])),
|
||||||
|
)
|
||||||
|
p2 = SimpleNamespace(
|
||||||
|
is_available=True,
|
||||||
|
name="P2",
|
||||||
|
search=MagicMock(return_value=_response([_result("fresh", fresh)])),
|
||||||
|
)
|
||||||
|
service._providers = [p1, p2]
|
||||||
|
|
||||||
|
resp = service.search_stock_news("600519", "贵州茅台", max_results=3)
|
||||||
|
self.assertEqual([r.title for r in resp.results], ["fresh"])
|
||||||
|
p1.search.assert_called_once()
|
||||||
|
p2.search.assert_called_once()
|
||||||
|
|
||||||
|
def test_search_comprehensive_intel_uses_same_filter(self) -> None:
|
||||||
|
"""Comprehensive intel should use same strict date filtering logic."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
old = (today - timedelta(days=20)).isoformat()
|
||||||
|
fresh = (today - timedelta(days=1)).isoformat()
|
||||||
|
|
||||||
|
service, mock_search = self._create_service_with_mock_provider(
|
||||||
|
news_max_age_days=3,
|
||||||
|
news_strategy_profile="medium", # min(7,3)=3
|
||||||
|
response=_response([_result("old", old), _result("fresh", fresh)]),
|
||||||
|
)
|
||||||
|
with patch("src.search_service.time.sleep"):
|
||||||
|
intel = service.search_comprehensive_intel(
|
||||||
stock_code="600519",
|
stock_code="600519",
|
||||||
stock_name="贵州茅台",
|
stock_name="贵州茅台",
|
||||||
max_searches=2,
|
max_searches=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertGreaterEqual(mock_search.call_count, 1)
|
self.assertGreaterEqual(mock_search.call_count, 1)
|
||||||
for call in mock_search.call_args_list:
|
for call in mock_search.call_args_list:
|
||||||
call_kwargs = call[1]
|
kwargs = call[1]
|
||||||
self.assertEqual(
|
self.assertEqual(kwargs["days"], 3)
|
||||||
call_kwargs["days"],
|
self.assertEqual(kwargs["max_results"], 6) # target 3 -> overfetch 6
|
||||||
2,
|
|
||||||
msg=f"Expected days=2, got {call_kwargs.get('days')}",
|
for resp in intel.values():
|
||||||
)
|
self.assertTrue(all(item.title != "old" for item in resp.results))
|
||||||
|
|
||||||
|
def test_effective_window_helper_has_no_side_effect(self) -> None:
|
||||||
|
"""_effective_news_window_days should not mutate stored news_window_days."""
|
||||||
|
service, _ = self._create_service_with_mock_provider(
|
||||||
|
news_max_age_days=3,
|
||||||
|
news_strategy_profile="short",
|
||||||
|
)
|
||||||
|
service.news_window_days = 99
|
||||||
|
resolved = service._effective_news_window_days()
|
||||||
|
self.assertEqual(resolved, 3)
|
||||||
|
self.assertEqual(service.news_window_days, 99)
|
||||||
|
|
||||||
|
def test_unix_timestamp_normalizes_to_local_date(self) -> None:
|
||||||
|
"""Unix timestamp should be converted to local date before window filtering."""
|
||||||
|
dt_utc = datetime(2026, 3, 15, 23, 30, tzinfo=timezone.utc)
|
||||||
|
timestamp = str(int(dt_utc.timestamp()))
|
||||||
|
expected_local_date = dt_utc.astimezone().date()
|
||||||
|
parsed = SearchService._normalize_news_publish_date(timestamp)
|
||||||
|
self.assertEqual(parsed, expected_local_date)
|
||||||
|
|
||||||
|
def test_iso_utc_string_normalizes_to_local_date(self) -> None:
|
||||||
|
"""ISO datetime with timezone should be converted to local date."""
|
||||||
|
dt_utc = datetime(2026, 3, 15, 23, 30, tzinfo=timezone.utc)
|
||||||
|
iso_text = "2026-03-15T23:30:00Z"
|
||||||
|
expected_local_date = dt_utc.astimezone().date()
|
||||||
|
parsed = SearchService._normalize_news_publish_date(iso_text)
|
||||||
|
self.assertEqual(parsed, expected_local_date)
|
||||||
|
|
||||||
|
def test_rfc_utc_string_normalizes_to_local_date(self) -> None:
|
||||||
|
"""RFC datetime with timezone should be converted to local date."""
|
||||||
|
dt_utc = datetime(2026, 3, 15, 23, 30, tzinfo=timezone.utc)
|
||||||
|
rfc_text = "Sun, 15 Mar 2026 23:30:00 +0000"
|
||||||
|
expected_local_date = dt_utc.astimezone().date()
|
||||||
|
parsed = SearchService._normalize_news_publish_date(rfc_text)
|
||||||
|
self.assertEqual(parsed, expected_local_date)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user