mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* feat: Web/API 指数入口与共享 canonical 去重基础 为 Issue #2303 Phase 2 PR1 落地 Web/API 指数入口适配: - API 使用 parse_analysis_target 构造结构化 AnalysisTarget 并贯通到 pipeline - API 与 TaskQueue 去重按 asset_type 分支(指数用 canonical_id,个股用 legacy code) - BatchTaskAcceptedResponse 追加可选 rejected 字段,未登记 CSI 单股 400、批量仅该目标失败 - TaskInfo 固化 dedupe_key,避免指数与同码个股折叠及 _analyzing_stocks 残留 - Web 移除 assetType=index 全局过滤,Chat 名称识别保护指数 canonical - 补齐 Pipeline 指数 DecisionSignal market_override=cn 真实分支测试 * fix: 收敛指数 canonical 身份与批量响应契约 PR #2312 review 修复:报告 meta 补充 asset_type 隐藏指数自选;/analyze 在解析前限制非空原始 token;is_single 统一驱动 metadata/409/单任务 202;HomePage 三元计数继续后续 chunk。 * fix: avoid double space in index news search query * fix: preserve canonical index identity * fix: validate legacy task asset type * fix: preserve canonical index identity in chat * fix: preserve index identity across chat backends
290 lines
11 KiB
Python
290 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
===================================
|
||
分析服务层
|
||
===================================
|
||
|
||
职责:
|
||
1. 封装股票分析逻辑
|
||
2. 调用 analyzer 和 pipeline 执行分析
|
||
3. 保存分析结果到数据库
|
||
"""
|
||
|
||
import logging
|
||
import copy
|
||
import uuid
|
||
from typing import Optional, Dict, Any, Callable, List
|
||
|
||
from src.repositories.analysis_repo import AnalysisRepository
|
||
from src.report_language import (
|
||
get_sentiment_label,
|
||
get_localized_stock_name,
|
||
localize_operation_advice,
|
||
localize_trend_prediction,
|
||
normalize_report_language,
|
||
)
|
||
from src.market_phase_summary import extract_market_phase_summary
|
||
from src.schemas.decision_action import build_action_fields
|
||
from src.services.run_diagnostics import (
|
||
activate_run_diagnostic_context,
|
||
build_run_diagnostic_summary,
|
||
get_current_diagnostic_context,
|
||
reset_run_diagnostic_context,
|
||
)
|
||
from src.services.empty_news import empty_news_disclosure
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def asset_type_from_canonical_code(code: Any) -> Optional[str]:
|
||
"""Derive the authoritative ``asset_type`` for a canonical stock/index code.
|
||
|
||
Uses :func:`parse_analysis_target` — the single asset-type authority — on
|
||
the *canonical* code, never the display code, so ``sh000016`` (index) and
|
||
bare ``000016`` (stock) are distinguished by the parser rather than by
|
||
display normalization. Returns ``None`` for market review / empty /
|
||
unsupported codes, so legacy clients and market reviews simply omit the
|
||
optional field.
|
||
"""
|
||
text = str(code or "").strip()
|
||
if not text:
|
||
return None
|
||
if text.upper() == "MARKET":
|
||
return None
|
||
|
||
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||
|
||
target = parse_analysis_target(text)
|
||
if target.asset_type == ParseStatus.INDEX:
|
||
return "index"
|
||
if target.asset_type == ParseStatus.STOCK:
|
||
return "stock"
|
||
return None
|
||
|
||
|
||
class AnalysisService:
|
||
"""
|
||
分析服务
|
||
|
||
封装股票分析相关的业务逻辑
|
||
"""
|
||
|
||
def __init__(self):
|
||
"""初始化分析服务"""
|
||
self.repo = AnalysisRepository()
|
||
self.last_error: Optional[str] = None
|
||
|
||
def analyze_stock(
|
||
self,
|
||
stock_code: str,
|
||
report_type: str = "detailed",
|
||
force_refresh: bool = False,
|
||
query_id: Optional[str] = None,
|
||
trace_id: Optional[str] = None,
|
||
send_notification: bool = True,
|
||
progress_callback: Optional[Callable[[int, str], None]] = None,
|
||
skills: Optional[List[str]] = None,
|
||
analysis_phase: str = "auto",
|
||
query_source: str = "api",
|
||
portfolio_context: Optional[Dict[str, Any]] = None,
|
||
report_language: Optional[str] = None,
|
||
analysis_target: Optional[Any] = None,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
执行股票分析
|
||
|
||
Args:
|
||
stock_code: 股票代码
|
||
report_type: 报告类型 (simple/detailed)
|
||
force_refresh: 是否强制刷新
|
||
query_id: 查询 ID(可选)
|
||
send_notification: 是否发送通知(API 触发默认发送)
|
||
analysis_phase: 请求的分析阶段覆盖(auto/premarket/intraday/postmarket)
|
||
analysis_target: 可选的结构化分析目标(指数目标贯穿到 pipeline,
|
||
否则指数会退化为股票语义)
|
||
|
||
Returns:
|
||
分析结果字典,包含:
|
||
- stock_code: 股票代码
|
||
- stock_name: 股票名称
|
||
- report: 分析报告
|
||
"""
|
||
try:
|
||
self.last_error = None
|
||
# 导入分析相关模块
|
||
from src.config import get_config
|
||
from src.core.pipeline import StockAnalysisPipeline
|
||
from src.enums import ReportType
|
||
|
||
# 生成 query_id
|
||
if query_id is None:
|
||
query_id = uuid.uuid4().hex
|
||
effective_trace_id = trace_id or query_id
|
||
diag_token = None
|
||
if get_current_diagnostic_context() is None:
|
||
diag_token = activate_run_diagnostic_context(
|
||
trace_id=effective_trace_id,
|
||
query_id=query_id,
|
||
stock_code=stock_code,
|
||
trigger_source=query_source or "api",
|
||
)
|
||
|
||
# 获取配置
|
||
config = get_config()
|
||
normalized_report_language = normalize_report_language(report_language, default="")
|
||
if normalized_report_language:
|
||
config = copy.copy(config)
|
||
config.report_language = normalized_report_language
|
||
|
||
# 创建分析流水线
|
||
pipeline = StockAnalysisPipeline(
|
||
config=config,
|
||
query_id=query_id,
|
||
trace_id=effective_trace_id,
|
||
query_source=query_source or "api",
|
||
progress_callback=progress_callback,
|
||
analysis_skills=skills,
|
||
analysis_phase=analysis_phase,
|
||
portfolio_context=portfolio_context,
|
||
)
|
||
|
||
# 确定报告类型 (API: simple/detailed/full/brief -> ReportType)
|
||
rt = ReportType.from_str(report_type)
|
||
|
||
# 执行分析
|
||
result = pipeline.process_single_stock(
|
||
code=stock_code,
|
||
skip_analysis=False,
|
||
single_stock_notify=send_notification,
|
||
report_type=rt,
|
||
analysis_target=analysis_target,
|
||
)
|
||
|
||
if result is None:
|
||
logger.warning(f"分析股票 {stock_code} 返回空结果")
|
||
self.last_error = self.last_error or f"分析股票 {stock_code} 返回空结果"
|
||
return None
|
||
|
||
if not getattr(result, "success", True):
|
||
self.last_error = getattr(result, "error_message", None) or f"分析股票 {stock_code} 失败"
|
||
logger.warning(f"分析股票 {stock_code} 未成功完成: {self.last_error}")
|
||
return None
|
||
|
||
# 构建响应
|
||
return self._build_analysis_response(result, query_id, report_type=rt.value)
|
||
|
||
except Exception as e:
|
||
self.last_error = str(e)
|
||
logger.error(f"分析股票 {stock_code} 失败: {e}", exc_info=True)
|
||
return None
|
||
finally:
|
||
reset_run_diagnostic_context(locals().get("diag_token"))
|
||
|
||
def _build_analysis_response(
|
||
self,
|
||
result: Any,
|
||
query_id: str,
|
||
report_type: str = "detailed",
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
构建分析响应
|
||
|
||
Args:
|
||
result: AnalysisResult 对象
|
||
query_id: 查询 ID
|
||
report_type: 归一化后的报告类型
|
||
|
||
Returns:
|
||
格式化的响应字典
|
||
"""
|
||
# 获取狙击点位
|
||
sniper_points = {}
|
||
if hasattr(result, 'get_sniper_points'):
|
||
sniper_points = result.get_sniper_points() or {}
|
||
|
||
# 计算情绪标签
|
||
report_language = normalize_report_language(getattr(result, "report_language", "zh"))
|
||
sentiment_label = get_sentiment_label(result.sentiment_score, report_language)
|
||
stock_name = get_localized_stock_name(getattr(result, "name", None), result.code, report_language)
|
||
action_fields = build_action_fields(
|
||
operation_advice=getattr(result, "operation_advice", None),
|
||
explicit_action=getattr(result, "action", None),
|
||
report_type=report_type,
|
||
report_language=report_language,
|
||
sentiment_score=getattr(result, "sentiment_score", None),
|
||
guardrail_reason=getattr(result, "guardrail_reason", None),
|
||
align_with_score=True,
|
||
)
|
||
diagnostic_context = get_current_diagnostic_context()
|
||
trace_id = diagnostic_context.trace_id if diagnostic_context is not None else query_id
|
||
diagnostic_snapshot = diagnostic_context.snapshot() if diagnostic_context is not None else None
|
||
diagnostic_context_snapshot = getattr(result, "diagnostic_context_snapshot", None)
|
||
market_phase_summary = extract_market_phase_summary(diagnostic_context_snapshot)
|
||
if isinstance(diagnostic_context_snapshot, dict):
|
||
context_snapshot = dict(diagnostic_context_snapshot)
|
||
if diagnostic_snapshot is not None:
|
||
context_snapshot["diagnostics"] = diagnostic_snapshot
|
||
elif diagnostic_snapshot is not None:
|
||
context_snapshot = {"diagnostics": diagnostic_snapshot}
|
||
else:
|
||
context_snapshot = None
|
||
diagnostic_summary = build_run_diagnostic_summary(
|
||
context_snapshot=context_snapshot,
|
||
raw_result=result.to_dict() if hasattr(result, "to_dict") else None,
|
||
report_saved=True,
|
||
query_id=query_id,
|
||
stock_code=result.code,
|
||
)
|
||
|
||
# 构建报告结构
|
||
report = {
|
||
"meta": {
|
||
"query_id": query_id,
|
||
"trace_id": trace_id,
|
||
"stock_code": result.code,
|
||
"stock_name": stock_name,
|
||
"report_type": report_type,
|
||
"report_language": report_language,
|
||
"current_price": result.current_price,
|
||
"change_pct": result.change_pct,
|
||
"model_used": getattr(result, "model_used", None),
|
||
"market_phase_summary": market_phase_summary,
|
||
"asset_type": asset_type_from_canonical_code(result.code),
|
||
},
|
||
"summary": {
|
||
"analysis_summary": result.analysis_summary,
|
||
"operation_advice": localize_operation_advice(result.operation_advice, report_language),
|
||
"action": action_fields["action"],
|
||
"action_label": action_fields["action_label"],
|
||
"trend_prediction": localize_trend_prediction(result.trend_prediction, report_language),
|
||
"sentiment_score": result.sentiment_score,
|
||
"sentiment_label": sentiment_label,
|
||
},
|
||
"strategy": {
|
||
"ideal_buy": sniper_points.get("ideal_buy"),
|
||
"secondary_buy": sniper_points.get("secondary_buy"),
|
||
"stop_loss": sniper_points.get("stop_loss"),
|
||
"take_profit": sniper_points.get("take_profit"),
|
||
},
|
||
"details": {
|
||
"news_summary": result.news_summary,
|
||
"empty_news_disclosure": empty_news_disclosure(result, report_language),
|
||
"technical_analysis": result.technical_analysis,
|
||
"fundamental_analysis": result.fundamental_analysis,
|
||
"risk_warning": result.risk_warning,
|
||
}
|
||
}
|
||
if hasattr(result, "to_dict"):
|
||
raw_result_payload = result.to_dict()
|
||
if isinstance(raw_result_payload, dict):
|
||
report["details"]["raw_result"] = raw_result_payload
|
||
|
||
return {
|
||
"query_id": query_id,
|
||
"trace_id": trace_id,
|
||
"stock_code": result.code,
|
||
"stock_name": stock_name,
|
||
"report": report,
|
||
"diagnostic_summary": diagnostic_summary,
|
||
}
|