Skip to content

Commit 7d592b0

Browse files
committed
fix(review-feedback-1245): address latest review comments
1 parent d7c919d commit 7d592b0

7 files changed

Lines changed: 247 additions & 10 deletions

File tree

docs/full-guide.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,7 @@ python main.py --debug
10111011
## 分析决策可操作性
10121012

10131013
个股报告的操作建议会结合支撑位、压力位、量能/筹码、主力资金流向和风险事件进行校准,避免仅因单日涨跌或评分跨线在“买入/卖出”之间剧烈切换。若价格处在支撑与压力之间且资金流不明确,报告会优先给出“持有、震荡观望、洗盘观察”等中性可执行建议;只有接近支撑确认、有效突破压力且量价/资金配合时才给出买入,跌破关键支撑或主力资金持续流出时才给出卖出/减仓。
1014+
该项调整仅影响决策后处理逻辑,不会变更 LLM 模型、LiteLLM 路由、Provider/Key 的配置与兼容语义。
10141015

10151016
## 回测功能
10161017

@@ -1030,7 +1031,7 @@ python main.py --debug
10301031
|---------|---------|---------|---------|
10311032
| 买入/加仓/strong buy | long | up | 涨幅 ≥ 中性带 |
10321033
| 卖出/减仓/strong sell | cash | down | 跌幅 ≥ 中性带 |
1033-
| 持有/hold | long | not_down | 未显著下跌 |
1034+
| 持有/持有观察/震荡观望/洗盘观察/hold/hold and watch/range-bound watch/shakeout watch | long | not_down | 未显著下跌 |
10341035
| 观望/等待/wait | cash | flat | 价格在中性带内 |
10351036

10361037
### 配置

docs/full-guide_EN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,7 @@ You can tune the behavior in `.env`:
857857
## Decision Actionability
858858

859859
Single-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.
860+
This post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).
860861

861862
## Backtesting
862863

@@ -876,7 +877,7 @@ The backtesting module automatically validates historical AI analysis records ag
876877
|-----------------|----------|-------------------|---------------|
877878
| Buy / Add / Strong Buy | long | up | Return >= neutral band |
878879
| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |
879-
| Hold | long | not_down | No significant decline |
880+
| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |
880881
| Wait / Observe | cash | flat | Price within neutral band |
881882

882883
### Configuration

src/analyzer.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,30 @@ def _is_value_placeholder(v: Any) -> bool:
252252
return s in ("", "n/a", "na", "数据缺失", "未知", "data unavailable", "unknown", "tbd")
253253

254254

255+
_RISK_WARNING_PLACEHOLDER_TEXTS = {
256+
"",
257+
"n/a",
258+
"na",
259+
"none",
260+
"null",
261+
"unknown",
262+
"tbd",
263+
"暂无",
264+
"待补充",
265+
"数据缺失",
266+
"未知",
267+
"无",
268+
}
269+
270+
271+
def _is_meaningful_text(value: Any) -> bool:
272+
text = str(value).strip() if value is not None else ""
273+
if not text:
274+
return False
275+
lowered = text.strip().lower()
276+
return lowered not in _RISK_WARNING_PLACEHOLDER_TEXTS
277+
278+
255279
def _safe_float(v: Any, default: float = 0.0) -> float:
256280
"""Safely convert to float; return default on failure. Private helper for chip fill."""
257281
if v is None:
@@ -671,6 +695,8 @@ def stabilize_decision_with_structure(
671695
and support * 1.03 < current_price < resistance * 0.97
672696
)
673697

