mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: add configurable report language (#764)
* feat: add configurable report language * Fix review follow-ups for history and fallback localization * fix: prefer .env report language at startup * fix: address report language review feedback * fix: address latest report language review feedback
This commit is contained in:
@@ -298,6 +298,8 @@ AGENT_SKILLS=bull_trend,ma_golden_cross,volume_breakout,shrink_pullback
|
||||
# 报告类型:simple(精简)、full(完整)、brief(3-5句概括)
|
||||
# Docker环境下如果推送内容不完整,可以设置为 full
|
||||
# REPORT_TYPE=simple
|
||||
# 报告输出语言:zh(中文,默认) / en(英文)
|
||||
# REPORT_LANGUAGE=zh
|
||||
# 仅分析结果摘要:设为 true 时只推送汇总,不含个股详情
|
||||
# REPORT_SUMMARY_ONLY=false
|
||||
#
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
| `RUN_IMMEDIATELY` | 非定时模式启动时是否立即执行一次分析 | 可选 |
|
||||
| `SINGLE_STOCK_NOTIFY` | 单股推送模式:设为 `true` 则每分析完一只股票立即推送 | 可选 |
|
||||
| `REPORT_TYPE` | 报告类型:`simple`(精简)、`full`(完整)、`brief`(3-5句概括),Docker环境推荐设为 `full` | 可选 |
|
||||
| `REPORT_LANGUAGE` | 报告输出语言:`zh`(默认中文) / `en`(英文);会同步影响 Prompt、Markdown 模板、通知 fallback 与 Web 报告页固定文案 | 可选 |
|
||||
| `REPORT_SUMMARY_ONLY` | 仅分析结果摘要:设为 `true` 时只推送汇总,不含个股详情 | 可选 |
|
||||
| `REPORT_TEMPLATES_DIR` | Jinja2 模板目录(相对项目根,默认 `templates`) | 可选 |
|
||||
| `REPORT_RENDERER_ENABLED` | 启用 Jinja2 模板渲染(默认 `false`,保证零回归) | 可选 |
|
||||
|
||||
@@ -48,6 +48,7 @@ from api.v1.schemas.history import (
|
||||
)
|
||||
from data_provider.base import canonical_stock_code
|
||||
from src.config import Config
|
||||
from src.report_language import get_localized_stock_name, normalize_report_language
|
||||
from src.services.task_queue import (
|
||||
get_task_queue,
|
||||
DuplicateTaskError,
|
||||
@@ -506,14 +507,19 @@ def get_analysis_status(task_id: str) -> TaskStatus:
|
||||
model_used = normalize_model_used(
|
||||
(raw_result or {}).get("model_used") if isinstance(raw_result, dict) else None
|
||||
)
|
||||
report_language = normalize_report_language(
|
||||
(raw_result or {}).get("report_language") if isinstance(raw_result, dict) else None
|
||||
)
|
||||
stock_name = get_localized_stock_name(record.name, record.code, report_language)
|
||||
# Build report from DB record so completed tasks return real data
|
||||
report_dict = AnalysisReport(
|
||||
meta=ReportMeta(
|
||||
id=record.id,
|
||||
query_id=task_id,
|
||||
stock_code=record.code,
|
||||
stock_name=record.name,
|
||||
stock_name=stock_name,
|
||||
report_type=getattr(record, 'report_type', None),
|
||||
report_language=report_language,
|
||||
created_at=record.created_at.isoformat() if record.created_at else None,
|
||||
model_used=model_used,
|
||||
),
|
||||
@@ -537,7 +543,7 @@ def get_analysis_status(task_id: str) -> TaskStatus:
|
||||
result=AnalysisResultResponse(
|
||||
query_id=task_id,
|
||||
stock_code=record.code,
|
||||
stock_name=record.name,
|
||||
stock_name=stock_name,
|
||||
report=report_dict,
|
||||
created_at=record.created_at.isoformat() if record.created_at else datetime.now().isoformat()
|
||||
),
|
||||
@@ -625,12 +631,23 @@ def _build_analysis_report(
|
||||
summary_data = report_data.get("summary", {})
|
||||
strategy_data = report_data.get("strategy", {})
|
||||
details_data = report_data.get("details", {})
|
||||
report_language = normalize_report_language(
|
||||
meta_data.get("report_language")
|
||||
or (context_snapshot or {}).get("report_language")
|
||||
or getattr(Config.get_instance(), "report_language", "zh")
|
||||
)
|
||||
localized_stock_name = get_localized_stock_name(
|
||||
meta_data.get("stock_name", stock_name),
|
||||
meta_data.get("stock_code", stock_code),
|
||||
report_language,
|
||||
)
|
||||
|
||||
meta = ReportMeta(
|
||||
query_id=meta_data.get("query_id", query_id),
|
||||
stock_code=meta_data.get("stock_code", stock_code),
|
||||
stock_name=meta_data.get("stock_name", stock_name),
|
||||
stock_name=localized_stock_name,
|
||||
report_type=meta_data.get("report_type", "detailed"),
|
||||
report_language=report_language,
|
||||
created_at=meta_data.get("created_at", datetime.now().isoformat()),
|
||||
current_price=meta_data.get("current_price"),
|
||||
change_pct=meta_data.get("change_pct"),
|
||||
|
||||
@@ -31,6 +31,13 @@ from api.v1.schemas.history import (
|
||||
)
|
||||
from api.v1.schemas.common import ErrorResponse
|
||||
from src.storage import DatabaseManager
|
||||
from src.report_language import (
|
||||
get_sentiment_label,
|
||||
get_localized_stock_name,
|
||||
localize_operation_advice,
|
||||
localize_trend_prediction,
|
||||
normalize_report_language,
|
||||
)
|
||||
from src.services.history_service import HistoryService, MarkdownReportGenerationError
|
||||
from src.utils.data_processing import normalize_model_used, extract_fundamental_detail_fields
|
||||
|
||||
@@ -226,13 +233,32 @@ def get_history_detail(
|
||||
current_price = realtime_quote_raw.get("price")
|
||||
change_pct = change_pct or realtime_quote_raw.get("change_pct") or realtime_quote_raw.get("pct_chg")
|
||||
|
||||
raw_result = result.get("raw_result")
|
||||
if not isinstance(raw_result, dict):
|
||||
raw_result = {}
|
||||
report_language = normalize_report_language(
|
||||
result.get("report_language")
|
||||
or raw_result.get("report_language")
|
||||
or (
|
||||
context_snapshot.get("report_language")
|
||||
if isinstance(context_snapshot, dict)
|
||||
else None
|
||||
)
|
||||
)
|
||||
stock_name = get_localized_stock_name(
|
||||
result.get("stock_name"),
|
||||
result.get("stock_code", ""),
|
||||
report_language,
|
||||
)
|
||||
|
||||
# 构建响应模型
|
||||
meta = ReportMeta(
|
||||
id=result.get("id"),
|
||||
query_id=result.get("query_id", ""),
|
||||
stock_code=result.get("stock_code", ""),
|
||||
stock_name=result.get("stock_name"),
|
||||
stock_name=stock_name,
|
||||
report_type=result.get("report_type"),
|
||||
report_language=report_language,
|
||||
created_at=result.get("created_at"),
|
||||
current_price=current_price,
|
||||
change_pct=change_pct,
|
||||
@@ -241,10 +267,20 @@ def get_history_detail(
|
||||
|
||||
summary = ReportSummary(
|
||||
analysis_summary=result.get("analysis_summary"),
|
||||
operation_advice=result.get("operation_advice"),
|
||||
trend_prediction=result.get("trend_prediction"),
|
||||
operation_advice=localize_operation_advice(
|
||||
result.get("operation_advice"),
|
||||
report_language,
|
||||
),
|
||||
trend_prediction=localize_trend_prediction(
|
||||
result.get("trend_prediction"),
|
||||
report_language,
|
||||
),
|
||||
sentiment_score=result.get("sentiment_score"),
|
||||
sentiment_label=result.get("sentiment_label")
|
||||
sentiment_label=(
|
||||
get_sentiment_label(result.get("sentiment_score"), report_language)
|
||||
if result.get("sentiment_score") is not None
|
||||
else result.get("sentiment_label")
|
||||
)
|
||||
)
|
||||
|
||||
strategy = ReportStrategy(
|
||||
|
||||
@@ -119,6 +119,7 @@ class ReportMeta(BaseModel):
|
||||
stock_code: str = Field(..., description="股票代码")
|
||||
stock_name: Optional[str] = Field(None, description="股票名称")
|
||||
report_type: Optional[str] = Field(None, description="报告类型")
|
||||
report_language: Optional[str] = Field(None, description="报告输出语言(zh/en)")
|
||||
created_at: Optional[str] = Field(None, description="创建时间")
|
||||
current_price: Optional[float] = Field(None, description="分析时股价")
|
||||
change_pct: Optional[float] = Field(None, description="分析时涨跌幅(%)")
|
||||
@@ -175,6 +176,7 @@ class AnalysisReport(BaseModel):
|
||||
"stock_code": "600519",
|
||||
"stock_name": "贵州茅台",
|
||||
"report_type": "detailed",
|
||||
"report_language": "zh",
|
||||
"created_at": "2024-01-01T12:00:00"
|
||||
},
|
||||
"summary": {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getSentimentLabel } from '../../types/analysis';
|
||||
import { getSentimentLabel, type ReportLanguage } from '../../types/analysis';
|
||||
import { cn } from '../../utils/cn';
|
||||
import { normalizeReportLanguage, getReportText } from '../../utils/reportLanguage';
|
||||
|
||||
interface ScoreGaugeProps {
|
||||
score: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
language?: ReportLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,7 @@ export const ScoreGauge: React.FC<ScoreGaugeProps> = ({
|
||||
size = 'md',
|
||||
showLabel = true,
|
||||
className = '',
|
||||
language = 'zh',
|
||||
}) => {
|
||||
// Animated score state.
|
||||
const [animatedScore, setAnimatedScore] = useState(0);
|
||||
@@ -60,7 +63,9 @@ export const ScoreGauge: React.FC<ScoreGaugeProps> = ({
|
||||
};
|
||||
}, [score]);
|
||||
|
||||
const label = getSentimentLabel(score);
|
||||
const reportLanguage = normalizeReportLanguage(language);
|
||||
const text = getReportText(reportLanguage);
|
||||
const label = getSentimentLabel(score, reportLanguage);
|
||||
|
||||
// Size configuration for each gauge variant.
|
||||
const sizeConfig = {
|
||||
@@ -115,7 +120,7 @@ export const ScoreGauge: React.FC<ScoreGaugeProps> = ({
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
{showLabel && (
|
||||
<span className="label-uppercase mb-3 text-secondary-text">
|
||||
恐惧贪婪指数
|
||||
{text.fearGreedIndex}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { ReportDetails as ReportDetailsType } from '../../types/analysis';
|
||||
import type { ReportDetails as ReportDetailsType, ReportLanguage } from '../../types/analysis';
|
||||
import { Card } from '../common';
|
||||
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
|
||||
|
||||
interface ReportDetailsProps {
|
||||
details?: ReportDetailsType;
|
||||
recordId?: number; // 分析历史记录主键 ID
|
||||
language?: ReportLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,7 +16,10 @@ interface ReportDetailsProps {
|
||||
export const ReportDetails: React.FC<ReportDetailsProps> = ({
|
||||
details,
|
||||
recordId,
|
||||
language = 'zh',
|
||||
}) => {
|
||||
const reportLanguage = normalizeReportLanguage(language);
|
||||
const text = getReportText(reportLanguage);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [showSnapshot, setShowSnapshot] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -42,7 +47,7 @@ export const ReportDetails: React.FC<ReportDetailsProps> = ({
|
||||
onClick={() => copyToClipboard(jsonStr)}
|
||||
className="home-accent-link absolute top-2 right-2 text-xs text-muted-text"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
{copied ? text.copied : text.copy}
|
||||
</button>
|
||||
<pre className="text-xs text-secondary-text font-mono overflow-x-auto p-3 bg-base rounded-lg max-h-80 overflow-y-auto text-left w-0 min-w-full">
|
||||
{jsonStr}
|
||||
@@ -54,14 +59,14 @@ export const ReportDetails: React.FC<ReportDetailsProps> = ({
|
||||
return (
|
||||
<Card variant="bordered" padding="md" className="home-panel-card text-left">
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<span className="label-uppercase">TRANSPARENCY</span>
|
||||
<h3 className="text-base font-semibold text-foreground mt-0.5">数据追溯</h3>
|
||||
<span className="label-uppercase">{text.transparency}</span>
|
||||
<h3 className="mt-0.5 text-base font-semibold text-foreground">{text.traceability}</h3>
|
||||
</div>
|
||||
|
||||
{/* Record ID */}
|
||||
{recordId && (
|
||||
<div className="home-divider mb-3 flex items-center gap-2 border-b pb-3 text-xs text-muted-text">
|
||||
<span>Record ID:</span>
|
||||
<span>{text.recordId}:</span>
|
||||
<code className="home-accent-chip px-1.5 py-0.5 font-mono text-xs">
|
||||
{recordId}
|
||||
</code>
|
||||
@@ -78,7 +83,7 @@ export const ReportDetails: React.FC<ReportDetailsProps> = ({
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="home-surface-button flex w-full items-center justify-between rounded-lg p-2.5"
|
||||
>
|
||||
<span className="text-xs text-foreground">原始分析结果</span>
|
||||
<span className="text-xs text-foreground">{text.rawResult}</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-muted-text transition-transform ${showRaw ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
@@ -104,7 +109,7 @@ export const ReportDetails: React.FC<ReportDetailsProps> = ({
|
||||
onClick={() => setShowSnapshot(!showSnapshot)}
|
||||
className="home-surface-button flex w-full items-center justify-between rounded-lg p-2.5"
|
||||
>
|
||||
<span className="text-xs text-foreground">分析快照</span>
|
||||
<span className="text-xs text-foreground">{text.analysisSnapshot}</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-muted-text transition-transform ${showSnapshot ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
|
||||
@@ -4,12 +4,15 @@ import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { historyApi } from '../../api/history';
|
||||
import { Drawer } from '../common/Drawer';
|
||||
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
|
||||
import type { ReportLanguage } from '../../types/analysis';
|
||||
|
||||
interface ReportMarkdownProps {
|
||||
recordId: number;
|
||||
stockName: string;
|
||||
stockCode: string;
|
||||
onClose: () => void;
|
||||
reportLanguage?: ReportLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,7 +24,10 @@ export const ReportMarkdown: React.FC<ReportMarkdownProps> = ({
|
||||
stockName,
|
||||
stockCode,
|
||||
onClose,
|
||||
reportLanguage = 'zh',
|
||||
}) => {
|
||||
const text = getReportText(normalizeReportLanguage(reportLanguage));
|
||||
const loadReportFailedText = text.loadReportFailed;
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -47,7 +53,7 @@ export const ReportMarkdown: React.FC<ReportMarkdownProps> = ({
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : '加载报告失败');
|
||||
setError(err instanceof Error ? err.message : loadReportFailedText);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
@@ -61,7 +67,7 @@ export const ReportMarkdown: React.FC<ReportMarkdownProps> = ({
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [recordId]);
|
||||
}, [recordId, loadReportFailedText]);
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isOpen} onClose={handleClose} width="max-w-3xl" zIndex={100}>
|
||||
@@ -74,7 +80,7 @@ export const ReportMarkdown: React.FC<ReportMarkdownProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">{stockName || stockCode}</h2>
|
||||
<p className="text-xs text-muted-text">完整分析报告</p>
|
||||
<p className="text-xs text-muted-text">{text.fullReport}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +88,7 @@ export const ReportMarkdown: React.FC<ReportMarkdownProps> = ({
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<div className="home-spinner h-10 w-10 animate-spin border-[3px]" />
|
||||
<p className="mt-4 text-secondary-text text-sm">加载报告中...</p>
|
||||
<p className="mt-4 text-secondary-text text-sm">{text.loadingReport}</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
@@ -97,7 +103,7 @@ export const ReportMarkdown: React.FC<ReportMarkdownProps> = ({
|
||||
onClick={handleClose}
|
||||
className="home-surface-button mt-4 rounded-lg px-4 py-2 text-sm text-secondary-text"
|
||||
>
|
||||
关闭
|
||||
{text.dismiss}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -134,7 +140,7 @@ export const ReportMarkdown: React.FC<ReportMarkdownProps> = ({
|
||||
onClick={handleClose}
|
||||
className="home-surface-button rounded-lg px-4 py-2 text-sm text-secondary-text hover:text-foreground"
|
||||
>
|
||||
关闭
|
||||
{text.dismiss}
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -5,17 +5,21 @@ import { getParsedApiError } from '../../api/error';
|
||||
import { Card } from '../common';
|
||||
import { ApiErrorAlert } from '../common';
|
||||
import { historyApi } from '../../api/history';
|
||||
import type { NewsIntelItem } from '../../types/analysis';
|
||||
import type { NewsIntelItem, ReportLanguage } from '../../types/analysis';
|
||||
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
|
||||
|
||||
interface ReportNewsProps {
|
||||
recordId?: number; // 分析历史记录主键 ID
|
||||
limit?: number;
|
||||
language?: ReportLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资讯区组件 - 终端风格
|
||||
*/
|
||||
export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 8 }) => {
|
||||
export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 8, language = 'zh' }) => {
|
||||
const reportLanguage = normalizeReportLanguage(language);
|
||||
const text = getReportText(reportLanguage);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [items, setItems] = useState<NewsIntelItem[]>([]);
|
||||
const [error, setError] = useState<ParsedApiError | null>(null);
|
||||
@@ -52,8 +56,8 @@ export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 8 }) =
|
||||
<Card variant="bordered" padding="md" className="home-panel-card">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="label-uppercase">NEWS FEED</span>
|
||||
<h3 className="text-base font-semibold text-foreground">相关资讯</h3>
|
||||
<span className="label-uppercase">{text.newsFeed}</span>
|
||||
<h3 className="text-base font-semibold text-foreground">{text.relatedNews}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && (
|
||||
@@ -64,7 +68,7 @@ export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 8 }) =
|
||||
onClick={fetchNews}
|
||||
className="home-accent-link text-xs"
|
||||
>
|
||||
刷新
|
||||
{text.refresh}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,20 +76,21 @@ export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 8 }) =
|
||||
{error && !isLoading && (
|
||||
<ApiErrorAlert
|
||||
error={error}
|
||||
actionLabel="重试"
|
||||
actionLabel={text.retry}
|
||||
onAction={() => void fetchNews()}
|
||||
dismissLabel={text.dismiss}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && !error && (
|
||||
<div className="flex items-center gap-2 text-xs text-secondary-text">
|
||||
{text.loadingNews}
|
||||
<div className="home-spinner h-4 w-4 animate-spin border-2" />
|
||||
加载资讯中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && items.length === 0 && (
|
||||
<div className="text-xs text-muted-text">暂无相关资讯</div>
|
||||
<div className="text-xs text-muted-text">{text.noNews}</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && items.length > 0 && (
|
||||
@@ -113,7 +118,7 @@ export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 8 }) =
|
||||
rel="noopener noreferrer"
|
||||
className="home-accent-pill-link shrink-0 whitespace-nowrap px-2.5 py-1 text-xs"
|
||||
>
|
||||
跳转
|
||||
{text.openLink}
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
|
||||
@@ -2,6 +2,7 @@ import type React from 'react';
|
||||
import type { ReportMeta, ReportSummary as ReportSummaryType } from '../../types/analysis';
|
||||
import { ScoreGauge, Card } from '../common';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
|
||||
|
||||
interface ReportOverviewProps {
|
||||
meta: ReportMeta;
|
||||
@@ -16,6 +17,8 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
|
||||
meta,
|
||||
summary
|
||||
}) => {
|
||||
const reportLanguage = normalizeReportLanguage(meta.reportLanguage);
|
||||
const text = getReportText(reportLanguage);
|
||||
const getPriceChangeStyle = (changePct: number | undefined): React.CSSProperties | undefined => {
|
||||
if (changePct === undefined || changePct === null) {
|
||||
return undefined;
|
||||
@@ -80,9 +83,9 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
|
||||
|
||||
{/* 关键结论 */}
|
||||
<div className="home-divider border-t pt-5">
|
||||
<span className="label-uppercase">KEY INSIGHTS</span>
|
||||
<p className="mt-2 whitespace-pre-wrap text-left text-[15px] leading-7 text-foreground max-w-[62ch]">
|
||||
{summary.analysisSummary || '暂无分析结论'}
|
||||
<span className="label-uppercase">{text.keyInsights}</span>
|
||||
<p className="mt-2 max-w-[62ch] whitespace-pre-wrap text-left text-[15px] leading-7 text-foreground">
|
||||
{summary.analysisSummary || text.noAnalysisSummary}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -98,9 +101,9 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[11px] font-medium uppercase tracking-[0.16em] text-success">操作建议</h4>
|
||||
<h4 className="text-[11px] font-medium uppercase tracking-[0.16em] text-success">{text.actionAdvice}</h4>
|
||||
<p className="text-sm leading-6 text-foreground">
|
||||
{summary.operationAdvice || '暂无建议'}
|
||||
{summary.operationAdvice || text.noAdvice}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,9 +118,9 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[11px] font-medium uppercase tracking-[0.16em] text-warning">趋势预测</h4>
|
||||
<h4 className="text-[11px] font-medium uppercase tracking-[0.16em] text-warning">{text.trendPrediction}</h4>
|
||||
<p className="text-sm leading-6 text-foreground">
|
||||
{summary.trendPrediction || '暂无预测'}
|
||||
{summary.trendPrediction || text.noPrediction}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,8 +132,8 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
|
||||
<div className="flex flex-col self-stretch min-h-full">
|
||||
<Card variant="bordered" padding="md" className="home-panel-card !overflow-visible flex-1 flex flex-col min-h-0">
|
||||
<div className="text-center flex-1 flex flex-col justify-center">
|
||||
<h3 className="mb-5 text-sm font-medium tracking-wide text-foreground">Market Sentiment</h3>
|
||||
<ScoreGauge score={summary.sentimentScore} size="lg" />
|
||||
<h3 className="mb-5 text-sm font-medium tracking-wide text-foreground">{text.marketSentiment}</h3>
|
||||
<ScoreGauge score={summary.sentimentScore} size="lg" language={reportLanguage} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type React from 'react';
|
||||
import type { ReportStrategy as ReportStrategyType } from '../../types/analysis';
|
||||
import type { ReportLanguage, ReportStrategy as ReportStrategyType } from '../../types/analysis';
|
||||
import { Card } from '../common';
|
||||
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
|
||||
|
||||
interface ReportStrategyProps {
|
||||
strategy?: ReportStrategyType;
|
||||
language?: ReportLanguage;
|
||||
}
|
||||
|
||||
interface StrategyItemProps {
|
||||
@@ -37,29 +39,32 @@ const StrategyItem: React.FC<StrategyItemProps> = ({
|
||||
/**
|
||||
* 策略点位区组件 - 终端风格
|
||||
*/
|
||||
export const ReportStrategy: React.FC<ReportStrategyProps> = ({ strategy }) => {
|
||||
export const ReportStrategy: React.FC<ReportStrategyProps> = ({ strategy, language = 'zh' }) => {
|
||||
if (!strategy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reportLanguage = normalizeReportLanguage(language);
|
||||
const text = getReportText(reportLanguage);
|
||||
|
||||
const strategyItems = [
|
||||
{
|
||||
label: '理想买入',
|
||||
label: text.idealBuy,
|
||||
value: strategy.idealBuy,
|
||||
tone: '--home-strategy-buy',
|
||||
},
|
||||
{
|
||||
label: '二次买入',
|
||||
label: text.secondaryBuy,
|
||||
value: strategy.secondaryBuy,
|
||||
tone: '--home-strategy-secondary',
|
||||
},
|
||||
{
|
||||
label: '止损价位',
|
||||
label: text.stopLoss,
|
||||
value: strategy.stopLoss,
|
||||
tone: '--home-strategy-stop',
|
||||
},
|
||||
{
|
||||
label: '止盈目标',
|
||||
label: text.takeProfit,
|
||||
value: strategy.takeProfit,
|
||||
tone: '--home-strategy-take',
|
||||
},
|
||||
@@ -68,8 +73,8 @@ export const ReportStrategy: React.FC<ReportStrategyProps> = ({ strategy }) => {
|
||||
return (
|
||||
<Card variant="bordered" padding="md" className="home-panel-card">
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<span className="label-uppercase">STRATEGY POINTS</span>
|
||||
<h3 className="text-base font-semibold text-foreground">狙击点位</h3>
|
||||
<span className="label-uppercase">{text.strategyPoints}</span>
|
||||
<h3 className="text-base font-semibold text-foreground">{text.sniperLevels}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{strategyItems.map((item) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ReportOverview } from './ReportOverview';
|
||||
import { ReportStrategy } from './ReportStrategy';
|
||||
import { ReportNews } from './ReportNews';
|
||||
import { ReportDetails } from './ReportDetails';
|
||||
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
|
||||
|
||||
interface ReportSummaryProps {
|
||||
data: AnalysisResult | AnalysisReport;
|
||||
@@ -24,6 +25,8 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
|
||||
const recordId = report.meta.id;
|
||||
|
||||
const { meta, summary, strategy, details } = report;
|
||||
const reportLanguage = normalizeReportLanguage(meta.reportLanguage);
|
||||
const text = getReportText(reportLanguage);
|
||||
const modelUsed = (meta.modelUsed || '').trim();
|
||||
const shouldShowModel = Boolean(
|
||||
modelUsed && !['unknown', 'error', 'none', 'null', 'n/a'].includes(modelUsed.toLowerCase()),
|
||||
@@ -39,18 +42,18 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
|
||||
/>
|
||||
|
||||
{/* 策略点位区 */}
|
||||
<ReportStrategy strategy={strategy} />
|
||||
<ReportStrategy strategy={strategy} language={reportLanguage} />
|
||||
|
||||
{/* 资讯区 */}
|
||||
<ReportNews recordId={recordId} limit={8} />
|
||||
<ReportNews recordId={recordId} limit={8} language={reportLanguage} />
|
||||
|
||||
{/* 透明度与追溯区 */}
|
||||
<ReportDetails details={details} recordId={recordId} />
|
||||
<ReportDetails details={details} recordId={recordId} language={reportLanguage} />
|
||||
|
||||
{/* 分析模型标记(Issue #528)— 报告末尾 */}
|
||||
{shouldShowModel && (
|
||||
<p className="px-1 text-xs text-muted-text">
|
||||
分析模型: {modelUsed}
|
||||
{text.analysisModel}: {modelUsed}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ReportMarkdown, ReportSummary } from '../components/report';
|
||||
import { TaskPanel } from '../components/tasks';
|
||||
import { useDashboardLifecycle } from '../hooks';
|
||||
import { useStockPoolStore } from '../stores';
|
||||
import { getReportText, normalizeReportLanguage } from '../utils/reportLanguage';
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -50,6 +51,8 @@ const HomePage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
document.title = '每日选股分析 - DSA';
|
||||
}, []);
|
||||
const reportLanguage = normalizeReportLanguage(selectedReport?.meta.reportLanguage);
|
||||
const reportText = getReportText(reportLanguage);
|
||||
|
||||
useDashboardLifecycle({
|
||||
loadInitialHistory,
|
||||
@@ -220,7 +223,7 @@ const HomePage: React.FC = () => {
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
详细报告
|
||||
{reportText.fullReport}
|
||||
</Button>
|
||||
</div>
|
||||
<ReportSummary data={selectedReport} isHistory />
|
||||
@@ -247,6 +250,7 @@ const HomePage: React.FC = () => {
|
||||
recordId={selectedReport.meta.id}
|
||||
stockName={selectedReport.meta.stockName || ''}
|
||||
stockCode={selectedReport.meta.stockCode}
|
||||
reportLanguage={reportLanguage}
|
||||
onClose={closeMarkdownDrawer}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface AnalysisRequest {
|
||||
|
||||
// ============ 报告类型 ============
|
||||
|
||||
export type ReportLanguage = 'zh' | 'en';
|
||||
|
||||
/** 报告元信息 */
|
||||
export interface ReportMeta {
|
||||
id?: number; // 分析历史记录主键 ID(历史报告时有此字段)
|
||||
@@ -22,6 +24,7 @@ export interface ReportMeta {
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
reportType: 'simple' | 'detailed' | 'full' | 'brief';
|
||||
reportLanguage?: ReportLanguage;
|
||||
createdAt: string;
|
||||
currentPrice?: number;
|
||||
changePct?: number;
|
||||
@@ -29,7 +32,17 @@ export interface ReportMeta {
|
||||
}
|
||||
|
||||
/** 情绪标签 */
|
||||
export type SentimentLabel = '极度悲观' | '悲观' | '中性' | '乐观' | '极度乐观';
|
||||
export type SentimentLabel =
|
||||
| '极度悲观'
|
||||
| '悲观'
|
||||
| '中性'
|
||||
| '乐观'
|
||||
| '极度乐观'
|
||||
| 'Very Bearish'
|
||||
| 'Bearish'
|
||||
| 'Neutral'
|
||||
| 'Bullish'
|
||||
| 'Very Bullish';
|
||||
|
||||
/** 报告概览区 */
|
||||
export interface ReportSummary {
|
||||
@@ -205,7 +218,15 @@ export interface ApiError {
|
||||
// ============ 辅助函数 ============
|
||||
|
||||
/** 根据情绪评分获取情绪标签 */
|
||||
export const getSentimentLabel = (score: number): SentimentLabel => {
|
||||
export const getSentimentLabel = (score: number, language: ReportLanguage = 'zh'): SentimentLabel => {
|
||||
if (language === 'en') {
|
||||
if (score <= 20) return 'Very Bearish';
|
||||
if (score <= 40) return 'Bearish';
|
||||
if (score <= 60) return 'Neutral';
|
||||
if (score <= 80) return 'Bullish';
|
||||
return 'Very Bullish';
|
||||
}
|
||||
|
||||
if (score <= 20) return '极度悲观';
|
||||
if (score <= 40) return '悲观';
|
||||
if (score <= 60) return '中性';
|
||||
|
||||
83
apps/dsa-web/src/utils/reportLanguage.ts
Normal file
83
apps/dsa-web/src/utils/reportLanguage.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ReportLanguage } from '../types/analysis';
|
||||
|
||||
export const normalizeReportLanguage = (value?: string | null): ReportLanguage =>
|
||||
value === 'en' ? 'en' : 'zh';
|
||||
|
||||
const REPORT_TEXT = {
|
||||
zh: {
|
||||
keyInsights: '核心洞察',
|
||||
noAnalysisSummary: '暂无分析结论',
|
||||
actionAdvice: '操作建议',
|
||||
noAdvice: '暂无建议',
|
||||
trendPrediction: '趋势预测',
|
||||
noPrediction: '暂无预测',
|
||||
marketSentiment: '市场情绪',
|
||||
strategyPoints: '策略点位',
|
||||
sniperLevels: '狙击点位',
|
||||
idealBuy: '理想买入',
|
||||
secondaryBuy: '二次买入',
|
||||
stopLoss: '止损价位',
|
||||
takeProfit: '止盈目标',
|
||||
noValue: '—',
|
||||
newsFeed: '资讯动态',
|
||||
relatedNews: '相关资讯',
|
||||
refresh: '刷新',
|
||||
retry: '重试',
|
||||
dismiss: '关闭',
|
||||
details: '查看详情',
|
||||
loadingNews: '加载资讯中...',
|
||||
noNews: '暂无相关资讯',
|
||||
openLink: '跳转',
|
||||
transparency: '透明度',
|
||||
traceability: '数据追溯',
|
||||
rawResult: '原始分析结果',
|
||||
analysisSnapshot: '分析快照',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
recordId: '记录 ID',
|
||||
fullReport: '完整分析报告',
|
||||
loadingReport: '加载报告中...',
|
||||
loadReportFailed: '加载报告失败',
|
||||
analysisModel: '分析模型',
|
||||
fearGreedIndex: '恐惧贪婪指数',
|
||||
},
|
||||
en: {
|
||||
keyInsights: 'KEY INSIGHTS',
|
||||
noAnalysisSummary: 'No analysis summary yet',
|
||||
actionAdvice: 'Action Advice',
|
||||
noAdvice: 'No advice yet',
|
||||
trendPrediction: 'Trend Outlook',
|
||||
noPrediction: 'No forecast yet',
|
||||
marketSentiment: 'Market Sentiment',
|
||||
strategyPoints: 'STRATEGY POINTS',
|
||||
sniperLevels: 'Action Levels',
|
||||
idealBuy: 'Ideal Entry',
|
||||
secondaryBuy: 'Secondary Entry',
|
||||
stopLoss: 'Stop Loss',
|
||||
takeProfit: 'Take Profit',
|
||||
noValue: '—',
|
||||
newsFeed: 'NEWS FEED',
|
||||
relatedNews: 'Related News',
|
||||
refresh: 'Refresh',
|
||||
retry: 'Retry',
|
||||
dismiss: 'Close',
|
||||
details: 'View details',
|
||||
loadingNews: 'Loading news...',
|
||||
noNews: 'No related news',
|
||||
openLink: 'Open',
|
||||
transparency: 'TRANSPARENCY',
|
||||
traceability: 'Data Traceability',
|
||||
rawResult: 'Raw Analysis Result',
|
||||
analysisSnapshot: 'Analysis Snapshot',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied!',
|
||||
recordId: 'Record ID',
|
||||
fullReport: 'Full Analysis Report',
|
||||
loadingReport: 'Loading report...',
|
||||
loadReportFailed: 'Failed to load report',
|
||||
analysisModel: 'Model',
|
||||
fearGreedIndex: 'Fear & Greed Index',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const getReportText = (language?: string | null) => REPORT_TEXT[normalizeReportLanguage(language)];
|
||||
@@ -9,12 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 修复
|
||||
|
||||
- 🌍 **补齐 `REPORT_LANGUAGE` 启动解析与历史展示本地化边界** — `Config` 在启动时继续遵循“真实环境变量优先、`.env` 兜底”的既有语义,并在两者冲突时输出显式告警,减少 `REPORT_LANGUAGE` 来源不清带来的误判;同时 `/api/v1/history/{id}` 英文详情响应会同步本地化 `sentiment_label`,历史 Markdown 也会正确识别英文 `bias_status` 的风险等级 emoji,避免出现 `乐观` 或 `🚨Safe` 这类中英混排/误报展示。
|
||||
|
||||
### 新功能
|
||||
|
||||
- 🔎 **SearXNG 公共实例自动发现与受控轮询**(#752)— 新增 `SEARXNG_PUBLIC_INSTANCES_ENABLED`,在未配置 `SEARXNG_BASE_URLS` 时默认从 `searx.space` 拉取公共实例列表,并按受控轮询顺序选择实例;同次请求内遇到超时、连接错误、HTTP 非 200 或无效 JSON 会自动切换到下一个实例。已配置自建实例的用户保持原有优先级与语义不变;`daily_analysis` GitHub Actions 工作流也已支持显式透传该开关并在启动日志中展示当前状态。
|
||||
- 📈 **TickFlow market review enhancement** (#632) — 新增可选 `TICKFLOW_API_KEY`;配置后,A 股大盘复盘的主要指数行情优先尝试 TickFlow;若当前 TickFlow 套餐支持标的池查询,市场涨跌统计也会优先尝试 TickFlow。失败或权限不足时立即回退到现有 `AkShare / Tushare / efinance` 链路;板块涨跌榜回退顺序保持不变。接入层同时适配了真实 SDK 契约:主指数查询按单次请求上限分批拉取,并将 TickFlow 返回的比例型 `change_pct` / `amplitude` 统一转换为项目内部的百分比口径。
|
||||
- 💼 **持仓账本并发写入串行化**(#742)— 持仓源事件写入/删除现在会在 SQLite 下先获取串行化写锁,减少并发卖出把超售流水写入账本的窗口;直接持仓写接口在锁竞争时返回 `409 portfolio_busy`,CSV 导入保持逐条提交并把 busy 计入 `failed_count`。
|
||||
- 🚀 **Agent 与普通分析模型解耦(Issue #692)** — 新增 `AGENT_LITELLM_MODEL`(留空继承 `LITELLM_MODEL`,无前缀按 `openai/<model>` 归一);Agent 执行链路与 `/api/v1/agent/models` 的 `is_primary/is_fallback` 标记改为基于 Agent 实际模型链路;系统配置与启动期校验补齐 `AGENT_LITELLM_MODEL` 的 `unknown_model/missing_runtime_source` 检查;Web 设置页新增 Agent 主模型选择并与渠道模式运行时配置同步。
|
||||
- 🌍 **报告输出语言可配置**(Issue #758)— 新增 `REPORT_LANGUAGE=zh|en`,默认 `zh`;语言设置会同步注入普通分析与 Agent Prompt,并覆盖 Markdown/Jinja 模板、通知 fallback、历史/API `report_language` 元数据及 Web 报告页固定文案,避免“英文内容 + 中文壳子”的混合输出。
|
||||
|
||||
### 文档
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
| `CUSTOM_WEBHOOK_BEARER_TOKEN` | 自定義 Webhook 的 Bearer Token(用於需要認證的 Webhook) | 可選 |
|
||||
| `SINGLE_STOCK_NOTIFY` | 單股推送模式:設為 `true` 則每分析完一隻股票立即推送 | 可選 |
|
||||
| `REPORT_TYPE` | 報告類型:`simple`(精簡) 或 `full`(完整),Docker環境推薦設為 `full` | 可選 |
|
||||
| `REPORT_LANGUAGE` | 報告輸出語言:`zh`(預設中文) / `en`(英文);會同步影響 Prompt、Markdown 模板、通知 fallback 與 Web 報告頁固定文案 | 可選 |
|
||||
| `ANALYSIS_DELAY` | 個股分析和大盤分析之間的延遲(秒),避免API限流,如 `10` | 可選 |
|
||||
|
||||
> 至少配置一個渠道,配置多個則同時推送。更多配置請參考 [完整指南](full-guide.md)
|
||||
|
||||
@@ -114,7 +114,8 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions`
|
||||
| `CUSTOM_WEBHOOK_URLS` | Custom Webhook URLs (supports DingTalk, etc., comma-separated) | Optional |
|
||||
| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer token for custom webhooks (if required) | Optional |
|
||||
| `SINGLE_STOCK_NOTIFY` | Send notification immediately after each stock | Optional |
|
||||
| `REPORT_TYPE` | `simple` or `full` (Docker recommended: `full`) | Optional |
|
||||
| `REPORT_TYPE` | `simple`, `full`, or `brief` (Docker recommended: `full`) | Optional |
|
||||
| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); affects prompt instructions, Markdown templates, notification fallbacks, and fixed labels in the Web report view | Optional |
|
||||
| `ANALYSIS_DELAY` | Delay between stocks and market review (seconds) | Optional |
|
||||
|
||||
> Note: Configure at least one channel; multiple channels will all receive notifications.
|
||||
|
||||
@@ -94,6 +94,7 @@ daily_stock_analysis/
|
||||
|------------|------|:----:|
|
||||
| `SINGLE_STOCK_NOTIFY` | 单股推送模式:设为 `true` 则每分析完一只股票立即推送 | 可选 |
|
||||
| `REPORT_TYPE` | 报告类型:`simple`(精简)、`full`(完整)、`brief`(3-5句概括),Docker环境推荐设为 `full` | 可选 |
|
||||
| `REPORT_LANGUAGE` | 报告输出语言:`zh`(默认中文) / `en`(英文);会同步影响 Prompt、模板、通知 fallback 与 Web 报告页固定文案 | 可选 |
|
||||
| `REPORT_SUMMARY_ONLY` | 仅分析结果摘要:设为 `true` 时只推送汇总,不含个股详情;多股时适合快速浏览(默认 false,Issue #262) | 可选 |
|
||||
| `REPORT_TEMPLATES_DIR` | Jinja2 模板目录(相对项目根,默认 `templates`) | 可选 |
|
||||
| `REPORT_RENDERER_ENABLED` | 启用 Jinja2 模板渲染(默认 `false`,保证零回归) | 可选 |
|
||||
|
||||
@@ -93,6 +93,7 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions`
|
||||
|------------|------|:----:|
|
||||
| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |
|
||||
| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |
|
||||
| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view | Optional |
|
||||
| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |
|
||||
| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |
|
||||
| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import List, Optional
|
||||
|
||||
from src.agent.agents.base_agent import BaseAgent
|
||||
from src.agent.protocols import AgentContext, AgentOpinion, normalize_decision_signal
|
||||
from src.report_language import normalize_report_language
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,8 +33,9 @@ class DecisionAgent(BaseAgent):
|
||||
return ctx.meta.get("response_mode") == "chat"
|
||||
|
||||
def system_prompt(self, ctx: AgentContext) -> str:
|
||||
report_language = normalize_report_language(ctx.meta.get("report_language", "zh"))
|
||||
if self._is_chat_mode(ctx):
|
||||
return """\
|
||||
prompt = """\
|
||||
You are a **Decision Synthesis Agent** replying directly to the user's latest
|
||||
stock-analysis question.
|
||||
|
||||
@@ -47,12 +49,15 @@ Requirements:
|
||||
- Highlight the main signal, key reasoning, and major risks
|
||||
- Do NOT output JSON or code fences unless the user explicitly asks for them
|
||||
"""
|
||||
if report_language == "en":
|
||||
return prompt + "\nAlways answer in English.\n"
|
||||
return prompt + "\n默认使用中文回答。\n"
|
||||
|
||||
skills = ""
|
||||
if self.skill_instructions:
|
||||
skills = f"\n## Active Trading Strategies\n\n{self.skill_instructions}\n"
|
||||
|
||||
return f"""\
|
||||
prompt = f"""\
|
||||
You are a **Decision Synthesis Agent** that produces the final investment \
|
||||
Decision Dashboard.
|
||||
|
||||
@@ -95,6 +100,21 @@ Important: ``decision_type`` must stay within the existing enum
|
||||
``buy|hold|sell``. Express stronger conviction via ``confidence_level``,
|
||||
``sentiment_score``, and the natural-language fields instead of inventing
|
||||
new decision_type values.
|
||||
"""
|
||||
if report_language == "en":
|
||||
return prompt + """
|
||||
|
||||
## Output Language
|
||||
- Keep every JSON key unchanged.
|
||||
- `decision_type` must remain `buy|hold|sell`.
|
||||
- Write all human-readable JSON values in English.
|
||||
"""
|
||||
return prompt + """
|
||||
|
||||
## 输出语言
|
||||
- 所有 JSON 键名保持不变。
|
||||
- `decision_type` 必须保持为 `buy|hold|sell`。
|
||||
- 所有面向用户的人类可读文本值必须使用中文。
|
||||
"""
|
||||
|
||||
def build_user_message(self, ctx: AgentContext) -> str:
|
||||
|
||||
@@ -22,6 +22,7 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
from src.agent.llm_adapter import LLMToolAdapter
|
||||
from src.agent.runner import run_agent_loop, parse_dashboard_json
|
||||
from src.agent.tools.registry import ToolRegistry
|
||||
from src.report_language import normalize_report_language
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -204,6 +205,8 @@ AGENT_SYSTEM_PROMPT = """你是一位专注于趋势交易的 A 股投资分析
|
||||
3. **精确狙击点**:必须给出具体价格,不说模糊的话
|
||||
4. **检查清单可视化**:用 ✅⚠️❌ 明确显示每项检查结果
|
||||
5. **风险优先级**:舆情中的风险点要醒目标出
|
||||
|
||||
{language_section}
|
||||
"""
|
||||
|
||||
CHAT_SYSTEM_PROMPT = """你是一位专注于趋势交易的 A 股投资分析 Agent,拥有数据工具和交易策略,负责解答用户的股票投资问题。
|
||||
@@ -269,6 +272,44 @@ CHAT_SYSTEM_PROMPT = """你是一位专注于趋势交易的 A 股投资分析 A
|
||||
5. **工具失败处理** — 记录失败原因,使用已有数据继续分析,不重复调用失败工具。
|
||||
|
||||
{skills_section}
|
||||
{language_section}
|
||||
"""
|
||||
|
||||
|
||||
def _build_language_section(report_language: str, *, chat_mode: bool = False) -> str:
|
||||
"""Build output-language guidance for the agent prompt."""
|
||||
normalized = normalize_report_language(report_language)
|
||||
if chat_mode:
|
||||
if normalized == "en":
|
||||
return """
|
||||
## Output Language
|
||||
|
||||
- Reply in English.
|
||||
- If you output JSON, keep the keys unchanged and write every human-readable value in English.
|
||||
"""
|
||||
return """
|
||||
## 输出语言
|
||||
|
||||
- 默认使用中文回答。
|
||||
- 若输出 JSON,键名保持不变,所有面向用户的文本值使用中文。
|
||||
"""
|
||||
|
||||
if normalized == "en":
|
||||
return """
|
||||
## Output Language
|
||||
|
||||
- Keep every JSON key unchanged.
|
||||
- `decision_type` must remain `buy|hold|sell`.
|
||||
- All human-readable JSON values must be written in English.
|
||||
- This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, all dashboard text, checklist items, and summaries.
|
||||
"""
|
||||
|
||||
return """
|
||||
## 输出语言
|
||||
|
||||
- 所有 JSON 键名保持不变。
|
||||
- `decision_type` 必须保持为 `buy|hold|sell`。
|
||||
- 所有面向用户的人类可读文本值必须使用中文。
|
||||
"""
|
||||
|
||||
|
||||
@@ -313,7 +354,11 @@ class AgentExecutor:
|
||||
skills_section = ""
|
||||
if self.skill_instructions:
|
||||
skills_section = f"## 激活的交易策略\n\n{self.skill_instructions}"
|
||||
system_prompt = AGENT_SYSTEM_PROMPT.format(skills_section=skills_section)
|
||||
report_language = normalize_report_language((context or {}).get("report_language", "zh"))
|
||||
system_prompt = AGENT_SYSTEM_PROMPT.format(
|
||||
skills_section=skills_section,
|
||||
language_section=_build_language_section(report_language),
|
||||
)
|
||||
|
||||
# Build tool declarations in OpenAI format (litellm handles all providers)
|
||||
tool_decls = self.tool_registry.to_openai_tools()
|
||||
@@ -344,7 +389,11 @@ class AgentExecutor:
|
||||
skills_section = ""
|
||||
if self.skill_instructions:
|
||||
skills_section = f"## 激活的交易策略\n\n{self.skill_instructions}"
|
||||
system_prompt = CHAT_SYSTEM_PROMPT.format(skills_section=skills_section)
|
||||
report_language = normalize_report_language((context or {}).get("report_language", "zh"))
|
||||
system_prompt = CHAT_SYSTEM_PROMPT.format(
|
||||
skills_section=skills_section,
|
||||
language_section=_build_language_section(report_language, chat_mode=True),
|
||||
)
|
||||
|
||||
# Build tool declarations in OpenAI format (litellm handles all providers)
|
||||
tool_decls = self.tool_registry.to_openai_tools()
|
||||
@@ -447,10 +496,15 @@ class AgentExecutor:
|
||||
"""Build the initial user message."""
|
||||
parts = [task]
|
||||
if context:
|
||||
report_language = normalize_report_language(context.get("report_language", "zh"))
|
||||
if context.get("stock_code"):
|
||||
parts.append(f"\n股票代码: {context['stock_code']}")
|
||||
if context.get("report_type"):
|
||||
parts.append(f"报告类型: {context['report_type']}")
|
||||
if report_language == "en":
|
||||
parts.append("输出语言: English(所有 JSON 键名保持不变,所有面向用户的文本值使用英文)")
|
||||
else:
|
||||
parts.append("输出语言: 中文(所有 JSON 键名保持不变,所有面向用户的文本值使用中文)")
|
||||
|
||||
# Inject pre-fetched context data to avoid redundant fetches
|
||||
if context.get("realtime_quote"):
|
||||
|
||||
@@ -42,6 +42,7 @@ from src.agent.protocols import (
|
||||
)
|
||||
from src.agent.runner import parse_dashboard_json
|
||||
from src.agent.tools.registry import ToolRegistry
|
||||
from src.report_language import normalize_report_language
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.agent.executor import AgentResult
|
||||
@@ -563,6 +564,7 @@ class AgentOrchestrator:
|
||||
ctx.stock_code = context.get("stock_code", "")
|
||||
ctx.stock_name = context.get("stock_name", "")
|
||||
ctx.meta["strategies_requested"] = context.get("strategies", [])
|
||||
ctx.meta["report_language"] = normalize_report_language(context.get("report_language", "zh"))
|
||||
|
||||
# Pre-populate data fields that the caller already has
|
||||
for data_key in ("realtime_quote", "daily_history", "chip_distribution",
|
||||
@@ -574,6 +576,9 @@ class AgentOrchestrator:
|
||||
if not ctx.stock_code:
|
||||
ctx.stock_code = _extract_stock_code(task)
|
||||
|
||||
if "report_language" not in ctx.meta:
|
||||
ctx.meta["report_language"] = "zh"
|
||||
|
||||
return ctx
|
||||
|
||||
@staticmethod
|
||||
|
||||
277
src/analyzer.py
277
src/analyzer.py
@@ -32,6 +32,16 @@ from src.config import (
|
||||
)
|
||||
from src.storage import persist_llm_usage
|
||||
from src.data.stock_mapping import STOCK_NAME_MAP
|
||||
from src.report_language import (
|
||||
get_signal_level,
|
||||
get_no_data_text,
|
||||
get_placeholder_text,
|
||||
get_unknown_text,
|
||||
infer_decision_type_from_advice,
|
||||
localize_chip_health,
|
||||
localize_confidence_level,
|
||||
normalize_report_language,
|
||||
)
|
||||
from src.schemas.report_schema import AnalysisReportSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,20 +83,21 @@ def check_content_integrity(result: "AnalysisResult") -> Tuple[bool, List[str]]:
|
||||
|
||||
def apply_placeholder_fill(result: "AnalysisResult", missing_fields: List[str]) -> None:
|
||||
"""Fill missing mandatory fields with placeholders (in-place). Module-level for pipeline."""
|
||||
placeholder = get_placeholder_text(getattr(result, "report_language", "zh"))
|
||||
for field in missing_fields:
|
||||
if field == "sentiment_score":
|
||||
result.sentiment_score = 50
|
||||
elif field == "operation_advice":
|
||||
result.operation_advice = result.operation_advice or "待补充"
|
||||
result.operation_advice = result.operation_advice or placeholder
|
||||
elif field == "analysis_summary":
|
||||
result.analysis_summary = result.analysis_summary or "待补充"
|
||||
result.analysis_summary = result.analysis_summary or placeholder
|
||||
elif field == "dashboard.core_conclusion.one_sentence":
|
||||
if not result.dashboard:
|
||||
result.dashboard = {}
|
||||
if "core_conclusion" not in result.dashboard:
|
||||
result.dashboard["core_conclusion"] = {}
|
||||
result.dashboard["core_conclusion"]["one_sentence"] = (
|
||||
result.dashboard["core_conclusion"].get("one_sentence") or "待补充"
|
||||
result.dashboard["core_conclusion"].get("one_sentence") or placeholder
|
||||
)
|
||||
elif field == "dashboard.intelligence.risk_alerts":
|
||||
if not result.dashboard:
|
||||
@@ -102,7 +113,7 @@ def apply_placeholder_fill(result: "AnalysisResult", missing_fields: List[str])
|
||||
result.dashboard["battle_plan"] = {}
|
||||
if "sniper_points" not in result.dashboard["battle_plan"]:
|
||||
result.dashboard["battle_plan"]["sniper_points"] = {}
|
||||
result.dashboard["battle_plan"]["sniper_points"]["stop_loss"] = "待补充"
|
||||
result.dashboard["battle_plan"]["sniper_points"]["stop_loss"] = placeholder
|
||||
|
||||
|
||||
# ---------- chip_structure fallback (Issue #589) ----------
|
||||
@@ -117,7 +128,7 @@ def _is_value_placeholder(v: Any) -> bool:
|
||||
if isinstance(v, (int, float)) and v == 0:
|
||||
return True
|
||||
s = str(v).strip().lower()
|
||||
return s in ("", "n/a", "na", "数据缺失", "未知")
|
||||
return s in ("", "n/a", "na", "数据缺失", "未知", "data unavailable", "unknown", "tbd")
|
||||
|
||||
|
||||
def _safe_float(v: Any, default: float = 0.0) -> float:
|
||||
@@ -135,18 +146,18 @@ def _safe_float(v: Any, default: float = 0.0) -> float:
|
||||
return default
|
||||
|
||||
|
||||
def _derive_chip_health(profit_ratio: float, concentration_90: float) -> str:
|
||||
def _derive_chip_health(profit_ratio: float, concentration_90: float, language: str = "zh") -> str:
|
||||
"""Derive chip_health from profit_ratio and concentration_90."""
|
||||
if profit_ratio >= 0.9:
|
||||
return "警惕" # 获利盘极高
|
||||
return localize_chip_health("警惕", language) # 获利盘极高
|
||||
if concentration_90 >= 0.25:
|
||||
return "警惕" # 筹码分散
|
||||
return localize_chip_health("警惕", language) # 筹码分散
|
||||
if concentration_90 < 0.15 and 0.3 <= profit_ratio < 0.9:
|
||||
return "健康" # 集中且获利比例适中
|
||||
return "一般"
|
||||
return localize_chip_health("健康", language) # 集中且获利比例适中
|
||||
return localize_chip_health("一般", language)
|
||||
|
||||
|
||||
def _build_chip_structure_from_data(chip_data: Any) -> Dict[str, Any]:
|
||||
def _build_chip_structure_from_data(chip_data: Any, language: str = "zh") -> Dict[str, Any]:
|
||||
"""Build chip_structure dict from ChipDistribution or dict."""
|
||||
if hasattr(chip_data, "profit_ratio"):
|
||||
pr = _safe_float(chip_data.profit_ratio)
|
||||
@@ -157,7 +168,7 @@ def _build_chip_structure_from_data(chip_data: Any) -> Dict[str, Any]:
|
||||
pr = _safe_float(d.get("profit_ratio"))
|
||||
ac = d.get("avg_cost")
|
||||
c90 = _safe_float(d.get("concentration_90"))
|
||||
chip_health = _derive_chip_health(pr, c90)
|
||||
chip_health = _derive_chip_health(pr, c90, language=language)
|
||||
return {
|
||||
"profit_ratio": f"{pr:.1%}",
|
||||
"avg_cost": ac if (ac is not None and _safe_float(ac) != 0.0) else "N/A",
|
||||
@@ -178,7 +189,10 @@ def fill_chip_structure_if_needed(result: "AnalysisResult", chip_data: Any) -> N
|
||||
dp = dash.get("data_perspective") or {}
|
||||
dash["data_perspective"] = dp
|
||||
cs = dp.get("chip_structure") or {}
|
||||
filled = _build_chip_structure_from_data(chip_data)
|
||||
filled = _build_chip_structure_from_data(
|
||||
chip_data,
|
||||
language=getattr(result, "report_language", "zh"),
|
||||
)
|
||||
# Start from a copy of cs to preserve any extra keys the LLM may have added
|
||||
merged = dict(cs)
|
||||
for k in _CHIP_KEYS:
|
||||
@@ -321,6 +335,7 @@ class AnalysisResult:
|
||||
operation_advice: str # 操作建议:买入/加仓/持有/减仓/卖出/观望
|
||||
decision_type: str = "hold" # 决策类型:buy/hold/sell(用于统计)
|
||||
confidence_level: str = "中" # 置信度:高/中/低
|
||||
report_language: str = "zh" # 报告输出语言:zh/en
|
||||
|
||||
# ========== 决策仪表盘 (新增) ==========
|
||||
dashboard: Optional[Dict[str, Any]] = None # 完整的决策仪表盘数据
|
||||
@@ -380,6 +395,7 @@ class AnalysisResult:
|
||||
'operation_advice': self.operation_advice,
|
||||
'decision_type': self.decision_type,
|
||||
'confidence_level': self.confidence_level,
|
||||
'report_language': self.report_language,
|
||||
'dashboard': self.dashboard, # 决策仪表盘数据
|
||||
'trend_analysis': self.trend_analysis,
|
||||
'short_term_outlook': self.short_term_outlook,
|
||||
@@ -442,44 +458,24 @@ class AnalysisResult:
|
||||
|
||||
def get_emoji(self) -> str:
|
||||
"""根据操作建议返回对应 emoji"""
|
||||
emoji_map = {
|
||||
'买入': '🟢',
|
||||
'加仓': '🟢',
|
||||
'强烈买入': '💚',
|
||||
'持有': '🟡',
|
||||
'观望': '⚪',
|
||||
'减仓': '🟠',
|
||||
'卖出': '🔴',
|
||||
'强烈卖出': '❌',
|
||||
}
|
||||
advice = self.operation_advice or ''
|
||||
# Direct match first
|
||||
if advice in emoji_map:
|
||||
return emoji_map[advice]
|
||||
# Handle compound advice like "卖出/观望" — use the first part
|
||||
for part in advice.replace('/', '|').split('|'):
|
||||
part = part.strip()
|
||||
if part in emoji_map:
|
||||
return emoji_map[part]
|
||||
# Score-based fallback
|
||||
score = self.sentiment_score
|
||||
if score >= 80:
|
||||
return '💚'
|
||||
elif score >= 65:
|
||||
return '🟢'
|
||||
elif score >= 55:
|
||||
return '🟡'
|
||||
elif score >= 45:
|
||||
return '⚪'
|
||||
elif score >= 35:
|
||||
return '🟠'
|
||||
else:
|
||||
return '🔴'
|
||||
_, emoji, _ = get_signal_level(
|
||||
self.operation_advice,
|
||||
self.sentiment_score,
|
||||
self.report_language,
|
||||
)
|
||||
return emoji
|
||||
|
||||
def get_confidence_stars(self) -> str:
|
||||
"""返回置信度星级"""
|
||||
star_map = {'高': '⭐⭐⭐', '中': '⭐⭐', '低': '⭐'}
|
||||
return star_map.get(self.confidence_level, '⭐⭐')
|
||||
star_map = {
|
||||
"高": "⭐⭐⭐",
|
||||
"high": "⭐⭐⭐",
|
||||
"中": "⭐⭐",
|
||||
"medium": "⭐⭐",
|
||||
"低": "⭐",
|
||||
"low": "⭐",
|
||||
}
|
||||
return star_map.get(str(self.confidence_level or "").strip().lower(), "⭐⭐")
|
||||
|
||||
|
||||
class GeminiAnalyzer:
|
||||
@@ -701,6 +697,28 @@ class GeminiAnalyzer:
|
||||
if not self._litellm_available:
|
||||
logger.warning("No LLM configured (LITELLM_MODEL / API keys), AI analysis will be unavailable")
|
||||
|
||||
def _get_analysis_system_prompt(self, report_language: str) -> str:
|
||||
"""Build the analyzer system prompt with output-language guidance."""
|
||||
if normalize_report_language(report_language) == "en":
|
||||
return self.SYSTEM_PROMPT + """
|
||||
|
||||
## Output Language (highest priority)
|
||||
|
||||
- Keep all JSON keys unchanged.
|
||||
- `decision_type` must remain `buy|hold|sell`.
|
||||
- All human-readable JSON values must be written in English.
|
||||
- Use the common English company name when you are confident; otherwise keep the original listed company name instead of inventing one.
|
||||
- This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, nested dashboard text, checklist items, and all narrative summaries.
|
||||
"""
|
||||
return self.SYSTEM_PROMPT + """
|
||||
|
||||
## 输出语言(最高优先级)
|
||||
|
||||
- 所有 JSON 键名保持不变。
|
||||
- `decision_type` 必须保持为 `buy|hold|sell`。
|
||||
- 所有面向用户的人类可读文本值必须使用中文。
|
||||
"""
|
||||
|
||||
def _has_channel_config(self, config: Config) -> bool:
|
||||
"""Check if multi-channel config (channels / YAML / legacy model_list) is active."""
|
||||
return bool(config.llm_model_list) and not all(
|
||||
@@ -772,7 +790,13 @@ class GeminiAnalyzer:
|
||||
"""Check if LiteLLM is properly configured with at least one API key."""
|
||||
return self._router is not None or self._litellm_available
|
||||
|
||||
def _call_litellm(self, prompt: str, generation_config: dict) -> Tuple[str, str, Dict[str, Any]]:
|
||||
def _call_litellm(
|
||||
self,
|
||||
prompt: str,
|
||||
generation_config: dict,
|
||||
*,
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> Tuple[str, str, Dict[str, Any]]:
|
||||
"""Call LLM via litellm with fallback across configured models.
|
||||
|
||||
When channels/YAML are configured, every model goes through the Router
|
||||
@@ -802,13 +826,14 @@ class GeminiAnalyzer:
|
||||
use_channel_router = self._has_channel_config(config)
|
||||
|
||||
last_error = None
|
||||
effective_system_prompt = system_prompt or self.SYSTEM_PROMPT
|
||||
for model in models_to_try:
|
||||
try:
|
||||
model_short = model.split("/")[-1] if "/" in model else model
|
||||
call_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": self.SYSTEM_PROMPT},
|
||||
{"role": "system", "content": effective_system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": temperature,
|
||||
@@ -910,6 +935,8 @@ class GeminiAnalyzer:
|
||||
"""
|
||||
code = context.get('code', 'Unknown')
|
||||
config = get_config()
|
||||
report_language = normalize_report_language(getattr(config, "report_language", "zh"))
|
||||
system_prompt = self._get_analysis_system_prompt(report_language)
|
||||
|
||||
# 请求前增加延时(防止连续请求触发限流)
|
||||
request_delay = config.gemini_request_delay
|
||||
@@ -933,19 +960,20 @@ class GeminiAnalyzer:
|
||||
code=code,
|
||||
name=name,
|
||||
sentiment_score=50,
|
||||
trend_prediction='震荡',
|
||||
operation_advice='持有',
|
||||
confidence_level='低',
|
||||
analysis_summary='AI 分析功能未启用(未配置 API Key)',
|
||||
risk_warning='请配置 LLM API Key(GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY)后重试',
|
||||
trend_prediction='Sideways' if report_language == "en" else '震荡',
|
||||
operation_advice='Hold' if report_language == "en" else '持有',
|
||||
confidence_level='Low' if report_language == "en" else '低',
|
||||
analysis_summary='AI analysis is unavailable because no API key is configured.' if report_language == "en" else 'AI 分析功能未启用(未配置 API Key)',
|
||||
risk_warning='Configure an LLM API key (GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY) and retry.' if report_language == "en" else '请配置 LLM API Key(GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY)后重试',
|
||||
success=False,
|
||||
error_message='LLM API Key 未配置',
|
||||
error_message='LLM API key is not configured' if report_language == "en" else 'LLM API Key 未配置',
|
||||
model_used=None,
|
||||
report_language=report_language,
|
||||
)
|
||||
|
||||
try:
|
||||
# 格式化输入(包含技术面数据和新闻)
|
||||
prompt = self._format_prompt(context, name, news_context)
|
||||
prompt = self._format_prompt(context, name, news_context, report_language=report_language)
|
||||
|
||||
config = get_config()
|
||||
model_name = config.litellm_model or "unknown"
|
||||
@@ -974,7 +1002,11 @@ class GeminiAnalyzer:
|
||||
|
||||
while True:
|
||||
start_time = time.time()
|
||||
response_text, model_used, llm_usage = self._call_litellm(current_prompt, generation_config)
|
||||
response_text, model_used, llm_usage = self._call_litellm(
|
||||
current_prompt,
|
||||
generation_config,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# 记录响应信息
|
||||
@@ -993,6 +1025,7 @@ class GeminiAnalyzer:
|
||||
result.search_performed = bool(news_context)
|
||||
result.market_snapshot = self._build_market_snapshot(context)
|
||||
result.model_used = model_used
|
||||
result.report_language = report_language
|
||||
|
||||
# 内容完整性校验(可选)
|
||||
if not config.report_integrity_enabled:
|
||||
@@ -1005,6 +1038,7 @@ class GeminiAnalyzer:
|
||||
prompt,
|
||||
response_text,
|
||||
missing_fields,
|
||||
report_language=report_language,
|
||||
)
|
||||
retry_count += 1
|
||||
logger.info(
|
||||
@@ -1032,21 +1066,23 @@ class GeminiAnalyzer:
|
||||
code=code,
|
||||
name=name,
|
||||
sentiment_score=50,
|
||||
trend_prediction='震荡',
|
||||
operation_advice='持有',
|
||||
confidence_level='低',
|
||||
analysis_summary=f'分析过程出错: {str(e)[:100]}',
|
||||
risk_warning='分析失败,请稍后重试或手动分析',
|
||||
trend_prediction='Sideways' if report_language == "en" else '震荡',
|
||||
operation_advice='Hold' if report_language == "en" else '持有',
|
||||
confidence_level='Low' if report_language == "en" else '低',
|
||||
analysis_summary=(f'Analysis failed: {str(e)[:100]}' if report_language == "en" else f'分析过程出错: {str(e)[:100]}'),
|
||||
risk_warning='Analysis failed. Please retry later or review manually.' if report_language == "en" else '分析失败,请稍后重试或手动分析',
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
model_used=None,
|
||||
report_language=report_language,
|
||||
)
|
||||
|
||||
def _format_prompt(
|
||||
self,
|
||||
context: Dict[str, Any],
|
||||
name: str,
|
||||
news_context: Optional[str] = None
|
||||
news_context: Optional[str] = None,
|
||||
report_language: str = "zh",
|
||||
) -> str:
|
||||
"""
|
||||
格式化分析提示词(决策仪表盘 v2.0)
|
||||
@@ -1059,6 +1095,7 @@ class GeminiAnalyzer:
|
||||
news_context: 预先搜索的新闻内容
|
||||
"""
|
||||
code = context.get('code', 'Unknown')
|
||||
report_language = normalize_report_language(report_language)
|
||||
|
||||
# 优先使用上下文中的股票名称(从 realtime_quote 获取)
|
||||
stock_name = context.get('stock_name', name)
|
||||
@@ -1066,6 +1103,8 @@ class GeminiAnalyzer:
|
||||
stock_name = STOCK_NAME_MAP.get(code, f'股票{code}')
|
||||
|
||||
today = context.get('today', {})
|
||||
unknown_text = get_unknown_text(report_language)
|
||||
no_data_text = get_no_data_text(report_language)
|
||||
|
||||
# ========== 构建决策仪表盘格式的输入 ==========
|
||||
prompt = f"""# 决策仪表盘分析请求
|
||||
@@ -1075,7 +1114,7 @@ class GeminiAnalyzer:
|
||||
|------|------|
|
||||
| 股票代码 | **{code}** |
|
||||
| 股票名称 | **{stock_name}** |
|
||||
| 分析日期 | {context.get('date', '未知')} |
|
||||
| 分析日期 | {context.get('date', unknown_text)} |
|
||||
|
||||
---
|
||||
|
||||
@@ -1098,7 +1137,7 @@ class GeminiAnalyzer:
|
||||
| MA5 | {today.get('ma5', 'N/A')} | 短期趋势线 |
|
||||
| MA10 | {today.get('ma10', 'N/A')} | 中短期趋势线 |
|
||||
| MA20 | {today.get('ma20', 'N/A')} | 中期趋势线 |
|
||||
| 均线形态 | {context.get('ma_status', '未知')} | 多头/空头/缠绕 |
|
||||
| 均线形态 | {context.get('ma_status', unknown_text)} | 多头/空头/缠绕 |
|
||||
"""
|
||||
|
||||
# 添加实时行情数据(量比、换手率等)
|
||||
@@ -1175,7 +1214,7 @@ class GeminiAnalyzer:
|
||||
| 平均成本 | {chip.get('avg_cost', 'N/A')} 元 | 现价应高于5-15% |
|
||||
| 90%筹码集中度 | {chip.get('concentration_90', 0):.2%} | <15%为集中 |
|
||||
| 70%筹码集中度 | {chip.get('concentration_70', 0):.2%} | |
|
||||
| 筹码状态 | {chip.get('chip_status', '未知')} | |
|
||||
| 筹码状态 | {chip.get('chip_status', unknown_text)} | |
|
||||
"""
|
||||
|
||||
# 添加趋势分析结果(基于交易理念的预判)
|
||||
@@ -1186,13 +1225,13 @@ class GeminiAnalyzer:
|
||||
### 趋势分析预判(基于交易理念)
|
||||
| 指标 | 数值 | 判定 |
|
||||
|------|------|------|
|
||||
| 趋势状态 | {trend.get('trend_status', '未知')} | |
|
||||
| 均线排列 | {trend.get('ma_alignment', '未知')} | MA5>MA10>MA20为多头 |
|
||||
| 趋势状态 | {trend.get('trend_status', unknown_text)} | |
|
||||
| 均线排列 | {trend.get('ma_alignment', unknown_text)} | MA5>MA10>MA20为多头 |
|
||||
| 趋势强度 | {trend.get('trend_strength', 0)}/100 | |
|
||||
| **乖离率(MA5)** | **{trend.get('bias_ma5', 0):+.2f}%** | {bias_warning} |
|
||||
| 乖离率(MA10) | {trend.get('bias_ma10', 0):+.2f}% | |
|
||||
| 量能状态 | {trend.get('volume_status', '未知')} | {trend.get('volume_trend', '')} |
|
||||
| 系统信号 | {trend.get('buy_signal', '未知')} | |
|
||||
| 量能状态 | {trend.get('volume_status', unknown_text)} | {trend.get('volume_trend', '')} |
|
||||
| 系统信号 | {trend.get('buy_signal', unknown_text)} | |
|
||||
| 系统评分 | {trend.get('signal_score', 0)}/100 | |
|
||||
|
||||
#### 系统分析理由
|
||||
@@ -1301,6 +1340,27 @@ class GeminiAnalyzer:
|
||||
- **消息面时间合规**:`latest_news`、`risk_alerts`、`positive_catalysts` 不得包含超出近{news_window_days}日或时间未知的信息
|
||||
|
||||
请输出完整的 JSON 格式决策仪表盘。"""
|
||||
|
||||
if report_language == "en":
|
||||
prompt += """
|
||||
|
||||
### Output language requirements (highest priority)
|
||||
- Keep every JSON key exactly as defined above; do not translate keys.
|
||||
- `decision_type` must remain `buy`, `hold`, or `sell`.
|
||||
- All human-readable JSON values must be in English.
|
||||
- This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, all nested dashboard text, checklist items, and every summary field.
|
||||
- Use the common English company name when you are confident. If not, keep the listed company name rather than inventing one.
|
||||
- When data is missing, explain it in English instead of Chinese.
|
||||
"""
|
||||
else:
|
||||
prompt += f"""
|
||||
|
||||
### 输出语言要求(最高优先级)
|
||||
- 所有 JSON 键名必须保持不变,不要翻译键名。
|
||||
- `decision_type` 必须保持为 `buy`、`hold`、`sell`。
|
||||
- 所有面向用户的人类可读文本值必须使用中文。
|
||||
- 当数据缺失时,请使用中文直接说明“{no_data_text},无法判断”。
|
||||
"""
|
||||
|
||||
return prompt
|
||||
|
||||
@@ -1396,8 +1456,26 @@ class GeminiAnalyzer:
|
||||
"""Delegate to module-level check_content_integrity."""
|
||||
return check_content_integrity(result)
|
||||
|
||||
def _build_integrity_complement_prompt(self, missing_fields: List[str]) -> str:
|
||||
def _build_integrity_complement_prompt(self, missing_fields: List[str], report_language: str = "zh") -> str:
|
||||
"""Build complement instruction for missing mandatory fields."""
|
||||
report_language = normalize_report_language(report_language)
|
||||
if report_language == "en":
|
||||
lines = ["### Completion requirements: fill the missing mandatory fields below and output the full JSON again:"]
|
||||
for f in missing_fields:
|
||||
if f == "sentiment_score":
|
||||
lines.append("- sentiment_score: integer score from 0 to 100")
|
||||
elif f == "operation_advice":
|
||||
lines.append("- operation_advice: localized action advice")
|
||||
elif f == "analysis_summary":
|
||||
lines.append("- analysis_summary: concise analysis summary")
|
||||
elif f == "dashboard.core_conclusion.one_sentence":
|
||||
lines.append("- dashboard.core_conclusion.one_sentence: one-line decision")
|
||||
elif f == "dashboard.intelligence.risk_alerts":
|
||||
lines.append("- dashboard.intelligence.risk_alerts: risk alert list (can be empty)")
|
||||
elif f == "dashboard.battle_plan.sniper_points.stop_loss":
|
||||
lines.append("- dashboard.battle_plan.sniper_points.stop_loss: stop-loss level")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines = ["### 补全要求:请在上方分析基础上补充以下必填内容,并输出完整 JSON:"]
|
||||
for f in missing_fields:
|
||||
if f == "sentiment_score":
|
||||
@@ -1419,13 +1497,18 @@ class GeminiAnalyzer:
|
||||
base_prompt: str,
|
||||
previous_response: str,
|
||||
missing_fields: List[str],
|
||||
report_language: str = "zh",
|
||||
) -> str:
|
||||
"""Build retry prompt using the previous response as the complement baseline."""
|
||||
complement = self._build_integrity_complement_prompt(missing_fields)
|
||||
complement = self._build_integrity_complement_prompt(missing_fields, report_language=report_language)
|
||||
previous_output = previous_response.strip()
|
||||
if normalize_report_language(report_language) == "en":
|
||||
prefix = "### The previous output is below. Complete the missing fields based on that output and return the full JSON again. Do not omit existing fields:"
|
||||
else:
|
||||
prefix = "### 上一次输出如下,请在该输出基础上补齐缺失字段,并重新输出完整 JSON。不要省略已有字段:"
|
||||
return "\n\n".join([
|
||||
base_prompt,
|
||||
"### 上一次输出如下,请在该输出基础上补齐缺失字段,并重新输出完整 JSON。不要省略已有字段:",
|
||||
prefix,
|
||||
previous_output,
|
||||
complement,
|
||||
])
|
||||
@@ -1447,6 +1530,7 @@ class GeminiAnalyzer:
|
||||
如果解析失败,尝试智能提取或返回默认结果
|
||||
"""
|
||||
try:
|
||||
report_language = normalize_report_language(getattr(get_config(), "report_language", "zh"))
|
||||
# 清理响应文本:移除 markdown 代码块标记
|
||||
cleaned_text = response_text
|
||||
if '```json' in cleaned_text:
|
||||
@@ -1487,23 +1571,22 @@ class GeminiAnalyzer:
|
||||
# 解析 decision_type,如果没有则根据 operation_advice 推断
|
||||
decision_type = data.get('decision_type', '')
|
||||
if not decision_type:
|
||||
op = data.get('operation_advice', '持有')
|
||||
if op in ['买入', '加仓', '强烈买入']:
|
||||
decision_type = 'buy'
|
||||
elif op in ['卖出', '减仓', '强烈卖出']:
|
||||
decision_type = 'sell'
|
||||
else:
|
||||
decision_type = 'hold'
|
||||
op = data.get('operation_advice', 'Hold' if report_language == "en" else '持有')
|
||||
decision_type = infer_decision_type_from_advice(op, default='hold')
|
||||
|
||||
return AnalysisResult(
|
||||
code=code,
|
||||
name=name,
|
||||
# 核心指标
|
||||
sentiment_score=int(data.get('sentiment_score', 50)),
|
||||
trend_prediction=data.get('trend_prediction', '震荡'),
|
||||
operation_advice=data.get('operation_advice', '持有'),
|
||||
trend_prediction=data.get('trend_prediction', 'Sideways' if report_language == "en" else '震荡'),
|
||||
operation_advice=data.get('operation_advice', 'Hold' if report_language == "en" else '持有'),
|
||||
decision_type=decision_type,
|
||||
confidence_level=data.get('confidence_level', '中'),
|
||||
confidence_level=localize_confidence_level(
|
||||
data.get('confidence_level', 'Medium' if report_language == "en" else '中'),
|
||||
report_language,
|
||||
),
|
||||
report_language=report_language,
|
||||
# 决策仪表盘
|
||||
dashboard=dashboard,
|
||||
# 走势分析
|
||||
@@ -1524,13 +1607,13 @@ class GeminiAnalyzer:
|
||||
market_sentiment=data.get('market_sentiment', ''),
|
||||
hot_topics=data.get('hot_topics', ''),
|
||||
# 综合
|
||||
analysis_summary=data.get('analysis_summary', '分析完成'),
|
||||
analysis_summary=data.get('analysis_summary', 'Analysis completed' if report_language == "en" else '分析完成'),
|
||||
key_points=data.get('key_points', ''),
|
||||
risk_warning=data.get('risk_warning', ''),
|
||||
buy_reason=data.get('buy_reason', ''),
|
||||
# 元数据
|
||||
search_performed=data.get('search_performed', False),
|
||||
data_sources=data.get('data_sources', '技术面数据'),
|
||||
data_sources=data.get('data_sources', 'Technical data' if report_language == "en" else '技术面数据'),
|
||||
success=True,
|
||||
)
|
||||
else:
|
||||
@@ -1569,10 +1652,11 @@ class GeminiAnalyzer:
|
||||
name: str
|
||||
) -> AnalysisResult:
|
||||
"""从纯文本响应中尽可能提取分析信息"""
|
||||
report_language = normalize_report_language(getattr(get_config(), "report_language", "zh"))
|
||||
# 尝试识别关键词来判断情绪
|
||||
sentiment_score = 50
|
||||
trend = '震荡'
|
||||
advice = '持有'
|
||||
trend = 'Sideways' if report_language == "en" else '震荡'
|
||||
advice = 'Hold' if report_language == "en" else '持有'
|
||||
|
||||
text_lower = response_text.lower()
|
||||
|
||||
@@ -1585,19 +1669,19 @@ class GeminiAnalyzer:
|
||||
|
||||
if positive_count > negative_count + 1:
|
||||
sentiment_score = 65
|
||||
trend = '看多'
|
||||
advice = '买入'
|
||||
trend = 'Bullish' if report_language == "en" else '看多'
|
||||
advice = 'Buy' if report_language == "en" else '买入'
|
||||
decision_type = 'buy'
|
||||
elif negative_count > positive_count + 1:
|
||||
sentiment_score = 35
|
||||
trend = '看空'
|
||||
advice = '卖出'
|
||||
trend = 'Bearish' if report_language == "en" else '看空'
|
||||
advice = 'Sell' if report_language == "en" else '卖出'
|
||||
decision_type = 'sell'
|
||||
else:
|
||||
decision_type = 'hold'
|
||||
|
||||
# 截取前500字符作为摘要
|
||||
summary = response_text[:500] if response_text else '无分析结果'
|
||||
summary = response_text[:500] if response_text else ('No analysis result' if report_language == "en" else '无分析结果')
|
||||
|
||||
return AnalysisResult(
|
||||
code=code,
|
||||
@@ -1606,12 +1690,13 @@ class GeminiAnalyzer:
|
||||
trend_prediction=trend,
|
||||
operation_advice=advice,
|
||||
decision_type=decision_type,
|
||||
confidence_level='低',
|
||||
confidence_level='Low' if report_language == "en" else '低',
|
||||
analysis_summary=summary,
|
||||
key_points='JSON解析失败,仅供参考',
|
||||
risk_warning='分析结果可能不准确,建议结合其他信息判断',
|
||||
key_points='JSON parsing failed; treat this as best-effort output.' if report_language == "en" else 'JSON解析失败,仅供参考',
|
||||
risk_warning='The result may be inaccurate. Cross-check with other information.' if report_language == "en" else '分析结果可能不准确,建议结合其他信息判断',
|
||||
raw_response=response_text,
|
||||
success=True,
|
||||
report_language=report_language,
|
||||
)
|
||||
|
||||
def batch_analyze(
|
||||
|
||||
@@ -20,6 +20,11 @@ from urllib.parse import urlparse
|
||||
from dotenv import load_dotenv, dotenv_values
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.report_language import (
|
||||
is_supported_report_language_value,
|
||||
normalize_report_language,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -573,6 +578,7 @@ class Config:
|
||||
|
||||
# 报告类型:simple(精简) 或 full(完整)
|
||||
report_type: str = "simple"
|
||||
report_language: str = "zh"
|
||||
|
||||
# 仅分析结果摘要:true 时只推送汇总,不含个股详情(Issue #262)
|
||||
report_summary_only: bool = False
|
||||
@@ -793,6 +799,8 @@ class Config:
|
||||
2. .env 文件
|
||||
3. 代码中的默认值
|
||||
"""
|
||||
preexisting_report_language = os.environ.get("REPORT_LANGUAGE")
|
||||
|
||||
# 确保环境变量已加载
|
||||
setup_env()
|
||||
|
||||
@@ -1036,6 +1044,10 @@ class Config:
|
||||
if schedule_run_immediately_env is not None
|
||||
else legacy_run_immediately
|
||||
)
|
||||
|
||||
report_language_raw = cls._resolve_report_language_env_value(
|
||||
preexisting_report_language
|
||||
)
|
||||
|
||||
return cls(
|
||||
stock_list=stock_list,
|
||||
@@ -1167,6 +1179,7 @@ class Config:
|
||||
astrbot_token=os.getenv('ASTRBOT_TOKEN'),
|
||||
single_stock_notify=os.getenv('SINGLE_STOCK_NOTIFY', 'false').lower() == 'true',
|
||||
report_type=cls._parse_report_type(os.getenv('REPORT_TYPE', 'simple')),
|
||||
report_language=cls._parse_report_language(report_language_raw),
|
||||
report_summary_only=os.getenv('REPORT_SUMMARY_ONLY', 'false').lower() == 'true',
|
||||
report_templates_dir=os.getenv('REPORT_TEMPLATES_DIR', 'templates'),
|
||||
report_renderer_enabled=os.getenv('REPORT_RENDERER_ENABLED', 'false').lower() == 'true',
|
||||
@@ -1577,6 +1590,69 @@ class Config:
|
||||
)
|
||||
return 'simple'
|
||||
|
||||
@classmethod
|
||||
def _get_env_file_value(cls, key: str) -> Optional[str]:
|
||||
"""Read one config key directly from the active `.env` file."""
|
||||
env_file = os.getenv("ENV_FILE")
|
||||
env_path = Path(env_file) if env_file else (Path(__file__).parent.parent / ".env")
|
||||
if not env_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
env_values = dotenv_values(env_path)
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logging.getLogger(__name__).warning(
|
||||
"Failed to read %s while resolving %s: %s",
|
||||
env_path,
|
||||
key,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
value = env_values.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def _resolve_report_language_env_value(
|
||||
cls,
|
||||
preexisting_env_value: Optional[str],
|
||||
) -> str:
|
||||
"""Resolve REPORT_LANGUAGE while preserving real process env overrides."""
|
||||
file_value = cls._get_env_file_value("REPORT_LANGUAGE")
|
||||
env_value = os.getenv("REPORT_LANGUAGE")
|
||||
|
||||
if preexisting_env_value is not None:
|
||||
env_text = preexisting_env_value.strip()
|
||||
file_text = (file_value or "").strip()
|
||||
if file_text and env_text and env_text.lower() != file_text.lower():
|
||||
env_file = os.getenv("ENV_FILE") or str(Path(__file__).parent.parent / ".env")
|
||||
logging.getLogger(__name__).warning(
|
||||
"REPORT_LANGUAGE environment value '%s' overrides %s ('%s')",
|
||||
preexisting_env_value,
|
||||
env_file,
|
||||
file_value,
|
||||
)
|
||||
return preexisting_env_value
|
||||
|
||||
if file_value is not None:
|
||||
return file_value
|
||||
|
||||
return env_value or "zh"
|
||||
|
||||
@classmethod
|
||||
def _parse_report_language(cls, value: Optional[str]) -> str:
|
||||
"""Parse REPORT_LANGUAGE, fallback to zh for invalid values."""
|
||||
normalized = normalize_report_language(value, default="zh")
|
||||
raw = (value or "").strip()
|
||||
if raw and not is_supported_report_language_value(raw):
|
||||
logging.getLogger(__name__).warning(
|
||||
"REPORT_LANGUAGE '%s' invalid, fallback to 'zh' (valid: zh/en)",
|
||||
value,
|
||||
)
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _parse_news_strategy_profile(cls, value: Optional[str]) -> str:
|
||||
"""Parse NEWS_STRATEGY_PROFILE, fallback to short for invalid values."""
|
||||
|
||||
@@ -1090,6 +1090,23 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"validation": {"enum": ["simple", "full", "brief"]},
|
||||
"display_order": 55,
|
||||
},
|
||||
"REPORT_LANGUAGE": {
|
||||
"title": "Report Language",
|
||||
"description": "Default output language for reports and notification templates. Supported values: zh, en.",
|
||||
"category": "notification",
|
||||
"data_type": "string",
|
||||
"ui_control": "select",
|
||||
"is_sensitive": False,
|
||||
"is_required": False,
|
||||
"is_editable": True,
|
||||
"default_value": "zh",
|
||||
"options": [
|
||||
{"label": "Chinese", "value": "zh"},
|
||||
{"label": "English", "value": "en"},
|
||||
],
|
||||
"validation": {"enum": ["zh", "en"]},
|
||||
"display_order": 56,
|
||||
},
|
||||
"REPORT_TEMPLATES_DIR": {
|
||||
"title": "Report Templates Dir",
|
||||
"description": "Directory for Jinja2 report templates (relative to project root).",
|
||||
@@ -1102,7 +1119,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"default_value": "templates",
|
||||
"options": [],
|
||||
"validation": {},
|
||||
"display_order": 56,
|
||||
"display_order": 57,
|
||||
},
|
||||
"REPORT_RENDERER_ENABLED": {
|
||||
"title": "Report Renderer Enabled",
|
||||
@@ -1116,7 +1133,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"default_value": "false",
|
||||
"options": [],
|
||||
"validation": {},
|
||||
"display_order": 57,
|
||||
"display_order": 58,
|
||||
},
|
||||
"REPORT_INTEGRITY_ENABLED": {
|
||||
"title": "Report Integrity Enabled",
|
||||
|
||||
@@ -28,6 +28,11 @@ from data_provider.realtime_types import ChipDistribution
|
||||
from src.analyzer import GeminiAnalyzer, AnalysisResult, fill_chip_structure_if_needed, fill_price_position_if_needed
|
||||
from src.data.stock_mapping import STOCK_NAME_MAP
|
||||
from src.notification import NotificationService, NotificationChannel
|
||||
from src.report_language import (
|
||||
get_unknown_text,
|
||||
localize_confidence_level,
|
||||
normalize_report_language,
|
||||
)
|
||||
from src.search_service import SearchService
|
||||
from src.services.social_sentiment_service import SocialSentimentService
|
||||
from src.enums import ReportType
|
||||
@@ -448,6 +453,7 @@ class StockAnalysisPipeline:
|
||||
增强后的上下文
|
||||
"""
|
||||
enhanced = context.copy()
|
||||
enhanced["report_language"] = normalize_report_language(getattr(self.config, "report_language", "zh"))
|
||||
|
||||
# 添加股票名称
|
||||
if stock_name:
|
||||
@@ -602,6 +608,7 @@ class StockAnalysisPipeline:
|
||||
"""
|
||||
try:
|
||||
from src.agent.factory import build_agent_executor
|
||||
report_language = normalize_report_language(getattr(self.config, "report_language", "zh"))
|
||||
|
||||
# Build executor from shared factory (ToolRegistry and SkillManager prototype are cached)
|
||||
executor = build_agent_executor(self.config, getattr(self.config, 'agent_skills', None) or None)
|
||||
@@ -611,6 +618,7 @@ class StockAnalysisPipeline:
|
||||
"stock_code": code,
|
||||
"stock_name": stock_name,
|
||||
"report_type": report_type.value,
|
||||
"report_language": report_language,
|
||||
"fundamental_context": fundamental_context,
|
||||
}
|
||||
|
||||
@@ -638,7 +646,10 @@ class StockAnalysisPipeline:
|
||||
logger.warning(f"[{code}] Agent mode: social sentiment fetch failed: {e}")
|
||||
|
||||
# 运行 Agent
|
||||
message = f"请分析股票 {code} ({stock_name}),并生成决策仪表盘报告。"
|
||||
if report_language == "en":
|
||||
message = f"Analyze stock {code} ({stock_name}) and return the full decision dashboard JSON in English."
|
||||
else:
|
||||
message = f"请分析股票 {code} ({stock_name}),并生成决策仪表盘报告。"
|
||||
agent_result = executor.run(message, context=initial_context)
|
||||
|
||||
# 转换为 AnalysisResult
|
||||
@@ -717,12 +728,15 @@ class StockAnalysisPipeline:
|
||||
"""
|
||||
将 AgentResult 转换为 AnalysisResult。
|
||||
"""
|
||||
report_language = normalize_report_language(getattr(self.config, "report_language", "zh"))
|
||||
result = AnalysisResult(
|
||||
code=code,
|
||||
name=stock_name,
|
||||
sentiment_score=50,
|
||||
trend_prediction="未知",
|
||||
operation_advice="观望",
|
||||
trend_prediction="Unknown" if report_language == "en" else "未知",
|
||||
operation_advice="Watch" if report_language == "en" else "观望",
|
||||
confidence_level=localize_confidence_level("medium", report_language),
|
||||
report_language=report_language,
|
||||
success=agent_result.success,
|
||||
error_message=agent_result.error or None,
|
||||
data_sources=f"agent:{agent_result.provider}",
|
||||
@@ -735,26 +749,33 @@ class StockAnalysisPipeline:
|
||||
if ai_stock_name and self._is_placeholder_stock_name(stock_name, code):
|
||||
result.name = ai_stock_name
|
||||
result.sentiment_score = self._safe_int(dash.get("sentiment_score"), 50)
|
||||
result.trend_prediction = dash.get("trend_prediction", "未知")
|
||||
raw_advice = dash.get("operation_advice", "观望")
|
||||
result.trend_prediction = dash.get("trend_prediction", "Unknown" if report_language == "en" else "未知")
|
||||
raw_advice = dash.get("operation_advice", "Watch" if report_language == "en" else "观望")
|
||||
if isinstance(raw_advice, dict):
|
||||
# LLM may return {"no_position": "...", "has_position": "..."}
|
||||
# Derive a short string from decision_type for the scalar field
|
||||
_signal_to_advice = {
|
||||
"buy": "买入", "sell": "卖出", "hold": "持有",
|
||||
"strong_buy": "强烈买入", "strong_sell": "强烈卖出",
|
||||
"buy": "Buy" if report_language == "en" else "买入",
|
||||
"sell": "Sell" if report_language == "en" else "卖出",
|
||||
"hold": "Hold" if report_language == "en" else "持有",
|
||||
"strong_buy": "Strong Buy" if report_language == "en" else "强烈买入",
|
||||
"strong_sell": "Strong Sell" if report_language == "en" else "强烈卖出",
|
||||
}
|
||||
# Normalize decision_type (strip/lower) before lookup so
|
||||
# variants like "BUY" or " Buy " map correctly.
|
||||
raw_dt = str(dash.get("decision_type") or "hold").strip().lower()
|
||||
result.operation_advice = _signal_to_advice.get(raw_dt, "观望")
|
||||
result.operation_advice = _signal_to_advice.get(raw_dt, "Watch" if report_language == "en" else "观望")
|
||||
else:
|
||||
result.operation_advice = str(raw_advice) if raw_advice else "观望"
|
||||
result.operation_advice = str(raw_advice) if raw_advice else ("Watch" if report_language == "en" else "观望")
|
||||
from src.agent.protocols import normalize_decision_signal
|
||||
|
||||
result.decision_type = normalize_decision_signal(
|
||||
dash.get("decision_type", "hold")
|
||||
)
|
||||
result.confidence_level = localize_confidence_level(
|
||||
dash.get("confidence_level", result.confidence_level),
|
||||
report_language,
|
||||
)
|
||||
result.analysis_summary = dash.get("analysis_summary", "")
|
||||
# The AI returns a top-level dict that contains a nested 'dashboard' sub-key
|
||||
# with core_conclusion / battle_plan / intelligence. AnalysisResult's helper
|
||||
@@ -763,9 +784,9 @@ class StockAnalysisPipeline:
|
||||
result.dashboard = dash.get("dashboard") or dash
|
||||
else:
|
||||
result.sentiment_score = 50
|
||||
result.operation_advice = "观望"
|
||||
result.operation_advice = "Watch" if report_language == "en" else "观望"
|
||||
if not result.error_message:
|
||||
result.error_message = "Agent 未能生成有效的决策仪表盘"
|
||||
result.error_message = "Agent failed to generate a valid decision dashboard" if report_language == "en" else "Agent 未能生成有效的决策仪表盘"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ from enum import Enum
|
||||
from src.config import get_config
|
||||
from src.analyzer import AnalysisResult
|
||||
from src.enums import ReportType
|
||||
from src.report_language import (
|
||||
get_localized_stock_name,
|
||||
get_report_labels,
|
||||
get_signal_level,
|
||||
localize_chip_health,
|
||||
localize_operation_advice,
|
||||
localize_trend_prediction,
|
||||
normalize_report_language,
|
||||
)
|
||||
from bot.models import BotMessage
|
||||
from src.utils.data_processing import normalize_model_used
|
||||
from src.notification_sender import (
|
||||
@@ -164,6 +173,29 @@ class NotificationService(
|
||||
return report_type
|
||||
return ReportType.from_str(report_type)
|
||||
|
||||
def _get_report_language(self, payload: Optional[Any] = None) -> str:
|
||||
"""Resolve report language from result payload or global config."""
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
language = getattr(item, "report_language", None)
|
||||
if language:
|
||||
return normalize_report_language(language)
|
||||
elif payload is not None:
|
||||
language = getattr(payload, "report_language", None)
|
||||
if language:
|
||||
return normalize_report_language(language)
|
||||
|
||||
return normalize_report_language(getattr(get_config(), "report_language", "zh"))
|
||||
|
||||
def _get_labels(self, payload: Optional[Any] = None) -> Dict[str, str]:
|
||||
return get_report_labels(self._get_report_language(payload))
|
||||
|
||||
def _get_display_name(self, result: AnalysisResult, language: Optional[str] = None) -> str:
|
||||
report_language = normalize_report_language(language or self._get_report_language(result))
|
||||
return self._escape_md(
|
||||
get_localized_stock_name(result.name, result.code, report_language)
|
||||
)
|
||||
|
||||
def _get_history_compare_context(self, results: List[AnalysisResult]) -> Dict[str, Any]:
|
||||
"""Fetch and cache history comparison data for markdown rendering."""
|
||||
config = get_config()
|
||||
@@ -499,12 +531,15 @@ class NotificationService(
|
||||
"""
|
||||
if report_date is None:
|
||||
report_date = datetime.now().strftime('%Y-%m-%d')
|
||||
report_language = self._get_report_language(results)
|
||||
labels = get_report_labels(report_language)
|
||||
|
||||
# 标题
|
||||
report_lines = [
|
||||
f"# 📅 {report_date} 股票智能分析报告",
|
||||
f"# 📅 {report_date} {labels['report_title']}",
|
||||
"",
|
||||
f"> 共分析 **{len(results)}** 只股票 | 报告生成时间:{datetime.now().strftime('%H:%M:%S')}",
|
||||
f"> {labels['analyzed_prefix']} **{len(results)}** {labels['stock_unit']} | "
|
||||
f"{labels['generated_at_label']}:{datetime.now().strftime('%H:%M:%S')}",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
@@ -524,14 +559,14 @@ class NotificationService(
|
||||
avg_score = sum(r.sentiment_score for r in results) / len(results) if results else 0
|
||||
|
||||
report_lines.extend([
|
||||
"## 📊 操作建议汇总",
|
||||
f"## 📊 {labels['summary_heading']}",
|
||||
"",
|
||||
"| 指标 | 数值 |",
|
||||
"|------|------|",
|
||||
f"| 🟢 建议买入/加仓 | **{buy_count}** 只 |",
|
||||
f"| 🟡 建议持有/观望 | **{hold_count}** 只 |",
|
||||
f"| 🔴 建议减仓/卖出 | **{sell_count}** 只 |",
|
||||
f"| 📈 平均看多评分 | **{avg_score:.1f}** 分 |",
|
||||
f"| 🟢 {labels['buy_label']} | **{buy_count}** {labels['stock_unit_compact']} |",
|
||||
f"| 🟡 {labels['watch_label']} | **{hold_count}** {labels['stock_unit_compact']} |",
|
||||
f"| 🔴 {labels['sell_label']} | **{sell_count}** {labels['stock_unit_compact']} |",
|
||||
f"| 📈 {labels['avg_score_label']} | **{avg_score:.1f}** |",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
@@ -539,24 +574,29 @@ class NotificationService(
|
||||
|
||||
# Issue #262: summary_only 时仅输出摘要,跳过个股详情
|
||||
if self._report_summary_only:
|
||||
report_lines.extend(["## 📊 分析结果摘要", ""])
|
||||
report_lines.extend([f"## 📊 {labels['summary_heading']}", ""])
|
||||
for r in sorted_results:
|
||||
emoji = r.get_emoji()
|
||||
_, emoji, _ = self._get_signal_level(r)
|
||||
report_lines.append(
|
||||
f"{emoji} **{r.name}({r.code})**: {r.operation_advice} | "
|
||||
f"评分 {r.sentiment_score} | {r.trend_prediction}"
|
||||
f"{emoji} **{self._get_display_name(r, report_language)}({r.code})**: "
|
||||
f"{localize_operation_advice(r.operation_advice, report_language)} | "
|
||||
f"{labels['score_label']} {r.sentiment_score} | "
|
||||
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
|
||||
)
|
||||
else:
|
||||
report_lines.extend(["## 📈 个股详细分析", ""])
|
||||
report_lines.extend([f"## 📈 {labels['report_title']}", ""])
|
||||
# 逐个股票的详细分析
|
||||
for result in sorted_results:
|
||||
emoji = result.get_emoji()
|
||||
_, emoji, _ = self._get_signal_level(result)
|
||||
confidence_stars = result.get_confidence_stars() if hasattr(result, 'get_confidence_stars') else '⭐⭐'
|
||||
|
||||
report_lines.extend([
|
||||
f"### {emoji} {result.name} ({result.code})",
|
||||
f"### {emoji} {self._get_display_name(result, report_language)} ({result.code})",
|
||||
"",
|
||||
f"**操作建议:{result.operation_advice}** | **综合评分:{result.sentiment_score}分** | **趋势预测:{result.trend_prediction}** | **置信度:{confidence_stars}**",
|
||||
f"**{labels['action_advice_label']}:{localize_operation_advice(result.operation_advice, report_language)}** | "
|
||||
f"**{labels['score_label']}:{result.sentiment_score}** | "
|
||||
f"**{labels['trend_label']}:{localize_trend_prediction(result.trend_prediction, report_language)}** | "
|
||||
f"**Confidence:{confidence_stars}**",
|
||||
"",
|
||||
])
|
||||
|
||||
@@ -681,7 +721,7 @@ class NotificationService(
|
||||
# 底部信息(去除免责声明)
|
||||
report_lines.extend([
|
||||
"",
|
||||
f"*报告生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*",
|
||||
f"*{labels['generated_at_label']}:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*",
|
||||
])
|
||||
|
||||
return "\n".join(report_lines)
|
||||
@@ -703,55 +743,20 @@ class NotificationService(
|
||||
if not value or value == 'N/A':
|
||||
return value
|
||||
prefixes = ['理想买入点:', '次优买入点:', '止损位:', '目标位:',
|
||||
'理想买入点:', '次优买入点:', '止损位:', '目标位:']
|
||||
'理想买入点:', '次优买入点:', '止损位:', '目标位:',
|
||||
'Ideal Entry:', 'Secondary Entry:', 'Stop Loss:', 'Target:']
|
||||
for prefix in prefixes:
|
||||
if value.startswith(prefix):
|
||||
return value[len(prefix):]
|
||||
return value
|
||||
|
||||
def _get_signal_level(self, result: AnalysisResult) -> tuple:
|
||||
"""
|
||||
Get signal level and color based on operation advice.
|
||||
|
||||
Priority: advice string takes precedence over score.
|
||||
Score-based fallback is used only when advice doesn't match
|
||||
any known value.
|
||||
|
||||
Returns:
|
||||
(signal_text, emoji, color_tag)
|
||||
"""
|
||||
advice = result.operation_advice
|
||||
score = result.sentiment_score
|
||||
|
||||
# Advice-first lookup (exact match takes priority)
|
||||
advice_map = {
|
||||
'强烈买入': ('强烈买入', '💚', '强买'),
|
||||
'买入': ('买入', '🟢', '买入'),
|
||||
'加仓': ('买入', '🟢', '买入'),
|
||||
'持有': ('持有', '🟡', '持有'),
|
||||
'观望': ('观望', '⚪', '观望'),
|
||||
'减仓': ('减仓', '🟠', '减仓'),
|
||||
'卖出': ('卖出', '🔴', '卖出'),
|
||||
'强烈卖出': ('卖出', '🔴', '卖出'),
|
||||
}
|
||||
if advice in advice_map:
|
||||
return advice_map[advice]
|
||||
|
||||
# Score-based fallback when advice is unrecognized
|
||||
if score >= 80:
|
||||
return ('强烈买入', '💚', '强买')
|
||||
elif score >= 65:
|
||||
return ('买入', '🟢', '买入')
|
||||
elif score >= 55:
|
||||
return ('持有', '🟡', '持有')
|
||||
elif score >= 45:
|
||||
return ('观望', '⚪', '观望')
|
||||
elif score >= 35:
|
||||
return ('减仓', '🟠', '减仓')
|
||||
elif score < 35:
|
||||
return ('卖出', '🔴', '卖出')
|
||||
else:
|
||||
return ('观望', '⚪', '观望')
|
||||
"""Get localized signal level and color based on operation advice."""
|
||||
return get_signal_level(
|
||||
result.operation_advice,
|
||||
result.sentiment_score,
|
||||
self._get_report_language(result),
|
||||
)
|
||||
|
||||
def generate_dashboard_report(
|
||||
self,
|
||||
@@ -771,6 +776,14 @@ class NotificationService(
|
||||
Markdown 格式的决策仪表盘日报
|
||||
"""
|
||||
config = get_config()
|
||||
report_language = self._get_report_language(results)
|
||||
labels = get_report_labels(report_language)
|
||||
reason_label = "Rationale" if report_language == "en" else "操作理由"
|
||||
risk_warning_label = "Risk Warning" if report_language == "en" else "风险提示"
|
||||
technical_heading = "Technicals" if report_language == "en" else "技术面"
|
||||
ma_label = "Moving Averages" if report_language == "en" else "均线"
|
||||
volume_analysis_label = "Volume" if report_language == "en" else "量能"
|
||||
news_heading = "News Flow" if report_language == "en" else "消息面"
|
||||
if getattr(config, 'report_renderer_enabled', False) and results:
|
||||
from src.services.report_renderer import render
|
||||
out = render(
|
||||
@@ -778,7 +791,10 @@ class NotificationService(
|
||||
results=results,
|
||||
report_date=report_date,
|
||||
summary_only=self._report_summary_only,
|
||||
extra_context=self._get_history_compare_context(results),
|
||||
extra_context={
|
||||
**self._get_history_compare_context(results),
|
||||
"report_language": report_language,
|
||||
},
|
||||
)
|
||||
if out:
|
||||
return out
|
||||
@@ -795,24 +811,27 @@ class NotificationService(
|
||||
hold_count = sum(1 for r in results if getattr(r, 'decision_type', '') in ('hold', ''))
|
||||
|
||||
report_lines = [
|
||||
f"# 🎯 {report_date} 决策仪表盘",
|
||||
f"# 🎯 {report_date} {labels['dashboard_title']}",
|
||||
"",
|
||||
f"> 共分析 **{len(results)}** 只股票 | 🟢买入:{buy_count} 🟡观望:{hold_count} 🔴卖出:{sell_count}",
|
||||
f"> {labels['analyzed_prefix']} **{len(results)}** {labels['stock_unit']} | "
|
||||
f"🟢{labels['buy_label']}:{buy_count} 🟡{labels['watch_label']}:{hold_count} 🔴{labels['sell_label']}:{sell_count}",
|
||||
"",
|
||||
]
|
||||
|
||||
# === 新增:分析结果摘要 (Issue #112) ===
|
||||
if results:
|
||||
report_lines.extend([
|
||||
"## 📊 分析结果摘要",
|
||||
f"## 📊 {labels['summary_heading']}",
|
||||
"",
|
||||
])
|
||||
for r in sorted_results:
|
||||
_, signal_emoji, _ = self._get_signal_level(r)
|
||||
display_name = self._escape_md(r.name)
|
||||
display_name = self._get_display_name(r, report_language)
|
||||
report_lines.append(
|
||||
f"{signal_emoji} **{display_name}({r.code})**: {r.operation_advice} | "
|
||||
f"评分 {r.sentiment_score} | {r.trend_prediction}"
|
||||
f"{signal_emoji} **{display_name}({r.code})**: "
|
||||
f"{localize_operation_advice(r.operation_advice, report_language)} | "
|
||||
f"{labels['score_label']} {r.sentiment_score} | "
|
||||
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
|
||||
)
|
||||
report_lines.extend([
|
||||
"",
|
||||
@@ -827,8 +846,7 @@ class NotificationService(
|
||||
dashboard = result.dashboard if hasattr(result, 'dashboard') and result.dashboard else {}
|
||||
|
||||
# 股票名称(优先使用 dashboard 或 result 中的名称,转义 *ST 等特殊字符)
|
||||
raw_name = result.name if result.name and not result.name.startswith('股票') else f'股票{result.code}'
|
||||
stock_name = self._escape_md(raw_name)
|
||||
stock_name = self._get_display_name(result, report_language)
|
||||
|
||||
report_lines.extend([
|
||||
f"## {signal_emoji} {stock_name} ({result.code})",
|
||||
@@ -839,58 +857,58 @@ class NotificationService(
|
||||
intel = dashboard.get('intelligence', {}) if dashboard else {}
|
||||
if intel:
|
||||
report_lines.extend([
|
||||
"### 📰 重要信息速览",
|
||||
f"### 📰 {labels['info_heading']}",
|
||||
"",
|
||||
])
|
||||
# 舆情情绪总结
|
||||
if intel.get('sentiment_summary'):
|
||||
report_lines.append(f"**💭 舆情情绪**: {intel['sentiment_summary']}")
|
||||
report_lines.append(f"**💭 {labels['sentiment_summary_label']}**: {intel['sentiment_summary']}")
|
||||
# 业绩预期
|
||||
if intel.get('earnings_outlook'):
|
||||
report_lines.append(f"**📊 业绩预期**: {intel['earnings_outlook']}")
|
||||
report_lines.append(f"**📊 {labels['earnings_outlook_label']}**: {intel['earnings_outlook']}")
|
||||
# 风险警报(醒目显示)
|
||||
risk_alerts = intel.get('risk_alerts', [])
|
||||
if risk_alerts:
|
||||
report_lines.append("")
|
||||
report_lines.append("**🚨 风险警报**:")
|
||||
report_lines.append(f"**🚨 {labels['risk_alerts_label']}**:")
|
||||
for alert in risk_alerts:
|
||||
report_lines.append(f"- {alert}")
|
||||
# 利好催化
|
||||
catalysts = intel.get('positive_catalysts', [])
|
||||
if catalysts:
|
||||
report_lines.append("")
|
||||
report_lines.append("**✨ 利好催化**:")
|
||||
report_lines.append(f"**✨ {labels['positive_catalysts_label']}**:")
|
||||
for cat in catalysts:
|
||||
report_lines.append(f"- {cat}")
|
||||
# 最新消息
|
||||
if intel.get('latest_news'):
|
||||
report_lines.append("")
|
||||
report_lines.append(f"**📢 最新动态**: {intel['latest_news']}")
|
||||
report_lines.append(f"**📢 {labels['latest_news_label']}**: {intel['latest_news']}")
|
||||
report_lines.append("")
|
||||
|
||||
# ========== 核心结论 ==========
|
||||
core = dashboard.get('core_conclusion', {}) if dashboard else {}
|
||||
one_sentence = core.get('one_sentence', result.analysis_summary)
|
||||
time_sense = core.get('time_sensitivity', '本周内')
|
||||
time_sense = core.get('time_sensitivity', labels['default_time_sensitivity'])
|
||||
pos_advice = core.get('position_advice', {})
|
||||
|
||||
report_lines.extend([
|
||||
"### 📌 核心结论",
|
||||
f"### 📌 {labels['core_conclusion_heading']}",
|
||||
"",
|
||||
f"**{signal_emoji} {signal_text}** | {result.trend_prediction}",
|
||||
f"**{signal_emoji} {signal_text}** | {localize_trend_prediction(result.trend_prediction, report_language)}",
|
||||
"",
|
||||
f"> **一句话决策**: {one_sentence}",
|
||||
f"> **{labels['one_sentence_label']}**: {one_sentence}",
|
||||
"",
|
||||
f"⏰ **时效性**: {time_sense}",
|
||||
f"⏰ **{labels['time_sensitivity_label']}**: {time_sense}",
|
||||
"",
|
||||
])
|
||||
# 持仓分类建议
|
||||
if pos_advice:
|
||||
report_lines.extend([
|
||||
"| 持仓情况 | 操作建议 |",
|
||||
f"| {labels['position_status_label']} | {labels['action_advice_label']} |",
|
||||
"|---------|---------|",
|
||||
f"| 🆕 **空仓者** | {pos_advice.get('no_position', result.operation_advice)} |",
|
||||
f"| 💼 **持仓者** | {pos_advice.get('has_position', '继续持有')} |",
|
||||
f"| 🆕 **{labels['no_position_label']}** | {pos_advice.get('no_position', localize_operation_advice(result.operation_advice, report_language))} |",
|
||||
f"| 💼 **{labels['has_position_label']}** | {pos_advice.get('has_position', labels['continue_holding'])} |",
|
||||
"",
|
||||
])
|
||||
|
||||
@@ -905,45 +923,51 @@ class NotificationService(
|
||||
chip_data = data_persp.get('chip_structure', {})
|
||||
|
||||
report_lines.extend([
|
||||
"### 📊 数据透视",
|
||||
f"### 📊 {labels['data_perspective_heading']}",
|
||||
"",
|
||||
])
|
||||
# 趋势状态
|
||||
if trend_data:
|
||||
is_bullish = "✅ 是" if trend_data.get('is_bullish', False) else "❌ 否"
|
||||
is_bullish = (
|
||||
f"✅ {labels['yes_label']}"
|
||||
if trend_data.get('is_bullish', False)
|
||||
else f"❌ {labels['no_label']}"
|
||||
)
|
||||
report_lines.extend([
|
||||
f"**均线排列**: {trend_data.get('ma_alignment', 'N/A')} | 多头排列: {is_bullish} | 趋势强度: {trend_data.get('trend_score', 'N/A')}/100",
|
||||
f"**{labels['ma_alignment_label']}**: {trend_data.get('ma_alignment', 'N/A')} | "
|
||||
f"{labels['bullish_alignment_label']}: {is_bullish} | "
|
||||
f"{labels['trend_strength_label']}: {trend_data.get('trend_score', 'N/A')}/100",
|
||||
"",
|
||||
])
|
||||
# 价格位置
|
||||
if price_data:
|
||||
bias_status = price_data.get('bias_status', 'N/A')
|
||||
bias_emoji = "✅" if bias_status == "安全" else ("⚠️" if bias_status == "警戒" else "🚨")
|
||||
report_lines.extend([
|
||||
"| 价格指标 | 数值 |",
|
||||
f"| {labels['price_metrics_label']} | {labels['current_price_label']} |",
|
||||
"|---------|------|",
|
||||
f"| 当前价 | {price_data.get('current_price', 'N/A')} |",
|
||||
f"| MA5 | {price_data.get('ma5', 'N/A')} |",
|
||||
f"| MA10 | {price_data.get('ma10', 'N/A')} |",
|
||||
f"| MA20 | {price_data.get('ma20', 'N/A')} |",
|
||||
f"| 乖离率(MA5) | {price_data.get('bias_ma5', 'N/A')}% {bias_emoji}{bias_status} |",
|
||||
f"| 支撑位 | {price_data.get('support_level', 'N/A')} |",
|
||||
f"| 压力位 | {price_data.get('resistance_level', 'N/A')} |",
|
||||
f"| {labels['current_price_label']} | {price_data.get('current_price', 'N/A')} |",
|
||||
f"| {labels['ma5_label']} | {price_data.get('ma5', 'N/A')} |",
|
||||
f"| {labels['ma10_label']} | {price_data.get('ma10', 'N/A')} |",
|
||||
f"| {labels['ma20_label']} | {price_data.get('ma20', 'N/A')} |",
|
||||
f"| {labels['bias_ma5_label']} | {price_data.get('bias_ma5', 'N/A')}% {bias_status} |",
|
||||
f"| {labels['support_level_label']} | {price_data.get('support_level', 'N/A')} |",
|
||||
f"| {labels['resistance_level_label']} | {price_data.get('resistance_level', 'N/A')} |",
|
||||
"",
|
||||
])
|
||||
# 量能分析
|
||||
if vol_data:
|
||||
report_lines.extend([
|
||||
f"**量能**: 量比 {vol_data.get('volume_ratio', 'N/A')} ({vol_data.get('volume_status', '')}) | 换手率 {vol_data.get('turnover_rate', 'N/A')}%",
|
||||
f"**{labels['volume_label']}**: {labels['volume_ratio_label']} {vol_data.get('volume_ratio', 'N/A')} ({vol_data.get('volume_status', '')}) | "
|
||||
f"{labels['turnover_rate_label']} {vol_data.get('turnover_rate', 'N/A')}%",
|
||||
f"💡 *{vol_data.get('volume_meaning', '')}*",
|
||||
"",
|
||||
])
|
||||
# 筹码结构
|
||||
if chip_data:
|
||||
chip_health = chip_data.get('chip_health', 'N/A')
|
||||
chip_emoji = "✅" if chip_health == "健康" else ("⚠️" if chip_health == "一般" else "🚨")
|
||||
chip_health = localize_chip_health(chip_data.get('chip_health', 'N/A'), report_language)
|
||||
report_lines.extend([
|
||||
f"**筹码**: 获利比例 {chip_data.get('profit_ratio', 'N/A')} | 平均成本 {chip_data.get('avg_cost', 'N/A')} | 集中度 {chip_data.get('concentration', 'N/A')} {chip_emoji}{chip_health}",
|
||||
f"**{labels['chip_label']}**: {chip_data.get('profit_ratio', 'N/A')} | {chip_data.get('avg_cost', 'N/A')} | "
|
||||
f"{chip_data.get('concentration', 'N/A')} {chip_health}",
|
||||
"",
|
||||
])
|
||||
|
||||
@@ -951,37 +975,37 @@ class NotificationService(
|
||||
battle = dashboard.get('battle_plan', {}) if dashboard else {}
|
||||
if battle:
|
||||
report_lines.extend([
|
||||
"### 🎯 作战计划",
|
||||
f"### 🎯 {labels['battle_plan_heading']}",
|
||||
"",
|
||||
])
|
||||
# 狙击点位
|
||||
sniper = battle.get('sniper_points', {})
|
||||
if sniper:
|
||||
report_lines.extend([
|
||||
"**📍 狙击点位**",
|
||||
f"**📍 {labels['action_points_heading']}**",
|
||||
"",
|
||||
"| 点位类型 | 价格 |",
|
||||
f"| {labels['action_points_heading']} | {labels['current_price_label']} |",
|
||||
"|---------|------|",
|
||||
f"| 🎯 理想买入点 | {self._clean_sniper_value(sniper.get('ideal_buy', 'N/A'))} |",
|
||||
f"| 🔵 次优买入点 | {self._clean_sniper_value(sniper.get('secondary_buy', 'N/A'))} |",
|
||||
f"| 🛑 止损位 | {self._clean_sniper_value(sniper.get('stop_loss', 'N/A'))} |",
|
||||
f"| 🎊 目标位 | {self._clean_sniper_value(sniper.get('take_profit', 'N/A'))} |",
|
||||
f"| 🎯 {labels['ideal_buy_label']} | {self._clean_sniper_value(sniper.get('ideal_buy', 'N/A'))} |",
|
||||
f"| 🔵 {labels['secondary_buy_label']} | {self._clean_sniper_value(sniper.get('secondary_buy', 'N/A'))} |",
|
||||
f"| 🛑 {labels['stop_loss_label']} | {self._clean_sniper_value(sniper.get('stop_loss', 'N/A'))} |",
|
||||
f"| 🎊 {labels['take_profit_label']} | {self._clean_sniper_value(sniper.get('take_profit', 'N/A'))} |",
|
||||
"",
|
||||
])
|
||||
# 仓位策略
|
||||
position = battle.get('position_strategy', {})
|
||||
if position:
|
||||
report_lines.extend([
|
||||
f"**💰 仓位建议**: {position.get('suggested_position', 'N/A')}",
|
||||
f"- 建仓策略: {position.get('entry_plan', 'N/A')}",
|
||||
f"- 风控策略: {position.get('risk_control', 'N/A')}",
|
||||
f"**💰 {labels['suggested_position_label']}**: {position.get('suggested_position', 'N/A')}",
|
||||
f"- {labels['entry_plan_label']}: {position.get('entry_plan', 'N/A')}",
|
||||
f"- {labels['risk_control_label']}: {position.get('risk_control', 'N/A')}",
|
||||
"",
|
||||
])
|
||||
# 检查清单
|
||||
checklist = battle.get('action_checklist', []) if battle else []
|
||||
if checklist:
|
||||
report_lines.extend([
|
||||
"**✅ 检查清单**",
|
||||
f"**✅ {labels['checklist_heading']}**",
|
||||
"",
|
||||
])
|
||||
for item in checklist:
|
||||
@@ -993,30 +1017,30 @@ class NotificationService(
|
||||
# 操作理由
|
||||
if result.buy_reason:
|
||||
report_lines.extend([
|
||||
f"**💡 操作理由**: {result.buy_reason}",
|
||||
f"**💡 {reason_label}**: {result.buy_reason}",
|
||||
"",
|
||||
])
|
||||
# 风险提示
|
||||
if result.risk_warning:
|
||||
report_lines.extend([
|
||||
f"**⚠️ 风险提示**: {result.risk_warning}",
|
||||
f"**⚠️ {risk_warning_label}**: {result.risk_warning}",
|
||||
"",
|
||||
])
|
||||
# 技术面分析
|
||||
if result.ma_analysis or result.volume_analysis:
|
||||
report_lines.extend([
|
||||
"### 📊 技术面",
|
||||
f"### 📊 {technical_heading}",
|
||||
"",
|
||||
])
|
||||
if result.ma_analysis:
|
||||
report_lines.append(f"**均线**: {result.ma_analysis}")
|
||||
report_lines.append(f"**{ma_label}**: {result.ma_analysis}")
|
||||
if result.volume_analysis:
|
||||
report_lines.append(f"**量能**: {result.volume_analysis}")
|
||||
report_lines.append(f"**{volume_analysis_label}**: {result.volume_analysis}")
|
||||
report_lines.append("")
|
||||
# 消息面
|
||||
if result.news_summary:
|
||||
report_lines.extend([
|
||||
"### 📰 消息面",
|
||||
f"### 📰 {news_heading}",
|
||||
f"{result.news_summary}",
|
||||
"",
|
||||
])
|
||||
@@ -1029,7 +1053,7 @@ class NotificationService(
|
||||
# 底部(去除免责声明)
|
||||
report_lines.extend([
|
||||
"",
|
||||
f"*报告生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*",
|
||||
f"*{labels['generated_at_label']}:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*",
|
||||
])
|
||||
|
||||
return "\n".join(report_lines)
|
||||
@@ -1047,6 +1071,8 @@ class NotificationService(
|
||||
精简版决策仪表盘
|
||||
"""
|
||||
config = get_config()
|
||||
report_language = self._get_report_language(results)
|
||||
labels = get_report_labels(report_language)
|
||||
if getattr(config, 'report_renderer_enabled', False) and results:
|
||||
from src.services.report_renderer import render
|
||||
out = render(
|
||||
@@ -1054,6 +1080,7 @@ class NotificationService(
|
||||
results=results,
|
||||
report_date=datetime.now().strftime('%Y-%m-%d'),
|
||||
summary_only=self._report_summary_only,
|
||||
extra_context={"report_language": report_language},
|
||||
)
|
||||
if out:
|
||||
return out
|
||||
@@ -1069,22 +1096,25 @@ class NotificationService(
|
||||
hold_count = sum(1 for r in results if getattr(r, 'decision_type', '') in ('hold', ''))
|
||||
|
||||
lines = [
|
||||
f"## 🎯 {report_date} 决策仪表盘",
|
||||
f"## 🎯 {report_date} {labels['dashboard_title']}",
|
||||
"",
|
||||
f"> {len(results)}只股票 | 🟢买入:{buy_count} 🟡观望:{hold_count} 🔴卖出:{sell_count}",
|
||||
f"> {len(results)} {labels['stock_unit']} | "
|
||||
f"🟢{labels['buy_label']}:{buy_count} 🟡{labels['watch_label']}:{hold_count} 🔴{labels['sell_label']}:{sell_count}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Issue #262: summary_only 时仅输出摘要列表
|
||||
if self._report_summary_only:
|
||||
lines.append("**📊 分析结果摘要**")
|
||||
lines.append(f"**📊 {labels['summary_heading']}**")
|
||||
lines.append("")
|
||||
for r in sorted_results:
|
||||
_, signal_emoji, _ = self._get_signal_level(r)
|
||||
stock_name = self._escape_md(r.name if r.name and not r.name.startswith('股票') else f'股票{r.code}')
|
||||
stock_name = self._get_display_name(r, report_language)
|
||||
lines.append(
|
||||
f"{signal_emoji} **{stock_name}({r.code})**: {r.operation_advice} | "
|
||||
f"评分 {r.sentiment_score} | {r.trend_prediction}"
|
||||
f"{signal_emoji} **{stock_name}({r.code})**: "
|
||||
f"{localize_operation_advice(r.operation_advice, report_language)} | "
|
||||
f"{labels['score_label']} {r.sentiment_score} | "
|
||||
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
|
||||
)
|
||||
else:
|
||||
for result in sorted_results:
|
||||
@@ -1095,8 +1125,7 @@ class NotificationService(
|
||||
intel = dashboard.get('intelligence', {}) if dashboard else {}
|
||||
|
||||
# 股票名称
|
||||
stock_name = result.name if result.name and not result.name.startswith('股票') else f'股票{result.code}'
|
||||
stock_name = self._escape_md(stock_name)
|
||||
stock_name = self._get_display_name(result, report_language)
|
||||
|
||||
# 标题行:信号等级 + 股票名称
|
||||
lines.append(f"### {signal_emoji} **{signal_text}** | {stock_name}({result.code})")
|
||||
@@ -1114,10 +1143,10 @@ class NotificationService(
|
||||
# 业绩预期
|
||||
if intel.get('earnings_outlook'):
|
||||
outlook = str(intel['earnings_outlook'])[:60]
|
||||
info_lines.append(f"📊 业绩: {outlook}")
|
||||
info_lines.append(f"📊 {labels['earnings_outlook_label']}: {outlook}")
|
||||
if intel.get('sentiment_summary'):
|
||||
sentiment = str(intel['sentiment_summary'])[:50]
|
||||
info_lines.append(f"💭 舆情: {sentiment}")
|
||||
info_lines.append(f"💭 {labels['sentiment_summary_label']}: {sentiment}")
|
||||
if info_lines:
|
||||
lines.extend(info_lines)
|
||||
lines.append("")
|
||||
@@ -1125,7 +1154,7 @@ class NotificationService(
|
||||
# 风险警报(最重要,醒目显示)
|
||||
risks = intel.get('risk_alerts', []) if intel else []
|
||||
if risks:
|
||||
lines.append("🚨 **风险**:")
|
||||
lines.append(f"🚨 **{labels['risk_alerts_label']}**:")
|
||||
for risk in risks[:2]: # 最多显示2条
|
||||
risk_str = str(risk)
|
||||
risk_text = risk_str[:50] + "..." if len(risk_str) > 50 else risk_str
|
||||
@@ -1135,7 +1164,7 @@ class NotificationService(
|
||||
# 利好催化
|
||||
catalysts = intel.get('positive_catalysts', []) if intel else []
|
||||
if catalysts:
|
||||
lines.append("✨ **利好**:")
|
||||
lines.append(f"✨ **{labels['positive_catalysts_label']}**:")
|
||||
for cat in catalysts[:2]: # 最多显示2条
|
||||
cat_str = str(cat)
|
||||
cat_text = cat_str[:50] + "..." if len(cat_str) > 50 else cat_str
|
||||
@@ -1150,11 +1179,11 @@ class NotificationService(
|
||||
take_profit = str(sniper.get('take_profit', ''))
|
||||
points = []
|
||||
if ideal_buy:
|
||||
points.append(f"🎯买点:{ideal_buy[:15]}")
|
||||
points.append(f"🎯{labels['ideal_buy_label']}:{ideal_buy[:15]}")
|
||||
if stop_loss:
|
||||
points.append(f"🛑止损:{stop_loss[:15]}")
|
||||
points.append(f"🛑{labels['stop_loss_label']}:{stop_loss[:15]}")
|
||||
if take_profit:
|
||||
points.append(f"🎊目标:{take_profit[:15]}")
|
||||
points.append(f"🎊{labels['take_profit_label']}:{take_profit[:15]}")
|
||||
if points:
|
||||
lines.append(" | ".join(points))
|
||||
lines.append("")
|
||||
@@ -1165,9 +1194,9 @@ class NotificationService(
|
||||
no_pos = str(pos_advice.get('no_position', ''))
|
||||
has_pos = str(pos_advice.get('has_position', ''))
|
||||
if no_pos:
|
||||
lines.append(f"🆕 空仓者: {no_pos[:50]}")
|
||||
lines.append(f"🆕 {labels['no_position_label']}: {no_pos[:50]}")
|
||||
if has_pos:
|
||||
lines.append(f"💼 持仓者: {has_pos[:50]}")
|
||||
lines.append(f"💼 {labels['has_position_label']}: {has_pos[:50]}")
|
||||
lines.append("")
|
||||
|
||||
# 检查清单简化版
|
||||
@@ -1176,7 +1205,7 @@ class NotificationService(
|
||||
# 只显示不通过的项目
|
||||
failed_checks = [str(c) for c in checklist if str(c).startswith('❌') or str(c).startswith('⚠️')]
|
||||
if failed_checks:
|
||||
lines.append("**检查未通过项**:")
|
||||
lines.append(f"**{labels['failed_checks_heading']}**:")
|
||||
for check in failed_checks[:3]:
|
||||
lines.append(f" {check[:40]}")
|
||||
lines.append("")
|
||||
@@ -1185,10 +1214,10 @@ class NotificationService(
|
||||
lines.append("")
|
||||
|
||||
# 底部
|
||||
lines.append(f"*生成时间: {datetime.now().strftime('%H:%M')}*")
|
||||
lines.append(f"*{labels['report_time_label']}: {datetime.now().strftime('%H:%M')}*")
|
||||
models = self._collect_models_used(results)
|
||||
if models:
|
||||
lines.append(f"*分析模型: {', '.join(models)}*")
|
||||
lines.append(f"*{labels['analysis_model_label']}: {', '.join(models)}*")
|
||||
|
||||
content = "\n".join(lines)
|
||||
|
||||
@@ -1205,6 +1234,8 @@ class NotificationService(
|
||||
精简版 Markdown 内容
|
||||
"""
|
||||
report_date = datetime.now().strftime('%Y-%m-%d')
|
||||
report_language = self._get_report_language(results)
|
||||
labels = get_report_labels(report_language)
|
||||
|
||||
# 按评分排序
|
||||
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
|
||||
@@ -1216,19 +1247,25 @@ class NotificationService(
|
||||
avg_score = sum(r.sentiment_score for r in results) / len(results) if results else 0
|
||||
|
||||
lines = [
|
||||
f"## 📅 {report_date} 股票分析报告",
|
||||
f"## 📅 {report_date} {labels['report_title']}",
|
||||
"",
|
||||
f"> 共 **{len(results)}** 只 | 🟢买入:{buy_count} 🟡持有:{hold_count} 🔴卖出:{sell_count} | 均分:{avg_score:.0f}",
|
||||
f"> {labels['analyzed_prefix']} **{len(results)}** {labels['stock_unit_compact']} | "
|
||||
f"🟢{labels['buy_label']}:{buy_count} 🟡{labels['watch_label']}:{hold_count} 🔴{labels['sell_label']}:{sell_count} | "
|
||||
f"{labels['avg_score_label']}:{avg_score:.0f}",
|
||||
"",
|
||||
]
|
||||
|
||||
# 每只股票精简信息(控制长度)
|
||||
for result in sorted_results:
|
||||
emoji = result.get_emoji()
|
||||
_, emoji, _ = self._get_signal_level(result)
|
||||
|
||||
# 核心信息行
|
||||
lines.append(f"### {emoji} {result.name}({result.code})")
|
||||
lines.append(f"**{result.operation_advice}** | 评分:{result.sentiment_score} | {result.trend_prediction}")
|
||||
lines.append(f"### {emoji} {self._get_display_name(result, report_language)}({result.code})")
|
||||
lines.append(
|
||||
f"**{localize_operation_advice(result.operation_advice, report_language)}** | "
|
||||
f"{labels['score_label']}:{result.sentiment_score} | "
|
||||
f"{localize_trend_prediction(result.trend_prediction, report_language)}"
|
||||
)
|
||||
|
||||
# 操作理由(截断)
|
||||
if hasattr(result, 'buy_reason') and result.buy_reason:
|
||||
@@ -1250,11 +1287,11 @@ class NotificationService(
|
||||
# 底部(模型行在 --- 之前,Issue #528)
|
||||
models = self._collect_models_used(results)
|
||||
if models:
|
||||
lines.append(f"*分析模型: {', '.join(models)}*")
|
||||
lines.append(f"*{labels['analysis_model_label']}: {', '.join(models)}*")
|
||||
lines.extend([
|
||||
"---",
|
||||
"*AI生成,仅供参考,不构成投资建议*",
|
||||
f"*详细报告见 reports/report_{report_date.replace('-', '')}.md*"
|
||||
f"*{labels['not_investment_advice']}*",
|
||||
f"*{labels['details_report_hint']} reports/report_{report_date.replace('-', '')}.md*"
|
||||
])
|
||||
|
||||
content = "\n".join(lines)
|
||||
@@ -1278,6 +1315,8 @@ class NotificationService(
|
||||
"""
|
||||
if report_date is None:
|
||||
report_date = datetime.now().strftime('%Y-%m-%d')
|
||||
report_language = self._get_report_language(results)
|
||||
labels = get_report_labels(report_language)
|
||||
config = get_config()
|
||||
if getattr(config, 'report_renderer_enabled', False) and results:
|
||||
from src.services.report_renderer import render
|
||||
@@ -1286,29 +1325,34 @@ class NotificationService(
|
||||
results=results,
|
||||
report_date=report_date,
|
||||
summary_only=False,
|
||||
extra_context={"report_language": report_language},
|
||||
)
|
||||
if out:
|
||||
return out
|
||||
# Fallback: brief summary from dashboard report
|
||||
if not results:
|
||||
return f"# {report_date} 决策简报\n\n无分析结果"
|
||||
return f"# {report_date} {labels['brief_title']}\n\n{labels['no_results']}"
|
||||
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
|
||||
buy_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'buy')
|
||||
sell_count = sum(1 for r in results if getattr(r, 'decision_type', '') == 'sell')
|
||||
hold_count = sum(1 for r in results if getattr(r, 'decision_type', '') in ('hold', ''))
|
||||
lines = [
|
||||
f"# {report_date} 决策简报",
|
||||
f"# {report_date} {labels['brief_title']}",
|
||||
"",
|
||||
f"> {len(results)}只 | 🟢{buy_count} 🟡{hold_count} 🔴{sell_count}",
|
||||
f"> {len(results)} {labels['stock_unit_compact']} | 🟢{buy_count} 🟡{hold_count} 🔴{sell_count}",
|
||||
"",
|
||||
]
|
||||
for r in sorted_results:
|
||||
_, emoji, _ = self._get_signal_level(r)
|
||||
name = r.name if r.name and not r.name.startswith('股票') else f'股票{r.code}'
|
||||
name = self._get_display_name(r, report_language)
|
||||
dash = r.dashboard or {}
|
||||
core = dash.get('core_conclusion', {}) or {}
|
||||
one = (core.get('one_sentence') or r.analysis_summary or '')[:60]
|
||||
lines.append(f"**{self._escape_md(name)}({r.code})** {emoji} {r.operation_advice} | 评分{r.sentiment_score} | {one}")
|
||||
lines.append(
|
||||
f"**{name}({r.code})** {emoji} "
|
||||
f"{localize_operation_advice(r.operation_advice, report_language)} | "
|
||||
f"{labels['score_label']} {r.sentiment_score} | {one}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"*{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
|
||||
return "\n".join(lines)
|
||||
@@ -1326,6 +1370,8 @@ class NotificationService(
|
||||
Markdown 格式的单股报告
|
||||
"""
|
||||
report_date = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
report_language = self._get_report_language(result)
|
||||
labels = get_report_labels(report_language)
|
||||
signal_text, signal_emoji, _ = self._get_signal_level(result)
|
||||
dashboard = result.dashboard if hasattr(result, 'dashboard') and result.dashboard else {}
|
||||
core = dashboard.get('core_conclusion', {}) if dashboard else {}
|
||||
@@ -1333,13 +1379,12 @@ class NotificationService(
|
||||
intel = dashboard.get('intelligence', {}) if dashboard else {}
|
||||
|
||||
# 股票名称(转义 *ST 等特殊字符)
|
||||
raw_name = result.name if result.name and not result.name.startswith('股票') else f'股票{result.code}'
|
||||
stock_name = self._escape_md(raw_name)
|
||||
stock_name = self._get_display_name(result, report_language)
|
||||
|
||||
lines = [
|
||||
f"## {signal_emoji} {stock_name} ({result.code})",
|
||||
"",
|
||||
f"> {report_date} | 评分: **{result.sentiment_score}** | {result.trend_prediction}",
|
||||
f"> {report_date} | {labels['score_label']}: **{result.sentiment_score}** | {localize_trend_prediction(result.trend_prediction, report_language)}",
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -1349,7 +1394,7 @@ class NotificationService(
|
||||
one_sentence = core.get('one_sentence', result.analysis_summary) if core else result.analysis_summary
|
||||
if one_sentence:
|
||||
lines.extend([
|
||||
"### 📌 核心结论",
|
||||
f"### 📌 {labels['core_conclusion_heading']}",
|
||||
"",
|
||||
f"**{signal_text}**: {one_sentence}",
|
||||
"",
|
||||
@@ -1360,27 +1405,27 @@ class NotificationService(
|
||||
if intel:
|
||||
if intel.get('earnings_outlook'):
|
||||
if not info_added:
|
||||
lines.append("### 📰 重要信息")
|
||||
lines.append(f"### 📰 {labels['info_heading']}")
|
||||
lines.append("")
|
||||
info_added = True
|
||||
lines.append(f"📊 **业绩预期**: {str(intel['earnings_outlook'])[:100]}")
|
||||
lines.append(f"📊 **{labels['earnings_outlook_label']}**: {str(intel['earnings_outlook'])[:100]}")
|
||||
|
||||
if intel.get('sentiment_summary'):
|
||||
if not info_added:
|
||||
lines.append("### 📰 重要信息")
|
||||
lines.append(f"### 📰 {labels['info_heading']}")
|
||||
lines.append("")
|
||||
info_added = True
|
||||
lines.append(f"💭 **舆情情绪**: {str(intel['sentiment_summary'])[:80]}")
|
||||
lines.append(f"💭 **{labels['sentiment_summary_label']}**: {str(intel['sentiment_summary'])[:80]}")
|
||||
|
||||
# 风险警报
|
||||
risks = intel.get('risk_alerts', [])
|
||||
if risks:
|
||||
if not info_added:
|
||||
lines.append("### 📰 重要信息")
|
||||
lines.append(f"### 📰 {labels['info_heading']}")
|
||||
lines.append("")
|
||||
info_added = True
|
||||
lines.append("")
|
||||
lines.append("🚨 **风险警报**:")
|
||||
lines.append(f"🚨 **{labels['risk_alerts_label']}**:")
|
||||
for risk in risks[:3]:
|
||||
lines.append(f"- {str(risk)[:60]}")
|
||||
|
||||
@@ -1388,7 +1433,7 @@ class NotificationService(
|
||||
catalysts = intel.get('positive_catalysts', [])
|
||||
if catalysts:
|
||||
lines.append("")
|
||||
lines.append("✨ **利好催化**:")
|
||||
lines.append(f"✨ **{labels['positive_catalysts_label']}**:")
|
||||
for cat in catalysts[:3]:
|
||||
lines.append(f"- {str(cat)[:60]}")
|
||||
|
||||
@@ -1399,9 +1444,9 @@ class NotificationService(
|
||||
sniper = battle.get('sniper_points', {}) if battle else {}
|
||||
if sniper:
|
||||
lines.extend([
|
||||
"### 🎯 操作点位",
|
||||
f"### 🎯 {labels['action_points_heading']}",
|
||||
"",
|
||||
"| 买点 | 止损 | 目标 |",
|
||||
f"| {labels['ideal_buy_label']} | {labels['stop_loss_label']} | {labels['take_profit_label']} |",
|
||||
"|------|------|------|",
|
||||
])
|
||||
ideal_buy = sniper.get('ideal_buy', '-')
|
||||
@@ -1414,42 +1459,52 @@ class NotificationService(
|
||||
pos_advice = core.get('position_advice', {}) if core else {}
|
||||
if pos_advice:
|
||||
lines.extend([
|
||||
"### 💼 持仓建议",
|
||||
f"### 💼 {labels['position_advice_heading']}",
|
||||
"",
|
||||
f"- 🆕 **空仓者**: {pos_advice.get('no_position', result.operation_advice)}",
|
||||
f"- 💼 **持仓者**: {pos_advice.get('has_position', '继续持有')}",
|
||||
f"- 🆕 **{labels['no_position_label']}**: {pos_advice.get('no_position', localize_operation_advice(result.operation_advice, report_language))}",
|
||||
f"- 💼 **{labels['has_position_label']}**: {pos_advice.get('has_position', labels['continue_holding'])}",
|
||||
"",
|
||||
])
|
||||
|
||||
lines.append("---")
|
||||
model_used = normalize_model_used(getattr(result, "model_used", None))
|
||||
if model_used:
|
||||
lines.append(f"*分析模型: {model_used}*")
|
||||
lines.append("*AI生成,仅供参考,不构成投资建议*")
|
||||
lines.append(f"*{labels['analysis_model_label']}: {model_used}*")
|
||||
lines.append(f"*{labels['not_investment_advice']}*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# Display name mapping for realtime data sources
|
||||
_SOURCE_DISPLAY_NAMES = {
|
||||
"tencent": "腾讯财经",
|
||||
"akshare_em": "东方财富",
|
||||
"akshare_sina": "新浪财经",
|
||||
"akshare_qq": "腾讯财经",
|
||||
"efinance": "东方财富(efinance)",
|
||||
"tushare": "Tushare Pro",
|
||||
"sina": "新浪财经",
|
||||
"fallback": "降级兜底",
|
||||
"tencent": {"zh": "腾讯财经", "en": "Tencent Finance"},
|
||||
"akshare_em": {"zh": "东方财富", "en": "Eastmoney"},
|
||||
"akshare_sina": {"zh": "新浪财经", "en": "Sina Finance"},
|
||||
"akshare_qq": {"zh": "腾讯财经", "en": "Tencent Finance"},
|
||||
"efinance": {"zh": "东方财富(efinance)", "en": "Eastmoney (efinance)"},
|
||||
"tushare": {"zh": "Tushare Pro", "en": "Tushare Pro"},
|
||||
"sina": {"zh": "新浪财经", "en": "Sina Finance"},
|
||||
"fallback": {"zh": "降级兜底", "en": "Fallback"},
|
||||
}
|
||||
|
||||
def _get_source_display_name(self, source: Any, language: Optional[str]) -> str:
|
||||
raw_source = str(source or "N/A")
|
||||
mapping = self._SOURCE_DISPLAY_NAMES.get(raw_source)
|
||||
if not mapping:
|
||||
return raw_source
|
||||
return mapping[normalize_report_language(language)]
|
||||
|
||||
def _append_market_snapshot(self, lines: List[str], result: AnalysisResult) -> None:
|
||||
snapshot = getattr(result, 'market_snapshot', None)
|
||||
if not snapshot:
|
||||
return
|
||||
|
||||
report_language = self._get_report_language(result)
|
||||
labels = get_report_labels(report_language)
|
||||
|
||||
lines.extend([
|
||||
"### 📈 当日行情",
|
||||
f"### 📈 {labels['market_snapshot_heading']}",
|
||||
"",
|
||||
"| 收盘 | 昨收 | 开盘 | 最高 | 最低 | 涨跌幅 | 涨跌额 | 振幅 | 成交量 | 成交额 |",
|
||||
f"| {labels['close_label']} | {labels['prev_close_label']} | {labels['open_label']} | {labels['high_label']} | {labels['low_label']} | {labels['change_pct_label']} | {labels['change_amount_label']} | {labels['amplitude_label']} | {labels['volume_label']} | {labels['amount_label']} |",
|
||||
"|------|------|------|------|------|-------|-------|------|--------|--------|",
|
||||
f"| {snapshot.get('close', 'N/A')} | {snapshot.get('prev_close', 'N/A')} | "
|
||||
f"{snapshot.get('open', 'N/A')} | {snapshot.get('high', 'N/A')} | "
|
||||
@@ -1459,11 +1514,10 @@ class NotificationService(
|
||||
])
|
||||
|
||||
if "price" in snapshot:
|
||||
raw_source = snapshot.get('source', 'N/A')
|
||||
display_source = self._SOURCE_DISPLAY_NAMES.get(raw_source, raw_source)
|
||||
display_source = self._get_source_display_name(snapshot.get('source', 'N/A'), report_language)
|
||||
lines.extend([
|
||||
"",
|
||||
"| 当前价 | 量比 | 换手率 | 行情来源 |",
|
||||
f"| {labels['current_price_label']} | {labels['volume_ratio_label']} | {labels['turnover_rate_label']} | {labels['source_label']} |",
|
||||
"|-------|------|--------|----------|",
|
||||
f"| {snapshot.get('price', 'N/A')} | {snapshot.get('volume_ratio', 'N/A')} | "
|
||||
f"{snapshot.get('turnover_rate', 'N/A')} | {display_source} |",
|
||||
@@ -1694,11 +1748,19 @@ class NotificationBuilder:
|
||||
|
||||
适用于快速通知
|
||||
"""
|
||||
lines = ["📊 **今日自选股摘要**", ""]
|
||||
report_language = normalize_report_language(
|
||||
next((getattr(result, "report_language", None) for result in results if getattr(result, "report_language", None)), None)
|
||||
)
|
||||
labels = get_report_labels(report_language)
|
||||
lines = [f"📊 **{labels['summary_heading']}**", ""]
|
||||
|
||||
for r in sorted(results, key=lambda x: x.sentiment_score, reverse=True):
|
||||
emoji = r.get_emoji()
|
||||
lines.append(f"{emoji} {r.name}({r.code}): {r.operation_advice} | 评分 {r.sentiment_score}")
|
||||
_, emoji, _ = get_signal_level(r.operation_advice, r.sentiment_score, report_language)
|
||||
name = get_localized_stock_name(r.name, r.code, report_language)
|
||||
lines.append(
|
||||
f"{emoji} {name}({r.code}): {localize_operation_advice(r.operation_advice, report_language)} | "
|
||||
f"{labels['score_label']} {r.sentiment_score}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
557
src/report_language.py
Normal file
557
src/report_language.py
Normal file
@@ -0,0 +1,557 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Helpers for report output language selection and localization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
SUPPORTED_REPORT_LANGUAGES = ("zh", "en")
|
||||
|
||||
_REPORT_LANGUAGE_ALIASES = {
|
||||
"zh-cn": "zh",
|
||||
"zh_cn": "zh",
|
||||
"zh-hans": "zh",
|
||||
"zh_hans": "zh",
|
||||
"zh-tw": "zh",
|
||||
"zh_tw": "zh",
|
||||
"cn": "zh",
|
||||
"chinese": "zh",
|
||||
"english": "en",
|
||||
"en-us": "en",
|
||||
"en_us": "en",
|
||||
"en-gb": "en",
|
||||
"en_gb": "en",
|
||||
}
|
||||
|
||||
_OPERATION_ADVICE_CANONICAL_MAP = {
|
||||
"强烈买入": "strong_buy",
|
||||
"strong buy": "strong_buy",
|
||||
"strong_buy": "strong_buy",
|
||||
"买入": "buy",
|
||||
"buy": "buy",
|
||||
"加仓": "buy",
|
||||
"accumulate": "buy",
|
||||
"add position": "buy",
|
||||
"持有": "hold",
|
||||
"hold": "hold",
|
||||
"观望": "watch",
|
||||
"watch": "watch",
|
||||
"wait": "watch",
|
||||
"wait and see": "watch",
|
||||
"减仓": "reduce",
|
||||
"reduce": "reduce",
|
||||
"trim": "reduce",
|
||||
"卖出": "sell",
|
||||
"sell": "sell",
|
||||
"强烈卖出": "strong_sell",
|
||||
"strong sell": "strong_sell",
|
||||
"strong_sell": "strong_sell",
|
||||
}
|
||||
|
||||
_OPERATION_ADVICE_TRANSLATIONS = {
|
||||
"strong_buy": {"zh": "强烈买入", "en": "Strong Buy"},
|
||||
"buy": {"zh": "买入", "en": "Buy"},
|
||||
"hold": {"zh": "持有", "en": "Hold"},
|
||||
"watch": {"zh": "观望", "en": "Watch"},
|
||||
"reduce": {"zh": "减仓", "en": "Reduce"},
|
||||
"sell": {"zh": "卖出", "en": "Sell"},
|
||||
"strong_sell": {"zh": "强烈卖出", "en": "Strong Sell"},
|
||||
}
|
||||
|
||||
_TREND_PREDICTION_CANONICAL_MAP = {
|
||||
"强烈看多": "strong_bullish",
|
||||
"strong bullish": "strong_bullish",
|
||||
"very bullish": "strong_bullish",
|
||||
"看多": "bullish",
|
||||
"bullish": "bullish",
|
||||
"uptrend": "bullish",
|
||||
"震荡": "sideways",
|
||||
"neutral": "sideways",
|
||||
"sideways": "sideways",
|
||||
"range-bound": "sideways",
|
||||
"看空": "bearish",
|
||||
"bearish": "bearish",
|
||||
"downtrend": "bearish",
|
||||
"强烈看空": "strong_bearish",
|
||||
"strong bearish": "strong_bearish",
|
||||
"very bearish": "strong_bearish",
|
||||
}
|
||||
|
||||
_TREND_PREDICTION_TRANSLATIONS = {
|
||||
"strong_bullish": {"zh": "强烈看多", "en": "Strong Bullish"},
|
||||
"bullish": {"zh": "看多", "en": "Bullish"},
|
||||
"sideways": {"zh": "震荡", "en": "Sideways"},
|
||||
"bearish": {"zh": "看空", "en": "Bearish"},
|
||||
"strong_bearish": {"zh": "强烈看空", "en": "Strong Bearish"},
|
||||
}
|
||||
|
||||
_CONFIDENCE_LEVEL_CANONICAL_MAP = {
|
||||
"高": "high",
|
||||
"high": "high",
|
||||
"中": "medium",
|
||||
"medium": "medium",
|
||||
"med": "medium",
|
||||
"低": "low",
|
||||
"low": "low",
|
||||
}
|
||||
|
||||
_CONFIDENCE_LEVEL_TRANSLATIONS = {
|
||||
"high": {"zh": "高", "en": "High"},
|
||||
"medium": {"zh": "中", "en": "Medium"},
|
||||
"low": {"zh": "低", "en": "Low"},
|
||||
}
|
||||
|
||||
_CHIP_HEALTH_CANONICAL_MAP = {
|
||||
"健康": "healthy",
|
||||
"healthy": "healthy",
|
||||
"一般": "average",
|
||||
"average": "average",
|
||||
"警惕": "caution",
|
||||
"caution": "caution",
|
||||
}
|
||||
|
||||
_CHIP_HEALTH_TRANSLATIONS = {
|
||||
"healthy": {"zh": "健康", "en": "Healthy"},
|
||||
"average": {"zh": "一般", "en": "Average"},
|
||||
"caution": {"zh": "警惕", "en": "Caution"},
|
||||
}
|
||||
|
||||
_BIAS_STATUS_CANONICAL_MAP = {
|
||||
"安全": "safe",
|
||||
"safe": "safe",
|
||||
"警戒": "caution",
|
||||
"警惕": "caution",
|
||||
"caution": "caution",
|
||||
"危险": "danger",
|
||||
"risk": "danger",
|
||||
"danger": "danger",
|
||||
}
|
||||
|
||||
_BIAS_STATUS_TRANSLATIONS = {
|
||||
"safe": {"zh": "安全", "en": "Safe"},
|
||||
"caution": {"zh": "警戒", "en": "Caution"},
|
||||
"danger": {"zh": "危险", "en": "Danger"},
|
||||
}
|
||||
|
||||
_PLACEHOLDER_BY_LANGUAGE = {
|
||||
"zh": "待补充",
|
||||
"en": "TBD",
|
||||
}
|
||||
|
||||
_UNKNOWN_BY_LANGUAGE = {
|
||||
"zh": "未知",
|
||||
"en": "Unknown",
|
||||
}
|
||||
|
||||
_NO_DATA_BY_LANGUAGE = {
|
||||
"zh": "数据缺失",
|
||||
"en": "Data unavailable",
|
||||
}
|
||||
|
||||
_GENERIC_STOCK_NAME_BY_LANGUAGE = {
|
||||
"zh": "待确认股票",
|
||||
"en": "Unnamed Stock",
|
||||
}
|
||||
|
||||
_REPORT_LABELS: Dict[str, Dict[str, str]] = {
|
||||
"zh": {
|
||||
"dashboard_title": "决策仪表盘",
|
||||
"brief_title": "决策简报",
|
||||
"analyzed_prefix": "共分析",
|
||||
"stock_unit": "只股票",
|
||||
"stock_unit_compact": "只",
|
||||
"buy_label": "买入",
|
||||
"watch_label": "观望",
|
||||
"sell_label": "卖出",
|
||||
"summary_heading": "分析结果摘要",
|
||||
"info_heading": "重要信息速览",
|
||||
"sentiment_summary_label": "舆情情绪",
|
||||
"earnings_outlook_label": "业绩预期",
|
||||
"risk_alerts_label": "风险警报",
|
||||
"positive_catalysts_label": "利好催化",
|
||||
"latest_news_label": "最新动态",
|
||||
"core_conclusion_heading": "核心结论",
|
||||
"one_sentence_label": "一句话决策",
|
||||
"time_sensitivity_label": "时效性",
|
||||
"default_time_sensitivity": "本周内",
|
||||
"position_status_label": "持仓情况",
|
||||
"action_advice_label": "操作建议",
|
||||
"no_position_label": "空仓者",
|
||||
"has_position_label": "持仓者",
|
||||
"continue_holding": "继续持有",
|
||||
"market_snapshot_heading": "当日行情",
|
||||
"close_label": "收盘",
|
||||
"prev_close_label": "昨收",
|
||||
"open_label": "开盘",
|
||||
"high_label": "最高",
|
||||
"low_label": "最低",
|
||||
"change_pct_label": "涨跌幅",
|
||||
"change_amount_label": "涨跌额",
|
||||
"amplitude_label": "振幅",
|
||||
"volume_label": "成交量",
|
||||
"amount_label": "成交额",
|
||||
"current_price_label": "当前价",
|
||||
"volume_ratio_label": "量比",
|
||||
"turnover_rate_label": "换手率",
|
||||
"source_label": "行情来源",
|
||||
"data_perspective_heading": "数据透视",
|
||||
"ma_alignment_label": "均线排列",
|
||||
"bullish_alignment_label": "多头排列",
|
||||
"yes_label": "是",
|
||||
"no_label": "否",
|
||||
"trend_strength_label": "趋势强度",
|
||||
"price_metrics_label": "价格指标",
|
||||
"ma5_label": "MA5",
|
||||
"ma10_label": "MA10",
|
||||
"ma20_label": "MA20",
|
||||
"bias_ma5_label": "乖离率(MA5)",
|
||||
"support_level_label": "支撑位",
|
||||
"resistance_level_label": "压力位",
|
||||
"chip_label": "筹码",
|
||||
"battle_plan_heading": "作战计划",
|
||||
"ideal_buy_label": "理想买入点",
|
||||
"secondary_buy_label": "次优买入点",
|
||||
"stop_loss_label": "止损位",
|
||||
"take_profit_label": "目标位",
|
||||
"suggested_position_label": "仓位建议",
|
||||
"entry_plan_label": "建仓策略",
|
||||
"risk_control_label": "风控策略",
|
||||
"checklist_heading": "检查清单",
|
||||
"failed_checks_heading": "检查未通过项",
|
||||
"history_compare_heading": "历史信号对比",
|
||||
"time_label": "时间",
|
||||
"score_label": "评分",
|
||||
"advice_label": "建议",
|
||||
"trend_label": "趋势",
|
||||
"generated_at_label": "报告生成时间",
|
||||
"report_time_label": "生成时间",
|
||||
"no_results": "无分析结果",
|
||||
"report_title": "股票分析报告",
|
||||
"avg_score_label": "均分",
|
||||
"action_points_heading": "操作点位",
|
||||
"position_advice_heading": "持仓建议",
|
||||
"analysis_model_label": "分析模型",
|
||||
"not_investment_advice": "AI生成,仅供参考,不构成投资建议",
|
||||
"details_report_hint": "详细报告见",
|
||||
},
|
||||
"en": {
|
||||
"dashboard_title": "Decision Dashboard",
|
||||
"brief_title": "Decision Brief",
|
||||
"analyzed_prefix": "Analyzed",
|
||||
"stock_unit": "stocks",
|
||||
"stock_unit_compact": "stocks",
|
||||
"buy_label": "Buy",
|
||||
"watch_label": "Watch",
|
||||
"sell_label": "Sell",
|
||||
"summary_heading": "Summary",
|
||||
"info_heading": "Key Updates",
|
||||
"sentiment_summary_label": "Sentiment",
|
||||
"earnings_outlook_label": "Earnings Outlook",
|
||||
"risk_alerts_label": "Risk Alerts",
|
||||
"positive_catalysts_label": "Positive Catalysts",
|
||||
"latest_news_label": "Latest News",
|
||||
"core_conclusion_heading": "Core Conclusion",
|
||||
"one_sentence_label": "One-line Decision",
|
||||
"time_sensitivity_label": "Time Sensitivity",
|
||||
"default_time_sensitivity": "This week",
|
||||
"position_status_label": "Position",
|
||||
"action_advice_label": "Action",
|
||||
"no_position_label": "No Position",
|
||||
"has_position_label": "Holding",
|
||||
"continue_holding": "Continue holding",
|
||||
"market_snapshot_heading": "Market Snapshot",
|
||||
"close_label": "Close",
|
||||
"prev_close_label": "Prev Close",
|
||||
"open_label": "Open",
|
||||
"high_label": "High",
|
||||
"low_label": "Low",
|
||||
"change_pct_label": "Change %",
|
||||
"change_amount_label": "Change",
|
||||
"amplitude_label": "Amplitude",
|
||||
"volume_label": "Volume",
|
||||
"amount_label": "Turnover",
|
||||
"current_price_label": "Price",
|
||||
"volume_ratio_label": "Volume Ratio",
|
||||
"turnover_rate_label": "Turnover Rate",
|
||||
"source_label": "Source",
|
||||
"data_perspective_heading": "Data View",
|
||||
"ma_alignment_label": "MA Alignment",
|
||||
"bullish_alignment_label": "Bullish Alignment",
|
||||
"yes_label": "Yes",
|
||||
"no_label": "No",
|
||||
"trend_strength_label": "Trend Strength",
|
||||
"price_metrics_label": "Price Metrics",
|
||||
"ma5_label": "MA5",
|
||||
"ma10_label": "MA10",
|
||||
"ma20_label": "MA20",
|
||||
"bias_ma5_label": "Bias (MA5)",
|
||||
"support_level_label": "Support",
|
||||
"resistance_level_label": "Resistance",
|
||||
"chip_label": "Chip Structure",
|
||||
"battle_plan_heading": "Battle Plan",
|
||||
"ideal_buy_label": "Ideal Entry",
|
||||
"secondary_buy_label": "Secondary Entry",
|
||||
"stop_loss_label": "Stop Loss",
|
||||
"take_profit_label": "Target",
|
||||
"suggested_position_label": "Position Size",
|
||||
"entry_plan_label": "Entry Plan",
|
||||
"risk_control_label": "Risk Control",
|
||||
"checklist_heading": "Checklist",
|
||||
"failed_checks_heading": "Failed Checks",
|
||||
"history_compare_heading": "Historical Signal Comparison",
|
||||
"time_label": "Time",
|
||||
"score_label": "Score",
|
||||
"advice_label": "Advice",
|
||||
"trend_label": "Trend",
|
||||
"generated_at_label": "Generated At",
|
||||
"report_time_label": "Generated",
|
||||
"no_results": "No analysis results",
|
||||
"report_title": "Stock Analysis Report",
|
||||
"avg_score_label": "Avg Score",
|
||||
"action_points_heading": "Action Levels",
|
||||
"position_advice_heading": "Position Advice",
|
||||
"analysis_model_label": "Model",
|
||||
"not_investment_advice": "AI-generated content for reference only. Not investment advice.",
|
||||
"details_report_hint": "See detailed report:",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def normalize_report_language(value: Optional[str], default: str = "zh") -> str:
|
||||
"""Normalize report language to a supported short code."""
|
||||
candidate = (value or default).strip().lower().replace(" ", "_")
|
||||
candidate = _REPORT_LANGUAGE_ALIASES.get(candidate, candidate)
|
||||
if candidate in SUPPORTED_REPORT_LANGUAGES:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def is_supported_report_language_value(value: Optional[str]) -> bool:
|
||||
"""Return whether the raw value is a supported language code or alias."""
|
||||
candidate = (value or "").strip().lower().replace(" ", "_")
|
||||
if not candidate:
|
||||
return False
|
||||
return candidate in SUPPORTED_REPORT_LANGUAGES or candidate in _REPORT_LANGUAGE_ALIASES
|
||||
|
||||
|
||||
def get_report_labels(language: Optional[str]) -> Dict[str, str]:
|
||||
"""Return UI copy for the selected report language."""
|
||||
normalized = normalize_report_language(language)
|
||||
return _REPORT_LABELS[normalized]
|
||||
|
||||
|
||||
def get_placeholder_text(language: Optional[str]) -> str:
|
||||
"""Return placeholder text for missing localized content."""
|
||||
return _PLACEHOLDER_BY_LANGUAGE[normalize_report_language(language)]
|
||||
|
||||
|
||||
def get_unknown_text(language: Optional[str]) -> str:
|
||||
"""Return localized unknown text."""
|
||||
return _UNKNOWN_BY_LANGUAGE[normalize_report_language(language)]
|
||||
|
||||
|
||||
def get_no_data_text(language: Optional[str]) -> str:
|
||||
"""Return localized data unavailable text."""
|
||||
return _NO_DATA_BY_LANGUAGE[normalize_report_language(language)]
|
||||
|
||||
|
||||
def _normalize_lookup_key(value: Any) -> str:
|
||||
return str(value or "").strip().lower().replace("_", " ").replace("-", " ")
|
||||
|
||||
|
||||
def _iter_lookup_candidates(value: Any) -> list[str]:
|
||||
raw_text = str(value or "").strip()
|
||||
if not raw_text:
|
||||
return []
|
||||
|
||||
candidates = [raw_text]
|
||||
for part in re.split(r"[/|,,、]+", raw_text):
|
||||
normalized = part.strip()
|
||||
if normalized and normalized not in candidates:
|
||||
candidates.append(normalized)
|
||||
return candidates
|
||||
|
||||
|
||||
def _canonicalize_lookup_value(value: Any, canonical_map: Dict[str, str]) -> Optional[str]:
|
||||
for candidate in _iter_lookup_candidates(value):
|
||||
canonical = canonical_map.get(_normalize_lookup_key(candidate))
|
||||
if canonical:
|
||||
return canonical
|
||||
return None
|
||||
|
||||
|
||||
def _is_placeholder_stock_name(value: Any, code: Any = None) -> bool:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return True
|
||||
|
||||
lowered = text.lower()
|
||||
if lowered in {"n/a", "na", "none", "null", "unknown"}:
|
||||
return True
|
||||
if text in {"-", "—", "未知", "待补充"}:
|
||||
return True
|
||||
|
||||
code_text = str(code or "").strip()
|
||||
if code_text and lowered == code_text.lower():
|
||||
return True
|
||||
|
||||
return text.startswith("股票")
|
||||
|
||||
|
||||
def _translate_from_map(
|
||||
value: Any,
|
||||
language: Optional[str],
|
||||
*,
|
||||
canonical_map: Dict[str, str],
|
||||
translations: Dict[str, Dict[str, str]],
|
||||
) -> str:
|
||||
normalized_language = normalize_report_language(language)
|
||||
raw_text = str(value or "").strip()
|
||||
if not raw_text:
|
||||
return raw_text
|
||||
|
||||
canonical = _canonicalize_lookup_value(raw_text, canonical_map)
|
||||
if canonical:
|
||||
return translations[canonical][normalized_language]
|
||||
return raw_text
|
||||
|
||||
|
||||
def localize_operation_advice(value: Any, language: Optional[str]) -> str:
|
||||
"""Translate operation advice between Chinese and English when recognized."""
|
||||
return _translate_from_map(
|
||||
value,
|
||||
language,
|
||||
canonical_map=_OPERATION_ADVICE_CANONICAL_MAP,
|
||||
translations=_OPERATION_ADVICE_TRANSLATIONS,
|
||||
)
|
||||
|
||||
|
||||
def localize_trend_prediction(value: Any, language: Optional[str]) -> str:
|
||||
"""Translate trend prediction between Chinese and English when recognized."""
|
||||
return _translate_from_map(
|
||||
value,
|
||||
language,
|
||||
canonical_map=_TREND_PREDICTION_CANONICAL_MAP,
|
||||
translations=_TREND_PREDICTION_TRANSLATIONS,
|
||||
)
|
||||
|
||||
|
||||
def localize_confidence_level(value: Any, language: Optional[str]) -> str:
|
||||
"""Translate confidence level between Chinese and English when recognized."""
|
||||
return _translate_from_map(
|
||||
value,
|
||||
language,
|
||||
canonical_map=_CONFIDENCE_LEVEL_CANONICAL_MAP,
|
||||
translations=_CONFIDENCE_LEVEL_TRANSLATIONS,
|
||||
)
|
||||
|
||||
|
||||
def localize_chip_health(value: Any, language: Optional[str]) -> str:
|
||||
"""Translate chip health labels between Chinese and English when recognized."""
|
||||
return _translate_from_map(
|
||||
value,
|
||||
language,
|
||||
canonical_map=_CHIP_HEALTH_CANONICAL_MAP,
|
||||
translations=_CHIP_HEALTH_TRANSLATIONS,
|
||||
)
|
||||
|
||||
|
||||
def localize_bias_status(value: Any, language: Optional[str]) -> str:
|
||||
"""Translate price bias status labels between Chinese and English when recognized."""
|
||||
return _translate_from_map(
|
||||
value,
|
||||
language,
|
||||
canonical_map=_BIAS_STATUS_CANONICAL_MAP,
|
||||
translations=_BIAS_STATUS_TRANSLATIONS,
|
||||
)
|
||||
|
||||
|
||||
def get_bias_status_emoji(value: Any) -> str:
|
||||
"""Return the stable alert emoji for a localized or canonical bias status."""
|
||||
canonical = _canonicalize_lookup_value(value, _BIAS_STATUS_CANONICAL_MAP)
|
||||
if canonical == "safe":
|
||||
return "✅"
|
||||
if canonical == "caution":
|
||||
return "⚠️"
|
||||
return "🚨"
|
||||
|
||||
|
||||
def infer_decision_type_from_advice(value: Any, default: str = "hold") -> str:
|
||||
"""Infer buy/hold/sell from human-readable operation advice."""
|
||||
canonical = _canonicalize_lookup_value(value, _OPERATION_ADVICE_CANONICAL_MAP)
|
||||
if canonical in {"strong_buy", "buy"}:
|
||||
return "buy"
|
||||
if canonical in {"reduce", "sell", "strong_sell"}:
|
||||
return "sell"
|
||||
if canonical in {"hold", "watch"}:
|
||||
return "hold"
|
||||
return default
|
||||
|
||||
|
||||
def get_signal_level(advice: Any, score: Any, language: Optional[str]) -> tuple[str, str, str]:
|
||||
"""Return localized signal text, emoji, and stable color tag."""
|
||||
normalized_language = normalize_report_language(language)
|
||||
canonical = _canonicalize_lookup_value(advice, _OPERATION_ADVICE_CANONICAL_MAP)
|
||||
if canonical == "strong_buy":
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["strong_buy"][normalized_language], "💚", "strong_buy")
|
||||
if canonical == "buy":
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["buy"][normalized_language], "🟢", "buy")
|
||||
if canonical == "hold":
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["hold"][normalized_language], "🟡", "hold")
|
||||
if canonical == "watch":
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["watch"][normalized_language], "⚪", "watch")
|
||||
if canonical == "reduce":
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["reduce"][normalized_language], "🟠", "reduce")
|
||||
if canonical in {"sell", "strong_sell"}:
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["sell"][normalized_language], "🔴", "sell")
|
||||
|
||||
try:
|
||||
numeric_score = int(float(score))
|
||||
except (TypeError, ValueError):
|
||||
numeric_score = 50
|
||||
|
||||
if numeric_score >= 80:
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["strong_buy"][normalized_language], "💚", "strong_buy")
|
||||
if numeric_score >= 65:
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["buy"][normalized_language], "🟢", "buy")
|
||||
if numeric_score >= 55:
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["hold"][normalized_language], "🟡", "hold")
|
||||
if numeric_score >= 45:
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["watch"][normalized_language], "⚪", "watch")
|
||||
if numeric_score >= 35:
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["reduce"][normalized_language], "🟠", "reduce")
|
||||
return (_OPERATION_ADVICE_TRANSLATIONS["sell"][normalized_language], "🔴", "sell")
|
||||
|
||||
|
||||
def get_localized_stock_name(value: Any, code: Any, language: Optional[str]) -> str:
|
||||
"""Return a localized stock name placeholder when the original name is missing."""
|
||||
raw_text = str(value or "").strip()
|
||||
if not _is_placeholder_stock_name(raw_text, code):
|
||||
return raw_text
|
||||
return _GENERIC_STOCK_NAME_BY_LANGUAGE[normalize_report_language(language)]
|
||||
|
||||
|
||||
def get_sentiment_label(score: int, language: Optional[str]) -> str:
|
||||
"""Return localized sentiment label by score band."""
|
||||
normalized = normalize_report_language(language)
|
||||
if normalized == "en":
|
||||
if score >= 80:
|
||||
return "Very Bullish"
|
||||
if score >= 60:
|
||||
return "Bullish"
|
||||
if score >= 40:
|
||||
return "Neutral"
|
||||
if score >= 20:
|
||||
return "Bearish"
|
||||
return "Very Bearish"
|
||||
|
||||
if score >= 80:
|
||||
return "极度乐观"
|
||||
if score >= 60:
|
||||
return "乐观"
|
||||
if score >= 40:
|
||||
return "中性"
|
||||
if score >= 20:
|
||||
return "悲观"
|
||||
return "极度悲观"
|
||||
@@ -15,6 +15,13 @@ import uuid
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,23 +126,26 @@ class AnalysisService:
|
||||
sniper_points = result.get_sniper_points() or {}
|
||||
|
||||
# 计算情绪标签
|
||||
sentiment_label = self._get_sentiment_label(result.sentiment_score)
|
||||
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)
|
||||
|
||||
# 构建报告结构
|
||||
report = {
|
||||
"meta": {
|
||||
"query_id": query_id,
|
||||
"stock_code": result.code,
|
||||
"stock_name": result.name,
|
||||
"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),
|
||||
},
|
||||
"summary": {
|
||||
"analysis_summary": result.analysis_summary,
|
||||
"operation_advice": result.operation_advice,
|
||||
"trend_prediction": result.trend_prediction,
|
||||
"operation_advice": localize_operation_advice(result.operation_advice, report_language),
|
||||
"trend_prediction": localize_trend_prediction(result.trend_prediction, report_language),
|
||||
"sentiment_score": result.sentiment_score,
|
||||
"sentiment_label": sentiment_label,
|
||||
},
|
||||
@@ -155,27 +165,6 @@ class AnalysisService:
|
||||
|
||||
return {
|
||||
"stock_code": result.code,
|
||||
"stock_name": result.name,
|
||||
"stock_name": stock_name,
|
||||
"report": report,
|
||||
}
|
||||
|
||||
def _get_sentiment_label(self, score: int) -> str:
|
||||
"""
|
||||
根据评分获取情绪标签
|
||||
|
||||
Args:
|
||||
score: 情绪评分 (0-100)
|
||||
|
||||
Returns:
|
||||
情绪标签
|
||||
"""
|
||||
if score >= 80:
|
||||
return "极度乐观"
|
||||
elif score >= 60:
|
||||
return "乐观"
|
||||
elif score >= 40:
|
||||
return "中性"
|
||||
elif score >= 20:
|
||||
return "悲观"
|
||||
else:
|
||||
return "极度悲观"
|
||||
|
||||
@@ -16,6 +16,17 @@ from datetime import date, datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List, Tuple, TYPE_CHECKING
|
||||
|
||||
from src.config import get_config, resolve_news_window_days
|
||||
from src.report_language import (
|
||||
get_bias_status_emoji,
|
||||
get_localized_stock_name,
|
||||
get_report_labels,
|
||||
get_signal_level,
|
||||
localize_bias_status,
|
||||
localize_chip_health,
|
||||
localize_operation_advice,
|
||||
localize_trend_prediction,
|
||||
normalize_report_language,
|
||||
)
|
||||
from src.storage import DatabaseManager
|
||||
from src.utils.data_processing import normalize_model_used, parse_json_field
|
||||
|
||||
@@ -514,6 +525,7 @@ class HistoryService:
|
||||
operation_advice=raw_result.get("operation_advice", record.operation_advice or ""),
|
||||
decision_type=raw_result.get("decision_type", "hold"),
|
||||
confidence_level=raw_result.get("confidence_level", "中"),
|
||||
report_language=normalize_report_language(raw_result.get("report_language")),
|
||||
dashboard=dashboard,
|
||||
trend_analysis=raw_result.get("trend_analysis", ""),
|
||||
short_term_outlook=raw_result.get("short_term_outlook", ""),
|
||||
@@ -565,18 +577,30 @@ class HistoryService:
|
||||
"""
|
||||
report_date = record.created_at.strftime("%Y-%m-%d") if record.created_at else datetime.now().strftime("%Y-%m-%d")
|
||||
report_time = record.created_at.strftime("%H:%M:%S") if record.created_at else datetime.now().strftime("%H:%M:%S")
|
||||
report_language = normalize_report_language(getattr(result, "report_language", "zh"))
|
||||
labels = get_report_labels(report_language)
|
||||
analysis_date_label = "Analysis Date" if report_language == "en" else "分析日期"
|
||||
report_time_label = "Report Time" if report_language == "en" else "报告生成时间"
|
||||
reason_label = "Rationale" if report_language == "en" else "操作理由"
|
||||
risk_warning_label = "Risk Warning" if report_language == "en" else "风险提示"
|
||||
technical_heading = "Technicals" if report_language == "en" else "技术面"
|
||||
ma_label = "Moving Averages" if report_language == "en" else "均线"
|
||||
volume_analysis_label = "Volume" if report_language == "en" else "量能"
|
||||
news_heading = "News Flow" if report_language == "en" else "消息面"
|
||||
|
||||
# Escape markdown special characters in stock name
|
||||
name_escaped = self._escape_md(result.name) if result.name else result.code
|
||||
name_escaped = self._escape_md(
|
||||
get_localized_stock_name(result.name, result.code, report_language)
|
||||
) or result.code
|
||||
|
||||
# Get signal level
|
||||
signal_text, signal_emoji, signal_tag = self._get_signal_level(result)
|
||||
dashboard = result.dashboard if hasattr(result, 'dashboard') and result.dashboard else {}
|
||||
|
||||
report_lines = [
|
||||
f"# 📊 {name_escaped} ({result.code}) 分析报告",
|
||||
f"# 📊 {name_escaped} ({result.code}) {labels['report_title']}",
|
||||
"",
|
||||
f"> 分析日期:**{report_date}** | 报告生成时间:{report_time}",
|
||||
f"> {analysis_date_label}: **{report_date}** | {report_time_label}: {report_time}",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
@@ -586,63 +610,63 @@ class HistoryService:
|
||||
intel = dashboard.get('intelligence', {}) if dashboard else {}
|
||||
if intel:
|
||||
report_lines.extend([
|
||||
"### 📰 重要信息速览",
|
||||
f"### 📰 {labels['info_heading']}",
|
||||
"",
|
||||
])
|
||||
# 舆情情绪总结
|
||||
if intel.get('sentiment_summary'):
|
||||
report_lines.append(f"**💭 舆情情绪**: {intel['sentiment_summary']}")
|
||||
report_lines.append(f"**💭 {labels['sentiment_summary_label']}**: {intel['sentiment_summary']}")
|
||||
# 业绩预期
|
||||
if intel.get('earnings_outlook'):
|
||||
report_lines.append(f"**📊 业绩预期**: {intel['earnings_outlook']}")
|
||||
report_lines.append(f"**📊 {labels['earnings_outlook_label']}**: {intel['earnings_outlook']}")
|
||||
# 风险警报(醒目显示)
|
||||
risk_alerts = intel.get('risk_alerts', [])
|
||||
if risk_alerts:
|
||||
report_lines.append("")
|
||||
report_lines.append("**🚨 风险警报**:")
|
||||
report_lines.append(f"**🚨 {labels['risk_alerts_label']}**:")
|
||||
for alert in risk_alerts:
|
||||
report_lines.append(f"- {alert}")
|
||||
# 利好催化
|
||||
catalysts = intel.get('positive_catalysts', [])
|
||||
if catalysts:
|
||||
report_lines.append("")
|
||||
report_lines.append("**✨ 利好催化**:")
|
||||
report_lines.append(f"**✨ {labels['positive_catalysts_label']}**:")
|
||||
for cat in catalysts:
|
||||
report_lines.append(f"- {cat}")
|
||||
# 最新消息
|
||||
if intel.get('latest_news'):
|
||||
report_lines.append("")
|
||||
report_lines.append(f"**📢 最新动态**: {intel['latest_news']}")
|
||||
report_lines.append(f"**📢 {labels['latest_news_label']}**: {intel['latest_news']}")
|
||||
report_lines.append("")
|
||||
|
||||
# ========== 核心结论 ==========
|
||||
core = dashboard.get('core_conclusion', {}) if dashboard else {}
|
||||
one_sentence = core.get('one_sentence', result.analysis_summary)
|
||||
time_sense = core.get('time_sensitivity', '本周内')
|
||||
time_sense = core.get('time_sensitivity', labels['default_time_sensitivity'])
|
||||
pos_advice = core.get('position_advice', {})
|
||||
|
||||
report_lines.extend([
|
||||
"### 📌 核心结论",
|
||||
f"### 📌 {labels['core_conclusion_heading']}",
|
||||
"",
|
||||
f"**{signal_emoji} {signal_text}** | {result.trend_prediction}",
|
||||
f"**{signal_emoji} {signal_text}** | {localize_trend_prediction(result.trend_prediction, report_language)}",
|
||||
"",
|
||||
f"> **一句话决策**: {one_sentence}",
|
||||
f"> **{labels['one_sentence_label']}**: {one_sentence}",
|
||||
"",
|
||||
f"⏰ **时效性**: {time_sense}",
|
||||
f"⏰ **{labels['time_sensitivity_label']}**: {time_sense}",
|
||||
"",
|
||||
])
|
||||
# 持仓分类建议
|
||||
if pos_advice:
|
||||
report_lines.extend([
|
||||
"| 持仓情况 | 操作建议 |",
|
||||
f"| {labels['position_status_label']} | {labels['action_advice_label']} |",
|
||||
"|---------|---------|",
|
||||
f"| 🆕 **空仓者** | {pos_advice.get('no_position', result.operation_advice)} |",
|
||||
f"| 💼 **持仓者** | {pos_advice.get('has_position', '继续持有')} |",
|
||||
f"| 🆕 **{labels['no_position_label']}** | {pos_advice.get('no_position', localize_operation_advice(result.operation_advice, report_language))} |",
|
||||
f"| 💼 **{labels['has_position_label']}** | {pos_advice.get('has_position', labels['continue_holding'])} |",
|
||||
"",
|
||||
])
|
||||
|
||||
# ========== 行情快照 ==========
|
||||
self._append_market_snapshot_to_report(report_lines, result)
|
||||
self._append_market_snapshot_to_report(report_lines, result, labels)
|
||||
|
||||
# ========== 数据透视 ==========
|
||||
data_persp = dashboard.get('data_perspective', {}) if dashboard else {}
|
||||
@@ -653,45 +677,61 @@ class HistoryService:
|
||||
chip_data = data_persp.get('chip_structure', {})
|
||||
|
||||
report_lines.extend([
|
||||
"### 📊 数据透视",
|
||||
f"### 📊 {labels['data_perspective_heading']}",
|
||||
"",
|
||||
])
|
||||
# 趋势状态
|
||||
if trend_data:
|
||||
is_bullish = "✅ 是" if trend_data.get('is_bullish', False) else "❌ 否"
|
||||
is_bullish = (
|
||||
f"✅ {labels['yes_label']}"
|
||||
if trend_data.get('is_bullish', False)
|
||||
else f"❌ {labels['no_label']}"
|
||||
)
|
||||
report_lines.extend([
|
||||
f"**均线排列**: {trend_data.get('ma_alignment', 'N/A')} | 多头排列: {is_bullish} | 趋势强度: {trend_data.get('trend_score', 'N/A')}/100",
|
||||
f"**{labels['ma_alignment_label']}**: {trend_data.get('ma_alignment', 'N/A')} | "
|
||||
f"{labels['bullish_alignment_label']}: {is_bullish} | "
|
||||
f"{labels['trend_strength_label']}: {trend_data.get('trend_score', 'N/A')}/100",
|
||||
"",
|
||||
])
|
||||
# 价格位置
|
||||
if price_data:
|
||||
bias_status = price_data.get('bias_status', 'N/A')
|
||||
bias_emoji = "✅" if bias_status == "安全" else ("⚠️" if bias_status == "警戒" else "🚨")
|
||||
raw_bias_status = price_data.get('bias_status', 'N/A')
|
||||
bias_status = localize_bias_status(raw_bias_status, report_language)
|
||||
bias_emoji = get_bias_status_emoji(raw_bias_status)
|
||||
report_lines.extend([
|
||||
"| 价格指标 | 数值 |",
|
||||
f"| {labels['price_metrics_label']} | {labels['current_price_label']} |",
|
||||
"|---------|------|",
|
||||
f"| 当前价 | {price_data.get('current_price', 'N/A')} |",
|
||||
f"| MA5 | {price_data.get('ma5', 'N/A')} |",
|
||||
f"| MA10 | {price_data.get('ma10', 'N/A')} |",
|
||||
f"| MA20 | {price_data.get('ma20', 'N/A')} |",
|
||||
f"| 乖离率(MA5) | {price_data.get('bias_ma5', 'N/A')}% {bias_emoji}{bias_status} |",
|
||||
f"| 支撑位 | {price_data.get('support_level', 'N/A')} |",
|
||||
f"| 压力位 | {price_data.get('resistance_level', 'N/A')} |",
|
||||
f"| {labels['current_price_label']} | {price_data.get('current_price', 'N/A')} |",
|
||||
f"| {labels['ma5_label']} | {price_data.get('ma5', 'N/A')} |",
|
||||
f"| {labels['ma10_label']} | {price_data.get('ma10', 'N/A')} |",
|
||||
f"| {labels['ma20_label']} | {price_data.get('ma20', 'N/A')} |",
|
||||
f"| {labels['bias_ma5_label']} | {price_data.get('bias_ma5', 'N/A')}% {bias_emoji}{bias_status} |",
|
||||
f"| {labels['support_level_label']} | {price_data.get('support_level', 'N/A')} |",
|
||||
f"| {labels['resistance_level_label']} | {price_data.get('resistance_level', 'N/A')} |",
|
||||
"",
|
||||
])
|
||||
# 量能分析
|
||||
if vol_data:
|
||||
report_lines.extend([
|
||||
f"**量能**: 量比 {vol_data.get('volume_ratio', 'N/A')} ({vol_data.get('volume_status', '')}) | 换手率 {vol_data.get('turnover_rate', 'N/A')}%",
|
||||
f"**{labels['volume_label']}**: {labels['volume_ratio_label']} {vol_data.get('volume_ratio', 'N/A')} "
|
||||
f"({vol_data.get('volume_status', '')}) | {labels['turnover_rate_label']} {vol_data.get('turnover_rate', 'N/A')}%",
|
||||
f"💡 *{vol_data.get('volume_meaning', '')}*",
|
||||
"",
|
||||
])
|
||||
# 筹码结构
|
||||
if chip_data:
|
||||
chip_health = chip_data.get('chip_health', 'N/A')
|
||||
chip_emoji = "✅" if chip_health == "健康" else ("⚠️" if chip_health == "一般" else "🚨")
|
||||
raw_chip_health = chip_data.get('chip_health', 'N/A')
|
||||
chip_health = localize_chip_health(raw_chip_health, report_language)
|
||||
normalized_chip_health = str(raw_chip_health or "").strip().lower()
|
||||
if normalized_chip_health in {"健康", "healthy"}:
|
||||
chip_emoji = "✅"
|
||||
elif normalized_chip_health in {"一般", "average"}:
|
||||
chip_emoji = "⚠️"
|
||||
else:
|
||||
chip_emoji = "🚨"
|
||||
report_lines.extend([
|
||||
f"**筹码**: 获利比例 {chip_data.get('profit_ratio', 'N/A')} | 平均成本 {chip_data.get('avg_cost', 'N/A')} | 集中度 {chip_data.get('concentration', 'N/A')} {chip_emoji}{chip_health}",
|
||||
f"**{labels['chip_label']}**: {chip_data.get('profit_ratio', 'N/A')} | {chip_data.get('avg_cost', 'N/A')} | "
|
||||
f"{chip_data.get('concentration', 'N/A')} {chip_emoji}{chip_health}",
|
||||
"",
|
||||
])
|
||||
|
||||
@@ -699,37 +739,37 @@ class HistoryService:
|
||||
battle = dashboard.get('battle_plan', {}) if dashboard else {}
|
||||
if battle:
|
||||
report_lines.extend([
|
||||
"### 🎯 作战计划",
|
||||
f"### 🎯 {labels['battle_plan_heading']}",
|
||||
"",
|
||||
])
|
||||
# 狙击点位
|
||||
sniper = battle.get('sniper_points', {})
|
||||
if sniper:
|
||||
report_lines.extend([
|
||||
"**📍 狙击点位**",
|
||||
f"**📍 {labels['action_points_heading']}**",
|
||||
"",
|
||||
"| 点位类型 | 价格 |",
|
||||
f"| {labels['action_points_heading']} | {labels['current_price_label']} |",
|
||||
"|---------|------|",
|
||||
f"| 🎯 理想买入点 | {self._clean_sniper_value(sniper.get('ideal_buy', 'N/A'))} |",
|
||||
f"| 🔵 次优买入点 | {self._clean_sniper_value(sniper.get('secondary_buy', 'N/A'))} |",
|
||||
f"| 🛑 止损位 | {self._clean_sniper_value(sniper.get('stop_loss', 'N/A'))} |",
|
||||
f"| 🎊 目标位 | {self._clean_sniper_value(sniper.get('take_profit', 'N/A'))} |",
|
||||
f"| 🎯 {labels['ideal_buy_label']} | {self._clean_sniper_value(sniper.get('ideal_buy', 'N/A'))} |",
|
||||
f"| 🔵 {labels['secondary_buy_label']} | {self._clean_sniper_value(sniper.get('secondary_buy', 'N/A'))} |",
|
||||
f"| 🛑 {labels['stop_loss_label']} | {self._clean_sniper_value(sniper.get('stop_loss', 'N/A'))} |",
|
||||
f"| 🎊 {labels['take_profit_label']} | {self._clean_sniper_value(sniper.get('take_profit', 'N/A'))} |",
|
||||
"",
|
||||
])
|
||||
# 仓位策略
|
||||
position = battle.get('position_strategy', {})
|
||||
if position:
|
||||
report_lines.extend([
|
||||
f"**💰 仓位建议**: {position.get('suggested_position', 'N/A')}",
|
||||
f"- 建仓策略: {position.get('entry_plan', 'N/A')}",
|
||||
f"- 风控策略: {position.get('risk_control', 'N/A')}",
|
||||
f"**💰 {labels['suggested_position_label']}**: {position.get('suggested_position', 'N/A')}",
|
||||
f"- {labels['entry_plan_label']}: {position.get('entry_plan', 'N/A')}",
|
||||
f"- {labels['risk_control_label']}: {position.get('risk_control', 'N/A')}",
|
||||
"",
|
||||
])
|
||||
# 检查清单
|
||||
checklist = battle.get('action_checklist', []) if battle else []
|
||||
if checklist:
|
||||
report_lines.extend([
|
||||
"**✅ 检查清单**",
|
||||
f"**✅ {labels['checklist_heading']}**",
|
||||
"",
|
||||
])
|
||||
for item in checklist:
|
||||
@@ -741,30 +781,30 @@ class HistoryService:
|
||||
# 操作理由
|
||||
if result.buy_reason:
|
||||
report_lines.extend([
|
||||
f"**💡 操作理由**: {result.buy_reason}",
|
||||
f"**💡 {reason_label}**: {result.buy_reason}",
|
||||
"",
|
||||
])
|
||||
# 风险提示
|
||||
if result.risk_warning:
|
||||
report_lines.extend([
|
||||
f"**⚠️ 风险提示**: {result.risk_warning}",
|
||||
f"**⚠️ {risk_warning_label}**: {result.risk_warning}",
|
||||
"",
|
||||
])
|
||||
# 技术面分析
|
||||
if result.ma_analysis or result.volume_analysis:
|
||||
report_lines.extend([
|
||||
"### 📊 技术面",
|
||||
f"### 📊 {technical_heading}",
|
||||
"",
|
||||
])
|
||||
if result.ma_analysis:
|
||||
report_lines.append(f"**均线**: {result.ma_analysis}")
|
||||
report_lines.append(f"**{ma_label}**: {result.ma_analysis}")
|
||||
if result.volume_analysis:
|
||||
report_lines.append(f"**量能**: {result.volume_analysis}")
|
||||
report_lines.append(f"**{volume_analysis_label}**: {result.volume_analysis}")
|
||||
report_lines.append("")
|
||||
# 消息面
|
||||
if result.news_summary:
|
||||
report_lines.extend([
|
||||
"### 📰 消息面",
|
||||
f"### 📰 {news_heading}",
|
||||
f"{result.news_summary}",
|
||||
"",
|
||||
])
|
||||
@@ -773,7 +813,7 @@ class HistoryService:
|
||||
report_lines.extend([
|
||||
"---",
|
||||
"",
|
||||
f"*报告生成时间:{report_time}*",
|
||||
f"*{labels['generated_at_label']}: {report_time}*",
|
||||
])
|
||||
|
||||
return "\n".join(report_lines)
|
||||
@@ -797,15 +837,11 @@ class HistoryService:
|
||||
|
||||
def _get_signal_level(self, result: AnalysisResult) -> Tuple[str, str, str]:
|
||||
"""Get signal level based on sentiment score and decision type."""
|
||||
score = result.sentiment_score or 50
|
||||
decision = getattr(result, 'decision_type', '')
|
||||
|
||||
if decision == 'buy' or score >= 70:
|
||||
return ('买入', '🟢', '买入')
|
||||
elif decision == 'sell' or score < 35:
|
||||
return ('卖出', '🔴', '卖出')
|
||||
else:
|
||||
return ('观望', '⚪', '观望')
|
||||
return get_signal_level(
|
||||
result.operation_advice,
|
||||
result.sentiment_score,
|
||||
getattr(result, "report_language", "zh"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_format_number(value: Any, fmt: str = ".2f") -> str:
|
||||
@@ -834,37 +870,44 @@ class HistoryService:
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _append_market_snapshot_to_report(lines: List[str], result: AnalysisResult) -> None:
|
||||
def _append_market_snapshot_to_report(
|
||||
lines: List[str],
|
||||
result: AnalysisResult,
|
||||
labels: Dict[str, str],
|
||||
) -> None:
|
||||
"""Append market snapshot data to report lines."""
|
||||
snapshot = getattr(result, 'market_snapshot', None)
|
||||
if not snapshot:
|
||||
return
|
||||
|
||||
lines.extend([
|
||||
"### 📈 行情快照",
|
||||
f"### 📈 {labels['market_snapshot_heading']}",
|
||||
"",
|
||||
"| 指标 | 数值 |",
|
||||
f"| {labels['price_metrics_label']} | {labels['current_price_label']} |",
|
||||
"|------|------|",
|
||||
])
|
||||
|
||||
# Price info
|
||||
current_price = snapshot.get('current_price') or result.current_price
|
||||
change_pct = snapshot.get('change_pct') or result.change_pct
|
||||
current_price = snapshot.get('price') or snapshot.get('current_price') or result.current_price
|
||||
change_pct = snapshot.get('change_pct') or snapshot.get('pct_chg') or result.change_pct
|
||||
if current_price is not None:
|
||||
current_str = HistoryService._safe_format_number(current_price, ".2f")
|
||||
if change_pct is not None:
|
||||
change_str = f"{HistoryService._safe_format_number(change_pct, '+.2f')}%"
|
||||
if isinstance(change_pct, str) and change_pct.strip().endswith("%"):
|
||||
change_str = change_pct.strip()
|
||||
else:
|
||||
change_str = f"{HistoryService._safe_format_number(change_pct, '+.2f')}%"
|
||||
else:
|
||||
change_str = "--"
|
||||
lines.append(f"| 当前价 | **{current_str}** ({change_str}) |")
|
||||
lines.append(f"| {labels['current_price_label']} | **{current_str}** ({change_str}) |")
|
||||
|
||||
# Other metrics
|
||||
metrics = [
|
||||
("开盘价", "open", ".2f"),
|
||||
("最高价", "high", ".2f"),
|
||||
("最低价", "low", ".2f"),
|
||||
("成交量", "volume", ",.0f"),
|
||||
("成交额", "amount", ",.0f"),
|
||||
(labels['open_label'], "open", ".2f"),
|
||||
(labels['high_label'], "high", ".2f"),
|
||||
(labels['low_label'], "low", ".2f"),
|
||||
(labels['volume_label'], "volume", ",.0f"),
|
||||
(labels['amount_label'], "amount", ",.0f"),
|
||||
]
|
||||
for label, key, fmt in metrics:
|
||||
value = snapshot.get(key)
|
||||
|
||||
@@ -15,41 +15,19 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.analyzer import AnalysisResult
|
||||
from src.config import get_config
|
||||
from src.report_language import (
|
||||
get_localized_stock_name,
|
||||
get_report_labels,
|
||||
get_signal_level,
|
||||
localize_chip_health,
|
||||
localize_operation_advice,
|
||||
localize_trend_prediction,
|
||||
normalize_report_language,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_signal_level(result: AnalysisResult) -> tuple:
|
||||
"""Return (signal_text, emoji, color_tag) for a result."""
|
||||
advice = result.operation_advice
|
||||
score = result.sentiment_score
|
||||
advice_map = {
|
||||
"强烈买入": ("强烈买入", "💚", "强买"),
|
||||
"买入": ("买入", "🟢", "买入"),
|
||||
"加仓": ("买入", "🟢", "买入"),
|
||||
"持有": ("持有", "🟡", "持有"),
|
||||
"观望": ("观望", "⚪", "观望"),
|
||||
"减仓": ("减仓", "🟠", "减仓"),
|
||||
"卖出": ("卖出", "🔴", "卖出"),
|
||||
"强烈卖出": ("卖出", "🔴", "卖出"),
|
||||
}
|
||||
if advice in advice_map:
|
||||
return advice_map[advice]
|
||||
if score >= 80:
|
||||
return ("强烈买入", "💚", "强买")
|
||||
elif score >= 65:
|
||||
return ("买入", "🟢", "买入")
|
||||
elif score >= 55:
|
||||
return ("持有", "🟡", "持有")
|
||||
elif score >= 45:
|
||||
return ("观望", "⚪", "观望")
|
||||
elif score >= 35:
|
||||
return ("减仓", "🟠", "减仓")
|
||||
elif score < 35:
|
||||
return ("卖出", "🔴", "卖出")
|
||||
return ("观望", "⚪", "观望")
|
||||
|
||||
|
||||
def _escape_md(text: str) -> str:
|
||||
"""Escape markdown special chars (*ST etc)."""
|
||||
if not text:
|
||||
@@ -69,6 +47,7 @@ def _clean_sniper_value(val: Any) -> str:
|
||||
prefixes = [
|
||||
"理想买入点:", "次优买入点:", "止损位:", "目标位:",
|
||||
"理想买入点:", "次优买入点:", "止损位:", "目标位:",
|
||||
"Ideal Entry:", "Secondary Entry:", "Stop Loss:", "Target:",
|
||||
]
|
||||
for prefix in prefixes:
|
||||
if s.startswith(prefix):
|
||||
@@ -124,17 +103,29 @@ def render(
|
||||
logger.debug("Report template not found: %s", template_path)
|
||||
return None
|
||||
|
||||
report_language = normalize_report_language(
|
||||
(extra_context or {}).get("report_language")
|
||||
or next(
|
||||
(getattr(result, "report_language", None) for result in results if getattr(result, "report_language", None)),
|
||||
None,
|
||||
)
|
||||
or getattr(get_config(), "report_language", "zh")
|
||||
)
|
||||
labels = get_report_labels(report_language)
|
||||
|
||||
# Build template context with pre-computed signal levels (sorted by score)
|
||||
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
|
||||
sorted_enriched = []
|
||||
for r in sorted_results:
|
||||
st, se, _ = _get_signal_level(r)
|
||||
rn = r.name if r.name and not r.name.startswith("股票") else f"股票{r.code}"
|
||||
st, se, _ = get_signal_level(r.operation_advice, r.sentiment_score, report_language)
|
||||
rn = get_localized_stock_name(r.name, r.code, report_language)
|
||||
sorted_enriched.append({
|
||||
"result": r,
|
||||
"signal_text": st,
|
||||
"signal_emoji": se,
|
||||
"stock_name": _escape_md(rn),
|
||||
"localized_operation_advice": localize_operation_advice(r.operation_advice, report_language),
|
||||
"localized_trend_prediction": localize_trend_prediction(r.trend_prediction, report_language),
|
||||
})
|
||||
|
||||
buy_count = sum(1 for r in results if getattr(r, "decision_type", "") == "buy")
|
||||
@@ -155,13 +146,21 @@ def render(
|
||||
"buy_count": buy_count,
|
||||
"sell_count": sell_count,
|
||||
"hold_count": hold_count,
|
||||
"labels": labels,
|
||||
"report_language": report_language,
|
||||
"escape_md": _escape_md,
|
||||
"clean_sniper": _clean_sniper_value,
|
||||
"failed_checks": failed_checks,
|
||||
"history_by_code": {},
|
||||
"localize_operation_advice": localize_operation_advice,
|
||||
"localize_trend_prediction": localize_trend_prediction,
|
||||
"localize_chip_health": localize_chip_health,
|
||||
}
|
||||
if extra_context:
|
||||
context.update(extra_context)
|
||||
safe_extra_context = dict(extra_context)
|
||||
safe_extra_context.pop("labels", None)
|
||||
safe_extra_context.pop("report_language", None)
|
||||
context.update(safe_extra_context)
|
||||
|
||||
try:
|
||||
env = Environment(
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
{% macro market_snapshot(result) %}
|
||||
{% set snapshot = result.market_snapshot if result.market_snapshot else {} %}
|
||||
{% if snapshot %}
|
||||
### 📈 当日行情
|
||||
### 📈 {{ labels.market_snapshot_heading }}
|
||||
|
||||
| 收盘 | 昨收 | 开盘 | 最高 | 最低 | 涨跌幅 | 涨跌额 | 振幅 | 成交量 | 成交额 |
|
||||
| {{ labels.close_label }} | {{ labels.prev_close_label }} | {{ labels.open_label }} | {{ labels.high_label }} | {{ labels.low_label }} | {{ labels.change_pct_label }} | {{ labels.change_amount_label }} | {{ labels.amplitude_label }} | {{ labels.volume_label }} | {{ labels.amount_label }} |
|
||||
|------|------|------|------|------|-------|-------|------|--------|--------|
|
||||
| {{ snapshot.get('close', 'N/A') }} | {{ snapshot.get('prev_close', 'N/A') }} | {{ snapshot.get('open', 'N/A') }} | {{ snapshot.get('high', 'N/A') }} | {{ snapshot.get('low', 'N/A') }} | {{ snapshot.get('pct_chg', 'N/A') }} | {{ snapshot.get('change_amount', 'N/A') }} | {{ snapshot.get('amplitude', 'N/A') }} | {{ snapshot.get('volume', 'N/A') }} | {{ snapshot.get('amount', 'N/A') }} |
|
||||
{% if snapshot.get('price') %}
|
||||
|
||||
| 当前价 | 量比 | 换手率 | 行情来源 |
|
||||
| {{ labels.current_price_label }} | {{ labels.volume_ratio_label }} | {{ labels.turnover_rate_label }} | {{ labels.source_label }} |
|
||||
|-------|------|--------|----------|
|
||||
| {{ snapshot.get('price', 'N/A') }} | {{ snapshot.get('volume_ratio', 'N/A') }} | {{ snapshot.get('turnover_rate', 'N/A') }} | {{ snapshot.get('source', 'N/A') }} |
|
||||
{% endif %}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 🎯 {{ report_date }} 决策简报
|
||||
# 🎯 {{ report_date }} {{ labels.brief_title }}
|
||||
|
||||
> {{ results|length }}只 | 🟢{{ buy_count }} 🟡{{ hold_count }} 🔴{{ sell_count }}
|
||||
> {{ results|length }} {{ labels.stock_unit_compact }} | 🟢{{ buy_count }} 🟡{{ hold_count }} 🔴{{ sell_count }}
|
||||
|
||||
{% for e in enriched %}
|
||||
{% set dash = e.result.dashboard or {} %}
|
||||
{% set core = (dash.get('core_conclusion') or {}) if dash else {} %}
|
||||
{% set one = (core.get('one_sentence') or e.result.analysis_summary or '')[:60] %}
|
||||
**{{ e.stock_name }}({{ e.result.code }})** {{ e.signal_emoji }} {{ e.result.operation_advice }} | 评分{{ e.result.sentiment_score }} | {{ one }}
|
||||
**{{ e.stock_name }}({{ e.result.code }})** {{ e.signal_emoji }} {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ one }}
|
||||
{% endfor %}
|
||||
|
||||
*{{ report_timestamp }}*
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% from '_macros.j2' import market_snapshot %}
|
||||
# 🎯 {{ report_date }} 决策仪表盘
|
||||
{% from '_macros.j2' import market_snapshot with context %}
|
||||
# 🎯 {{ report_date }} {{ labels.dashboard_title }}
|
||||
|
||||
> 共分析 **{{ results|length }}** 只股票 | 🟢买入:{{ buy_count }} 🟡观望:{{ hold_count }} 🔴卖出:{{ sell_count }}
|
||||
> {{ labels.analyzed_prefix }} **{{ results|length }}** {{ labels.stock_unit }} | 🟢{{ labels.buy_label }}:{{ buy_count }} 🟡{{ labels.watch_label }}:{{ hold_count }} 🔴{{ labels.sell_label }}:{{ sell_count }}
|
||||
|
||||
## 📊 分析结果摘要
|
||||
## 📊 {{ labels.summary_heading }}
|
||||
|
||||
{% for e in enriched %}
|
||||
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.result.operation_advice }} | 评分 {{ e.result.sentiment_score }} | {{ e.result.trend_prediction }}
|
||||
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
@@ -22,111 +22,111 @@
|
||||
## {{ e.signal_emoji }} {{ e.stock_name }} ({{ result.code }})
|
||||
|
||||
{% if intel %}
|
||||
### 📰 重要信息速览
|
||||
### 📰 {{ labels.info_heading }}
|
||||
|
||||
{% if intel.get('sentiment_summary') %}
|
||||
**💭 舆情情绪**: {{ intel.sentiment_summary }}
|
||||
**💭 {{ labels.sentiment_summary_label }}**: {{ intel.sentiment_summary }}
|
||||
{% endif %}
|
||||
{% if intel.get('earnings_outlook') %}
|
||||
**📊 业绩预期**: {{ intel.earnings_outlook }}
|
||||
**📊 {{ labels.earnings_outlook_label }}**: {{ intel.earnings_outlook }}
|
||||
{% endif %}
|
||||
{% if intel.get('risk_alerts') %}
|
||||
|
||||
**🚨 风险警报**:
|
||||
**🚨 {{ labels.risk_alerts_label }}**:
|
||||
{% for alert in intel.risk_alerts %}
|
||||
- {{ alert }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if intel.get('positive_catalysts') %}
|
||||
|
||||
**✨ 利好催化**:
|
||||
**✨ {{ labels.positive_catalysts_label }}**:
|
||||
{% for cat in intel.positive_catalysts %}
|
||||
- {{ cat }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if intel.get('latest_news') %}
|
||||
|
||||
**📢 最新动态**: {{ intel.latest_news }}
|
||||
**📢 {{ labels.latest_news_label }}**: {{ intel.latest_news }}
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
### 📌 核心结论
|
||||
### 📌 {{ labels.core_conclusion_heading }}
|
||||
|
||||
**{{ e.signal_emoji }} {{ e.signal_text }}** | {{ result.trend_prediction }}
|
||||
**{{ e.signal_emoji }} {{ e.signal_text }}** | {{ localize_trend_prediction(result.trend_prediction, report_language) }}
|
||||
|
||||
> **一句话决策**: {{ core.get('one_sentence', result.analysis_summary) }}
|
||||
> **{{ labels.one_sentence_label }}**: {{ core.get('one_sentence', result.analysis_summary) }}
|
||||
|
||||
⏰ **时效性**: {{ core.get('time_sensitivity', '本周内') }}
|
||||
⏰ **{{ labels.time_sensitivity_label }}**: {{ core.get('time_sensitivity', labels.default_time_sensitivity) }}
|
||||
|
||||
{% set pos_advice = core.get('position_advice', {}) %}
|
||||
{% if pos_advice %}
|
||||
| 持仓情况 | 操作建议 |
|
||||
| {{ labels.position_status_label }} | {{ labels.action_advice_label }} |
|
||||
|---------|---------|
|
||||
| 🆕 **空仓者** | {{ pos_advice.get('no_position', result.operation_advice) }} |
|
||||
| 💼 **持仓者** | {{ pos_advice.get('has_position', '继续持有') }} |
|
||||
| 🆕 **{{ labels.no_position_label }}** | {{ pos_advice.get('no_position', localize_operation_advice(result.operation_advice, report_language)) }} |
|
||||
| 💼 **{{ labels.has_position_label }}** | {{ pos_advice.get('has_position', labels.continue_holding) }} |
|
||||
{% endif %}
|
||||
|
||||
{{ market_snapshot(result) }}
|
||||
|
||||
{% if data_persp %}
|
||||
### 📊 数据透视
|
||||
### 📊 {{ labels.data_perspective_heading }}
|
||||
|
||||
{% set trend_data = data_persp.get('trend_status', {}) %}
|
||||
{% if trend_data %}
|
||||
**均线排列**: {{ trend_data.get('ma_alignment', 'N/A') }} | 多头排列: {{ '✅ 是' if trend_data.get('is_bullish') else '❌ 否' }} | 趋势强度: {{ trend_data.get('trend_score', 'N/A') }}/100
|
||||
**{{ labels.ma_alignment_label }}**: {{ trend_data.get('ma_alignment', 'N/A') }} | {{ labels.bullish_alignment_label }}: {{ '✅ ' ~ labels.yes_label if trend_data.get('is_bullish') else '❌ ' ~ labels.no_label }} | {{ labels.trend_strength_label }}: {{ trend_data.get('trend_score', 'N/A') }}/100
|
||||
{% endif %}
|
||||
|
||||
{% set price_data = data_persp.get('price_position', {}) %}
|
||||
{% if price_data %}
|
||||
| 价格指标 | 数值 |
|
||||
| {{ labels.price_metrics_label }} | {{ labels.current_price_label }} |
|
||||
|---------|------|
|
||||
| 当前价 | {{ price_data.get('current_price', 'N/A') }} |
|
||||
| MA5 | {{ price_data.get('ma5', 'N/A') }} |
|
||||
| MA10 | {{ price_data.get('ma10', 'N/A') }} |
|
||||
| MA20 | {{ price_data.get('ma20', 'N/A') }} |
|
||||
| 乖离率(MA5) | {{ price_data.get('bias_ma5', 'N/A') }}% {{ price_data.get('bias_status', 'N/A') }} |
|
||||
| 支撑位 | {{ price_data.get('support_level', 'N/A') }} |
|
||||
| 压力位 | {{ price_data.get('resistance_level', 'N/A') }} |
|
||||
| {{ labels.current_price_label }} | {{ price_data.get('current_price', 'N/A') }} |
|
||||
| {{ labels.ma5_label }} | {{ price_data.get('ma5', 'N/A') }} |
|
||||
| {{ labels.ma10_label }} | {{ price_data.get('ma10', 'N/A') }} |
|
||||
| {{ labels.ma20_label }} | {{ price_data.get('ma20', 'N/A') }} |
|
||||
| {{ labels.bias_ma5_label }} | {{ price_data.get('bias_ma5', 'N/A') }}% {{ price_data.get('bias_status', 'N/A') }} |
|
||||
| {{ labels.support_level_label }} | {{ price_data.get('support_level', 'N/A') }} |
|
||||
| {{ labels.resistance_level_label }} | {{ price_data.get('resistance_level', 'N/A') }} |
|
||||
{% endif %}
|
||||
|
||||
{% set vol_data = data_persp.get('volume_analysis', {}) %}
|
||||
{% if vol_data %}
|
||||
**量能**: 量比 {{ vol_data.get('volume_ratio', 'N/A') }} ({{ vol_data.get('volume_status', '') }}) | 换手率 {{ vol_data.get('turnover_rate', 'N/A') }}%
|
||||
**{{ labels.volume_label }}**: {{ labels.volume_ratio_label }} {{ vol_data.get('volume_ratio', 'N/A') }} ({{ vol_data.get('volume_status', '') }}) | {{ labels.turnover_rate_label }} {{ vol_data.get('turnover_rate', 'N/A') }}%
|
||||
💡 *{{ vol_data.get('volume_meaning', '') }}*
|
||||
{% endif %}
|
||||
|
||||
{% set chip_data = data_persp.get('chip_structure', {}) %}
|
||||
{% if chip_data %}
|
||||
**筹码**: 获利比例 {{ chip_data.get('profit_ratio', 'N/A') }} | 平均成本 {{ chip_data.get('avg_cost', 'N/A') }} | 集中度 {{ chip_data.get('concentration', 'N/A') }} {{ chip_data.get('chip_health', 'N/A') }}
|
||||
**{{ labels.chip_label }}**: {{ chip_data.get('profit_ratio', 'N/A') }} | {{ chip_data.get('avg_cost', 'N/A') }} | {{ chip_data.get('concentration', 'N/A') }} {{ localize_chip_health(chip_data.get('chip_health', 'N/A'), report_language) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if battle %}
|
||||
### 🎯 作战计划
|
||||
### 🎯 {{ labels.battle_plan_heading }}
|
||||
|
||||
{% set sniper = battle.get('sniper_points', {}) %}
|
||||
{% if sniper %}
|
||||
**📍 狙击点位**
|
||||
**📍 {{ labels.action_points_heading }}**
|
||||
|
||||
| 点位类型 | 价格 |
|
||||
| {{ labels.action_points_heading }} | {{ labels.current_price_label }} |
|
||||
|---------|------|
|
||||
| 🎯 理想买入点 | {{ clean_sniper(sniper.get('ideal_buy')) }} |
|
||||
| 🔵 次优买入点 | {{ clean_sniper(sniper.get('secondary_buy')) }} |
|
||||
| 🛑 止损位 | {{ clean_sniper(sniper.get('stop_loss')) }} |
|
||||
| 🎊 目标位 | {{ clean_sniper(sniper.get('take_profit')) }} |
|
||||
| 🎯 {{ labels.ideal_buy_label }} | {{ clean_sniper(sniper.get('ideal_buy')) }} |
|
||||
| 🔵 {{ labels.secondary_buy_label }} | {{ clean_sniper(sniper.get('secondary_buy')) }} |
|
||||
| 🛑 {{ labels.stop_loss_label }} | {{ clean_sniper(sniper.get('stop_loss')) }} |
|
||||
| 🎊 {{ labels.take_profit_label }} | {{ clean_sniper(sniper.get('take_profit')) }} |
|
||||
{% endif %}
|
||||
|
||||
{% set position = battle.get('position_strategy', {}) %}
|
||||
{% if position %}
|
||||
**💰 仓位建议**: {{ position.get('suggested_position', 'N/A') }}
|
||||
- 建仓策略: {{ position.get('entry_plan', 'N/A') }}
|
||||
- 风控策略: {{ position.get('risk_control', 'N/A') }}
|
||||
**💰 {{ labels.suggested_position_label }}**: {{ position.get('suggested_position', 'N/A') }}
|
||||
- {{ labels.entry_plan_label }}: {{ position.get('entry_plan', 'N/A') }}
|
||||
- {{ labels.risk_control_label }}: {{ position.get('risk_control', 'N/A') }}
|
||||
{% endif %}
|
||||
|
||||
{% set checklist = battle.get('action_checklist', []) %}
|
||||
{% if checklist %}
|
||||
**✅ 检查清单**
|
||||
**✅ {{ labels.checklist_heading }}**
|
||||
{% for item in checklist %}
|
||||
- {{ item }}
|
||||
{% endfor %}
|
||||
@@ -144,11 +144,11 @@
|
||||
|
||||
{% set hist = history_by_code.get(result.code, []) %}
|
||||
{% if hist %}
|
||||
### 📜 历史信号对比
|
||||
| 时间 | 评分 | 建议 | 趋势 |
|
||||
### 📜 {{ labels.history_compare_heading }}
|
||||
| {{ labels.time_label }} | {{ labels.score_label }} | {{ labels.advice_label }} | {{ labels.trend_label }} |
|
||||
|------|------|------|------|
|
||||
{% for h in hist %}
|
||||
| {{ h.created_at[:16] if h.created_at else 'N/A' }} | {{ h.sentiment_score or 'N/A' }} | {{ h.operation_advice or 'N/A' }} | {{ h.trend_prediction or 'N/A' }} |
|
||||
| {{ h.created_at[:16] if h.created_at else 'N/A' }} | {{ h.sentiment_score or 'N/A' }} | {{ localize_operation_advice(h.operation_advice or 'N/A', report_language) }} | {{ localize_trend_prediction(h.trend_prediction or 'N/A', report_language) }} |
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -156,4 +156,4 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
*报告生成时间:{{ report_timestamp }}*
|
||||
*{{ labels.generated_at_label }}:{{ report_timestamp }}*
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
## 🎯 {{ report_date }} 决策仪表盘
|
||||
## 🎯 {{ report_date }} {{ labels.dashboard_title }}
|
||||
|
||||
> {{ results|length }}只股票 | 🟢买入:{{ buy_count }} 🟡观望:{{ hold_count }} 🔴卖出:{{ sell_count }}
|
||||
> {{ results|length }} {{ labels.stock_unit }} | 🟢{{ labels.buy_label }}:{{ buy_count }} 🟡{{ labels.watch_label }}:{{ hold_count }} 🔴{{ labels.sell_label }}:{{ sell_count }}
|
||||
|
||||
{% if summary_only %}
|
||||
**📊 分析结果摘要**
|
||||
**📊 {{ labels.summary_heading }}**
|
||||
{% for e in enriched %}
|
||||
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.result.operation_advice }} | 评分 {{ e.result.sentiment_score }} | {{ e.result.trend_prediction }}
|
||||
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for e in enriched %}
|
||||
@@ -23,21 +23,21 @@
|
||||
{% endif %}
|
||||
|
||||
{% if intel.get('earnings_outlook') %}
|
||||
📊 业绩: {{ intel.earnings_outlook[:60] }}
|
||||
📊 {{ labels.earnings_outlook_label }}: {{ intel.earnings_outlook[:60] }}
|
||||
{% endif %}
|
||||
{% if intel.get('sentiment_summary') %}
|
||||
💭 舆情: {{ intel.sentiment_summary[:50] }}
|
||||
💭 {{ labels.sentiment_summary_label }}: {{ intel.sentiment_summary[:50] }}
|
||||
{% endif %}
|
||||
|
||||
{% if intel.get('risk_alerts') %}
|
||||
🚨 **风险**:
|
||||
🚨 **{{ labels.risk_alerts_label }}**:
|
||||
{% for risk in intel.risk_alerts[:2] %}
|
||||
• {{ risk[:50] }}{{ '...' if risk|length > 50 else '' }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if intel.get('positive_catalysts') %}
|
||||
✨ **利好**:
|
||||
✨ **{{ labels.positive_catalysts_label }}**:
|
||||
{% for cat in intel.positive_catalysts[:2] %}
|
||||
• {{ cat[:50] }}{{ '...' if cat|length > 50 else '' }}
|
||||
{% endfor %}
|
||||
@@ -46,26 +46,26 @@
|
||||
{% set sniper = battle.get('sniper_points', {}) if battle else {} %}
|
||||
{% if sniper.ideal_buy or sniper.stop_loss or sniper.take_profit %}
|
||||
{% set ns = namespace(parts=[]) %}
|
||||
{% if sniper.ideal_buy %}{% set ns.parts = ns.parts + ['🎯买点:' ~ (sniper.ideal_buy|string)[:15]] %}{% endif %}
|
||||
{% if sniper.stop_loss %}{% set ns.parts = ns.parts + ['🛑止损:' ~ (sniper.stop_loss|string)[:15]] %}{% endif %}
|
||||
{% if sniper.take_profit %}{% set ns.parts = ns.parts + ['🎊目标:' ~ (sniper.take_profit|string)[:15]] %}{% endif %}
|
||||
{% if sniper.ideal_buy %}{% set ns.parts = ns.parts + ['🎯' ~ labels.ideal_buy_label ~ ':' ~ (sniper.ideal_buy|string)[:15]] %}{% endif %}
|
||||
{% if sniper.stop_loss %}{% set ns.parts = ns.parts + ['🛑' ~ labels.stop_loss_label ~ ':' ~ (sniper.stop_loss|string)[:15]] %}{% endif %}
|
||||
{% if sniper.take_profit %}{% set ns.parts = ns.parts + ['🎊' ~ labels.take_profit_label ~ ':' ~ (sniper.take_profit|string)[:15]] %}{% endif %}
|
||||
{{ ns.parts|join(' | ') }}
|
||||
{% endif %}
|
||||
|
||||
{% set pos_advice = core.get('position_advice', {}) if core else {} %}
|
||||
{% if pos_advice %}
|
||||
{% if pos_advice.get('no_position') %}
|
||||
🆕 空仓者: {{ pos_advice.no_position[:50] }}
|
||||
🆕 {{ labels.no_position_label }}: {{ pos_advice.no_position[:50] }}
|
||||
{% endif %}
|
||||
{% if pos_advice.get('has_position') %}
|
||||
💼 持仓者: {{ pos_advice.has_position[:50] }}
|
||||
💼 {{ labels.has_position_label }}: {{ pos_advice.has_position[:50] }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% set checklist = battle.get('action_checklist', []) if battle else [] %}
|
||||
{% set fc = failed_checks(checklist) %}
|
||||
{% if fc %}
|
||||
**检查未通过项**:
|
||||
**{{ labels.failed_checks_heading }}**:
|
||||
{% for check in fc[:3] %}
|
||||
{{ check[:40] }}
|
||||
{% endfor %}
|
||||
@@ -75,4 +75,4 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
*生成时间: {{ report_timestamp[11:16] }}*
|
||||
*{{ labels.report_time_label }}: {{ report_timestamp[11:16] }}*
|
||||
|
||||
@@ -76,6 +76,33 @@ class AnalysisApiContractTestCase(unittest.TestCase):
|
||||
|
||||
self.assertEqual(result["report"]["meta"]["report_type"], "full")
|
||||
|
||||
def test_build_analysis_response_localizes_placeholder_stock_name_for_english(self) -> None:
|
||||
service = AnalysisService()
|
||||
result = service._build_analysis_response(
|
||||
SimpleNamespace(
|
||||
code="AAPL",
|
||||
name="股票AAPL",
|
||||
current_price=180.35,
|
||||
change_pct=1.04,
|
||||
model_used="test-model",
|
||||
analysis_summary="Momentum remains constructive.",
|
||||
operation_advice="Buy",
|
||||
trend_prediction="Bullish",
|
||||
sentiment_score=78,
|
||||
news_summary="news",
|
||||
technical_analysis="tech",
|
||||
fundamental_analysis="fundamental",
|
||||
risk_warning="risk",
|
||||
report_language="en",
|
||||
get_sniper_points=lambda: {},
|
||||
),
|
||||
"q1",
|
||||
report_type="full",
|
||||
)
|
||||
|
||||
self.assertEqual(result["stock_name"], "Unnamed Stock")
|
||||
self.assertEqual(result["report"]["meta"]["stock_name"], "Unnamed Stock")
|
||||
|
||||
def test_build_analysis_report_extracts_fundamental_fields_from_snapshot(self) -> None:
|
||||
if _build_analysis_report is None:
|
||||
self.skipTest("analysis endpoint helpers unavailable in this environment")
|
||||
@@ -108,6 +135,26 @@ class AnalysisApiContractTestCase(unittest.TestCase):
|
||||
self.assertEqual(report.details.financial_report["report_date"], "2025-12-31")
|
||||
self.assertEqual(report.details.dividend_metrics["ttm_dividend_yield_pct"], 2.5)
|
||||
|
||||
def test_build_analysis_report_preserves_report_language(self) -> None:
|
||||
if _build_analysis_report is None:
|
||||
self.skipTest("analysis endpoint helpers unavailable in this environment")
|
||||
|
||||
report = _build_analysis_report(
|
||||
report_data={
|
||||
"meta": {"report_language": "en"},
|
||||
"summary": {"analysis_summary": "English output"},
|
||||
"strategy": {},
|
||||
"details": {},
|
||||
},
|
||||
query_id="q1",
|
||||
stock_code="AAPL",
|
||||
stock_name="Apple",
|
||||
context_snapshot={"report_language": "zh"},
|
||||
fallback_fundamental_payload=None,
|
||||
)
|
||||
|
||||
self.assertEqual(report.meta.report_language, "en")
|
||||
|
||||
def test_load_sync_fundamental_sources_uses_query_and_code_for_fallback(self) -> None:
|
||||
if _load_sync_fundamental_sources is None:
|
||||
self.skipTest("analysis endpoint helpers unavailable in this environment")
|
||||
|
||||
@@ -360,6 +360,154 @@ class AnalysisHistoryTestCase(unittest.TestCase):
|
||||
self.assertIsNone(report.details.financial_report)
|
||||
self.assertIsNone(report.details.dividend_metrics)
|
||||
|
||||
def test_history_markdown_localizes_english_report_and_placeholder_name(self) -> None:
|
||||
"""History markdown should preserve report_language for English reports."""
|
||||
result = AnalysisResult(
|
||||
code="AAPL",
|
||||
name="股票AAPL",
|
||||
sentiment_score=78,
|
||||
trend_prediction="Bullish",
|
||||
operation_advice="Buy",
|
||||
analysis_summary="Momentum remains constructive.",
|
||||
report_language="en",
|
||||
dashboard={
|
||||
"core_conclusion": {
|
||||
"one_sentence": "Favor buying on pullbacks.",
|
||||
"position_advice": {
|
||||
"no_position": "Open a starter position.",
|
||||
"has_position": "Hold and trail the stop.",
|
||||
},
|
||||
},
|
||||
"intelligence": {
|
||||
"risk_alerts": [],
|
||||
},
|
||||
"battle_plan": {
|
||||
"sniper_points": {
|
||||
"ideal_buy": "180-182",
|
||||
"stop_loss": "172",
|
||||
"take_profit": "195",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
saved = self.db.save_analysis_history(
|
||||
result=result,
|
||||
query_id="query_english_markdown_001",
|
||||
report_type="full",
|
||||
news_content="news",
|
||||
context_snapshot=None,
|
||||
save_snapshot=False,
|
||||
)
|
||||
self.assertEqual(saved, 1)
|
||||
|
||||
with self.db.get_session() as session:
|
||||
row = session.query(AnalysisHistory).filter(
|
||||
AnalysisHistory.query_id == "query_english_markdown_001"
|
||||
).first()
|
||||
if row is None:
|
||||
self.fail("未找到保存的历史记录")
|
||||
record_id = row.id
|
||||
|
||||
markdown = HistoryService(self.db).get_markdown_report(str(record_id))
|
||||
|
||||
self.assertIsNotNone(markdown)
|
||||
self.assertIn("Stock Analysis Report", markdown)
|
||||
self.assertIn("Core Conclusion", markdown)
|
||||
self.assertIn("Unnamed Stock (AAPL)", markdown)
|
||||
self.assertNotIn("核心结论", markdown)
|
||||
|
||||
def test_history_detail_localizes_english_summary_fields(self) -> None:
|
||||
"""History detail should localize summary enums for English reports."""
|
||||
if get_history_detail is None:
|
||||
self.skipTest("fastapi is not installed in this test environment")
|
||||
|
||||
result = AnalysisResult(
|
||||
code="AAPL",
|
||||
name="股票AAPL",
|
||||
sentiment_score=78,
|
||||
trend_prediction="看多",
|
||||
operation_advice="买入",
|
||||
analysis_summary="Momentum remains constructive.",
|
||||
report_language="en",
|
||||
)
|
||||
|
||||
saved = self.db.save_analysis_history(
|
||||
result=result,
|
||||
query_id="query_english_detail_001",
|
||||
report_type="full",
|
||||
news_content="news",
|
||||
context_snapshot=None,
|
||||
save_snapshot=False,
|
||||
)
|
||||
self.assertEqual(saved, 1)
|
||||
|
||||
with self.db.get_session() as session:
|
||||
row = session.query(AnalysisHistory).filter(
|
||||
AnalysisHistory.query_id == "query_english_detail_001"
|
||||
).first()
|
||||
if row is None:
|
||||
self.fail("未找到保存的历史记录")
|
||||
record_id = row.id
|
||||
|
||||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||||
|
||||
self.assertEqual(report.meta.report_language, "en")
|
||||
self.assertEqual(report.meta.stock_name, "Unnamed Stock")
|
||||
self.assertEqual(report.summary.operation_advice, "Buy")
|
||||
self.assertEqual(report.summary.trend_prediction, "Bullish")
|
||||
self.assertEqual(report.summary.sentiment_label, "Bullish")
|
||||
|
||||
def test_history_markdown_uses_safe_bias_emoji_for_english_status(self) -> None:
|
||||
"""English bias status should keep the correct non-risk emoji in markdown."""
|
||||
result = AnalysisResult(
|
||||
code="AAPL",
|
||||
name="股票AAPL",
|
||||
sentiment_score=80,
|
||||
trend_prediction="Bullish",
|
||||
operation_advice="Buy",
|
||||
analysis_summary="Momentum remains constructive.",
|
||||
report_language="en",
|
||||
dashboard={
|
||||
"data_perspective": {
|
||||
"price_position": {
|
||||
"current_price": 190.5,
|
||||
"ma5": 188.0,
|
||||
"ma10": 184.5,
|
||||
"ma20": 179.2,
|
||||
"bias_ma5": 1.33,
|
||||
"bias_status": "Safe",
|
||||
"support_level": 184.5,
|
||||
"resistance_level": 195.0,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
saved = self.db.save_analysis_history(
|
||||
result=result,
|
||||
query_id="query_english_markdown_bias_001",
|
||||
report_type="full",
|
||||
news_content="news",
|
||||
context_snapshot=None,
|
||||
save_snapshot=False,
|
||||
)
|
||||
self.assertEqual(saved, 1)
|
||||
|
||||
with self.db.get_session() as session:
|
||||
row = session.query(AnalysisHistory).filter(
|
||||
AnalysisHistory.query_id == "query_english_markdown_bias_001"
|
||||
).first()
|
||||
if row is None:
|
||||
self.fail("未找到保存的历史记录")
|
||||
record_id = row.id
|
||||
|
||||
markdown = HistoryService(self.db).get_markdown_report(str(record_id))
|
||||
|
||||
self.assertIsNotNone(markdown)
|
||||
self.assertIn("✅Safe", markdown)
|
||||
self.assertNotIn("🚨Safe", markdown)
|
||||
|
||||
def test_delete_analysis_history_records_also_cleans_backtests(self) -> None:
|
||||
"""删除历史记录时应一并清理关联回测结果。"""
|
||||
record_id = self._save_history("query_delete_001")
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"""Tests for backward-compatible config env aliases and TickFlow loading."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.config import Config
|
||||
@@ -119,6 +121,57 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase):
|
||||
self.assertFalse(config.schedule_run_immediately)
|
||||
self.assertTrue(config.run_immediately)
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_report_language_prefers_preexisting_process_env_over_env_file(
|
||||
self,
|
||||
_mock_parse_yaml,
|
||||
_mock_setup_env,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
env_path = Path(temp_dir) / ".env"
|
||||
env_path.write_text("REPORT_LANGUAGE=zh\n", encoding="utf-8")
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"ENV_FILE": str(env_path),
|
||||
"REPORT_LANGUAGE": "en",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
config = Config._load_from_env()
|
||||
|
||||
self.assertEqual(config.report_language, "en")
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_report_language_uses_env_file_when_process_env_is_absent(
|
||||
self,
|
||||
_mock_parse_yaml,
|
||||
_mock_setup_env,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
env_path = Path(temp_dir) / ".env"
|
||||
env_path.write_text("REPORT_LANGUAGE=en\n", encoding="utf-8")
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"ENV_FILE": str(env_path),
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
config = Config._load_from_env()
|
||||
|
||||
self.assertEqual(config.report_language, "en")
|
||||
|
||||
def test_parse_report_language_accepts_known_alias_without_warning(self) -> None:
|
||||
with self.assertNoLogs("src.config", level="WARNING"):
|
||||
parsed = Config._parse_report_language("zh-cn")
|
||||
|
||||
self.assertEqual(parsed, "zh")
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_invalid_numeric_env_values_fall_back_to_defaults(
|
||||
|
||||
@@ -220,6 +220,108 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
|
||||
self.assertIn("贵州茅台", out)
|
||||
self.assertIn("600519", out)
|
||||
|
||||
@mock.patch("src.notification.get_config")
|
||||
def test_generate_dashboard_report_localizes_english_fallback(self, mock_get_config: mock.MagicMock):
|
||||
mock_get_config.return_value = _make_config(report_renderer_enabled=False, report_language="en")
|
||||
service = NotificationService()
|
||||
result = AnalysisResult(
|
||||
code="AAPL",
|
||||
name="Apple",
|
||||
sentiment_score=78,
|
||||
trend_prediction="Bullish",
|
||||
operation_advice="Buy",
|
||||
analysis_summary="Momentum remains constructive.",
|
||||
decision_type="buy",
|
||||
report_language="en",
|
||||
dashboard={
|
||||
"core_conclusion": {
|
||||
"one_sentence": "Favor buying on pullbacks.",
|
||||
"position_advice": {
|
||||
"no_position": "Open a starter position.",
|
||||
"has_position": "Hold and trail the stop.",
|
||||
},
|
||||
},
|
||||
"battle_plan": {
|
||||
"sniper_points": {
|
||||
"ideal_buy": "180-182",
|
||||
"stop_loss": "172",
|
||||
"take_profit": "195",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
out = service.generate_dashboard_report([result], report_date="2026-03-18")
|
||||
|
||||
self.assertIn("Decision Dashboard", out)
|
||||
self.assertIn("Summary", out)
|
||||
self.assertIn("Action Levels", out)
|
||||
self.assertIn("Buy", out)
|
||||
|
||||
@mock.patch("src.notification.get_config")
|
||||
def test_generate_dashboard_report_localizes_english_no_dashboard_fallback(
|
||||
self, mock_get_config: mock.MagicMock
|
||||
):
|
||||
mock_get_config.return_value = _make_config(report_renderer_enabled=False, report_language="en")
|
||||
service = NotificationService()
|
||||
result = AnalysisResult(
|
||||
code="AAPL",
|
||||
name="Apple",
|
||||
sentiment_score=61,
|
||||
trend_prediction="看多",
|
||||
operation_advice="持有",
|
||||
analysis_summary="Wait for confirmation.",
|
||||
report_language="en",
|
||||
buy_reason="Momentum remains constructive.",
|
||||
risk_warning="Watch for a failed breakout.",
|
||||
ma_analysis="Price remains above MA20.",
|
||||
volume_analysis="Volume is steady.",
|
||||
news_summary="Product cycle remains supportive.",
|
||||
)
|
||||
|
||||
out = service.generate_dashboard_report([result], report_date="2026-03-19")
|
||||
|
||||
self.assertIn("Rationale", out)
|
||||
self.assertIn("Risk Warning", out)
|
||||
self.assertIn("Technicals", out)
|
||||
self.assertIn("Moving Averages", out)
|
||||
self.assertIn("Volume", out)
|
||||
self.assertIn("News Flow", out)
|
||||
self.assertNotIn("操作理由", out)
|
||||
self.assertNotIn("风险提示", out)
|
||||
self.assertNotIn("技术面", out)
|
||||
self.assertNotIn("消息面", out)
|
||||
|
||||
@mock.patch("src.notification.get_config")
|
||||
def test_generate_single_stock_report_localizes_english_fallback(self, mock_get_config: mock.MagicMock):
|
||||
mock_get_config.return_value = _make_config(report_renderer_enabled=False, report_language="en")
|
||||
service = NotificationService()
|
||||
result = AnalysisResult(
|
||||
code="AAPL",
|
||||
name="Apple",
|
||||
sentiment_score=65,
|
||||
trend_prediction="Sideways",
|
||||
operation_advice="Hold",
|
||||
analysis_summary="Wait for a cleaner breakout.",
|
||||
report_language="en",
|
||||
dashboard={
|
||||
"core_conclusion": {"one_sentence": "Wait for confirmation."},
|
||||
"battle_plan": {
|
||||
"sniper_points": {
|
||||
"ideal_buy": "190",
|
||||
"stop_loss": "182",
|
||||
"take_profit": "205",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
out = service.generate_single_stock_report(result)
|
||||
|
||||
self.assertIn("Core Conclusion", out)
|
||||
self.assertIn("Action Levels", out)
|
||||
self.assertIn("Hold", out)
|
||||
|
||||
@mock.patch("src.notification.get_config")
|
||||
def test_history_compare_context_uses_cache(self, mock_get_config: mock.MagicMock):
|
||||
mock_get_config.return_value = _make_config(report_history_compare_n=3)
|
||||
|
||||
50
tests/test_report_language.py
Normal file
50
tests/test_report_language.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for report language helpers."""
|
||||
|
||||
import unittest
|
||||
|
||||
from src.report_language import (
|
||||
get_bias_status_emoji,
|
||||
get_localized_stock_name,
|
||||
get_sentiment_label,
|
||||
get_signal_level,
|
||||
localize_bias_status,
|
||||
)
|
||||
|
||||
|
||||
class ReportLanguageTestCase(unittest.TestCase):
|
||||
def test_get_signal_level_handles_compound_sell_advice(self) -> None:
|
||||
signal_text, emoji, signal_tag = get_signal_level("卖出/观望", 60, "zh")
|
||||
|
||||
self.assertEqual(signal_text, "卖出")
|
||||
self.assertEqual(emoji, "🔴")
|
||||
self.assertEqual(signal_tag, "sell")
|
||||
|
||||
def test_get_signal_level_handles_compound_buy_advice_in_english(self) -> None:
|
||||
signal_text, emoji, signal_tag = get_signal_level("Buy / Watch", 40, "en")
|
||||
|
||||
self.assertEqual(signal_text, "Buy")
|
||||
self.assertEqual(emoji, "🟢")
|
||||
self.assertEqual(signal_tag, "buy")
|
||||
|
||||
def test_get_localized_stock_name_replaces_placeholder_for_english(self) -> None:
|
||||
self.assertEqual(
|
||||
get_localized_stock_name("股票AAPL", "AAPL", "en"),
|
||||
"Unnamed Stock",
|
||||
)
|
||||
|
||||
def test_get_sentiment_label_preserves_higher_band_thresholds(self) -> None:
|
||||
self.assertEqual(get_sentiment_label(80, "en"), "Very Bullish")
|
||||
self.assertEqual(get_sentiment_label(60, "en"), "Bullish")
|
||||
self.assertEqual(get_sentiment_label(40, "zh"), "中性")
|
||||
self.assertEqual(get_sentiment_label(20, "zh"), "悲观")
|
||||
|
||||
def test_bias_status_helpers_support_english_values(self) -> None:
|
||||
self.assertEqual(localize_bias_status("Safe", "en"), "Safe")
|
||||
self.assertEqual(localize_bias_status("警戒", "en"), "Caution")
|
||||
self.assertEqual(get_bias_status_emoji("Safe"), "✅")
|
||||
self.assertEqual(get_bias_status_emoji("Caution"), "⚠️")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,6 +28,7 @@ def _make_result(
|
||||
analysis_summary: str = "稳健",
|
||||
decision_type: str = "hold",
|
||||
dashboard: dict = None,
|
||||
report_language: str = "zh",
|
||||
) -> AnalysisResult:
|
||||
if dashboard is None:
|
||||
dashboard = {
|
||||
@@ -44,6 +45,7 @@ def _make_result(
|
||||
analysis_summary=analysis_summary,
|
||||
decision_type=decision_type,
|
||||
dashboard=dashboard,
|
||||
report_language=report_language,
|
||||
)
|
||||
|
||||
|
||||
@@ -82,6 +84,51 @@ class TestReportRenderer(unittest.TestCase):
|
||||
self.assertIn("决策简报", out)
|
||||
self.assertIn("贵州茅台", out)
|
||||
|
||||
def test_render_markdown_in_english(self) -> None:
|
||||
"""Markdown renderer switches headings and summary labels for English reports."""
|
||||
r = _make_result(
|
||||
name="Kweichow Moutai",
|
||||
operation_advice="Buy",
|
||||
analysis_summary="Momentum remains constructive.",
|
||||
report_language="en",
|
||||
)
|
||||
out = render("markdown", [r], summary_only=True)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertIn("Decision Dashboard", out)
|
||||
self.assertIn("Summary", out)
|
||||
self.assertIn("Buy", out)
|
||||
|
||||
def test_render_markdown_market_snapshot_uses_template_context(self) -> None:
|
||||
"""Market snapshot macro should render localized labels with template context."""
|
||||
r = _make_result(
|
||||
code="AAPL",
|
||||
name="Apple",
|
||||
operation_advice="Buy",
|
||||
report_language="en",
|
||||
)
|
||||
r.market_snapshot = {
|
||||
"close": "180.10",
|
||||
"prev_close": "178.25",
|
||||
"open": "179.00",
|
||||
"high": "181.20",
|
||||
"low": "177.80",
|
||||
"pct_chg": "+1.04%",
|
||||
"change_amount": "1.85",
|
||||
"amplitude": "1.91%",
|
||||
"volume": "1200000",
|
||||
"amount": "215000000",
|
||||
"price": "180.35",
|
||||
"volume_ratio": "1.2",
|
||||
"turnover_rate": "0.8%",
|
||||
"source": "polygon",
|
||||
}
|
||||
|
||||
out = render("markdown", [r], summary_only=False)
|
||||
|
||||
self.assertIsNotNone(out)
|
||||
self.assertIn("Market Snapshot", out)
|
||||
self.assertIn("Volume Ratio", out)
|
||||
|
||||
def test_render_unknown_platform_returns_none(self) -> None:
|
||||
"""Unknown platform returns None (caller fallback)."""
|
||||
r = _make_result()
|
||||
|
||||
@@ -206,6 +206,10 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertEqual(agent_arch_schema["options"][1]["label"], "Multi Agent (Orchestrator)")
|
||||
self.assertEqual(agent_arch_schema["validation"]["enum"], ["single", "multi"])
|
||||
|
||||
report_language_schema = items["REPORT_LANGUAGE"]["schema"]
|
||||
self.assertEqual(report_language_schema["validation"]["enum"], ["zh", "en"])
|
||||
self.assertEqual(report_language_schema["options"][1]["value"], "en")
|
||||
|
||||
self.assertEqual(items["AGENT_ORCHESTRATOR_TIMEOUT_S"]["schema"]["default_value"], "600")
|
||||
self.assertFalse(items["AGENT_DEEP_RESEARCH_BUDGET"]["schema"]["is_editable"])
|
||||
self.assertFalse(items["AGENT_EVENT_MONITOR_ENABLED"]["schema"]["is_editable"])
|
||||
@@ -216,6 +220,12 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertFalse(validation["valid"])
|
||||
self.assertTrue(any(issue["code"] == "invalid_enum" for issue in validation["issues"]))
|
||||
|
||||
def test_validate_accepts_report_language_english(self) -> None:
|
||||
validation = self.service.validate(items=[{"key": "REPORT_LANGUAGE", "value": "en"}])
|
||||
|
||||
self.assertTrue(validation["valid"])
|
||||
self.assertEqual(validation["issues"], [])
|
||||
|
||||
@patch.object(
|
||||
Config,
|
||||
"_parse_litellm_yaml",
|
||||
|
||||
Reference in New Issue
Block a user