feat: 新增建议动作 taxonomy 字段边界 P0 (#1631)

* feat: add decision action taxonomy fields

* fix: tighten decision action fallback handling

* fix: align legacy decision action guard fallback

* fix: tighten legacy decision action word boundaries

* fix: align decision action compound parsing

* fix: clean decision action residue

* fix: align web action label contract

* fix: classify avoided sell actions as hold
This commit is contained in:
Alfred
2026-06-08 07:24:07 +08:00
committed by GitHub
parent e2fe6e3056
commit ff6494b5d7
36 changed files with 2574 additions and 155 deletions
+52
View File
@@ -70,6 +70,7 @@ from src.analysis_context_pack_overview import (
)
from src.market_phase_summary import extract_market_phase_summary, render_market_phase_summary
from src.report_language import get_localized_stock_name, normalize_report_language
from src.schemas.decision_action import build_action_fields
from src.services.name_to_code_resolver import resolve_name_to_code
from src.services.stock_code_utils import is_code_like
from src.services.task_queue import (
@@ -711,6 +712,27 @@ def _prepare_report_for_task_enrichment(
return enriched_report
def _ensure_report_action_fields(report_data: Dict[str, Any]) -> Dict[str, Any]:
enriched_report = dict(report_data)
meta = dict(enriched_report.get("meta") or {})
summary = dict(enriched_report.get("summary") or {})
details = enriched_report.get("details") if isinstance(enriched_report.get("details"), dict) else {}
raw_result = details.get("raw_result") if isinstance(details.get("raw_result"), dict) else {}
report_language = normalize_report_language(
meta.get("report_language") or raw_result.get("report_language")
)
action_fields = build_action_fields(
operation_advice=raw_result.get("operation_advice") or summary.get("operation_advice"),
explicit_action=raw_result.get("action") or summary.get("action"),
report_type=meta.get("report_type"),
report_language=report_language,
)
summary["action"] = action_fields["action"]
summary["action_label"] = action_fields["action_label"]
enriched_report["summary"] = summary
return enriched_report
def _build_task_analysis_result(task: Any) -> AnalysisResultResponse:
"""
Normalize an in-memory completed task result to the public API contract.
@@ -741,6 +763,8 @@ def _build_task_analysis_result(task: Any) -> AnalysisResultResponse:
report_data = payload.get("report")
stock_code = payload.get("stock_code")
query_id = payload.get("query_id")
report_enriched = False
if isinstance(report_data, dict) and stock_code and query_id:
context_snapshot, fundamental_snapshot = _load_sync_fundamental_sources(
query_id=query_id,
@@ -760,6 +784,7 @@ def _build_task_analysis_result(task: Any) -> AnalysisResultResponse:
fallback_fundamental_payload=fundamental_snapshot,
)
payload["report"] = report.model_dump()
report_enriched = True
except Exception as e:
logger.debug(
"enrich in-memory task report failed (fail-open): task_id=%s err=%s",
@@ -767,6 +792,9 @@ def _build_task_analysis_result(task: Any) -> AnalysisResultResponse:
e,
)
if not report_enriched and isinstance(report_data, dict):
payload["report"] = _ensure_report_action_fields(report_data)
return AnalysisResultResponse.model_validate(payload)
@@ -924,6 +952,14 @@ def get_analysis_status(task_id: str) -> TaskStatus:
sector_rankings=extracted_boards.get("sector_rankings"),
)
raw_dict = raw_result if isinstance(raw_result, dict) else {}
action_fields = build_action_fields(
operation_advice=raw_dict.get("operation_advice") or record.operation_advice,
explicit_action=raw_dict.get("action"),
report_type=getattr(record, 'report_type', None),
report_language=report_language,
)
# Build report from DB record so completed tasks return real data
report_dict = AnalysisReport(
meta=ReportMeta(
@@ -942,6 +978,8 @@ def get_analysis_status(task_id: str) -> TaskStatus:
summary=ReportSummary(
sentiment_score=record.sentiment_score,
operation_advice=record.operation_advice,
action=action_fields["action"],
action_label=action_fields["action_label"],
trend_prediction=record.trend_prediction,
analysis_summary=record.analysis_summary,
),
@@ -1090,9 +1128,23 @@ def _build_analysis_report(
market_phase_summary=market_phase_summary,
)
raw_result_data = details_data.get("raw_result") if isinstance(details_data.get("raw_result"), dict) else {}
action_fields = build_action_fields(
operation_advice=(
raw_result_data.get("operation_advice")
or details_data.get("operation_advice")
or summary_data.get("operation_advice")
),
explicit_action=raw_result_data.get("action") or details_data.get("action") or summary_data.get("action"),
report_type=meta.report_type,
report_language=report_language,
)
summary = ReportSummary(
analysis_summary=summary_data.get("analysis_summary"),
operation_advice=summary_data.get("operation_advice"),
action=action_fields["action"],
action_label=action_fields["action_label"],
trend_prediction=summary_data.get("trend_prediction"),
sentiment_score=summary_data.get("sentiment_score"),
sentiment_label=summary_data.get("sentiment_label")
+18
View File
@@ -42,6 +42,7 @@ from src.report_language import (
normalize_report_language,
)
from src.services.history_service import HistoryService, MarkdownReportGenerationError
from src.schemas.decision_action import build_action_fields
from src.utils.data_processing import (
normalize_model_used,
extract_fundamental_detail_fields,
@@ -130,6 +131,8 @@ def get_history_list(
analysis_summary=item.get("analysis_summary"),
sentiment_score=item.get("sentiment_score"),
operation_advice=item.get("operation_advice"),
action=item.get("action"),
action_label=item.get("action_label"),
current_price=item.get("current_price"),
change_pct=item.get("change_pct"),
volume_ratio=item.get("volume_ratio"),
@@ -279,6 +282,17 @@ def get_stock_bar(
record = seen[norm_code]
raw_result = parse_json_field(getattr(record, "raw_result", None))
model_used = raw_result.get("model_used") if isinstance(raw_result, dict) else None
action_fields = build_action_fields(
operation_advice=(
raw_result.get("operation_advice") if isinstance(raw_result, dict) else None
)
or record.operation_advice,
explicit_action=raw_result.get("action") if isinstance(raw_result, dict) else None,
report_type=record.report_type,
report_language=normalize_report_language(
raw_result.get("report_language") if isinstance(raw_result, dict) else None
),
)
analysis_count = db_manager.get_analysis_history_paginated(
code=HistoryService._history_code_filter_candidates(
@@ -294,6 +308,8 @@ def get_stock_bar(
report_type=record.report_type,
sentiment_score=record.sentiment_score,
operation_advice=record.operation_advice,
action=action_fields["action"],
action_label=action_fields["action_label"],
analysis_count=analysis_count,
last_analysis_time=(
record.created_at.isoformat() if record.created_at else None
@@ -413,6 +429,8 @@ def get_history_detail(
result.get("operation_advice"),
report_language,
),
action=result.get("action"),
action_label=result.get("action_label"),
trend_prediction=localize_trend_prediction(
result.get("trend_prediction"),
report_language,
+3
View File
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from api.v1.schemas.market_phase import MarketPhaseSummary
from src.schemas.decision_action import DecisionAction
class BacktestRunRequest(BaseModel):
@@ -36,6 +37,8 @@ class BacktestResultItem(BaseModel):
eval_status: str
evaluated_at: Optional[str] = None
operation_advice: Optional[str] = None
action: Optional[DecisionAction] = None
action_label: Optional[str] = None
trend_prediction: Optional[str] = None
market_phase: Optional[str] = None
market_phase_summary: Optional[MarketPhaseSummary] = None
+7
View File
@@ -14,6 +14,7 @@ from typing import Optional, List, Any, Dict, Literal
from pydantic import BaseModel, ConfigDict, Field
from api.v1.schemas.market_phase import MarketPhaseSummary
from src.schemas.decision_action import DecisionAction
class HistoryItem(BaseModel):
@@ -31,6 +32,8 @@ class HistoryItem(BaseModel):
description="情绪评分(历史数据可能超出 0-100 范围,读取时不做约束)",
)
operation_advice: Optional[str] = Field(None, description="操作建议")
action: Optional[DecisionAction] = Field(None, description="结构化建议动作 taxonomy")
action_label: Optional[str] = Field(None, description="建议动作展示标签")
current_price: Optional[float] = Field(None, description="分析时股价")
change_pct: Optional[float] = Field(None, description="分析时涨跌幅(%)")
volume_ratio: Optional[float] = Field(None, description="分析时量比")
@@ -148,6 +151,8 @@ class ReportSummary(BaseModel):
analysis_summary: Optional[str] = Field(None, description="关键结论")
operation_advice: Optional[str] = Field(None, description="操作建议")
action: Optional[DecisionAction] = Field(None, description="结构化建议动作 taxonomy")
action_label: Optional[str] = Field(None, description="建议动作展示标签")
trend_prediction: Optional[str] = Field(None, description="趋势预测")
sentiment_score: Optional[int] = Field(
None,
@@ -317,6 +322,8 @@ class StockBarItem(BaseModel):
description="最新情绪评分",
)
operation_advice: Optional[str] = Field(None, description="最新操作建议")
action: Optional[DecisionAction] = Field(None, description="结构化建议动作 taxonomy")
action_label: Optional[str] = Field(None, description="建议动作展示标签")
analysis_count: int = Field(..., description="该股票的历史分析总次数")
last_analysis_time: Optional[str] = Field(None, description="最近一次分析时间")
model_used: Optional[str] = Field(
@@ -2,11 +2,11 @@ import type React from 'react';
import { Badge } from '../common';
import type { HistoryItem } from '../../types/analysis';
import { getSentimentColor } from '../../types/analysis';
import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction';
import { formatDateTime } from '../../utils/format';
import { getMarketPhaseSummaryLabel } from '../../utils/marketPhase';
import { truncateStockName } from '../../utils/stockName';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { UiTextKey } from '../../i18n/uiText';
interface HistoryListItemProps {
item: HistoryItem;
@@ -17,26 +17,6 @@ interface HistoryListItemProps {
onClick: (recordId: number) => void;
}
const getOperationBadgeLabel = (advice: string | undefined, t: (key: UiTextKey) => string) => {
const normalized = advice?.trim();
if (!normalized) {
return t('history.sentiment');
}
if (normalized.includes('减仓')) {
return t('history.operationReduce');
}
if (normalized.includes('卖')) {
return t('history.operationSell');
}
if (normalized.includes('观望') || normalized.includes('等待')) {
return t('history.operationHold');
}
if (normalized.includes('买') || normalized.includes('布局')) {
return t('history.operationBuy');
}
return normalized.split(/[,。;、\s]/)[0] || t('history.operationAdvice');
};
export const HistoryListItem: React.FC<HistoryListItemProps> = ({
item,
isViewing,
@@ -48,6 +28,14 @@ export const HistoryListItem: React.FC<HistoryListItemProps> = ({
const { language, t } = useUiLanguage();
const sentimentColor = item.sentimentScore !== undefined ? getSentimentColor(item.sentimentScore) : null;
const stockName = item.stockName || item.stockCode;
const actionLabels = buildDecisionActionLabelMap(t);
const operationLabel = getDecisionActionLabel(
item.action,
item.actionLabel,
item.operationAdvice,
t('history.sentiment'),
actionLabels,
);
const phaseLabel = getMarketPhaseSummaryLabel(item.marketPhaseSummary, language)
?.replace('市场阶段: ', '')
.replace('市场阶段:', '')
@@ -101,7 +89,7 @@ export const HistoryListItem: React.FC<HistoryListItemProps> = ({
backgroundColor: `${sentimentColor}10`,
}}
>
{getOperationBadgeLabel(item.operationAdvice, t)} {item.sentimentScore}
{operationLabel} {item.sentimentScore}
</Badge>
)}
</div>
@@ -2,11 +2,11 @@ import type React from 'react';
import { Badge, Button } from '../common';
import type { StockBarItem as StockBarItemType } from '../../types/analysis';
import { getSentimentColor } from '../../types/analysis';
import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction';
import { formatDateTime } from '../../utils/format';
import { getMarketPhaseSummaryLabel } from '../../utils/marketPhase';
import { truncateStockName } from '../../utils/stockName';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { UiTextKey } from '../../i18n/uiText';
interface StockBarItemProps {
item: StockBarItemType;
@@ -17,16 +17,6 @@ interface StockBarItemProps {
isMarketReview?: boolean;
}
const getOperationBadgeLabel = (advice: string | undefined, t: (key: UiTextKey) => string) => {
const normalized = advice?.trim();
if (!normalized) return null;
if (normalized.includes('减仓')) return t('history.operationReduce');
if (normalized.includes('卖')) return t('history.operationSell');
if (normalized.includes('观望') || normalized.includes('等待')) return t('history.operationHold');
if (normalized.includes('买') || normalized.includes('布局')) return t('history.operationBuy');
return normalized.split(/[,。;、\s]/)[0] || t('history.operationAdvice');
};
export const StockBarItemComponent: React.FC<StockBarItemProps> = ({
item,
isViewing,
@@ -38,7 +28,14 @@ export const StockBarItemComponent: React.FC<StockBarItemProps> = ({
const { language, t } = useUiLanguage();
const sentimentColor = item.sentimentScore !== undefined ? getSentimentColor(item.sentimentScore) : null;
const stockName = item.stockName || item.stockCode;
const operationLabel = getOperationBadgeLabel(item.operationAdvice, t);
const actionLabels = buildDecisionActionLabelMap(t);
const operationLabel = getDecisionActionLabel(
item.action,
item.actionLabel,
item.operationAdvice,
null,
actionLabels,
);
const phaseLabel = getMarketPhaseSummaryLabel(item.marketPhaseSummary, language)
?.replace('市场阶段: ', '')
.replace('市场阶段:', '')
@@ -2,6 +2,12 @@ import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { AnalysisReport, HistoryItem, StockHistoryFilters, StockHistoryRange } from '../../types/analysis';
import { getSentimentColor } from '../../types/analysis';
import {
buildDecisionActionLabelMap,
getDecisionActionLabel,
getDecisionActionTone,
type DecisionActionLabelMap,
} from '../../utils/decisionAction';
import { formatDateTime } from '../../utils/format';
import { Badge, Button, Card } from '../common';
import { DashboardStateBlock } from '../dashboard';
@@ -65,32 +71,27 @@ const formatModelName = (value: string | undefined, t: (key: UiTextKey, params?:
return parts[parts.length - 1] || model;
};
const formatAdviceParts = (item: Pick<HistoryItem, 'operationAdvice' | 'trendPrediction'>): string[] => {
const parts = [item.operationAdvice?.trim(), item.trendPrediction?.trim()]
type AdviceSource = Pick<HistoryItem, 'operationAdvice' | 'trendPrediction' | 'action' | 'actionLabel'>;
const formatAdviceParts = (item: AdviceSource, actionLabels: DecisionActionLabelMap): string[] => {
const actionLabel = getDecisionActionLabel(item.action, item.actionLabel, null, null, actionLabels);
const adviceText = actionLabel || item.operationAdvice?.trim();
const parts = [actionLabel?.trim(), item.trendPrediction?.trim()]
.filter((part): part is string => Boolean(part));
if (!actionLabel && adviceText) {
return [adviceText, ...(item.trendPrediction?.trim() ? [item.trendPrediction.trim()] : [])];
}
return parts.length ? parts : ['--'];
};
const formatAdvice = (item: Pick<HistoryItem, 'operationAdvice' | 'trendPrediction'>): string =>
formatAdviceParts(item)[0];
const getAdviceVariant = (value: string): 'success' | 'warning' | 'danger' | 'default' => {
if (value.includes('买') || value.includes('多') || value.includes('持有')) {
return 'success';
}
if (value.includes('卖') || value.includes('减') || value.includes('空')) {
return 'danger';
}
if (value.includes('观望') || value.includes('震荡')) {
return 'warning';
}
return 'default';
};
const formatAdvice = (item: AdviceSource, actionLabels: DecisionActionLabelMap): string =>
formatAdviceParts(item, actionLabels)[0];
const summarizeView = (
items: HistoryItem[],
report: AnalysisReport,
t: (key: UiTextKey, params?: Record<string, string | number>) => string,
actionLabels: DecisionActionLabelMap,
currentId?: number,
) => {
const scores = items
@@ -112,11 +113,13 @@ const summarizeView = (
return {
currentScore: current?.sentimentScore ?? report.summary.sentimentScore,
currentAdvice: current
? formatAdvice(current)
? formatAdvice(current, actionLabels)
: formatAdvice({
operationAdvice: report.summary.operationAdvice,
action: report.summary.action,
actionLabel: report.summary.actionLabel,
trendPrediction: report.summary.trendPrediction,
}),
}, actionLabels),
averageScore,
latestTime: formatDateTime(items[0]?.createdAt || report.meta.createdAt),
modelSummary: modelEntries
@@ -186,9 +189,10 @@ export const StockHistoryTrendDrawer: React.FC<StockHistoryTrendDrawerProps> = (
const { t } = useUiLanguage();
const currentRecordId = report.meta.id;
const [selectedRecordId, setSelectedRecordId] = useState(currentRecordId);
const actionLabels = useMemo(() => buildDecisionActionLabelMap(t), [t]);
const summary = useMemo(
() => summarizeView(items, report, t, currentRecordId),
[currentRecordId, items, report, t],
() => summarizeView(items, report, t, actionLabels, currentRecordId),
[actionLabels, currentRecordId, items, report, t],
);
useEffect(() => {
@@ -333,11 +337,11 @@ export const StockHistoryTrendDrawer: React.FC<StockHistoryTrendDrawerProps> = (
</td>
<td className="whitespace-nowrap px-3 py-3">
<Badge
variant={getAdviceVariant(formatAdvice(item))}
variant={getDecisionActionTone(item.action, item.actionLabel, item.operationAdvice)}
size="sm"
className="shadow-none"
>
{formatAdvice(item)}
{formatAdvice(item, actionLabels)}
</Badge>
</td>
<td
@@ -77,6 +77,180 @@ describe('HistoryList', () => {
expect(onToggleItemSelection).toHaveBeenCalledWith(1);
});
it('uses structured action before legacy operation advice', () => {
render(
<HistoryList
{...baseProps}
items={[
{
...items[0],
action: 'avoid',
actionLabel: '回避',
operationAdvice: '买入',
sentimentScore: 35,
},
]}
/>,
);
expect(screen.getByText('回避 35')).toBeInTheDocument();
expect(screen.queryByText('买入 35')).not.toBeInTheDocument();
});
it('uses the unified legacy fallback for negated buy advice without structured action', () => {
render(
<HistoryList
{...baseProps}
items={[
{
...items[0],
action: null,
actionLabel: null,
operationAdvice: '不建议买入,等待确认',
sentimentScore: 28,
},
]}
/>,
);
expect(screen.getByText('回避 28')).toBeInTheDocument();
expect(screen.queryByText('买入 28')).not.toBeInTheDocument();
});
it('uses the unified legacy fallback for backend-aligned hold advice without structured action', () => {
render(
<HistoryList
{...baseProps}
items={[
{
...items[0],
action: null,
actionLabel: null,
operationAdvice: '洗盘观察',
sentimentScore: 48,
},
]}
/>,
);
expect(screen.getByText('持有 48')).toBeInTheDocument();
expect(screen.queryByText('情绪 48')).not.toBeInTheDocument();
});
it('does not render ambiguous English legacy advice as a buy action', () => {
render(
<HistoryList
{...baseProps}
items={[
{
...items[0],
action: null,
actionLabel: null,
operationAdvice: 'buy or sell',
sentimentScore: 28,
},
]}
/>,
);
expect(screen.getByText('情绪 28')).toBeInTheDocument();
expect(screen.queryByText('buy 28')).not.toBeInTheDocument();
});
it('does not render financial compound English advice as an action badge', () => {
render(
<HistoryList
{...baseProps}
items={[
{
...items[0],
action: null,
actionLabel: null,
operationAdvice: 'no buyback announced',
sentimentScore: 28,
},
{
...items[0],
id: 2,
queryId: 'q-2',
action: null,
actionLabel: null,
operationAdvice: 'no selloff risk',
sentimentScore: 31,
},
{
...items[0],
id: 3,
queryId: 'q-3',
action: null,
actionLabel: null,
operationAdvice: 'sell-off risk remains low',
sentimentScore: 33,
},
]}
/>,
);
expect(screen.getByText('情绪 28')).toBeInTheDocument();
expect(screen.getByText('情绪 31')).toBeInTheDocument();
expect(screen.getByText('情绪 33')).toBeInTheDocument();
expect(screen.queryByText('回避 28')).not.toBeInTheDocument();
expect(screen.queryByText('持有 31')).not.toBeInTheDocument();
expect(screen.queryByText('卖出 33')).not.toBeInTheDocument();
});
it('does not render Chinese financial context legacy advice as an action badge', () => {
render(
<HistoryList
{...baseProps}
items={[
{
...items[0],
action: null,
actionLabel: null,
operationAdvice: '买盘增强,继续观察',
sentimentScore: 32,
},
{
...items[0],
id: 2,
queryId: 'q-2',
action: null,
actionLabel: null,
operationAdvice: '卖压缓解,继续观察',
sentimentScore: 34,
},
]}
/>,
);
expect(screen.getByText('情绪 32')).toBeInTheDocument();
expect(screen.getByText('情绪 34')).toBeInTheDocument();
expect(screen.queryByText('买入 32')).not.toBeInTheDocument();
expect(screen.queryByText('卖出 34')).not.toBeInTheDocument();
});
it('does not render multi-guard legacy advice as an avoid or alert action', () => {
render(
<HistoryList
{...baseProps}
items={[
{
...items[0],
action: null,
actionLabel: null,
operationAdvice: 'risk alert, avoid buying',
sentimentScore: 28,
},
]}
/>,
);
expect(screen.getByText('情绪 28')).toBeInTheDocument();
expect(screen.queryByText('回避 28')).not.toBeInTheDocument();
expect(screen.queryByText('预警 28')).not.toBeInTheDocument();
});
it('toggles select-all when clicking the label text', () => {
const onToggleSelectAll = vi.fn();
@@ -44,4 +44,180 @@ describe('StockBarItemComponent', () => {
}),
).toBeInTheDocument();
});
it('uses structured action before legacy operation advice', () => {
render(
<StockBarItemComponent
item={{
...issue1600Item,
action: 'avoid',
actionLabel: '回避',
operationAdvice: '买入',
sentimentScore: 35,
}}
isViewing={false}
onClick={vi.fn()}
/>,
);
const actions = screen.getByTestId('history-card-actions');
expect(within(actions).getByText('回避 35')).toBeInTheDocument();
expect(within(actions).queryByText('买入 35')).not.toBeInTheDocument();
});
it('uses the unified legacy fallback for negated buy advice without structured action', () => {
render(
<StockBarItemComponent
item={{
...issue1600Item,
action: null,
actionLabel: null,
operationAdvice: '不建议买入,等待确认',
sentimentScore: 28,
}}
isViewing={false}
onClick={vi.fn()}
/>,
);
const actions = screen.getByTestId('history-card-actions');
expect(within(actions).getByText('回避 28')).toBeInTheDocument();
expect(within(actions).queryByText('买入 28')).not.toBeInTheDocument();
});
it('uses the unified legacy fallback for backend-aligned hold advice without structured action', () => {
render(
<StockBarItemComponent
item={{
...issue1600Item,
action: null,
actionLabel: null,
operationAdvice: '洗盘观察',
sentimentScore: 48,
}}
isViewing={false}
onClick={vi.fn()}
/>,
);
const actions = screen.getByTestId('history-card-actions');
expect(within(actions).getByText('持有 48')).toBeInTheDocument();
});
it('does not render ambiguous English legacy advice as a buy action', () => {
render(
<StockBarItemComponent
item={{
...issue1600Item,
action: null,
actionLabel: null,
operationAdvice: 'buy or sell',
sentimentScore: 28,
}}
isViewing={false}
onClick={vi.fn()}
/>,
);
const actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('buy 28')).not.toBeInTheDocument();
expect(within(actions).queryByText(/28/)).not.toBeInTheDocument();
});
it('does not render financial compound English advice as an action badge', () => {
const { rerender } = render(
<StockBarItemComponent
item={{
...issue1600Item,
action: null,
actionLabel: null,
operationAdvice: 'no selloff risk',
sentimentScore: 28,
}}
isViewing={false}
onClick={vi.fn()}
/>,
);
let actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('持有 28')).not.toBeInTheDocument();
expect(within(actions).queryByText(/28/)).not.toBeInTheDocument();
rerender(
<StockBarItemComponent
item={{
...issue1600Item,
action: null,
actionLabel: null,
operationAdvice: 'sell-off risk remains low',
sentimentScore: 31,
}}
isViewing={false}
onClick={vi.fn()}
/>,
);
actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('卖出 31')).not.toBeInTheDocument();
expect(within(actions).queryByText(/31/)).not.toBeInTheDocument();
});
it('does not render Chinese financial context legacy advice as an action badge', () => {
const { rerender } = render(
<StockBarItemComponent
item={{
...issue1600Item,
action: null,
actionLabel: null,
operationAdvice: '买盘增强,继续观察',
sentimentScore: 32,
}}
isViewing={false}
onClick={vi.fn()}
/>,
);
let actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('买入 32')).not.toBeInTheDocument();
expect(within(actions).queryByText(/32/)).not.toBeInTheDocument();
rerender(
<StockBarItemComponent
item={{
...issue1600Item,
action: null,
actionLabel: null,
operationAdvice: '卖压缓解,继续观察',
sentimentScore: 34,
}}
isViewing={false}
onClick={vi.fn()}
/>,
);
actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('卖出 34')).not.toBeInTheDocument();
expect(within(actions).queryByText(/34/)).not.toBeInTheDocument();
});
it('does not render multi-guard legacy advice as an action badge', () => {
render(
<StockBarItemComponent
item={{
...issue1600Item,
action: null,
actionLabel: null,
operationAdvice: 'risk alert, avoid buying',
sentimentScore: 28,
}}
isViewing={false}
onClick={vi.fn()}
/>,
);
const actions = screen.getByTestId('history-card-actions');
expect(within(actions).queryByText('回避 28')).not.toBeInTheDocument();
expect(within(actions).queryByText('预警 28')).not.toBeInTheDocument();
expect(within(actions).queryByText(/28/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,182 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { UiLanguageProvider } from '../../../contexts/UiLanguageContext';
import { UI_LANGUAGE_STORAGE_KEY } from '../../../utils/uiLanguage';
import { StockHistoryTrendDrawer } from '../StockHistoryTrendDrawer';
import type { AnalysisReport, HistoryItem } from '../../../types/analysis';
const report: AnalysisReport = {
meta: {
id: 1,
queryId: 'q-1',
stockCode: '600519',
stockName: '贵州茅台',
reportType: 'detailed',
createdAt: '2026-03-20T08:00:00Z',
},
summary: {
analysisSummary: '等待确认',
operationAdvice: '买入',
action: 'avoid',
actionLabel: '回避',
trendPrediction: '震荡',
sentimentScore: 35,
},
};
const items: HistoryItem[] = [
{
id: 1,
queryId: 'q-1',
stockCode: '600519',
stockName: '贵州茅台',
sentimentScore: 35,
operationAdvice: '买入',
action: 'avoid',
actionLabel: '回避',
trendPrediction: '震荡',
createdAt: '2026-03-20T08:00:00Z',
},
];
describe('StockHistoryTrendDrawer', () => {
beforeEach(() => {
window.localStorage.clear();
});
it('uses structured action in summary and rows', () => {
render(
<StockHistoryTrendDrawer
report={report}
items={items}
total={1}
hasMore={false}
isLoading={false}
isLoadingMore={false}
filters={{ range: 'all', model: 'all', sort: 'desc' }}
onClose={vi.fn()}
onRangeChange={vi.fn()}
onLoadMore={vi.fn()}
onSelectRecord={vi.fn()}
onRetry={vi.fn()}
/>,
);
expect(screen.getAllByText('回避').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('买入')).not.toBeInTheDocument();
});
it('keeps full legacy operation advice when structured action is absent', () => {
render(
<StockHistoryTrendDrawer
report={{
...report,
summary: {
...report.summary,
operationAdvice: '继续持有,等待突破',
action: null,
actionLabel: null,
},
}}
items={[
{
...items[0],
operationAdvice: '继续持有,等待突破',
action: null,
actionLabel: null,
},
]}
total={1}
hasMore={false}
isLoading={false}
isLoadingMore={false}
filters={{ range: 'all', model: 'all', sort: 'desc' }}
onClose={vi.fn()}
onRangeChange={vi.fn()}
onLoadMore={vi.fn()}
onSelectRecord={vi.fn()}
onRetry={vi.fn()}
/>,
);
expect(screen.getAllByText('继续持有,等待突破').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('持有')).not.toBeInTheDocument();
});
it('keeps multi-guard legacy advice as full text when structured action is absent', () => {
render(
<StockHistoryTrendDrawer
report={{
...report,
summary: {
...report.summary,
operationAdvice: 'risk alert, avoid buying',
action: null,
actionLabel: null,
},
}}
items={[
{
...items[0],
operationAdvice: 'risk alert, avoid buying',
action: null,
actionLabel: null,
},
]}
total={1}
hasMore={false}
isLoading={false}
isLoadingMore={false}
filters={{ range: 'all', model: 'all', sort: 'desc' }}
onClose={vi.fn()}
onRangeChange={vi.fn()}
onLoadMore={vi.fn()}
onSelectRecord={vi.fn()}
onRetry={vi.fn()}
/>,
);
expect(screen.getAllByText('risk alert, avoid buying').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('回避')).not.toBeInTheDocument();
expect(screen.queryByText('预警')).not.toBeInTheDocument();
});
it('uses localized taxonomy labels before server labels in English UI mode', () => {
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'en');
render(
<UiLanguageProvider>
<StockHistoryTrendDrawer
report={{
...report,
summary: {
...report.summary,
action: 'sell',
actionLabel: '买入',
},
}}
items={[
{
...items[0],
action: 'sell',
actionLabel: '买入',
},
]}
total={1}
hasMore={false}
isLoading={false}
isLoadingMore={false}
filters={{ range: 'all', model: 'all', sort: 'desc' }}
onClose={vi.fn()}
onRangeChange={vi.fn()}
onLoadMore={vi.fn()}
onSelectRecord={vi.fn()}
onRetry={vi.fn()}
/>
</UiLanguageProvider>,
);
expect(screen.getAllByText('Sell').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('买入')).not.toBeInTheDocument();
});
});
+16 -10
View File
@@ -154,11 +154,14 @@ const zh = {
'history.deleteRecord': '删除 {name} 历史记录',
'history.itemAria': '{name} {code} 历史记录',
'history.loading': '加载历史记录中...',
'history.operationAdvice': '建议',
'history.operationBuy': '买入',
'history.operationHold': '观望',
'history.operationReduce': '减仓',
'history.operationSell': '卖出',
'history.actionAdd': '加仓',
'history.actionAlert': '预警',
'history.actionAvoid': '回避',
'history.actionBuy': '买入',
'history.actionHold': '持有',
'history.actionReduce': '减仓',
'history.actionSell': '卖出',
'history.actionWatch': '观望',
'history.sentiment': '情绪',
'history.selectAllHistoryAria': '全选当前已加载历史记录',
'history.selectAllStockAria': '全选当前个股',
@@ -547,11 +550,14 @@ const en: Record<UiTextKey, string> = {
'history.deleteRecord': 'Delete {name} history record',
'history.itemAria': '{name} {code} history record',
'history.loading': 'Loading history...',
'history.operationAdvice': 'Advice',
'history.operationBuy': 'Buy',
'history.operationHold': 'Watch',
'history.operationReduce': 'Reduce',
'history.operationSell': 'Sell',
'history.actionAdd': 'Add',
'history.actionAlert': 'Alert',
'history.actionAvoid': 'Avoid',
'history.actionBuy': 'Buy',
'history.actionHold': 'Hold',
'history.actionReduce': 'Reduce',
'history.actionSell': 'Sell',
'history.actionWatch': 'Watch',
'history.sentiment': 'Sentiment',
'history.selectAllHistoryAria': 'Select all loaded history records',
'history.selectAllStockAria': 'Select all current stocks',
+65 -52
View File
@@ -22,6 +22,7 @@ import type {
PerformanceMetrics,
BacktestPhaseFilter,
} from '../types/backtest';
import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../utils/decisionAction';
import { getMarketPhaseSummaryLabel } from '../utils/marketPhase';
const BACKTEST_INPUT_CLASS =
@@ -220,9 +221,10 @@ const RunSummary: React.FC<{ data: BacktestRunResponse; language: UiLanguage }>
// ============ Main Page ============
const BacktestPage: React.FC = () => {
const { language } = useUiLanguage();
const { language, t } = useUiLanguage();
const text = BACKTEST_TEXT[language];
const phaseFilterOptions = BACKTEST_PHASE_FILTER_OPTIONS[language];
const actionLabels = buildDecisionActionLabelMap(t);
// Set page title
useEffect(() => {
@@ -601,58 +603,69 @@ const BacktestPage: React.FC = () => {
</tr>
</thead>
<tbody>
{results.map((row) => (
<tr
key={row.analysisHistoryId}
className="backtest-table-row"
>
<td className="backtest-table-cell backtest-table-code">
<div className="flex flex-col">
<span>{row.code}</span>
<span className="text-xs text-muted-text">{row.stockName || '--'}</span>
</div>
</td>
<td className="backtest-table-cell text-secondary-text">{row.analysisDate || '--'}</td>
<td className="backtest-table-cell text-secondary-text">{phaseLabel(row, language)}</td>
<td className="backtest-table-cell max-w-[220px] text-foreground">
{(row.trendPrediction || row.operationAdvice) ? (
<Tooltip
content={[row.trendPrediction, row.operationAdvice].filter(Boolean).join(' / ')}
focusable
>
<div className="flex flex-col gap-1">
<span className="block truncate">{row.trendPrediction || '--'}</span>
<span className="block truncate text-xs text-secondary-text">{row.operationAdvice || '--'}</span>
</div>
</Tooltip>
) : (
'--'
)}
</td>
<td className="backtest-table-cell">
<div className="flex items-center gap-2">
{actualMovementBadge(row.actualMovement, language)}
<span className={
row.actualReturnPct != null
? row.actualReturnPct > 0 ? 'text-success' : row.actualReturnPct < 0 ? 'text-danger' : 'text-secondary-text'
: 'text-muted-text'
}>
{pct(row.actualReturnPct)}
{results.map((row) => {
const actionLabel = getDecisionActionLabel(row.action, row.actionLabel, null, null, actionLabels);
const predictionParts = [actionLabel, row.trendPrediction, row.operationAdvice]
.filter((part): part is string => Boolean(part));
return (
<tr
key={row.analysisHistoryId}
className="backtest-table-row"
>
<td className="backtest-table-cell backtest-table-code">
<div className="flex flex-col">
<span>{row.code}</span>
<span className="text-xs text-muted-text">{row.stockName || '--'}</span>
</div>
</td>
<td className="backtest-table-cell text-secondary-text">{row.analysisDate || '--'}</td>
<td className="backtest-table-cell text-secondary-text">{phaseLabel(row, language)}</td>
<td className="backtest-table-cell max-w-[220px] text-foreground">
{predictionParts.length ? (
<Tooltip
content={predictionParts.join(' / ')}
focusable
>
<div className="flex flex-col gap-1">
<span className="block truncate">{actionLabel || row.trendPrediction || '--'}</span>
{actionLabel && row.trendPrediction && (
<span className="block truncate text-xs text-secondary-text">{row.trendPrediction}</span>
)}
{row.operationAdvice && (
<span className="block truncate text-xs text-secondary-text">{row.operationAdvice}</span>
)}
</div>
</Tooltip>
) : (
'--'
)}
</td>
<td className="backtest-table-cell">
<div className="flex items-center gap-2">
{actualMovementBadge(row.actualMovement, language)}
<span className={
row.actualReturnPct != null
? row.actualReturnPct > 0 ? 'text-success' : row.actualReturnPct < 0 ? 'text-danger' : 'text-secondary-text'
: 'text-muted-text'
}>
{pct(row.actualReturnPct)}
</span>
</div>
</td>
<td className="backtest-table-cell">
<span className="flex items-center gap-2">
{boolIcon(row.directionCorrect, text)}
<span className="text-muted-text">
{row.directionExpected ? labelFromMap(row.directionExpected, BACKTEST_DIRECTION_EXPECTED_LABELS[language]) : ''}
</span>
</span>
</div>
</td>
<td className="backtest-table-cell">
<span className="flex items-center gap-2">
{boolIcon(row.directionCorrect, text)}
<span className="text-muted-text">
{row.directionExpected ? labelFromMap(row.directionExpected, BACKTEST_DIRECTION_EXPECTED_LABELS[language]) : ''}
</span>
</span>
</td>
<td className="backtest-table-cell">{outcomeBadge(row.outcome, language)}</td>
<td className="backtest-table-cell">{statusBadge(row.evalStatus, language)}</td>
</tr>
))}
</td>
<td className="backtest-table-cell">{outcomeBadge(row.outcome, language)}</td>
<td className="backtest-table-cell">{statusBadge(row.evalStatus, language)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { UiLanguageProvider } from '../../contexts/UiLanguageContext';
import { UI_LANGUAGE_STORAGE_KEY } from '../../utils/uiLanguage';
@@ -50,6 +50,26 @@ const basePerformance = {
diagnostics: {},
};
const baseResultItem = {
analysisHistoryId: 101,
code: '600519',
stockName: '贵州茅台',
analysisDate: '2026-03-20',
evalWindowDays: 10,
engineVersion: 'test-engine',
evalStatus: 'completed',
operationAdvice: '继续持有',
action: 'watch',
actionLabel: '观望',
trendPrediction: '震荡偏多',
actualMovement: 'up',
actualReturnPct: 3.8,
directionExpected: 'long',
directionCorrect: true,
outcome: 'win',
simulatedReturnPct: 3.8,
};
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
@@ -59,25 +79,7 @@ beforeEach(() => {
total: 1,
page: 1,
limit: 20,
items: [
{
analysisHistoryId: 101,
code: '600519',
stockName: '贵州茅台',
analysisDate: '2026-03-20',
evalWindowDays: 10,
engineVersion: 'test-engine',
evalStatus: 'completed',
operationAdvice: '继续持有',
trendPrediction: '震荡偏多',
actualMovement: 'up',
actualReturnPct: 3.8,
directionExpected: 'long',
directionCorrect: true,
outcome: 'win',
simulatedReturnPct: 3.8,
},
],
items: [baseResultItem],
});
mockRun.mockResolvedValue({
processed: 1,
@@ -113,7 +115,12 @@ describe('BacktestPage', () => {
expect(screen.getByText('已完成')).toBeInTheDocument();
expect(screen.getByText('600519')).toBeInTheDocument();
expect(screen.getByText('贵州茅台')).toBeInTheDocument();
expect(screen.getByText('震荡偏多')).toBeInTheDocument();
const resultRow = screen.getByText('600519').closest('tr');
expect(resultRow).not.toBeNull();
const rowScope = within(resultRow as HTMLElement);
expect(rowScope.getByText('观望')).toBeInTheDocument();
expect(rowScope.getByText('震荡偏多')).toBeInTheDocument();
expect(rowScope.getByText('继续持有')).toBeInTheDocument();
expect(screen.getByText('上涨')).toBeInTheDocument();
expect(screen.getByText('窗口收益')).toBeInTheDocument();
expect(screen.getByText('方向匹配')).toBeInTheDocument();
@@ -123,6 +130,84 @@ describe('BacktestPage', () => {
expect(screen.getByText('平均模拟收益')).toBeInTheDocument();
});
it('falls back to the taxonomy label when backtest actionLabel is missing', async () => {
mockGetResults.mockResolvedValueOnce({
total: 1,
page: 1,
limit: 20,
items: [
{
...baseResultItem,
action: 'watch',
actionLabel: null,
},
],
});
render(<BacktestPage />);
const codeCell = await screen.findByText('600519');
const resultRow = codeCell.closest('tr');
expect(resultRow).not.toBeNull();
const rowScope = within(resultRow as HTMLElement);
expect(rowScope.getByText('观望')).toBeInTheDocument();
expect(rowScope.getByText('继续持有')).toBeInTheDocument();
});
it('uses localized taxonomy labels before server labels in English UI mode', async () => {
mockGetResults.mockResolvedValueOnce({
total: 1,
page: 1,
limit: 20,
items: [
{
...baseResultItem,
operationAdvice: 'continue holding',
action: 'watch',
actionLabel: '观望',
trendPrediction: 'range-bound',
},
],
});
renderEnglishPage();
const codeCell = await screen.findByText('600519');
const resultRow = codeCell.closest('tr');
expect(resultRow).not.toBeNull();
const rowScope = within(resultRow as HTMLElement);
expect(rowScope.getByText('Watch')).toBeInTheDocument();
expect(rowScope.getByText('continue holding')).toBeInTheDocument();
expect(rowScope.queryByText('观望')).not.toBeInTheDocument();
});
it('keeps operation advice visible when backtest action fields are absent for multi-guard advice', async () => {
mockGetResults.mockResolvedValueOnce({
total: 1,
page: 1,
limit: 20,
items: [
{
...baseResultItem,
operationAdvice: 'risk alert, avoid buying',
action: null,
actionLabel: null,
},
],
});
render(<BacktestPage />);
const codeCell = await screen.findByText('600519');
const resultRow = codeCell.closest('tr');
expect(resultRow).not.toBeNull();
const rowScope = within(resultRow as HTMLElement);
expect(rowScope.getByText('震荡偏多')).toBeInTheDocument();
expect(rowScope.getByText('risk alert, avoid buying')).toBeInTheDocument();
expect(rowScope.queryByText('回避')).not.toBeInTheDocument();
expect(rowScope.queryByText('预警')).not.toBeInTheDocument();
});
it('renders backtest controls and result headings in English UI mode', async () => {
renderEnglishPage();
@@ -216,6 +216,8 @@ function reportToHistoryItem(report: AnalysisReport): HistoryItem | null {
analysisSummary: report.summary.analysisSummary,
sentimentScore: report.summary.sentimentScore,
operationAdvice: report.summary.operationAdvice,
action: report.summary.action,
actionLabel: report.summary.actionLabel,
currentPrice: report.meta.currentPrice,
changePct: report.meta.changePct,
modelUsed: report.meta.modelUsed,
+8
View File
@@ -94,10 +94,14 @@ export type SentimentLabel =
| 'Bullish'
| 'Very Bullish';
export type DecisionAction = 'buy' | 'add' | 'hold' | 'reduce' | 'sell' | 'watch' | 'avoid' | 'alert';
/** Report summary section */
export interface ReportSummary {
analysisSummary: string;
operationAdvice: string;
action?: DecisionAction | null;
actionLabel?: string | null;
trendPrediction: string;
sentimentScore: number;
sentimentLabel?: SentimentLabel;
@@ -402,6 +406,8 @@ export interface HistoryItem {
analysisSummary?: string;
sentimentScore?: number;
operationAdvice?: string;
action?: DecisionAction | null;
actionLabel?: string | null;
currentPrice?: number;
changePct?: number;
volumeRatio?: number;
@@ -463,6 +469,8 @@ export interface StockBarItem {
reportType?: string;
sentimentScore?: number;
operationAdvice?: string;
action?: DecisionAction | null;
actionLabel?: string | null;
analysisCount: number;
lastAnalysisTime?: string;
modelUsed?: string;
+3 -1
View File
@@ -2,7 +2,7 @@
* Backtest API type definitions
* Mirrors api/v1/schemas/backtest.py
*/
import type { MarketPhaseSummary } from './analysis';
import type { DecisionAction, MarketPhaseSummary } from './analysis';
// ============ Request / Response ============
@@ -37,6 +37,8 @@ export interface BacktestResultItem {
evalStatus: string;
evaluatedAt?: string;
operationAdvice?: string;
action?: DecisionAction | null;
actionLabel?: string | null;
trendPrediction?: string;
marketPhase?: string | null;
marketPhaseSummary?: MarketPhaseSummary | null;
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import {
type DecisionActionLabelMap,
getDecisionActionLabel,
getLegacyDecisionAction,
getDecisionActionTone,
getLegacyDecisionActionLabel,
} from '../decisionAction';
const englishLabels: DecisionActionLabelMap = {
buy: 'Buy',
add: 'Add',
hold: 'Hold',
reduce: 'Reduce',
sell: 'Sell',
watch: 'Watch',
avoid: 'Avoid',
alert: 'Alert',
};
describe('decisionAction helpers', () => {
it('uses structured action taxonomy before server label and legacy advice text', () => {
expect(getDecisionActionLabel('avoid', '回避', '买入', '建议')).toBe('回避');
expect(getDecisionActionLabel('sell', '买入', null, 'Advice', englishLabels)).toBe('Sell');
expect(getDecisionActionTone('sell', '买入', null)).toBe('danger');
expect(getDecisionActionLabel(null, '买入', null, 'Advice', englishLabels)).toBe('买入');
});
it('falls back to the action taxonomy label when actionLabel is absent', () => {
expect(getDecisionActionLabel('add', null, '持有', '建议')).toBe('加仓');
expect(getDecisionActionLabel('watch', null, '持有', 'Advice', englishLabels)).toBe('Watch');
});
it('keeps legacy fallback compatible with negated buy advice', () => {
expect(getLegacyDecisionActionLabel('不建议买入,等待确认')).toBe('回避');
expect(getDecisionActionLabel(null, null, '避免买入', '建议')).toBe('回避');
expect(getLegacyDecisionActionLabel('暂不买入,等待确认')).toBe('回避');
expect(getLegacyDecisionActionLabel('先不建仓,等待放量')).toBe('回避');
expect(getLegacyDecisionActionLabel('无需买入,等待确认')).toBe('回避');
expect(getLegacyDecisionActionLabel('无须建仓,继续观察')).toBe('回避');
expect(getLegacyDecisionActionLabel('无需布局,等待突破')).toBe('回避');
expect(getLegacyDecisionActionLabel('no buy until breakout')).toBe('回避');
expect(getLegacyDecisionActionLabel('no need to buy before confirmation')).toBe('回避');
expect(getLegacyDecisionActionLabel('cannot buy before confirmation')).toBe('回避');
expect(getLegacyDecisionActionLabel("can't buy before confirmation")).toBe('回避');
expect(getLegacyDecisionActionLabel('not a buy yet')).toBe('回避');
expect(getLegacyDecisionActionLabel('not a buy yet', englishLabels)).toBe('Avoid');
expect(getLegacyDecisionActionLabel('not to buy', englishLabels)).toBe('Avoid');
expect(getLegacyDecisionActionLabel('avoid buying', englishLabels)).toBe('Avoid');
expect(getLegacyDecisionActionLabel('avoid buying into weakness', englishLabels)).toBe('Avoid');
expect(getLegacyDecisionActionLabel('waiting to buy')).toBeNull();
});
it('keeps legacy fallback compatible with negated sell and add advice', () => {
expect(getLegacyDecisionActionLabel('不建议卖出,继续观察')).toBe('持有');
expect(getLegacyDecisionActionLabel('洗盘观察')).toBe('持有');
expect(getLegacyDecisionActionLabel('洗盘观察', englishLabels)).toBe('Hold');
expect(getLegacyDecisionActionLabel('无需减仓,维持仓位')).toBe('持有');
expect(getLegacyDecisionActionLabel('无须减仓,维持仓位')).toBe('持有');
expect(getLegacyDecisionActionLabel('不建议加仓,等待回踩')).toBe('持有');
expect(getLegacyDecisionActionLabel('无须加仓,等待回踩')).toBe('持有');
expect(getLegacyDecisionActionLabel('no add before confirmation')).toBe('持有');
expect(getLegacyDecisionActionLabel('cannot add before confirmation')).toBe('持有');
expect(getLegacyDecisionActionLabel('no need to accumulate here')).toBe('持有');
expect(getLegacyDecisionActionLabel("can't accumulate here")).toBe('持有');
expect(getLegacyDecisionActionLabel('no sell before earnings')).toBe('持有');
expect(getLegacyDecisionActionLabel('cannot sell before earnings')).toBe('持有');
expect(getLegacyDecisionActionLabel('no need to reduce exposure')).toBe('持有');
expect(getLegacyDecisionActionLabel("can't reduce exposure")).toBe('持有');
expect(getLegacyDecisionActionLabel('no trim while trend holds')).toBe('持有');
expect(getLegacyDecisionActionLabel('cannot trim while trend holds')).toBe('持有');
expect(getLegacyDecisionActionLabel('not a sell yet')).toBe('持有');
expect(getLegacyDecisionActionLabel('not a trim yet')).toBe('持有');
expect(getLegacyDecisionActionLabel('not to sell')).toBe('持有');
expect(getLegacyDecisionActionLabel('not to trim')).toBe('持有');
expect(getLegacyDecisionActionLabel('not a trim yet', englishLabels)).toBe('Hold');
expect(getLegacyDecisionActionLabel('avoid selling into weakness', englishLabels)).toBe('Hold');
expect(getLegacyDecisionActionLabel('avoid trimming before earnings', englishLabels)).toBe('Hold');
expect(getLegacyDecisionActionLabel('avoid reducing exposure before earnings', englishLabels)).toBe('Hold');
expect(getDecisionActionTone(null, null, '不建议卖出,继续观察')).toBe('success');
});
it('does not turn ambiguous English advice into a badge action', () => {
expect(getLegacyDecisionActionLabel('buy or sell')).toBeNull();
expect(getDecisionActionLabel(null, null, 'buy or sell', 'Advice', englishLabels)).toBe('Advice');
});
it('does not match financial compound words as legacy actions', () => {
expect(getLegacyDecisionActionLabel('no buyback announced', englishLabels)).toBeNull();
expect(getLegacyDecisionActionLabel('cannot buyback shares now', englishLabels)).toBeNull();
expect(getLegacyDecisionActionLabel('share buy-back announced', englishLabels)).toBeNull();
expect(getLegacyDecisionActionLabel('share buy back announced', englishLabels)).toBeNull();
expect(getLegacyDecisionActionLabel('no selloff risk', englishLabels)).toBeNull();
expect(getLegacyDecisionActionLabel('not selloff yet', englishLabels)).toBeNull();
expect(getLegacyDecisionActionLabel('sell-off risk remains low', englishLabels)).toBeNull();
expect(getLegacyDecisionActionLabel('sell off risk remains low', englishLabels)).toBeNull();
expect(getLegacyDecisionActionLabel('no sell-off pressure', englishLabels)).toBeNull();
expect(getDecisionActionLabel(null, null, 'no buyback announced', 'Advice', englishLabels)).toBe('Advice');
expect(getDecisionActionLabel(null, null, 'no selloff risk', 'Advice', englishLabels)).toBe('Advice');
expect(getLegacyDecisionActionLabel('no buy until breakout', englishLabels)).toBe('Avoid');
expect(getLegacyDecisionActionLabel('cannot buy before confirmation', englishLabels)).toBe('Avoid');
expect(getLegacyDecisionActionLabel('no sell before earnings', englishLabels)).toBe('Hold');
});
it('keeps separate action terms next to financial compounds', () => {
expect(getLegacyDecisionAction('buy after sell-off')).toBe('buy');
expect(getLegacyDecisionActionLabel('buy after sell-off', englishLabels)).toBe('Buy');
expect(getLegacyDecisionAction('sell after buy-back rumor')).toBe('sell');
expect(getLegacyDecisionActionLabel('sell after buy-back rumor', englishLabels)).toBe('Sell');
});
it('does not match Chinese financial context words as legacy actions', () => {
expect(getLegacyDecisionActionLabel('买盘增强,继续观察')).toBeNull();
expect(getLegacyDecisionActionLabel('卖压缓解,继续观察')).toBeNull();
expect(getLegacyDecisionActionLabel('卖方评级分歧')).toBeNull();
expect(getDecisionActionLabel(null, null, '买盘增强,继续观察', '建议')).toBe('建议');
expect(getDecisionActionLabel(null, null, '卖压缓解,继续观察', '建议')).toBe('建议');
});
it('keeps multi-guard legacy advice empty instead of prioritizing avoid or alert', () => {
expect(getLegacyDecisionActionLabel('risk alert, avoid buying')).toBeNull();
expect(getLegacyDecisionActionLabel('风险预警,避免买入')).toBeNull();
expect(getDecisionActionLabel(null, null, 'risk alert, avoid buying', 'Advice', englishLabels)).toBe('Advice');
expect(getLegacyDecisionActionLabel('avoid buying', englishLabels)).toBe('Avoid');
expect(getLegacyDecisionActionLabel('risk alert', englishLabels)).toBe('Alert');
});
it('maps action tone without reading legacy text when action is present', () => {
expect(getDecisionActionTone('buy', null, '卖出')).toBe('success');
expect(getDecisionActionTone('reduce', null, '买入')).toBe('danger');
expect(getDecisionActionTone('alert', null, '买入')).toBe('warning');
expect(getDecisionActionTone(null, '观望', '买入')).toBe('warning');
expect(getDecisionActionTone(null, 'Sell', '买入')).toBe('danger');
expect(getDecisionActionTone(null, null, 'avoid buying')).toBe('warning');
});
});
+259
View File
@@ -0,0 +1,259 @@
import type { DecisionAction } from '../types/analysis';
export type DecisionActionTone = 'success' | 'warning' | 'danger' | 'default';
export type DecisionActionLabelMap = Record<DecisionAction, string>;
export type DecisionActionLabelTextKey =
| 'history.actionBuy'
| 'history.actionAdd'
| 'history.actionHold'
| 'history.actionReduce'
| 'history.actionSell'
| 'history.actionWatch'
| 'history.actionAvoid'
| 'history.actionAlert';
export type DecisionActionLabelTranslator = (key: DecisionActionLabelTextKey) => string;
export const DEFAULT_DECISION_ACTION_LABELS: DecisionActionLabelMap = {
buy: '买入',
add: '加仓',
hold: '持有',
reduce: '减仓',
sell: '卖出',
watch: '观望',
avoid: '回避',
alert: '预警',
};
const resolveActionLabels = (labels?: Partial<DecisionActionLabelMap>): DecisionActionLabelMap => ({
...DEFAULT_DECISION_ACTION_LABELS,
...labels,
});
export const buildDecisionActionLabelMap = (
t: DecisionActionLabelTranslator,
): DecisionActionLabelMap => ({
buy: t('history.actionBuy'),
add: t('history.actionAdd'),
hold: t('history.actionHold'),
reduce: t('history.actionReduce'),
sell: t('history.actionSell'),
watch: t('history.actionWatch'),
avoid: t('history.actionAvoid'),
alert: t('history.actionAlert'),
});
const toneForAction = (action: DecisionAction): DecisionActionTone => {
if (action === 'buy' || action === 'add' || action === 'hold') return 'success';
if (action === 'sell' || action === 'reduce') return 'danger';
return 'warning';
};
const includesAny = (value: string, phrases: readonly string[]): boolean =>
phrases.some((phrase) => value.includes(phrase));
const normalizeEnglishAdvice = (value: string): string =>
value.toLowerCase().replace(/[_-]/g, ' ');
const maskEnglishFinancialCompounds = (value: string): string =>
value
.replace(/(^|[^a-z0-9_])buy\s*back(?=$|[^a-z0-9_])/g, '$1financialcompound')
.replace(/(^|[^a-z0-9_])sell\s*off(?=$|[^a-z0-9_])/g, '$1financialcompound');
const matchesEnglishTerm = (value: string, terms: readonly string[]): boolean =>
terms.some((term) => new RegExp(`(^|[^a-z0-9_])${term}(?=$|[^a-z0-9_])`).test(value));
const matchesEnglishNegatedAction = (value: string, terms: readonly string[]): boolean => {
const negationPrefix = String.raw`(?:not\s+(?:a\s+|an\s+|to\s+)?|no\s+(?:need\s+to\s+)?|need\s+not\s+|cannot\s+|can't\s+|cant\s+|do\s+not\s+|don't\s+|dont\s+)`;
return terms.some((term) =>
new RegExp(`(^|[^a-z0-9_])${negationPrefix}${term}(?=$|[^a-z0-9_])`).test(value),
);
};
const hasEnglishAvoidedHoldAction = (value: string): boolean => {
const terms = String.raw`(?:adding|accumulating|selling|reducing|trimming)`;
return new RegExp(`(^|[^a-z0-9_])avoid\\s+${terms}(?=$|[^a-z0-9_])`).test(value);
};
const hasEnglishDeferredAction = (value: string): boolean => {
const terms = String.raw`(?:buy|add|accumulate|sell|reduce|trim)`;
return (
new RegExp(`(^|[^a-z0-9_])wait(?:ing)?\\s+to\\s+${terms}(?=$|[^a-z0-9_])`).test(value) ||
new RegExp(`(^|[^a-z0-9_])waiting\\s+(?:for|until)\\b.*?${terms}(?=$|[^a-z0-9_])`).test(value)
);
};
export const getLegacyDecisionActionLabel = (
advice?: string | null,
labels?: Partial<DecisionActionLabelMap>,
): string | null => {
const action = getLegacyDecisionAction(advice);
if (!action) return null;
return resolveActionLabels(labels)[action];
};
export const getLegacyDecisionAction = (advice?: string | null): DecisionAction | null => {
const normalized = advice?.trim();
if (!normalized) return null;
const lower = maskEnglishFinancialCompounds(normalizeEnglishAdvice(normalized));
if (hasEnglishDeferredAction(lower)) {
return null;
}
if (
includesAny(normalized, [
'暂不买入',
'不要买入',
'不宜买入',
'先不买入',
'无需买入',
'无须买入',
'不建议建仓',
'暂不建仓',
'不要建仓',
'不宜建仓',
'先不建仓',
'无需建仓',
'无须建仓',
'不建议布局',
'暂不布局',
'不要布局',
'不宜布局',
'先不布局',
'无需布局',
'无须布局',
]) ||
matchesEnglishNegatedAction(lower, ['buy'])
) {
return 'avoid';
}
if (
includesAny(normalized, [
'不建议加仓',
'无需加仓',
'无须加仓',
'不要加仓',
'不宜加仓',
'暂不加仓',
'不建议增持',
'无需增持',
'无须增持',
'不要增持',
'不宜增持',
'暂不增持',
'不建议卖出',
'无需卖出',
'无须卖出',
'不要卖出',
'不宜卖出',
'暂不卖出',
'不建议减仓',
'无需减仓',
'无须减仓',
'不要减仓',
'不宜减仓',
'暂不减仓',
'不建议清仓',
'无需清仓',
'无须清仓',
'不要清仓',
'不宜清仓',
'暂不清仓',
]) ||
hasEnglishAvoidedHoldAction(lower) ||
matchesEnglishNegatedAction(lower, ['add', 'accumulate', 'sell', 'reduce', 'trim'])
) {
return 'hold';
}
const guardMatches = new Set<DecisionAction>();
if (
normalized.includes('不建议买入') ||
normalized.includes('避免买入') ||
normalized.includes('回避') ||
normalized.includes('规避') ||
matchesEnglishTerm(lower, ['avoid'])
) {
guardMatches.add('avoid');
}
if (
normalized.includes('风险预警') ||
normalized.includes('触发告警') ||
normalized.includes('警惕') ||
lower.includes('risk alert') ||
matchesEnglishTerm(lower, ['alert'])
) {
guardMatches.add('alert');
}
if (guardMatches.size === 1) {
return Array.from(guardMatches)[0];
}
if (guardMatches.size > 1) {
return null;
}
const matches = new Set<DecisionAction>();
if (normalized.includes('加仓') || normalized.includes('增持') || matchesEnglishTerm(lower, ['add', 'accumulate'])) {
matches.add('add');
}
if (normalized.includes('减仓') || matchesEnglishTerm(lower, ['reduce', 'trim'])) {
matches.add('reduce');
}
if (normalized.includes('强烈卖出') || normalized.includes('卖出') || normalized.includes('清仓') || matchesEnglishTerm(lower, ['sell'])) {
matches.add('sell');
}
if (normalized.includes('持有') || normalized.includes('洗盘观察') || matchesEnglishTerm(lower, ['hold'])) {
matches.add('hold');
}
if (normalized.includes('观望') || normalized.includes('等待') || matchesEnglishTerm(lower, ['watch', 'wait'])) {
matches.add('watch');
}
if (normalized.includes('强烈买入') || normalized.includes('买入') || normalized.includes('布局') || normalized.includes('建仓') || matchesEnglishTerm(lower, ['buy'])) {
matches.add('buy');
}
if (matches.size === 1) {
return Array.from(matches)[0];
}
return null;
};
export const getDecisionActionLabel = (
action?: DecisionAction | null,
actionLabel?: string | null,
legacyAdvice?: string | null,
emptyLabel: string | null = '建议',
labels?: Partial<DecisionActionLabelMap>,
): string | null => {
const actionLabels = resolveActionLabels(labels);
if (action) return actionLabels[action];
const explicitLabel = actionLabel?.trim();
if (explicitLabel) return explicitLabel;
return getLegacyDecisionActionLabel(legacyAdvice, actionLabels) || emptyLabel;
};
export const getDecisionActionTone = (
action?: DecisionAction | null,
actionLabel?: string | null,
legacyAdvice?: string | null,
): DecisionActionTone => {
if (action) return toneForAction(action);
const label = actionLabel?.trim() || '';
if (label) {
const lowerLabel = normalizeEnglishAdvice(label);
if (label.includes('买') || label.includes('加仓') || label.includes('持有')) return 'success';
if (label.includes('卖') || label.includes('减仓') || label.includes('清仓')) return 'danger';
if (label.includes('观望') || label.includes('等待') || label.includes('回避') || label.includes('预警')) {
return 'warning';
}
if (matchesEnglishTerm(lowerLabel, ['buy', 'add', 'hold'])) return 'success';
if (matchesEnglishTerm(lowerLabel, ['sell', 'reduce', 'trim'])) return 'danger';
if (matchesEnglishTerm(lowerLabel, ['watch', 'wait', 'avoid', 'alert'])) return 'warning';
return 'default';
}
const legacyAction = getLegacyDecisionAction(legacyAdvice);
if (legacyAction) return toneForAction(legacyAction);
return 'default';
};
+2
View File
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
- [改进] #1390 P0 为个股分析与历史/回测展示新增可选八态 `action` / `action_label` 建议动作字段,保留 `operation_advice` 自由文本和 `decision_type=buy|hold|sell` 统计口径,不新增迁移或配置项。
- [修复] #1390 收紧建议动作 legacy fallback:英文 `not to ...` 与 `avoid selling/reducing/trimming ...` 等否定/回避表达不再误判为买卖动作,Web 旧记录不再把中文金融上下文、`buy or sell`、多 guard 歧义文本或 `buyback` / `buy-back` / `buy back` / `selloff` / `sell-off` / `sell off` 等英文复合词渲染成 action badge,并在有结构化 `action` 时让回测/历史趋势等入口按界面语言显示 action 标签。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [修复] 桌面发布打包改用冻结可执行文件运行时探针校验 `alphasift.dsa_adapter`,避免 macOS PyInstaller 将模块内嵌进可执行文件时被文件系统/zip 扫描误判为缺失。
+40
View File
@@ -1292,6 +1292,26 @@
"operation_advice": {
"type": "string"
},
"action": {
"type": "string",
"description": "结构化建议动作 taxonomy",
"nullable": true,
"enum": [
"buy",
"add",
"hold",
"reduce",
"sell",
"watch",
"avoid",
"alert"
]
},
"action_label": {
"type": "string",
"description": "建议动作展示标签",
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -1345,6 +1365,26 @@
"type": "string",
"description": "操作建议"
},
"action": {
"type": "string",
"description": "结构化建议动作 taxonomy",
"nullable": true,
"enum": [
"buy",
"add",
"hold",
"reduce",
"sell",
"watch",
"avoid",
"alert"
]
},
"action_label": {
"type": "string",
"description": "建议动作展示标签",
"nullable": true
},
"trend_prediction": {
"type": "string",
"description": "趋势预测"
+21
View File
@@ -1278,6 +1278,27 @@ python main.py --debug
兼容性核验结论:除配置和模型侧语义外,该决策稳定性链路覆盖 `src/analyzer.py`、`src/core/pipeline.py`、`src/core/backtest_engine.py`、`src/report_language.py` 及 `src/agent` 决策路径的运行时行为,建议复核报告决策类型映射与回测入口联动。
核验路径:相关逻辑在上述运行时路径与对应测试(`tests/test_backtest_engine.py`、`tests/test_analyzer_news_prompt.py`、`tests/test_decision_stability.py`、`tests/test_agent_pipeline.py` 等)中生效;未在 `src/config.py`、`src/report.py`、存储/持久化链路新增配置字段或清理逻辑。
### 建议动作 Taxonomy(#1390 P0)
个股报告在保留 `operation_advice` 自由文本的同时,新增可选 `action` / `action_label` 字段,作为 Web 历史列表、同股历史、StockBar 和回测结果行的结构化展示辅助。`decision_type` 仍保持旧的 `buy|hold|sell` 三态统计口径;`action` 为空时不会改写既有 `decision_type` 推断链。
| `action` | 常见来源文本 | `decision_type` 桥接 |
| --- | --- | --- |
| `buy` | `strong_buy`、`强烈买入`、`买入`、`布局`、`建仓` | `buy` |
| `add` | `add`、`加仓`、`增持`、`accumulate` | `buy` |
| `hold` | `hold`、`持有`、`持有观察`、`洗盘观察` | `hold` |
| `watch` | `watch`、`观望`、`等待`、`wait` | `hold` |
| `reduce` | `reduce`、`减仓`、`trim` | `sell` |
| `sell` | `sell`、`卖出`、`清仓`、`strong_sell`、`强烈卖出` | `sell` |
| `avoid` | `avoid`、`回避`、`规避`、`不建议买入`、`避免买入`、`do not buy` | `hold` |
| `alert` | `alert`、`风险预警`、`警惕`、`触发告警`、`risk alert` | `hold` |
上表的 `decision_type` 桥接只说明八态 action 与旧三态统计口径的兼容关系;#1390 P0 不会把 `action` 自动反写到既有 `decision_type`。若上游显式 `action` 与 `decision_type` 同时存在但语义不一致,三态统计、回测和旧报表口径仍以 `decision_type` / 原有推断链为准,`action/action_label` 只承担结构化展示辅助。
未知或歧义建议不会兜底成 `watch` 或 `hold`,而是返回空 `action/action_label`。Web 历史卡片、StockBar、同股历史抽屉和回测结果行会在旧记录缺少 `action/action_label` 时从 `operation_advice` 做展示级 fallback;该 fallback 只影响前端标签,不等价于稳定 API action 或后续信号资产。Web 展示层在同时收到 `action` 与 `action_label` 时,会优先按当前界面语言从 `action` 生成标签;API 中的 `action_label` 仍按报告语言生成,供非 Web 客户端或无 `action` 的兼容展示使用。大盘复盘和其他非个股报告不会产生交易 `action`,只保留 `operation_advice` 文本。`dashboard.phase_decision.immediate_action` 属于市场阶段护栏报告字段,不参与 #1390 P0 的八态 action 派生;最终市场阶段仍来自 `report.meta.market_phase_summary.phase`。
#1390 P0 不定义或输出后续信号资产字段;`horizon`、`plan_quality`、`status` 等更细粒度计划字段留待后续独立设计。本阶段不平铺到现有 summary、历史列表、StockBar 或回测响应,不做 DB migration、不回填历史、不新增配置项。
## 回测功能
回测模块自动对历史 AI 分析记录进行事后验证,评估分析建议的准确性。
+21
View File
@@ -1109,6 +1109,27 @@ This post-processing update only adjusts advisory wording and stability logic an
Compatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.
Verification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.
### Decision Action Taxonomy (#1390 P0)
Single-stock reports now keep the existing free-text `operation_advice` and add optional `action` / `action_label` fields for structured display in Web history, StockBar, same-stock history, and backtest result rows. `decision_type` remains the legacy `buy|hold|sell` statistics contract; an empty `action` does not rewrite the existing `decision_type` inference chain.
| `action` | Common source text | `decision_type` bridge |
| --- | --- | --- |
| `buy` | `strong_buy`, `强烈买入`, `buy`, `买入`, `布局`, `建仓` | `buy` |
| `add` | `add`, `加仓`, `增持`, `accumulate` | `buy` |
| `hold` | `hold`, `持有`, `持有观察`, `洗盘观察` | `hold` |
| `watch` | `watch`, `观望`, `等待`, `wait` | `hold` |
| `reduce` | `reduce`, `减仓`, `trim` | `sell` |
| `sell` | `sell`, `卖出`, `清仓`, `strong_sell`, `强烈卖出` | `sell` |
| `avoid` | `avoid`, `回避`, `规避`, `不建议买入`, `避免买入`, `do not buy` | `hold` |
| `alert` | `alert`, `风险预警`, `警惕`, `触发告警`, `risk alert` | `hold` |
The `decision_type` bridge in the table only documents compatibility between the eight-state action taxonomy and the legacy three-state statistics contract. #1390 P0 does not automatically write `action` back into the existing `decision_type`. If upstream sends both an explicit `action` and a semantically different `decision_type`, legacy statistics, backtesting, and old report semantics still follow `decision_type` / the existing inference chain; `action/action_label` remains structured display metadata.
Unknown or ambiguous advice is not coerced into `watch` or `hold`; it returns empty `action/action_label`. Web history cards, StockBar, same-stock history drawers, and backtest result rows use `operation_advice` as a display-only fallback when old records do not have `action/action_label`; that fallback affects only the UI label and is not a stable API action or future signal asset. When Web receives both `action` and `action_label`, it first renders the label from `action` in the current UI language; API `action_label` remains report-language display metadata for non-Web clients or compatibility display when `action` is absent. Market review and other non-stock reports do not emit trading `action` values and keep only the `operation_advice` text. `dashboard.phase_decision.immediate_action` belongs to the market-phase guardrail report block and is not used by the #1390 P0 eight-state action derivation. The final market phase still comes from `report.meta.market_phase_summary.phase`.
#1390 P0 does not define or emit future signal-asset fields. More granular plan fields such as `horizon`, `plan_quality`, and `status` are left for a separate follow-up design. This phase does not flatten them into current report summaries, history lists, StockBar rows, or backtest responses; it adds no DB migration, no historical backfill, and no new configuration.
## Backtesting
The backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.
+3 -1
View File
@@ -51,6 +51,8 @@
"summary": {
"analysis_summary": "...",
"operation_advice": "持有",
"action": "hold",
"action_label": "持有",
"trend_prediction": "看多",
"sentiment_score": 75
},
@@ -136,7 +138,7 @@ metadata:
```
> `skills` 为可选策略 ID 数组;历史字段 `strategies` 仍保留兼容,建议优先使用 `skills`。
3. **等待响应**:同步模式下分析约需 2–5 分钟,请确保 HTTP 客户端超时足够(建议 ≥300 秒)。
4. **解析结果**:从响应的 `report.summary` 中提取 `operation_advice`、`trend_prediction`、`analysis_summary`,从 `report.strategy` 中提取 `ideal_buy`、`stop_loss`、`take_profit`,以简洁格式呈现给用户。
4. **解析结果**:从响应的 `report.summary` 中提取 `operation_advice`、`trend_prediction`、`analysis_summary`,从 `report.strategy` 中提取 `ideal_buy`、`stop_loss`、`take_profit`,以简洁格式呈现给用户。外部集成可继续只读取自由文本 `operation_advice`;若需要结构化展示,可优先读取可选的 `action` / `action_label`(八态:`buy|add|hold|reduce|sell|watch|avoid|alert`)。旧历史缺字段时可回退到 `operation_advice` 文本展示,但该回退不等价于稳定 API action;旧三态统计口径仍以 `decision_type` 为准。
5. **错误处理**:
- 连接失败:提示检查 DSA 是否运行、DSA_BASE_URL 是否正确
- 400:检查 stock_code 格式
+37 -2
View File
@@ -54,6 +54,7 @@ from src.report_language import (
localize_confidence_level,
normalize_report_language,
)
from src.schemas.decision_action import build_action_fields
from src.schemas.report_schema import AnalysisReportSchema
from src.market_context import get_market_role, get_market_guidelines
from src.market_phase_prompt import format_market_phase_prompt_section
@@ -1505,6 +1506,8 @@ class AnalysisResult:
decision_type: str = "hold" # 决策类型:buy/hold/sell(用于统计)
confidence_level: str = "中" # 置信度:高/中/低
report_language: str = "zh" # 报告输出语言:zh/en
action: Optional[str] = None # 建议动作 taxonomy:buy/add/hold/reduce/sell/watch/avoid/alert
action_label: Optional[str] = None # 本地化建议动作标签
# ========== 决策仪表盘 (新增) ==========
dashboard: Optional[Dict[str, Any]] = None # 完整的决策仪表盘数据
@@ -1568,6 +1571,8 @@ class AnalysisResult:
'decision_type': self.decision_type,
'confidence_level': self.confidence_level,
'report_language': self.report_language,
'action': self.action,
'action_label': self.action_label,
'dashboard': self.dashboard, # 决策仪表盘数据
'trend_analysis': self.trend_analysis,
'short_term_outlook': self.short_term_outlook,
@@ -1650,6 +1655,30 @@ class AnalysisResult:
return star_map.get(str(self.confidence_level or "").strip().lower(), "⭐⭐")
def populate_decision_action_fields(
result: AnalysisResult,
*,
explicit_action: Any = None,
report_type: Any = None,
use_existing_action: bool = True,
) -> AnalysisResult:
"""Populate optional decision action fields without changing legacy advice."""
action_source = explicit_action
if action_source is None and use_existing_action:
action_source = getattr(result, "action", None)
fields = build_action_fields(
operation_advice=getattr(result, "operation_advice", None),
explicit_action=action_source,
report_type=report_type,
report_language=getattr(result, "report_language", "zh"),
)
result.action = fields["action"]
result.action_label = fields["action_label"]
return result
class GeminiAnalyzer:
"""
Gemini AI 分析器
@@ -3565,7 +3594,11 @@ class GeminiAnalyzer:
op = data.get('operation_advice', 'Hold' if report_language == "en" else '持有')
decision_type = infer_decision_type_from_advice(op, default='hold')
return AnalysisResult(
explicit_action = data.get("action")
if explicit_action is None and isinstance(dashboard, dict):
explicit_action = dashboard.get("action")
result = AnalysisResult(
code=code,
name=name,
# 核心指标
@@ -3607,6 +3640,7 @@ class GeminiAnalyzer:
data_sources=data.get('data_sources', 'Technical data' if report_language == "en" else '技术面数据'),
success=True,
)
return populate_decision_action_fields(result, explicit_action=explicit_action)
else:
# 没有找到 JSON,标记为失败
logger.warning(f"无法从响应中提取 JSON,标记为解析失败")
@@ -3704,7 +3738,7 @@ class GeminiAnalyzer:
# 截取前500字符作为摘要
summary = response_text[:500] if response_text else ('No analysis result' if report_language == "en" else '无分析结果')
return AnalysisResult(
result = AnalysisResult(
code=code,
name=name,
sentiment_score=sentiment_score,
@@ -3720,6 +3754,7 @@ class GeminiAnalyzer:
error_message='LLM response is not valid JSON; analysis result will not be persisted',
report_language=report_language,
)
return populate_decision_action_fields(result)
def batch_analyze(
self,
+33 -1
View File
@@ -32,6 +32,7 @@ from src.analyzer import (
AnalysisResult,
fill_price_position_if_needed,
normalize_chip_structure_availability,
populate_decision_action_fields,
stabilize_decision_with_structure,
)
from src.notification import NotificationService, NotificationChannel
@@ -619,6 +620,7 @@ class StockAnalysisPipeline:
# Step 7.7: price_position fallback
if result:
fill_price_position_if_needed(result, trend_result, realtime_quote)
action_source_advice = getattr(result, "operation_advice", None)
stabilize_decision_with_structure(result, trend_result, fundamental_context)
adjustments = apply_phase_decision_guardrails(
result,
@@ -633,6 +635,11 @@ class StockAnalysisPipeline:
result.fundamental_context = fundamental_context
result.market_phase_summary = market_phase_summary
result.analysis_context_pack_overview = analysis_context_pack_overview
self._refresh_decision_action_for_final_result(
result,
report_type=report_type.value,
previous_operation_advice=action_source_advice,
)
# Step 8: 保存分析历史记录
if result and result.success:
@@ -1131,6 +1138,7 @@ class StockAnalysisPipeline:
if isinstance(realtime_data, dict):
result.current_price = realtime_data.get("price")
result.change_pct = realtime_data.get("change_pct")
action_source_advice = getattr(result, "operation_advice", None)
stabilize_decision_with_structure(result, trend_result, fundamental_context)
adjustments = apply_phase_decision_guardrails(
result,
@@ -1145,6 +1153,11 @@ class StockAnalysisPipeline:
result.fundamental_context = fundamental_context
result.market_phase_summary = market_phase_summary
result.analysis_context_pack_overview = analysis_context_pack_overview
self._refresh_decision_action_for_final_result(
result,
report_type=report_type.value,
previous_operation_advice=action_source_advice,
)
resolved_stock_name = result.name if result and result.name else stock_name
@@ -1258,6 +1271,7 @@ class StockAnalysisPipeline:
将 AgentResult 转换为 AnalysisResult。
"""
report_language = normalize_report_language(getattr(self.config, "report_language", "zh"))
dash = None
result = AnalysisResult(
code=code,
name=stock_name,
@@ -1421,7 +1435,25 @@ class StockAnalysisPipeline:
if not result.error_message:
result.error_message = "Agent failed to generate a valid decision dashboard" if report_language == "en" else "Agent 未能生成有效的决策仪表盘"
return result
explicit_action = dash.get("action") if isinstance(dash, dict) else None
if explicit_action is None and isinstance(getattr(result, "dashboard", None), dict):
explicit_action = result.dashboard.get("action")
return populate_decision_action_fields(result, explicit_action=explicit_action)
@staticmethod
def _refresh_decision_action_for_final_result(
result: AnalysisResult,
*,
report_type: Any,
previous_operation_advice: Any,
) -> AnalysisResult:
previous_advice = str(previous_operation_advice or "").strip()
current_advice = str(getattr(result, "operation_advice", None) or "").strip()
return populate_decision_action_fields(
result,
report_type=report_type,
use_existing_action=(previous_advice == current_advice),
)
@staticmethod
def _agent_dashboard_value(
+15 -2
View File
@@ -18,6 +18,15 @@ from src.storage import BacktestResult, BacktestSummary, DatabaseManager, Analys
logger = logging.getLogger(__name__)
MARKET_REVIEW_REPORT_TYPE = "market_review"
BacktestResultContextRow = Tuple[
BacktestResult,
Optional[str],
Optional[str],
Optional[datetime],
Optional[str],
Optional[str],
Optional[str],
]
class BacktestRepository:
@@ -111,7 +120,7 @@ class BacktestRepository:
days: Optional[int],
offset: int,
limit: int,
) -> Tuple[List[Tuple[BacktestResult, Optional[str], Optional[str], Optional[datetime], Optional[str]]], int]:
) -> Tuple[List[BacktestResultContextRow], int]:
with self.db.get_session() as session:
conditions = self._build_result_conditions(
code=code,
@@ -137,6 +146,8 @@ class BacktestRepository:
AnalysisHistory.trend_prediction,
AnalysisHistory.created_at,
AnalysisHistory.context_snapshot,
AnalysisHistory.raw_result,
AnalysisHistory.report_type,
)
.join(AnalysisHistory, AnalysisHistory.id == BacktestResult.analysis_history_id)
.where(where_clause)
@@ -157,7 +168,7 @@ class BacktestRepository:
days: Optional[int],
offset: int,
limit: int,
) -> List[Tuple[BacktestResult, Optional[str], Optional[str], Optional[datetime], Optional[str]]]:
) -> List[BacktestResultContextRow]:
"""Return result rows plus AnalysisHistory.context_snapshot for dynamic filtering."""
with self.db.get_session() as session:
conditions = self._build_result_conditions(
@@ -176,6 +187,8 @@ class BacktestRepository:
AnalysisHistory.trend_prediction,
AnalysisHistory.created_at,
AnalysisHistory.context_snapshot,
AnalysisHistory.raw_result,
AnalysisHistory.report_type,
)
.join(AnalysisHistory, AnalysisHistory.id == BacktestResult.analysis_history_id)
.where(where_clause)
+382
View File
@@ -0,0 +1,382 @@
# -*- coding: utf-8 -*-
"""Decision action taxonomy helpers for Issue #1390 P0.
This module is deliberately separate from ``src.agent.protocols``:
``DecisionAction`` is the new eight-state display taxonomy, while
``decision_type`` remains the existing buy/hold/sell statistics contract.
"""
from __future__ import annotations
import re
from typing import Any, Dict, Literal, Optional, TypedDict, get_args
from src.report_language import normalize_report_language
DecisionAction = Literal["buy", "add", "hold", "reduce", "sell", "watch", "avoid", "alert"]
class DecisionActionFields(TypedDict):
action: Optional[DecisionAction]
action_label: Optional[str]
_ACTION_VALUES = set(get_args(DecisionAction))
_NON_STOCK_REPORT_TYPES = {"market_review"}
_ACTION_LABELS: Dict[str, Dict[str, str]] = {
"buy": {"zh": "买入", "en": "Buy"},
"add": {"zh": "加仓", "en": "Add"},
"hold": {"zh": "持有", "en": "Hold"},
"reduce": {"zh": "减仓", "en": "Reduce"},
"sell": {"zh": "卖出", "en": "Sell"},
"watch": {"zh": "观望", "en": "Watch"},
"avoid": {"zh": "回避", "en": "Avoid"},
"alert": {"zh": "预警", "en": "Alert"},
}
_EXPLICIT_ALIASES: Dict[str, DecisionAction] = {
"strong buy": "buy",
"accumulate": "add",
"trim": "reduce",
"strong sell": "sell",
"wait": "watch",
}
_ACTION_PHRASES: Dict[DecisionAction, tuple[str, ...]] = {
"avoid": (
"不建议买入",
"避免买入",
"do not buy",
"don't buy",
"dont buy",
"回避",
"规避",
"avoid",
),
"alert": (
"风险预警",
"触发告警",
"risk alert",
"警惕",
"alert",
),
"buy": (
"强烈买入",
"strong_buy",
"strong buy",
"买入",
"布局",
"建仓",
"buy",
),
"add": (
"加仓",
"增持",
"accumulate",
"add",
),
"hold": (
"持有观察",
"洗盘观察",
"持有",
"hold",
),
"watch": (
"观望",
"等待",
"wait",
"watch",
),
"reduce": (
"减仓",
"trim",
"reduce",
),
"sell": (
"强烈卖出",
"strong_sell",
"strong sell",
"卖出",
"清仓",
"sell",
),
}
_NEGATED_ACTION_PHRASES: Dict[DecisionAction, tuple[str, ...]] = {
"avoid": (
"暂不买入",
"不要买入",
"不宜买入",
"先不买入",
"不建议建仓",
"暂不建仓",
"不要建仓",
"不宜建仓",
"先不建仓",
"无需建仓",
"无须建仓",
"不建议布局",
"暂不布局",
"不要布局",
"不宜布局",
"先不布局",
"无需布局",
"无须布局",
"无需买入",
"无须买入",
"not buy",
"do not buy",
"don't buy",
"dont buy",
"no buy",
"no need to buy",
"need not buy",
"cannot buy",
"can't buy",
"cant buy",
),
"hold": (
"不建议加仓",
"无需加仓",
"不要加仓",
"不宜加仓",
"暂不加仓",
"无须加仓",
"不建议增持",
"无需增持",
"不要增持",
"不宜增持",
"暂不增持",
"无须增持",
"不建议卖出",
"无需卖出",
"不要卖出",
"不宜卖出",
"暂不卖出",
"无须卖出",
"不建议减仓",
"无需减仓",
"不要减仓",
"不宜减仓",
"暂不减仓",
"无须减仓",
"不建议清仓",
"无需清仓",
"不要清仓",
"不宜清仓",
"暂不清仓",
"无须清仓",
"not add",
"do not add",
"don't add",
"dont add",
"no add",
"no need to add",
"need not add",
"cannot add",
"can't add",
"cant add",
"not accumulate",
"do not accumulate",
"don't accumulate",
"dont accumulate",
"no accumulate",
"no need to accumulate",
"need not accumulate",
"cannot accumulate",
"can't accumulate",
"cant accumulate",
"not sell",
"do not sell",
"don't sell",
"dont sell",
"no sell",
"no need to sell",
"need not sell",
"cannot sell",
"can't sell",
"cant sell",
"not reduce",
"do not reduce",
"don't reduce",
"dont reduce",
"no reduce",
"no need to reduce",
"need not reduce",
"cannot reduce",
"can't reduce",
"cant reduce",
"not trim",
"do not trim",
"don't trim",
"dont trim",
"no trim",
"no need to trim",
"need not trim",
"cannot trim",
"can't trim",
"cant trim",
),
}
_GUARD_ACTIONS: tuple[DecisionAction, ...] = ("avoid", "alert")
_ENGLISH_NEGATED_ACTION_TERMS: Dict[DecisionAction, tuple[str, ...]] = {
"avoid": ("buy",),
"hold": ("add", "accumulate", "sell", "reduce", "trim"),
}
_ENGLISH_AVOIDED_HOLD_ACTION_TERMS = ("adding", "accumulating", "selling", "reducing", "trimming")
_ENGLISH_DEFERRED_ACTION_TERMS = ("buy", "add", "accumulate", "sell", "reduce", "trim")
_FINANCIAL_COMPOUND_SENTINEL = "financialcompound"
def _normalize_key(value: Any) -> str:
return str(value or "").strip().lower().replace("_", " ").replace("-", " ")
def _mask_english_financial_compounds(text: str) -> str:
text = re.sub(
r"(?<![a-z0-9_])buy\s*back(?![a-z0-9_])",
_FINANCIAL_COMPOUND_SENTINEL,
text,
)
return re.sub(
r"(?<![a-z0-9_])sell\s*off(?![a-z0-9_])",
_FINANCIAL_COMPOUND_SENTINEL,
text,
)
def _word_or_substring_match(text: str, phrase: str) -> bool:
if not text or not phrase:
return False
normalized_phrase = _normalize_key(phrase)
if re.search(r"[a-z]", normalized_phrase):
return bool(re.search(rf"(?<![a-z0-9_]){re.escape(normalized_phrase)}(?![a-z0-9_])", text))
return normalized_phrase in text
def _english_negated_action_matches(text: str) -> set[DecisionAction]:
matches: set[DecisionAction] = set()
negation_prefix = (
r"(?:not\s+(?:a\s+|an\s+|to\s+)?|"
r"no\s+(?:need\s+to\s+)?|"
r"need\s+not\s+|"
r"cannot\s+|can't\s+|cant\s+|"
r"do\s+not\s+|don't\s+|dont\s+)"
)
for action, terms in _ENGLISH_NEGATED_ACTION_TERMS.items():
for term in terms:
if re.search(rf"(?<![a-z0-9_]){negation_prefix}{re.escape(term)}(?![a-z0-9_])", text):
matches.add(action)
return matches
def _has_english_avoided_hold_action(text: str) -> bool:
terms = "|".join(re.escape(term) for term in _ENGLISH_AVOIDED_HOLD_ACTION_TERMS)
return bool(re.search(rf"(?<![a-z0-9_])avoid\s+(?:{terms})(?![a-z0-9_])", text))
def _has_english_deferred_action(text: str) -> bool:
terms = "|".join(re.escape(term) for term in _ENGLISH_DEFERRED_ACTION_TERMS)
if re.search(rf"(?<![a-z0-9_])wait(?:ing)?\s+to\s+(?:{terms})(?![a-z0-9_])", text):
return True
return bool(
re.search(
rf"(?<![a-z0-9_])waiting\s+(?:for|until)\b.*?(?<![a-z0-9_])(?:{terms})(?![a-z0-9_])",
text,
)
)
def _explicit_action(value: Any) -> Optional[DecisionAction]:
normalized = _normalize_key(value)
if not normalized:
return None
if normalized in _ACTION_VALUES:
return normalized # type: ignore[return-value]
return _EXPLICIT_ALIASES.get(normalized)
def normalize_decision_action(value: Any) -> Optional[DecisionAction]:
"""Return a unique eight-state action for explicit values or clear text.
Unknown or ambiguous human-readable advice returns ``None`` rather than
defaulting to a neutral action.
"""
explicit = _explicit_action(value)
if explicit:
return explicit
text = _mask_english_financial_compounds(_normalize_key(value))
if not text:
return None
if _has_english_deferred_action(text):
return None
negated_matches: set[DecisionAction] = set()
if _has_english_avoided_hold_action(text):
negated_matches.add("hold")
negated_matches.update(_english_negated_action_matches(text))
for action, phrases in _NEGATED_ACTION_PHRASES.items():
if any(_word_or_substring_match(text, phrase) for phrase in phrases):
negated_matches.add(action)
if len(negated_matches) == 1:
return next(iter(negated_matches))
if len(negated_matches) > 1:
return None
guard_matches: set[DecisionAction] = set()
for action in _GUARD_ACTIONS:
if any(_word_or_substring_match(text, phrase) for phrase in _ACTION_PHRASES[action]):
guard_matches.add(action)
if len(guard_matches) == 1:
return next(iter(guard_matches))
if len(guard_matches) > 1:
return None
matches: set[DecisionAction] = set()
for action, phrases in _ACTION_PHRASES.items():
if action in _GUARD_ACTIONS:
continue
if any(_word_or_substring_match(text, phrase) for phrase in phrases):
matches.add(action)
if len(matches) == 1:
return next(iter(matches))
return None
def localize_action_label(action: Any, language: Optional[str] = "zh") -> Optional[str]:
"""Return a localized display label for a decision action."""
normalized = _explicit_action(action)
if not normalized:
return None
return _ACTION_LABELS[normalized][normalize_report_language(language)]
def build_action_fields(
*,
operation_advice: Any = None,
explicit_action: Any = None,
report_type: Any = None,
report_language: Optional[str] = "zh",
) -> DecisionActionFields:
"""Build optional public action fields without mutating legacy contracts."""
if str(report_type or "").strip().lower() in _NON_STOCK_REPORT_TYPES:
return {"action": None, "action_label": None}
action = normalize_decision_action(explicit_action)
if action is None:
advice_text = str(operation_advice or "").strip()
if advice_text:
action = normalize_decision_action(advice_text)
return {
"action": action,
"action_label": localize_action_label(action, report_language) if action else None,
}
+9
View File
@@ -24,6 +24,7 @@ from src.report_language import (
normalize_report_language,
)
from src.market_phase_summary import extract_market_phase_summary
from src.schemas.decision_action import build_action_fields
from src.services.run_diagnostics import (
activate_run_diagnostic_context,
build_run_diagnostic_summary,
@@ -174,6 +175,12 @@ class AnalysisService:
report_language = normalize_report_language(getattr(result, "report_language", "zh"))
sentiment_label = get_sentiment_label(result.sentiment_score, report_language)
stock_name = get_localized_stock_name(getattr(result, "name", None), result.code, report_language)
action_fields = build_action_fields(
operation_advice=getattr(result, "operation_advice", None),
explicit_action=getattr(result, "action", None),
report_type=report_type,
report_language=report_language,
)
diagnostic_context = get_current_diagnostic_context()
trace_id = diagnostic_context.trace_id if diagnostic_context is not None else query_id
diagnostic_snapshot = diagnostic_context.snapshot() if diagnostic_context is not None else None
@@ -212,6 +219,8 @@ class AnalysisService:
"summary": {
"analysis_summary": result.analysis_summary,
"operation_advice": localize_operation_advice(result.operation_advice, report_language),
"action": action_fields["action"],
"action_label": action_fields["action_label"],
"trend_prediction": localize_trend_prediction(result.trend_prediction, report_language),
"sentiment_score": result.sentiment_score,
"sentiment_label": sentiment_label,
+48 -6
View File
@@ -15,7 +15,9 @@ from src.core.backtest_engine import OVERALL_SENTINEL_CODE, BacktestEngine, Eval
from src.market_phase_summary import extract_market_phase_summary, normalize_analysis_phase_bucket
from src.repositories.backtest_repo import BacktestRepository
from src.repositories.stock_repo import StockRepository
from src.schemas.decision_action import build_action_fields
from src.storage import BacktestResult, BacktestSummary, DatabaseManager
from src.utils.data_processing import parse_json_field
logger = logging.getLogger(__name__)
@@ -260,7 +262,7 @@ class BacktestService:
limit=limit,
)
items = []
for result, stock_name, trend_prediction, _created_at, context_snapshot in rows:
for result, stock_name, trend_prediction, _created_at, context_snapshot, raw_result, report_type in rows:
summary = extract_market_phase_summary(context_snapshot)
items.append(
self._result_to_dict(
@@ -269,6 +271,8 @@ class BacktestService:
trend_prediction,
market_phase_summary=summary,
market_phase=self._phase_bucket_from_summary(summary),
raw_result=raw_result,
report_type=report_type,
)
)
return {"total": total, "page": page, "limit": limit, "items": items}
@@ -431,7 +435,17 @@ class BacktestService:
sql_offset = 0
scanned = 0
matched_total = 0
page_rows: List[Tuple[BacktestResult, Optional[str], Optional[str], Optional[Dict[str, Any]], str]] = []
page_rows: List[
Tuple[
BacktestResult,
Optional[str],
Optional[str],
Optional[Dict[str, Any]],
str,
Optional[str],
Optional[str],
]
] = []
while True:
remaining_probe_rows = self.MAX_DYNAMIC_SUMMARY_ROWS + 1 - scanned
@@ -454,20 +468,36 @@ class BacktestService:
if scanned > self.MAX_DYNAMIC_SUMMARY_ROWS:
raise ValueError("Phase-filtered results match too many rows; narrow the analysis date range or stock code.")
sql_offset += len(batch)
for result, stock_name, trend_prediction, _created_at, context_snapshot in batch:
for (
result,
stock_name,
trend_prediction,
_created_at,
context_snapshot,
raw_result,
report_type,
) in batch:
summary = extract_market_phase_summary(context_snapshot)
bucket = self._phase_bucket_from_summary(summary)
if bucket != phase_bucket:
continue
if matched_total >= page_offset and len(page_rows) < limit:
page_rows.append((result, stock_name, trend_prediction, summary, bucket))
page_rows.append((result, stock_name, trend_prediction, summary, bucket, raw_result, report_type))
matched_total += 1
if len(batch) < batch_limit:
break
items = [
self._result_to_dict(result, stock_name, trend_prediction, market_phase_summary=summary, market_phase=bucket)
for result, stock_name, trend_prediction, summary, bucket in page_rows
self._result_to_dict(
result,
stock_name,
trend_prediction,
market_phase_summary=summary,
market_phase=bucket,
raw_result=raw_result,
report_type=report_type,
)
for result, stock_name, trend_prediction, summary, bucket, raw_result, report_type in page_rows
]
return {"total": matched_total, "page": page, "limit": limit, "items": items}
@@ -610,7 +640,17 @@ class BacktestService:
trend_prediction: Optional[str] = None,
market_phase_summary: Optional[Dict[str, Any]] = None,
market_phase: Optional[str] = None,
raw_result: Optional[Any] = None,
report_type: Optional[str] = None,
) -> Dict[str, Any]:
parsed_raw_result = parse_json_field(raw_result)
raw = parsed_raw_result if isinstance(parsed_raw_result, dict) else {}
action_fields = build_action_fields(
operation_advice=raw.get("operation_advice") or row.operation_advice,
explicit_action=raw.get("action"),
report_type=report_type or ("market_review" if row.code == "market_review" else None),
report_language=raw.get("report_language"),
)
return {
"analysis_history_id": row.analysis_history_id,
"code": row.code,
@@ -621,6 +661,8 @@ class BacktestService:
"eval_status": row.eval_status,
"evaluated_at": row.evaluated_at.isoformat() if row.evaluated_at else None,
"operation_advice": row.operation_advice,
"action": action_fields["action"],
"action_label": action_fields["action_label"],
"trend_prediction": trend_prediction,
"market_phase": market_phase,
"market_phase_summary": market_phase_summary,
+18
View File
@@ -32,6 +32,7 @@ from src.report_language import (
from src.storage import DatabaseManager
from src.services.run_diagnostics import build_run_diagnostic_summary
from src.market_phase_summary import extract_market_phase_summary
from src.schemas.decision_action import build_action_fields
from src.utils.data_processing import (
extract_realtime_detail_fields,
normalize_model_used,
@@ -263,6 +264,7 @@ class HistoryService:
getattr(record, "context_snapshot", None)
)
market_phase_summary = extract_market_phase_summary(getattr(record, "context_snapshot", None))
action_fields = self._decision_action_fields_for_record(record, raw_result)
return {
"id": record.id,
@@ -274,6 +276,8 @@ class HistoryService:
"analysis_summary": record.analysis_summary,
"sentiment_score": record.sentiment_score,
"operation_advice": record.operation_advice,
"action": action_fields["action"],
"action_label": action_fields["action_label"],
"model_used": normalize_model_used(model_used),
"created_at": record.created_at.isoformat() if record.created_at else None,
"market_phase_summary": market_phase_summary,
@@ -471,6 +475,7 @@ class HistoryService:
if getattr(record, "report_type", None) == "market_review":
market_review_content = self._extract_market_review_content(record, raw_result)
action_fields = self._decision_action_fields_for_record(record, raw_result)
return {
"id": record.id,
"query_id": record.query_id,
@@ -481,6 +486,8 @@ class HistoryService:
"model_used": model_used,
"analysis_summary": market_review_content or record.analysis_summary,
"operation_advice": record.operation_advice,
"action": action_fields["action"],
"action_label": action_fields["action_label"],
"trend_prediction": record.trend_prediction,
"sentiment_score": record.sentiment_score,
"sentiment_label": self._get_sentiment_label(record.sentiment_score or 50),
@@ -493,6 +500,15 @@ class HistoryService:
"context_snapshot": context_snapshot,
}
def _decision_action_fields_for_record(self, record, raw_result: Any) -> Dict[str, Any]:
raw = raw_result if isinstance(raw_result, dict) else {}
return build_action_fields(
operation_advice=raw.get("operation_advice") or getattr(record, "operation_advice", None),
explicit_action=raw.get("action"),
report_type=getattr(record, "report_type", None),
report_language=normalize_report_language(raw.get("report_language")),
)
def delete_history_records(self, record_ids: List[int]) -> int:
"""
Delete specified analysis history records.
@@ -740,6 +756,8 @@ class HistoryService:
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")),
action=raw_result.get("action"),
action_label=raw_result.get("action_label"),
dashboard=dashboard,
trend_analysis=raw_result.get("trend_analysis", ""),
short_term_outlook=raw_result.get("short_term_outlook", ""),
+109
View File
@@ -712,6 +712,115 @@ class TestAgentResultConversion(unittest.TestCase):
self.assertEqual(result.operation_advice, "观望")
self.assertIn("Max steps exceeded", result.error_message)
def test_convert_agent_dashboard_preserves_explicit_action(self):
"""Explicit Agent action is display taxonomy; decision_type remains the legacy bridge."""
pipeline = self._make_pipeline()
from src.agent.executor import AgentResult
from src.enums import ReportType
agent_result = AgentResult(
success=True,
content="{}",
dashboard={
"sentiment_score": 52,
"trend_prediction": "震荡",
"operation_advice": "持有观察",
"decision_type": "hold",
"action": "watch",
"analysis_summary": "等待确认",
},
provider="gemini",
)
result = pipeline._agent_result_to_analysis_result(
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q-action"
)
raw_result = result.to_dict()
self.assertEqual(result.operation_advice, "持有观察")
self.assertEqual(result.decision_type, "hold")
self.assertEqual(result.action, "watch")
self.assertEqual(result.action_label, "观望")
self.assertEqual(raw_result["action"], "watch")
self.assertEqual(raw_result["action_label"], "观望")
def test_final_action_refresh_preserves_explicit_action_when_advice_is_unchanged(self):
"""Pre-save refresh must not overwrite an explicit Agent action without a final advice rewrite."""
pipeline = self._make_pipeline()
from src.agent.executor import AgentResult
from src.enums import ReportType
agent_result = AgentResult(
success=True,
content="{}",
dashboard={
"sentiment_score": 52,
"trend_prediction": "震荡",
"operation_advice": "持有观察",
"decision_type": "hold",
"action": "watch",
"analysis_summary": "等待确认",
},
provider="gemini",
)
result = pipeline._agent_result_to_analysis_result(
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q-action-preserve"
)
previous_operation_advice = result.operation_advice
pipeline._refresh_decision_action_for_final_result(
result,
report_type=ReportType.SIMPLE.value,
previous_operation_advice=previous_operation_advice,
)
raw_result = result.to_dict()
self.assertEqual(result.operation_advice, "持有观察")
self.assertEqual(result.action, "watch")
self.assertEqual(result.action_label, "观望")
self.assertEqual(raw_result["action"], "watch")
self.assertEqual(raw_result["action_label"], "观望")
def test_final_action_refresh_ignores_stale_pre_guardrail_action(self):
"""Post-processing can rewrite advice; refreshed action must follow the final advice."""
pipeline = self._make_pipeline()
from src.agent.executor import AgentResult
from src.enums import ReportType
agent_result = AgentResult(
success=True,
content="{}",
dashboard={
"sentiment_score": 68,
"trend_prediction": "震荡",
"operation_advice": "买入",
"decision_type": "buy",
"action": "buy",
"analysis_summary": "等待确认",
},
provider="gemini",
)
result = pipeline._agent_result_to_analysis_result(
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q-action-refresh"
)
previous_operation_advice = result.operation_advice
result.operation_advice = "持有观察"
result.decision_type = "hold"
pipeline._refresh_decision_action_for_final_result(
result,
report_type=ReportType.SIMPLE.value,
previous_operation_advice=previous_operation_advice,
)
self.assertEqual(result.action, "hold")
self.assertEqual(result.action_label, "持有")
def test_convert_invalid_dashboard_preserves_local_trend_result(self):
"""Invalid Agent dashboard should not erase already-computed trend data."""
pipeline = self._make_pipeline()
+117 -1
View File
@@ -515,7 +515,7 @@ class AnalysisApiContractTestCase(unittest.TestCase):
"stock_name": "贵州茅台",
"report": {
"meta": {"query_id": "task-queue-1", "stock_code": "600519"},
"summary": {"analysis_summary": "summary"},
"summary": {"analysis_summary": "summary", "operation_advice": "不建议买入"},
},
},
error=None,
@@ -539,6 +539,61 @@ class AnalysisApiContractTestCase(unittest.TestCase):
status.result.report["summary"]["analysis_summary"],
"summary",
)
self.assertEqual(status.result.report["summary"]["operation_advice"], "不建议买入")
self.assertEqual(status.result.report["summary"]["action"], "avoid")
self.assertEqual(status.result.report["summary"]["action_label"], "回避")
def test_get_analysis_status_prefers_raw_result_action_over_summary_action(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
created_at = datetime(2026, 5, 21, 17, 40, 0)
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id="task-queue-action-conflict",
stock_code="600519",
stock_name="贵州茅台",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result={
"stock_code": "600519",
"stock_name": "贵州茅台",
"report": {
"meta": {
"query_id": "task-queue-action-conflict",
"stock_code": "600519",
"report_type": "detailed",
"report_language": "zh",
},
"summary": {
"analysis_summary": "summary",
"operation_advice": "持有观察",
"action": "buy",
},
"details": {
"raw_result": {
"operation_advice": "持有观察",
"action": "watch",
"report_language": "zh",
},
},
},
},
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
created_at=created_at,
completed_at=datetime(2026, 5, 21, 17, 45, 0),
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
status = get_analysis_status("task-queue-action-conflict")
self.assertEqual(status.status, "completed")
self.assertIsNotNone(status.result)
self.assertEqual(status.result.report["summary"]["action"], "watch")
self.assertEqual(status.result.report["summary"]["action_label"], "观望")
def test_get_analysis_status_preserves_queue_report_created_at_when_enriching(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
@@ -1141,6 +1196,67 @@ 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_derives_decision_action_fields(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_type": "detailed", "report_language": "zh"},
"summary": {
"analysis_summary": "等待确认",
"operation_advice": "不建议买入",
"trend_prediction": "震荡",
"sentiment_score": 45,
},
"strategy": {},
"details": {"decision_type": "buy"},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot=None,
fallback_fundamental_payload=None,
)
self.assertEqual(report.summary.operation_advice, "不建议买入")
self.assertEqual(report.summary.action, "avoid")
self.assertEqual(report.summary.action_label, "回避")
def test_build_analysis_report_reads_decision_action_from_raw_result(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_type": "detailed", "report_language": "zh"},
"summary": {
"analysis_summary": "等待确认",
"operation_advice": "持有观察",
"action": "buy",
"trend_prediction": "震荡",
"sentiment_score": 45,
},
"strategy": {},
"details": {
"action": "sell",
"raw_result": {
"operation_advice": "持有观察",
"action": "watch",
"report_language": "zh",
},
},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot=None,
fallback_fundamental_payload=None,
)
self.assertEqual(report.summary.action, "watch")
self.assertEqual(report.summary.action_label, "观望")
def test_build_analysis_report_stringifies_strategy_price_fields(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
+72 -1
View File
@@ -26,11 +26,12 @@ except ModuleNotFoundError:
try:
from fastapi.testclient import TestClient
from api.app import create_app
from api.v1.endpoints.history import get_history_detail
from api.v1.endpoints.history import get_history_detail, get_stock_bar
except ModuleNotFoundError:
TestClient = None
create_app = None
get_history_detail = None
get_stock_bar = None
from src.config import Config
from src.storage import DatabaseManager, AnalysisHistory, BacktestResult
@@ -345,6 +346,8 @@ class AnalysisHistoryTestCase(unittest.TestCase):
self.assertEqual(item["trend_prediction"], "看多")
self.assertEqual(item["analysis_summary"], "基本面稳健,短期震荡")
self.assertEqual(item["operation_advice"], "持有")
self.assertEqual(item["action"], "hold")
self.assertEqual(item["action_label"], "持有")
self.assertEqual(item["model_used"], "gemini/gemini-2.5-pro")
self.assertEqual(item["current_price"], 51.5)
self.assertEqual(item["change_pct"], -4.61)
@@ -405,6 +408,8 @@ class AnalysisHistoryTestCase(unittest.TestCase):
self.assertEqual(payload["total"], 1)
self.assertEqual(payload["items"][0]["stock_code"], "MARKET")
self.assertEqual(payload["items"][0]["report_type"], "market_review")
self.assertIsNone(payload["items"][0]["action"])
self.assertIsNone(payload["items"][0]["action_label"])
def test_distinct_stock_bar_excludes_market_review_records_by_default(self) -> None:
"""The stock bar aggregation should not mix MARKET into ordinary stock entries."""
@@ -445,6 +450,68 @@ class AnalysisHistoryTestCase(unittest.TestCase):
self.assertEqual([record.code for record in records], ["600519"])
def test_stock_bar_item_derives_action_fields_from_legacy_advice(self) -> None:
if get_stock_bar is None:
self.skipTest("fastapi is not installed in this test environment")
result = self._build_result()
result.operation_advice = "不建议买入"
saved = self.db.save_analysis_history(
result=result,
query_id="query_stock_bar_action",
report_type="detailed",
news_content="个股正文",
context_snapshot=None,
save_snapshot=False,
)
self.assertEqual(saved, 1)
response = get_stock_bar(
start_date=None,
end_date=None,
limit=10,
db_manager=self.db,
)
self.assertEqual(len(response.items), 1)
self.assertEqual(response.items[0].operation_advice, "不建议买入")
self.assertEqual(response.items[0].action, "avoid")
self.assertEqual(response.items[0].action_label, "回避")
def test_history_detail_uses_service_resolved_action_fields(self) -> None:
if get_history_detail is None:
self.skipTest("fastapi is not installed in this test environment")
service = MagicMock()
service.resolve_and_get_detail.return_value = {
"id": 1,
"query_id": "query_action_conflict",
"stock_code": "600519",
"stock_name": "贵州茅台",
"report_type": "detailed",
"report_language": "zh",
"created_at": "2026-05-21T17:40:00",
"sentiment_score": 45,
"operation_advice": "持有观察",
"action": "watch",
"action_label": "观望",
"trend_prediction": "震荡",
"analysis_summary": "等待确认",
"raw_result": {
"operation_advice": "持有观察",
"action": "watch",
"report_language": "zh",
},
}
with patch("api.v1.endpoints.history.HistoryService", return_value=service):
response = get_history_detail("query_action_conflict", db_manager=self.db)
self.assertEqual(response.summary.operation_advice, "持有观察")
self.assertEqual(response.summary.action, "watch")
self.assertEqual(response.summary.action_label, "观望")
def test_history_list_matches_equivalent_suffixed_stock_codes(self) -> None:
"""Same-stock history should include rows saved with supported suffixed codes."""
@@ -1231,6 +1298,8 @@ class AnalysisHistoryTestCase(unittest.TestCase):
self.assertEqual(report.meta.report_type, "market_review")
self.assertEqual(report.summary.analysis_summary, report_content)
self.assertIsNone(report.summary.action)
self.assertIsNone(report.summary.action_label)
self.assertEqual(report.details.news_content, report_content)
def test_history_detail_localizes_english_summary_fields(self) -> None:
@@ -1271,6 +1340,8 @@ class AnalysisHistoryTestCase(unittest.TestCase):
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.action, "buy")
self.assertEqual(report.summary.action_label, "Buy")
self.assertEqual(report.summary.trend_prediction, "Bullish")
self.assertEqual(report.summary.sentiment_label, "Bullish")
+50
View File
@@ -303,6 +303,44 @@ class BacktestServiceTestCase(unittest.TestCase):
self.assertEqual(item["direction_expected"], "up")
self.assertTrue(item["direction_correct"])
def test_get_recent_evaluations_prefers_persisted_raw_action(self) -> None:
service = BacktestService(self.db)
with self.db.get_session() as session:
history = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "q1").one()
history.operation_advice = "持有观察"
history.raw_result = json.dumps(
{
"operation_advice": "持有观察",
"action": "watch",
"action_label": "观望",
},
ensure_ascii=False,
)
result = self._make_backtest_result(
analysis_history_id=history.id,
analysis_date=date(2024, 1, 1),
eval_window_days=1,
)
result.operation_advice = "持有观察"
result.position_recommendation = "long"
session.add(result)
session.commit()
data = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=10,
page=1,
)
self.assertEqual(data["total"], 1)
item = data["items"][0]
self.assertEqual(item["operation_advice"], "持有观察")
self.assertEqual(item["action"], "watch")
self.assertEqual(item["action_label"], "观望")
self.assertEqual(item["position_recommendation"], "long")
def test_get_recent_evaluations_supports_tracking_fields_and_analysis_date_filters(self) -> None:
self._seed_analysis(
query_id="q2",
@@ -436,6 +474,10 @@ class BacktestServiceTestCase(unittest.TestCase):
self.assertEqual(evaluations["total"], 1)
self.assertEqual(len(evaluations["items"]), 1)
self.assertEqual(evaluations["items"][0]["engine_version"], "v1")
self.assertEqual(evaluations["items"][0]["operation_advice"], "买入")
self.assertEqual(evaluations["items"][0]["action"], "buy")
self.assertEqual(evaluations["items"][0]["action_label"], "买入")
self.assertEqual(evaluations["items"][0]["position_recommendation"], "long")
# Without explicit eval_window_days, summary infers the smallest
# window from matched rows (window=1 in this dataset) instead of
@@ -502,6 +544,10 @@ class BacktestServiceTestCase(unittest.TestCase):
def test_phase_filter_results_allows_exact_dynamic_cap(self) -> None:
service = BacktestService(self.db)
phase_snapshot = json.dumps({"market_phase_summary": {"phase": "intraday", "market": "cn"}})
raw_result = json.dumps(
{"operation_advice": "持有观察", "action": "watch", "action_label": "观望"},
ensure_ascii=False,
)
rows = [
(
self._make_backtest_result(analysis_history_id=idx + 1, analysis_date=date(2024, 1, idx + 1)),
@@ -509,6 +555,8 @@ class BacktestServiceTestCase(unittest.TestCase):
"看多",
datetime(2024, 1, idx + 1, 0, 0, 0),
phase_snapshot,
raw_result,
"simple",
)
for idx in range(2)
]
@@ -532,6 +580,8 @@ class BacktestServiceTestCase(unittest.TestCase):
self.assertEqual(data["total"], 2)
self.assertEqual(len(data["items"]), 2)
self.assertEqual(data["items"][0]["action"], "watch")
self.assertEqual(data["items"][0]["action_label"], "观望")
def test_phase_filter_without_window_matches_summary_window(self) -> None:
service = BacktestService(self.db)
+321
View File
@@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
"""Tests for Issue #1390 P0 decision action taxonomy helpers."""
import pytest
from src.schemas.decision_action import (
build_action_fields,
localize_action_label,
normalize_decision_action,
)
@pytest.mark.parametrize(
("value", "expected"),
[
("strong_buy", "buy"),
("强烈买入", "buy"),
("买入", "buy"),
("布局", "buy"),
("建仓", "buy"),
("add", "add"),
("加仓", "add"),
("增持", "add"),
("accumulate", "add"),
("hold", "hold"),
("持有", "hold"),
("持有观察", "hold"),
("洗盘观察", "hold"),
("watch", "watch"),
("观望", "watch"),
("等待", "watch"),
("wait", "watch"),
("reduce", "reduce"),
("减仓", "reduce"),
("trim", "reduce"),
("sell", "sell"),
("卖出", "sell"),
("清仓", "sell"),
("strong_sell", "sell"),
("强烈卖出", "sell"),
("avoid", "avoid"),
("回避", "avoid"),
("规避", "avoid"),
("不建议买入", "avoid"),
("避免买入", "avoid"),
("do not buy", "avoid"),
("alert", "alert"),
("风险预警", "alert"),
("警惕", "alert"),
("触发告警", "alert"),
("risk alert", "alert"),
],
)
def test_normalize_decision_action_matrix(value: str, expected: str) -> None:
assert normalize_decision_action(value) == expected
@pytest.mark.parametrize(
"value",
[
"",
None,
"观察",
"等待突破后买入",
"waiting to buy",
"买入或卖出",
"buy or sell",
"买盘增强,继续观察",
"卖压缓解,继续观察",
"卖方评级分歧",
"no buyback announced",
"cannot buyback shares now",
"share buy-back announced",
"share buy back announced",
"no selloff risk",
"not selloff yet",
"sell-off risk remains low",
"sell off risk remains low",
"no sell-off pressure",
"risk alert, avoid buying",
"风险预警,避免买入",
"普通复盘说明",
],
)
def test_normalize_decision_action_unknown_or_ambiguous_returns_none(value: str | None) -> None:
assert normalize_decision_action(value) is None
@pytest.mark.parametrize(
("value", "expected"),
[
("暂不买入", "avoid"),
("不要买入", "avoid"),
("不宜买入", "avoid"),
("先不买入", "avoid"),
("无需买入", "avoid"),
("无须买入", "avoid"),
("不建议建仓", "avoid"),
("暂不建仓", "avoid"),
("无需建仓", "avoid"),
("无须建仓", "avoid"),
("不建议布局", "avoid"),
("先不布局", "avoid"),
("无需布局", "avoid"),
("无须布局", "avoid"),
("no buy", "avoid"),
("no need to buy", "avoid"),
("need not buy", "avoid"),
("cannot buy", "avoid"),
("can't buy", "avoid"),
("not a buy yet", "avoid"),
("not to buy", "avoid"),
("avoid buying", "avoid"),
("avoid buying into weakness", "avoid"),
("不建议加仓", "hold"),
("无须加仓", "hold"),
("no add", "hold"),
("no need to add", "hold"),
("need not add", "hold"),
("cannot add", "hold"),
("not to add", "hold"),
("no accumulate", "hold"),
("can't accumulate", "hold"),
("not to accumulate", "hold"),
("不建议卖出", "hold"),
("无需卖出", "hold"),
("无须卖出", "hold"),
("不要卖出", "hold"),
("暂不卖出", "hold"),
("no sell", "hold"),
("no need to sell", "hold"),
("cannot sell", "hold"),
("can't sell", "hold"),
("not a sell yet", "hold"),
("not to sell", "hold"),
("无需减仓", "hold"),
("无须减仓", "hold"),
("no reduce", "hold"),
("no need to reduce", "hold"),
("cannot reduce", "hold"),
("not to reduce", "hold"),
("no trim", "hold"),
("can't trim", "hold"),
("not a trim yet", "hold"),
("not to trim", "hold"),
("avoid selling into weakness", "hold"),
("avoid trimming before earnings", "hold"),
("avoid reducing exposure before earnings", "hold"),
("不建议清仓", "hold"),
],
)
def test_normalize_decision_action_handles_negated_trade_actions(value: str, expected: str) -> None:
assert normalize_decision_action(value) == expected
@pytest.mark.parametrize(
"advice",
[
"无需买入,等待确认",
"无须建仓,继续观察",
"无需布局,等待突破",
"no buy until breakout",
"no need to buy before confirmation",
"cannot buy before confirmation",
"can't buy before confirmation",
"not a buy yet",
"not to buy",
],
)
def test_build_action_fields_prioritizes_negated_buy_advice_over_embedded_buy_phrase(advice: str) -> None:
assert build_action_fields(operation_advice=advice) == {
"action": "avoid",
"action_label": "回避",
}
@pytest.mark.parametrize(
"advice",
[
"无须加仓,维持仓位",
"无需卖出,继续持有",
"无须减仓,等待确认",
"no add before confirmation",
"cannot add before confirmation",
"no need to accumulate here",
"can't accumulate here",
"no sell before earnings",
"cannot sell before earnings",
"no need to reduce exposure",
"can't reduce exposure",
"no trim while trend holds",
"cannot trim while trend holds",
"not a sell yet",
"not a trim yet",
"not to sell",
"not to trim",
"avoid selling into weakness",
"avoid trimming before earnings",
"avoid reducing exposure before earnings",
],
)
def test_build_action_fields_prioritizes_negated_hold_advice_over_embedded_trade_phrase(advice: str) -> None:
assert build_action_fields(operation_advice=advice) == {
"action": "hold",
"action_label": "持有",
}
@pytest.mark.parametrize(
"advice",
[
"risk alert, avoid buying",
"风险预警,避免买入",
],
)
def test_build_action_fields_keeps_multi_guard_advice_empty(advice: str) -> None:
assert build_action_fields(operation_advice=advice) == {
"action": None,
"action_label": None,
}
@pytest.mark.parametrize(
"advice",
[
"买盘增强,继续观察",
"卖压缓解,继续观察",
"卖方评级分歧",
],
)
def test_build_action_fields_keeps_chinese_financial_context_empty(advice: str) -> None:
assert build_action_fields(operation_advice=advice) == {
"action": None,
"action_label": None,
}
@pytest.mark.parametrize(
"advice",
[
"no buyback announced",
"cannot buyback shares now",
"no selloff risk",
"not selloff yet",
],
)
def test_build_action_fields_keeps_financial_compound_terms_empty(advice: str) -> None:
assert build_action_fields(operation_advice=advice) == {
"action": None,
"action_label": None,
}
@pytest.mark.parametrize(
"advice",
[
"share buy-back announced",
"share buy back announced",
"sell-off risk remains low",
"sell off risk remains low",
"no sell-off pressure",
],
)
def test_build_action_fields_keeps_hyphenated_financial_compound_terms_empty(advice: str) -> None:
assert build_action_fields(operation_advice=advice) == {
"action": None,
"action_label": None,
}
@pytest.mark.parametrize(
("advice", "expected_action", "expected_label"),
[
("buy after sell-off", "buy", "买入"),
("sell after buy-back rumor", "sell", "卖出"),
],
)
def test_financial_compound_mask_preserves_separate_action_terms(
advice: str,
expected_action: str,
expected_label: str,
) -> None:
assert normalize_decision_action(advice) == expected_action
assert build_action_fields(operation_advice=advice) == {
"action": expected_action,
"action_label": expected_label,
}
def test_localize_action_label_uses_report_language() -> None:
assert localize_action_label("avoid", "zh") == "回避"
assert localize_action_label("avoid", "en") == "Avoid"
def test_build_action_fields_respects_market_review_exclusion() -> None:
fields = build_action_fields(
operation_advice="买入",
explicit_action="buy",
report_type="market_review",
)
assert fields == {"action": None, "action_label": None}
def test_build_action_fields_prefers_explicit_action_over_advice() -> None:
fields = build_action_fields(
operation_advice="买入",
explicit_action="watch",
report_language="zh",
)
assert fields == {"action": "watch", "action_label": "观望"}
def test_build_action_fields_keeps_empty_action_without_advice_or_explicit_action() -> None:
fields = build_action_fields(
operation_advice=None,
report_language="zh",
)
assert fields == {"action": None, "action_label": None}
+23
View File
@@ -160,6 +160,29 @@ class TestAnalyzerSchemaFallback(unittest.TestCase):
self.assertEqual(result.name, "贵州茅台")
self.assertEqual(result.sentiment_score, 72)
self.assertEqual(result.analysis_summary, "技术面向好")
self.assertEqual(result.action, "hold")
self.assertEqual(result.action_label, "持有")
def test_parse_response_preserves_explicit_action_in_raw_result(self) -> None:
analyzer = GeminiAnalyzer()
response = json.dumps({
"stock_name": "贵州茅台",
"sentiment_score": 58,
"trend_prediction": "震荡",
"operation_advice": "持有观察",
"decision_type": "hold",
"action": "watch",
"analysis_summary": "等待确认",
})
result = analyzer._parse_response(response, "600519", "股票600519")
raw_result = result.to_dict()
self.assertEqual(result.action, "watch")
self.assertEqual(result.action_label, "观望")
self.assertEqual(result.decision_type, "hold")
self.assertEqual(raw_result["action"], "watch")
self.assertEqual(raw_result["action_label"], "观望")
def test_parse_response_keeps_unknown_dashboard_fields(self) -> None:
analyzer = GeminiAnalyzer()