698+
has_significant_risk = _has_structural_risk_alert(result)
699+
674700
if decision_type == "buy":
675701
if near_resistance and flow_bias != "inflow":
676702
_downgrade_to_structural_hold(
@@ -706,7 +732,7 @@ def stabilize_decision_with_structure(
706732
flow_bias=flow_bias,
707733
)
708734
elif decision_type == "sell":
709-
if near_support and flow_bias != "outflow":
735+
if near_support and (flow_bias != "outflow") and not has_significant_risk:
710736
_downgrade_to_structural_hold(
711737
result,
712738
language,
@@ -717,7 +743,7 @@ def stabilize_decision_with_structure(
717743
resistance=resistance,
718744
flow_bias=flow_bias,
719745
)
720-
elif flow_bias == "inflow" and not broke_support:
746+
elif flow_bias == "inflow" and not broke_support and not has_significant_risk:
721747
_downgrade_to_structural_hold(
722748
result,
723749
language,
@@ -752,10 +778,44 @@ def stabilize_decision_with_structure(
752778
resistance=resistance,
753779
flow_bias=flow_bias,
754780
)
781+
_sync_stability_dashboard_fields(result)
755782
except Exception as exc:
756783
logger.warning("[decision_stability] skipped: %s", exc)
757784

758785

786+
def _has_structural_risk_alert(result: "AnalysisResult") -> bool:
787+
dashboard = result.dashboard if isinstance(result.dashboard, dict) else {}
788+
789+
risk_text = getattr(result, "risk_warning", "")
790+
if _is_meaningful_text(risk_text):
791+
return True
792+
793+
intelligence = dashboard.get("intelligence") if isinstance(dashboard, dict) else None
794+
if isinstance(intelligence, dict):
795+
risk_alerts = intelligence.get("risk_alerts")
796+
if isinstance(risk_alerts, str):
797+
if _is_meaningful_text(risk_alerts):
798+
return True
799+
elif isinstance(risk_alerts, (list, tuple, set)):
800+
if any(_is_meaningful_text(item) for item in risk_alerts):
801+
return True
802+
803+
core_conclusion = dashboard.get("core_conclusion") if isinstance(dashboard, dict) else None
804+
if isinstance(core_conclusion, dict):
805+
signal_type = str(core_conclusion.get("signal_type", "")).strip()
806+
if "风险" in signal_type:
807+
return True
808+
return False
809+
810+
811+
def _sync_stability_dashboard_fields(result: "AnalysisResult") -> None:
812+
dashboard = result.dashboard if isinstance(result.dashboard, dict) else {}
813+
result.dashboard = dashboard
814+
dashboard["sentiment_score"] = getattr(result, "sentiment_score", None)
815+
dashboard["operation_advice"] = getattr(result, "operation_advice", None)
816+
dashboard["decision_type"] = getattr(result, "decision_type", None)
817+
818+
759819
def _as_dict_for_decision_guard(value: Any) -> Dict[str, Any]:
760820
if isinstance(value, dict):
761821
return value

src/core/backtest_engine.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from dataclasses import dataclass
1111
from datetime import date
12+
import re
1213
from typing import Any, Dict, Iterable, List, Optional, Protocol, Sequence
1314

1415

@@ -72,7 +73,13 @@ class BacktestEngine:
7273
)
7374
_HOLD_KEYWORDS = (
7475
"持有",
76+
"震荡观望",
77+
"洗盘观察",
78+
"持有观察",
7579
"hold",
80+
"range-bound watch",
81+
"shakeout watch",
82+
"hold and watch",
7683
)
7784
_WAIT_KEYWORDS = (
7885
"观望",
@@ -109,7 +116,9 @@ def infer_position_recommendation(cls, operation_advice: Optional[str]) -> str:
109116
Priority: bearish/wait -> cash, bullish/hold -> long, unrecognized -> cash.
110117
"""
111118
text = cls._normalize_text(operation_advice)
112-
if cls._matches_intent(text, cls._BEARISH_KEYWORDS) or cls._matches_intent(text, cls._WAIT_KEYWORDS):
119+
if cls._matches_intent(text, cls._BEARISH_KEYWORDS):
120+
return "cash"
121+
if cls._matches_intent(text, cls._WAIT_KEYWORDS):
113122
return "cash"
114123
if cls._matches_intent(text, cls._BULLISH_KEYWORDS) or cls._matches_intent(text, cls._HOLD_KEYWORDS):
115124
return "long"
@@ -363,14 +372,39 @@ def _matches_intent(cls, text: str, keywords: Sequence[str]) -> bool:
363372
if not text:
364373
return False
365374
for kw in keywords:
375+
if not kw:
376+
continue
366377
if text == kw:
367378
return True
368-
for kw in keywords:
369-
idx = text.find(kw)
370-
if idx == -1:
379+
380+
keyword = kw.lower().strip()
381+
if not keyword:
371382
continue
372-
if not cls._is_negated(text[:idx]):
373-
return True
383+
384+
# Use word-boundary matching for ASCII keywords to avoid
385+
# false positives such as "watch" matching "wait".
386+
if bool(re.search(r"[a-z]", keyword)):
387+
match = re.search(
388+
rf"(?<![a-zA-Z0-9_]){re.escape(keyword)}(?![a-zA-Z0-9_])",
389+
text,
390+
)
391+
if not match:
392+
continue
393+
if not cls._is_negated(text[: match.start()]):
394+
return True
395+
continue
396+
397+
# For non-ASCII terms (Chinese), avoid matching fragments embedded
398+
# in another Chinese word to reduce false positives.
399+
if re.search(r"[\u4e00-\u9fff]", keyword):
400+
match = re.search(
401+
rf"(?<![\u4e00-\u9fff]){re.escape(keyword)}(?![\u4e00-\u9fff])",
402+
text,
403+
)
404+
if match and not cls._is_negated(text[: match.start()]):
405+
return True
406+
continue
407+
374408
return False
375409

376410
@classmethod

tests/test_agent_pipeline.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,98 @@ def test_analyze_with_agent_uses_resolved_name_for_news_persistence(self):
12941294
saved_kwargs = pipeline.db.save_news_intel.call_args.kwargs
12951295
self.assertEqual(saved_kwargs["name"], "科创芯片ETF")
12961296

1297+
def test_analyze_with_agent_keeps_dashboard_top_level_fields_after_stability(self):
1298+
"""Decision stability downgrade in agent flow should sync dashboard and top-level decision fields."""
1299+
with patch('src.core.pipeline.get_config') as mock_config, \
1300+
patch('src.core.pipeline.get_db'), \
1301+
patch('src.core.pipeline.DataFetcherManager'), \
1302+
patch('src.core.pipeline.GeminiAnalyzer'), \
1303+
patch('src.core.pipeline.NotificationService'), \
1304+
patch('src.core.pipeline.SearchService'), \
1305+
patch('src.agent.factory.build_agent_executor') as mock_build_executor:
1306+
1307+
mock_cfg = MagicMock()
1308+
mock_cfg.max_workers = 2
1309+
mock_cfg.agent_mode = True
1310+
mock_cfg.agent_max_steps = 10
1311+
mock_cfg.agent_skills = []
1312+
mock_cfg.bocha_api_keys = []
1313+
mock_cfg.tavily_api_keys = []
1314+
mock_cfg.brave_api_keys = []
1315+
mock_cfg.serpapi_keys = []
1316+
mock_cfg.searxng_base_urls = []
1317+
mock_cfg.searxng_public_instances_enabled = False
1318+
mock_cfg.news_max_age_days = 7
1319+
mock_cfg.enable_realtime_quote = True
1320+
mock_cfg.enable_chip_distribution = True
1321+
mock_cfg.realtime_source_priority = []
1322+
mock_cfg.save_context_snapshot = False
1323+
mock_cfg.report_language = "zh"
1324+
mock_cfg.agent_orchestrator_timeout_s = 600
1325+
mock_config.return_value = mock_cfg
1326+
1327+
from src.core.pipeline import StockAnalysisPipeline
1328+
from src.agent.executor import AgentResult
1329+
from src.enums import ReportType
1330+
from src.stock_analyzer import TrendAnalysisResult, TrendStatus, BuySignal
1331+
pipeline = StockAnalysisPipeline(config=mock_cfg)
1332+
1333+
agent_result = AgentResult(
1334+
success=True,
1335+
content="{}",
1336+
dashboard={
1337+
"sentiment_score": 30,
1338+
"trend_prediction": "震荡",
1339+
"operation_advice": "卖出",
1340+
"decision_type": "sell",
1341+
"analysis_summary": "原始建议",
1342+
"dashboard": {
1343+
"core_conclusion": {"one_sentence": "初始结论"},
1344+
},
1345+
},
1346+
provider="gemini",
1347+
)
1348+
mock_executor = MagicMock()
1349+
mock_executor.run.return_value = agent_result
1350+
mock_build_executor.return_value = mock_executor
1351+
1352+
trend_result = TrendAnalysisResult(
1353+
code="002812",
1354+
trend_status=TrendStatus.BULL,
1355+
buy_signal=BuySignal.SELL,
1356+
signal_score=30,
1357+
support_levels=[30.0],
1358+
resistance_levels=[34.0],
1359+
)
1360+
fundamental_context = {
1361+
"capital_flow": {
1362+
"status": "ok",
1363+
"data": {
1364+
"stock_flow": {
1365+
"main_net_inflow": 800_000,
1366+
}
1367+
},
1368+
}
1369+
}
1370+
1371+
result = pipeline._analyze_with_agent(
1372+
code="002812",
1373+
report_type=ReportType.SIMPLE,
1374+
query_id="q-agent-stability",
1375+
stock_name="恩捷股份",
1376+
realtime_quote={"price": 30.4, "change_pct": -2.1},
1377+
chip_data=None,
1378+
fundamental_context=fundamental_context,
1379+
trend_result=trend_result,
1380+
)
1381+
1382+
self.assertIsNotNone(result)
1383+
self.assertEqual(result.decision_type, "hold")
1384+
self.assertEqual(result.operation_advice, "洗盘观察")
1385+
self.assertEqual(result.dashboard.get("decision_type"), "hold")
1386+
self.assertEqual(result.dashboard.get("operation_advice"), "洗盘观察")
1387+
self.assertEqual(result.dashboard.get("sentiment_score"), result.sentiment_score)
1388+
12971389

12981390
# ============================================================
12991391
# Agent construction chain (real objects, mocked LLM)

tests/test_backtest_engine.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,34 @@ def test_wait_maps_to_cash_and_flat_direction(self):
7575
self.assertEqual(res["direction_expected"], "flat")
7676
self.assertEqual(res["outcome"], "loss")
7777

78+
def test_range_bound_watch_is_treated_as_hold_long_path(self):
79+
self.assertEqual(
80+
BacktestEngine.infer_position_recommendation("震荡观望"),
81+
"long",
82+
)
83+
self.assertEqual(
84+
BacktestEngine.infer_direction_expected("Range-bound watch"),
85+
"not_down",
86+
)
87+
self.assertEqual(
88+
BacktestEngine.infer_position_recommendation("Range-bound watch"),
89+
"long",
90+
)
91+
92+
def test_shakeout_watch_is_treated_as_hold_long_path(self):
93+
self.assertEqual(
94+
BacktestEngine.infer_position_recommendation("洗盘观察"),
95+
"long",
96+
)
97+
self.assertEqual(
98+
BacktestEngine.infer_direction_expected("Shakeout watch"),
99+
"not_down",
100+
)
101+
self.assertEqual(
102+
BacktestEngine.infer_position_recommendation("Hold and watch"),
103+
"long",
104+
)
105+
78106
def test_hold_win_when_flat(self):
79107
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
80108
bars = self._bars(date(2024, 1, 1), [100.5, 100.2, 101], highs=[101, 101, 101], lows=[99.8, 99.9, 100])

tests/test_decision_stability.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,27 @@ def test_downgrades_sell_near_support_without_sustained_outflow() -> None:
179179
assert "不宜仅因单日下跌直接卖出" in result.risk_warning
180180

181181

182+
def test_preserves_sell_signal_when_significant_risk_exists_near_support() -> None:
183+
result = _result(
184+
decision_type="sell",
185+
operation_advice="卖出",
186+
score=30,
187+
current_price=30.4,
188+
change_pct=-2.1,
189+
)
190+
result.risk_warning = "重大利空消息:公司发布重大减持计划"
191+
result.dashboard["intelligence"] = {"risk_alerts": ["股东高位减持预告"]}
192+
193+
stabilize_decision_with_structure(
194+
result,
195+
SimpleNamespace(support_levels=[30.0], resistance_levels=[34.0]),
196+
_fund_flow(main=800_000, five_day=1_200_000),
197+
)
198+
199+
assert result.decision_type == "sell"
200+
assert result.operation_advice == "卖出"
201+
202+
182203
def test_refines_hold_pullback_near_support_as_shakeout_watch() -> None:
183204
result = _result(
184205
decision_type="hold",

0 commit comments

Comments
 (0)