mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: restore stock bar summary badges (#1848)
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Body
|
||||
|
||||
@@ -71,6 +71,47 @@ def _normalize_code_for_grouping(code: str) -> str:
|
||||
return normalize_stock_code(code or "")
|
||||
|
||||
|
||||
def _raw_result_value(raw_result: Any, key: str) -> Any:
|
||||
if not isinstance(raw_result, dict):
|
||||
return None
|
||||
|
||||
value = raw_result.get(key)
|
||||
if value is not None and value != "":
|
||||
return value
|
||||
|
||||
for container_key in ("summary", "dashboard"):
|
||||
container = raw_result.get(container_key)
|
||||
if isinstance(container, dict):
|
||||
nested_value = container.get(key)
|
||||
if nested_value is not None and nested_value != "":
|
||||
return nested_value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _coalesce_text(*values: Any) -> Optional[str]:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _coalesce_int(*values: Any) -> Optional[int]:
|
||||
for value in values:
|
||||
if value is None or isinstance(value, bool):
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=HistoryListResponse,
|
||||
@@ -285,15 +326,20 @@ 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
|
||||
sentiment_score = _coalesce_int(
|
||||
record.sentiment_score,
|
||||
_raw_result_value(raw_result, "sentiment_score"),
|
||||
)
|
||||
operation_advice = _coalesce_text(
|
||||
record.operation_advice,
|
||||
_raw_result_value(raw_result, "operation_advice"),
|
||||
)
|
||||
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,
|
||||
operation_advice=operation_advice,
|
||||
explicit_action=_raw_result_value(raw_result, "action"),
|
||||
report_type=record.report_type,
|
||||
report_language=normalize_report_language(
|
||||
raw_result.get("report_language") if isinstance(raw_result, dict) else None
|
||||
_raw_result_value(raw_result, "report_language")
|
||||
),
|
||||
)
|
||||
|
||||
@@ -308,8 +354,8 @@ def get_stock_bar(
|
||||
stock_code=display_stock_code,
|
||||
stock_name=record.name,
|
||||
report_type=record.report_type,
|
||||
sentiment_score=record.sentiment_score,
|
||||
operation_advice=record.operation_advice,
|
||||
sentiment_score=sentiment_score,
|
||||
operation_advice=operation_advice,
|
||||
action=action_fields["action"],
|
||||
action_label=action_fields["action_label"],
|
||||
analysis_count=analysis_count,
|
||||
|
||||
@@ -26,14 +26,15 @@ export const StockBarItemComponent: React.FC<StockBarItemProps> = ({
|
||||
isMarketReview = false,
|
||||
}) => {
|
||||
const { language, t } = useUiLanguage();
|
||||
const sentimentColor = item.sentimentScore !== undefined ? getSentimentColor(item.sentimentScore) : null;
|
||||
const sentimentScore = typeof item.sentimentScore === 'number' ? item.sentimentScore : null;
|
||||
const sentimentColor = sentimentScore !== null ? getSentimentColor(sentimentScore) : null;
|
||||
const stockName = item.stockName || item.stockCode;
|
||||
const actionLabels = buildDecisionActionLabelMap(t);
|
||||
const operationLabel = getDecisionActionLabel(
|
||||
item.action,
|
||||
item.actionLabel,
|
||||
item.operationAdvice,
|
||||
null,
|
||||
t('history.sentiment'),
|
||||
actionLabels,
|
||||
);
|
||||
const phaseLabel = getMarketPhaseSummaryLabel(item.marketPhaseSummary, language)
|
||||
@@ -85,7 +86,7 @@ export const StockBarItemComponent: React.FC<StockBarItemProps> = ({
|
||||
>
|
||||
{t('stockBar.market')}
|
||||
</Badge>
|
||||
) : operationLabel && sentimentColor ? (
|
||||
) : sentimentColor ? (
|
||||
<Badge
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -96,7 +97,7 @@ export const StockBarItemComponent: React.FC<StockBarItemProps> = ({
|
||||
backgroundColor: `${sentimentColor}10`,
|
||||
}}
|
||||
>
|
||||
{operationLabel} {item.sentimentScore}
|
||||
{operationLabel} {sentimentScore}
|
||||
</Badge>
|
||||
) : null}
|
||||
{onDelete && (
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('StockBarItemComponent', () => {
|
||||
|
||||
const actions = screen.getByTestId('history-card-actions');
|
||||
expect(within(actions).queryByText('buy 28')).not.toBeInTheDocument();
|
||||
expect(within(actions).queryByText(/28/)).not.toBeInTheDocument();
|
||||
expect(within(actions).getByText(/28/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render financial compound English advice as an action badge', () => {
|
||||
@@ -141,7 +141,7 @@ describe('StockBarItemComponent', () => {
|
||||
|
||||
let actions = screen.getByTestId('history-card-actions');
|
||||
expect(within(actions).queryByText('持有 28')).not.toBeInTheDocument();
|
||||
expect(within(actions).queryByText(/28/)).not.toBeInTheDocument();
|
||||
expect(within(actions).getByText(/28/)).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<StockBarItemComponent
|
||||
@@ -159,7 +159,7 @@ describe('StockBarItemComponent', () => {
|
||||
|
||||
actions = screen.getByTestId('history-card-actions');
|
||||
expect(within(actions).queryByText('卖出 31')).not.toBeInTheDocument();
|
||||
expect(within(actions).queryByText(/31/)).not.toBeInTheDocument();
|
||||
expect(within(actions).getByText(/31/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Chinese financial context legacy advice as an action badge', () => {
|
||||
@@ -179,7 +179,7 @@ describe('StockBarItemComponent', () => {
|
||||
|
||||
let actions = screen.getByTestId('history-card-actions');
|
||||
expect(within(actions).queryByText('买入 32')).not.toBeInTheDocument();
|
||||
expect(within(actions).queryByText(/32/)).not.toBeInTheDocument();
|
||||
expect(within(actions).getByText(/32/)).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<StockBarItemComponent
|
||||
@@ -197,7 +197,7 @@ describe('StockBarItemComponent', () => {
|
||||
|
||||
actions = screen.getByTestId('history-card-actions');
|
||||
expect(within(actions).queryByText('卖出 34')).not.toBeInTheDocument();
|
||||
expect(within(actions).queryByText(/34/)).not.toBeInTheDocument();
|
||||
expect(within(actions).getByText(/34/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render multi-guard legacy advice as an action badge', () => {
|
||||
@@ -218,6 +218,6 @@ describe('StockBarItemComponent', () => {
|
||||
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();
|
||||
expect(within(actions).getByText(/28/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
|
||||
- [修复] 修复 Web 首页个股栏在 stock-bar 摘要字段缺失或动作建议无法归类时隐藏情绪分与建议标识的问题。
|
||||
|
||||
## [3.24.1] - 2026-06-28
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -755,6 +755,45 @@ class AnalysisHistoryTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.items[0].action, "avoid")
|
||||
self.assertEqual(response.items[0].action_label, "回避")
|
||||
|
||||
def test_stock_bar_item_falls_back_to_raw_result_summary_fields(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 = "Hold"
|
||||
result.report_language = "en"
|
||||
|
||||
saved = self.db.save_analysis_history(
|
||||
result=result,
|
||||
query_id="query_stock_bar_raw_fallback",
|
||||
report_type="detailed",
|
||||
news_content="stock report",
|
||||
context_snapshot=None,
|
||||
save_snapshot=False,
|
||||
)
|
||||
self.assertGreater(saved, 0)
|
||||
|
||||
with self.db.session_scope() as session:
|
||||
row = session.query(AnalysisHistory).filter(
|
||||
AnalysisHistory.query_id == "query_stock_bar_raw_fallback"
|
||||
).first()
|
||||
self.assertIsNotNone(row)
|
||||
row.sentiment_score = None
|
||||
row.operation_advice = None
|
||||
|
||||
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].sentiment_score, 78)
|
||||
self.assertEqual(response.items[0].operation_advice, "Hold")
|
||||
self.assertEqual(response.items[0].action, "hold")
|
||||
self.assertEqual(response.items[0].action_label, "Hold")
|
||||
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user