mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 14:33:11 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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 扫描误判为缺失。
|
||||
|
||||
@@ -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": "趋势预测"
|
||||
|
||||
@@ -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 分析记录进行事后验证,评估分析建议的准确性。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user