Skip to content

Commit 067050b

Browse files
HenryHenry
authored andcommitted
fix: preserve directional bias in AI analysis
1 parent d873787 commit 067050b

4 files changed

Lines changed: 115 additions & 13 deletions

File tree

‎backend_api_python/app/services/fast_analysis.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from app.services.fast_analysis_fundamentals import build_score_payload, format_financial_statements, format_fundamental_metric, fundamental_provenance
1414
from app.services.fast_analysis_geo import is_major_geopolitical_news_text
1515
from app.services.fast_analysis_plan import finalize_trading_plan, trading_plan_risk_fields
16+
from app.services.fast_analysis_policy import direction_supported_by_consensus, should_override_with_consensus
1617
from app.services.fast_analysis_scoring import FastAnalysisScoringMixin
1718

1819
logger = get_logger(__name__)
@@ -1135,7 +1136,7 @@ def _trend_strength(score_val: float) -> str:
11351136
):
11361137
min_abs_override = max(min_abs_override, 55.0 if risk_context.get("panic_breakdown") else 40.0)
11371138

1138-
if consensus_abs >= min_abs_override:
1139+
if should_override_with_consensus(consensus_decision, consensus_abs, min_abs_override):
11391140
final_decision = consensus_decision
11401141
if llm_decision != final_decision:
11411142
logger.warning(
@@ -1557,11 +1558,18 @@ def _validate_decision_against_indicators(self, analysis: Dict, indicators: Dict
15571558
)
15581559

15591560
if confidence < 60:
1560-
if decision != "HOLD":
1561+
if decision == "HOLD":
1562+
return analysis
1563+
if not direction_supported_by_consensus(analysis, decision):
15611564
logger.warning(f"Decision {decision} with low confidence {confidence}, forcing to HOLD")
15621565
analysis["decision"] = "HOLD"
15631566
analysis["confidence"] = max(confidence, 45) # 降低置信度
1564-
return analysis
1567+
analysis["decision_guard"] = "low_confidence_without_consensus"
1568+
return analysis
1569+
logger.info(
1570+
f"Keeping low-confidence {decision} because directional consensus confirms it "
1571+
f"(confidence={confidence})"
1572+
)
15651573

15661574
allow_override = has_major_news or has_macro_event
15671575

@@ -1673,7 +1681,7 @@ def _score_to_decision(self, score: float, *, market: str = "Crypto") -> str:
16731681
return "SELL"
16741682
else:
16751683
return "HOLD"
1676-
1684+
16771685
def _calculate_overall_score(self, analysis: Dict) -> int:
16781686
"""Calculate weighted overall score (legacy method, now uses objective score if available)."""
16791687
if "objective_score" in analysis:

‎backend_api_python/app/services/fast_analysis_formatters.py‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,22 @@ def build_trend_outlook_summary(trend_outlook: Dict[str, Any], language: str) ->
2424
return ""
2525
is_zh = str(language or "").lower().startswith("zh")
2626

27-
def label(trend: str) -> str:
27+
def label(trend: str, score: Any = None) -> str:
2828
normalized = str(trend or "HOLD").upper()
29+
try:
30+
numeric_score = float(score)
31+
except (TypeError, ValueError):
32+
numeric_score = 0.0
2933
if is_zh:
34+
if normalized == "HOLD" and numeric_score >= 10:
35+
return "轻微利多·观望"
36+
if normalized == "HOLD" and numeric_score <= -10:
37+
return "轻微利空·观望"
3038
return {"BUY": "看多", "SELL": "看空", "HOLD": "震荡/中性"}.get(normalized, "震荡/中性")
39+
if normalized == "HOLD" and numeric_score >= 10:
40+
return "mildly bullish · wait"
41+
if normalized == "HOLD" and numeric_score <= -10:
42+
return "mildly bearish · wait"
3143
return {"BUY": "bullish", "SELL": "bearish", "HOLD": "neutral / range"}.get(
3244
normalized,
3345
"neutral / range",
@@ -41,17 +53,17 @@ def label(trend: str) -> str:
4153
if is_zh:
4254
return ";".join(
4355
[
44-
f"约24小时:{label(n24.get('trend'))}(强度 {n24.get('strength', 'neutral')})",
45-
f"约3天:{label(d3.get('trend'))}(强度 {d3.get('strength', 'neutral')})",
46-
f"约1周:{label(w1.get('trend'))}(强度 {w1.get('strength', 'neutral')})",
47-
f"约1月:{label(m1.get('trend'))}(强度 {m1.get('strength', 'neutral')})",
56+
f"约24小时:{label(n24.get('trend'), n24.get('score'))}(强度 {n24.get('strength', 'neutral')})",
57+
f"约3天:{label(d3.get('trend'), d3.get('score'))}(强度 {d3.get('strength', 'neutral')})",
58+
f"约1周:{label(w1.get('trend'), w1.get('score'))}(强度 {w1.get('strength', 'neutral')})",
59+
f"约1月:{label(m1.get('trend'), m1.get('score'))}(强度 {m1.get('strength', 'neutral')})",
4860
]
4961
)
5062
return " | ".join(
5163
[
52-
f"~24h: {label(n24.get('trend'))} ({n24.get('strength', 'neutral')})",
53-
f"~3d: {label(d3.get('trend'))} ({d3.get('strength', 'neutral')})",
54-
f"~1w: {label(w1.get('trend'))} ({w1.get('strength', 'neutral')})",
55-
f"~1m: {label(m1.get('trend'))} ({m1.get('strength', 'neutral')})",
64+
f"~24h: {label(n24.get('trend'), n24.get('score'))} ({n24.get('strength', 'neutral')})",
65+
f"~3d: {label(d3.get('trend'), d3.get('score'))} ({d3.get('strength', 'neutral')})",
66+
f"~1w: {label(w1.get('trend'), w1.get('score'))} ({w1.get('strength', 'neutral')})",
67+
f"~1m: {label(m1.get('trend'), m1.get('score'))} ({m1.get('strength', 'neutral')})",
5668
]
5769
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Decision guardrails shared by the fast-analysis orchestration."""
2+
3+
from typing import Any, Dict
4+
5+
6+
def should_override_with_consensus(
7+
consensus_decision: str,
8+
consensus_abs: float,
9+
min_abs_override: float,
10+
) -> bool:
11+
"""Only directional consensus may replace the model's decision."""
12+
decision = str(consensus_decision or "HOLD").upper()
13+
return decision in ("BUY", "SELL") and float(consensus_abs) >= float(min_abs_override)
14+
15+
16+
def direction_supported_by_consensus(analysis: Dict[str, Any], decision: str) -> bool:
17+
"""Return whether a directional decision is confirmed by objective consensus."""
18+
normalized = str(decision or "HOLD").upper()
19+
if normalized not in ("BUY", "SELL"):
20+
return False
21+
consensus = analysis.get("consensus") or {}
22+
return str(consensus.get("consensus_decision") or "HOLD").upper() == normalized

‎backend_api_python/tests/test_fast_analysis_scoring.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from app.services.fast_analysis import FastAnalysisService
2+
from app.services.fast_analysis_formatters import build_trend_outlook_summary
3+
from app.services.fast_analysis_policy import should_override_with_consensus
24

35

46
def _service():
@@ -41,3 +43,61 @@ def test_bearish_breakdown_suppresses_oversold_buy_bias():
4143
assert risk["panic_breakdown"] is True
4244
assert score <= -20
4345

46+
47+
def test_neutral_consensus_never_overrides_directional_model_judgment():
48+
svc = _service()
49+
50+
assert svc._score_to_decision(17.6, market="USStock") == "HOLD"
51+
assert should_override_with_consensus("HOLD", 17.6, 15.0) is False
52+
assert should_override_with_consensus("BUY", 20.0, 15.0) is True
53+
54+
55+
def test_low_confidence_direction_survives_when_consensus_confirms_it():
56+
svc = _service()
57+
analysis = {
58+
"decision": "BUY",
59+
"confidence": 56,
60+
"consensus": {"consensus_decision": "BUY", "consensus_score": 22.0},
61+
"objective_scores_by_timeframe": {"4H": {"decision": "BUY"}, "1D": {"decision": "BUY"}},
62+
}
63+
indicators = {
64+
"rsi": {"value": 55},
65+
"macd": {"signal": "bullish"},
66+
"moving_averages": {"trend": "uptrend", "ma20": 95},
67+
"current_price": 100,
68+
"price_position": 60,
69+
"volume_ratio": 1.0,
70+
}
71+
72+
result = svc._validate_decision_against_indicators(analysis, indicators, 56)
73+
74+
assert result["decision"] == "BUY"
75+
assert result.get("decision_guard") is None
76+
77+
78+
def test_low_confidence_direction_without_consensus_is_still_held():
79+
svc = _service()
80+
analysis = {
81+
"decision": "BUY",
82+
"confidence": 56,
83+
"consensus": {"consensus_decision": "HOLD", "consensus_score": 17.6},
84+
}
85+
86+
result = svc._validate_decision_against_indicators(analysis, {}, 56)
87+
88+
assert result["decision"] == "HOLD"
89+
assert result["decision_guard"] == "low_confidence_without_consensus"
90+
91+
92+
def test_mild_direction_is_presented_without_changing_hold_action():
93+
svc = _service()
94+
outlook = {
95+
"next_24h": {"trend": "HOLD", "score": 17.6, "strength": "neutral"},
96+
"next_3d": {},
97+
"next_1w": {},
98+
"next_1m": {},
99+
}
100+
101+
summary = build_trend_outlook_summary(outlook, "zh-CN")
102+
103+
assert "轻微利多·观望" in summary

0 commit comments

Comments
 (0)