feat: AI analysis engine refactor, dark theme polish & virtual position management

Core changes:
- Refactor FastAnalysisService: single LLM multi-factor analysis replaces
  7-agent pipeline; add multi-timeframe consensus, threshold calibration,
  confidence calibration, multi-model ensemble voting
- Add RAG memory injection and reflection validation (analysis_memory +
  reflection worker)
- Simplify billing config: remove unused strategy_run/backtest/portfolio_monitor,
  add ai_code_gen separate billing (different token consumption scale)
- Settings hot-reload after save, no backend restart needed

Frontend:
- Global dark theme overhaul: pure black palette replacing blue-tinted colors
  across sidebar/header/dashboard/analysis/K-line/user-manage/profile/settings/billing
- Fix USDT payment modal dark theme (portal rendering broke CSS selectors)
- Refactor position modal: direction + quantity + entry price, remove add/reduce
  logic, show raw DB values on re-open, save exactly what user inputs
- Fix Polymarket prediction market dark text
- i18n for position modal title

Backend:
- Position management: one record per symbol (DELETE+INSERT replacing
  ON CONFLICT with side), fixes PnL showing 0 when switching long/short
- MarketDataCollector data fetching optimization
- portfolio_monitor scheduled monitoring improvements
- env.example reorganized: common config first, advanced config last

Documentation:
- README architecture diagram updated to FastAnalysisService flow
- Add virtual position, AI tuning config, billing items documentation
- Add INDICATOR_DEFINITIONS_CN.md, FRONTEND_FAST_ANALYSIS.md

Made-with: Cursor
This commit is contained in:
Dinger
2026-03-23 23:01:04 +08:00
parent 05f07ee544
commit 2e9c7cd69e
96 changed files with 2131 additions and 780 deletions
+550 -124
View File
@@ -10,8 +10,9 @@ Fast Analysis Service 3.0
"""
import json
import os
import re
import time
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List, Tuple
from decimal import Decimal, ROUND_HALF_UP
from app.utils.logger import get_logger
@@ -21,6 +22,167 @@ from app.services.market_data_collector import get_market_data_collector
logger = get_logger(__name__)
def _safe_float_price(value: Any, default: Optional[float] = None) -> Optional[float]:
"""Coerce LLM/string prices to float; invalid -> default."""
if value is None:
return default
if isinstance(value, (int, float)):
if isinstance(value, float) and (value != value): # NaN
return default
return float(value)
try:
s = str(value).strip().replace(",", "")
if not s:
return default
return float(s)
except (TypeError, ValueError):
return default
def _build_trend_outlook_summary(trend_outlook: Dict[str, Any], language: str) -> str:
"""Human-readable multi-horizon outlook for API / legacy clients."""
if not trend_outlook:
return ""
is_zh = str(language or "").lower().startswith("zh")
def _lbl(trend: str) -> str:
t = str(trend or "HOLD").upper()
if is_zh:
return {"BUY": "看多", "SELL": "看空", "HOLD": "震荡/中性"}.get(t, "震荡/中性")
return {"BUY": "bullish", "SELL": "bearish", "HOLD": "neutral / range"}.get(t, "neutral / range")
n24 = trend_outlook.get("next_24h") or {}
d3 = trend_outlook.get("next_3d") or {}
w1 = trend_outlook.get("next_1w") or {}
m1 = trend_outlook.get("next_1m") or {}
if is_zh:
parts = [
f"约24小时:{_lbl(n24.get('trend'))}(强度 {n24.get('strength', 'neutral')}",
f"约3天:{_lbl(d3.get('trend'))}(强度 {d3.get('strength', 'neutral')}",
f"约1周:{_lbl(w1.get('trend'))}(强度 {w1.get('strength', 'neutral')}",
f"约1月:{_lbl(m1.get('trend'))}(强度 {m1.get('strength', 'neutral')}",
]
return "".join(parts)
parts = [
f"~24h: {_lbl(n24.get('trend'))} ({n24.get('strength', 'neutral')})",
f"~3d: {_lbl(d3.get('trend'))} ({d3.get('strength', 'neutral')})",
f"~1w: {_lbl(w1.get('trend'))} ({w1.get('strength', 'neutral')})",
f"~1m: {_lbl(m1.get('trend'))} ({m1.get('strength', 'neutral')})",
]
return " | ".join(parts)
# -----------------------------------------------------------------------------
# Geopolitical / major-conflict detection (word boundaries + tiers)
# Avoid false positives: "war" in "toward/award", "tension" in "extension",
# "us" in "focus/status", bare country names without conflict context, etc.
# -----------------------------------------------------------------------------
_GEO_SEVERE_PATTERNS: List[re.Pattern] = [
re.compile(r"\b(?:war|wars|warfare|wartime)\b", re.I),
re.compile(r"\b(?:invasion|invaded|invading|invade)\b", re.I),
re.compile(r"\b(?:airstrike|air\s*strikes?|missile\s+strike|drone\s+strike)\b", re.I),
re.compile(r"\b(?:military\s+attack|armed\s+attack|troops?\s+(?:fire|attack|invade))\b", re.I),
re.compile(r"\b(?:declare[sd]?\s+war|state\s+of\s+war|act\s+of\s+war)\b", re.I),
re.compile(r"\b(?:martial\s+law|military\s+coup|coup\s+d['\u2019]?etat)\b", re.I),
re.compile(r"\b(?:terror(?:ist)?\s+attack|mass\s+shooting\s+at)\b", re.I),
]
_GEO_MODERATE_PATTERNS: List[re.Pattern] = [
re.compile(r"\bgeopolitical\b", re.I),
re.compile(r"\b(?:armed|military)\s+conflict\b", re.I),
re.compile(r"\b(?:international\s+)?sanctions?\s+(?:on|against|targeting|hit)\b", re.I),
re.compile(r"\b(?:naval\s+blockade|border\s+clash|ceasefire\s+(?:broken|violated))\b", re.I),
re.compile(r"\b(?:evacuat\w+\s+(?:the\s+)?embassy|embassy\s+evacuation)\b", re.I),
re.compile(r"\b(?:nuclear\s+(?:threat|strike|weapon)|nuclear\s+war)\b", re.I),
]
# "Crisis" / "tension" only in clearly geopolitical phrases (not substring of "extension")
_GEO_CONTEXT_MODERATE: List[re.Pattern] = [
re.compile(r"\b(?:geopolitical|diplomatic|border)\s+(?:crisis|tension|standoff)\b", re.I),
re.compile(r"\b(?:tensions?\s+(?:rise|escalat|flare|mount)\s+(?:with|between))\b", re.I),
re.compile(r"\b(?:middle\s+east|south\s+china\s+sea|taiwan\s+strait)\s+(?:crisis|tension|conflict)\b", re.I),
]
_GEO_ZH_SEVERE = (
"宣战", "战争爆发", "全面战争", "武装冲突", "军事打击", "军事入侵", "空袭", "导弹袭击",
"开战", "交火", "战火",
)
_GEO_ZH_MODERATE = (
"地缘政治危机", "国际制裁升级", "断交", "撤侨", "军事对峙", "地区冲突升级",
)
# Optional: country/region + conflict verb (single pattern, avoids "NYSE" noise)
_GEO_REGION_CONFLICT: List[re.Pattern] = [
re.compile(
r"\b(?:russia|ukraine|iran|israel|gaza|hamas|taiwan|north\s+korea|dprk|"
r"syria|yemen|lebanon|nato)\b.{0,40}\b(?:invade|attack|strike|war|conflict|sanction)\b",
re.I,
),
re.compile(
r"\b(?:invade|attack|strike|war|conflict|sanction)\b.{0,40}\b(?:russia|ukraine|iran|israel|"
r"gaza|hamas|taiwan|north\s+korea|dprk|syria|nato)\b",
re.I,
),
]
_GEO_MAJOR_NEWS_SEVERE = [
re.compile(r"\b(?:war|wars|warfare)\b", re.I),
re.compile(r"\b(?:invasion|invaded|military\s+attack|airstrike)\b", re.I),
re.compile(r"\b(?:armed\s+conflict|military\s+conflict)\b", re.I),
]
def _geopolitical_match_level(combined_text: str) -> Tuple[str, Optional[str]]:
"""
Returns (level, reason_tag) where level is 'none'|'severe'|'moderate'.
combined_text: title + summary (original case OK; English patterns use lower via regex I flag).
"""
if not combined_text or len(combined_text.strip()) < 4:
return "none", None
low = combined_text.lower()
for pat in _GEO_SEVERE_PATTERNS:
if pat.search(low):
return "severe", pat.pattern[:48]
for z in _GEO_ZH_SEVERE:
if z in combined_text:
return "severe", z
for pat in _GEO_REGION_CONFLICT:
if pat.search(low):
return "severe", "region+conflict"
for pat in _GEO_MODERATE_PATTERNS:
if pat.search(low):
return "moderate", pat.pattern[:48]
for pat in _GEO_CONTEXT_MODERATE:
if pat.search(low):
return "moderate", pat.pattern[:48]
for z in _GEO_ZH_MODERATE:
if z in combined_text:
return "moderate", z
return "none", None
def _geopolitical_sentiment_penalty_delta(level: str) -> int:
if level == "severe":
return -42
if level == "moderate":
return -18
return 0
def _is_major_geopolitical_news_text(combined_text: str) -> bool:
"""Stricter than sentiment: only clear conflict / war signals for _has_major_news."""
if not combined_text:
return False
low = combined_text.lower()
for pat in _GEO_MAJOR_NEWS_SEVERE:
if pat.search(low):
return True
for z in _GEO_ZH_SEVERE:
if z in combined_text:
return True
if any(p.search(low) for p in _GEO_REGION_CONFLICT):
return True
return False
class FastAnalysisService:
"""
快速分析服务 3.0
@@ -364,10 +526,12 @@ You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not spe
⚠️ CRITICAL PRICE RULES:
1. Current price: ${current_price}
2. Your stop_loss MUST be near ${suggested_stop_loss:.4f} (range: ${price_lower_bound:.4f} ~ ${current_price})
3. Your take_profit MUST be near ${suggested_take_profit:.4f} (range: ${current_price} ~ ${price_upper_bound:.4f})
4. Entry price: ${entry_range_low:.4f} ~ ${entry_range_high:.4f}
5. These levels are based on ATR and support/resistance analysis - use them as reference!
2. If decision=BUY: stop_loss should be below current price, take_profit above current price.
3. If decision=SELL (short): stop_loss MUST be above current price; take_profit MUST be below current price.
4. BUY stop_loss reference: near ${suggested_stop_loss:.4f} (range: ${price_lower_bound:.4f} ~ ${current_price})
5. BUY take_profit reference: near ${suggested_take_profit:.4f} (range: ${current_price} ~ ${price_upper_bound:.4f})
6. Entry price: ${entry_range_low:.4f} ~ ${entry_range_high:.4f}
7. These levels are based on ATR and support/resistance analysis - use them as reference!
📊 YOUR ANALYSIS MUST INCLUDE (ALL factors are important):
1. **Technical Analysis**: Objectively interpret RSI, MACD, MA, support/resistance. Be honest about conflicting signals.
@@ -500,6 +664,9 @@ When the score is neutral (-20 to +20), you can use your judgment, but still con
📈 EARNINGS DATA:
{self._format_earnings_data(fundamental.get('earnings', {}))}
📚 HISTORICAL PATTERNS (similar conditions in the past):
{self._get_memory_context(data.get('market', ''), data.get('symbol', ''), indicators)}
IMPORTANT:
1. **CRITICAL**: Check for GEOPOLITICAL EVENTS (wars, conflicts, military actions) in the news section. These events have HIGHEST PRIORITY and can override all technical indicators.
2. Consider the macro environment (especially DXY, VIX, rates, geopolitical events) when making your recommendation.
@@ -817,6 +984,55 @@ IMPORTANT:
weighted_score_sum += overall_score * w
weighted_score_w_sum += w
# Extra horizon score (not used in consensus override):
# add 1W objective score for short/medium trend outlook.
if "1W" not in objective_by_tf:
try:
d_1w = self._collect_market_data(
market,
symbol,
"1W",
include_macro=False,
include_news=False,
include_polymarket=False,
timeout=25,
)
cp_1w = _extract_current_price(d_1w) or 0.0
obj_1w = self._calculate_objective_score(d_1w, cp_1w)
sc_1w = float(obj_1w.get("overall_score", 0.0) or 0.0)
objective_by_tf["1W"] = {
"objective_score": obj_1w,
"overall_score": sc_1w,
"decision": self._score_to_decision(sc_1w, market=market),
"abs_score": abs(sc_1w),
}
except Exception as e:
logger.debug(f"1W outlook score skipped: {e}")
# Short-horizon outlook: 1H bar (24h-style), not 1D close
if "1H" not in objective_by_tf:
try:
d_1h = self._collect_market_data(
market,
symbol,
"1H",
include_macro=False,
include_news=False,
include_polymarket=False,
timeout=18,
)
cp_1h = _extract_current_price(d_1h) or 0.0
obj_1h = self._calculate_objective_score(d_1h, cp_1h)
sc_1h = float(obj_1h.get("overall_score", 0.0) or 0.0)
objective_by_tf["1H"] = {
"objective_score": obj_1h,
"overall_score": sc_1h,
"decision": self._score_to_decision(sc_1h, market=market),
"abs_score": abs(sc_1h),
}
except Exception as e:
logger.debug(f"1H outlook score skipped: {e}")
consensus_score = weighted_score_sum / weighted_score_w_sum if weighted_score_w_sum > 0 else 0.0
consensus_decision = self._score_to_decision(consensus_score, market=market)
consensus_abs = abs(consensus_score)
@@ -892,32 +1108,52 @@ IMPORTANT:
# Phase 2: Build prompt
system_prompt, user_prompt = self._build_analysis_prompt(data, language)
# Phase 3: Single LLM call
logger.info(f"Calling LLM for analysis...")
default_struct = {
"decision": "HOLD",
"confidence": 50,
"summary": "Analysis failed",
"entry_price": current_price,
"stop_loss": current_price * 0.95,
"take_profit": current_price * 1.05,
"position_size_pct": 10,
"timeframe": "medium",
"key_reasons": ["Unable to analyze"],
"risks": ["Analysis error"],
"technical_score": 50,
"fundamental_score": 50,
"sentiment_score": 50,
}
# Phase 3: LLM call(s) - single or ensemble voting
logger.info("Calling LLM for analysis...")
llm_start = time.time()
analysis = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
default_structure={
"decision": "HOLD",
"confidence": 50,
"summary": "Analysis failed",
"entry_price": current_price,
"stop_loss": current_price * 0.95,
"take_profit": current_price * 1.05,
"position_size_pct": 10,
"timeframe": "medium",
"key_reasons": ["Unable to analyze"],
"risks": ["Analysis error"],
"technical_score": 50,
"fundamental_score": 50,
"sentiment_score": 50,
},
model=model
)
ensemble_models = []
if os.getenv("ENABLE_AI_ENSEMBLE", "false").lower() == "true":
env_models = (os.getenv("AI_ENSEMBLE_MODELS") or "").strip()
if env_models:
ensemble_models = [m.strip() for m in env_models.split(",") if m.strip()]
if len(ensemble_models) >= 2:
analyses_list = []
for em in ensemble_models[:3]:
a = self.llm_service.safe_call_llm(
system_prompt, user_prompt, default_structure=default_struct, model=em
)
analyses_list.append(a)
decisions = [str(a.get("decision", "HOLD") or "HOLD").upper() for a in analyses_list]
from collections import Counter
vote = Counter(decisions).most_common(1)[0][0]
idx = decisions.index(vote)
analysis = analyses_list[idx].copy()
analysis["decision"] = vote
analysis["_ensemble_vote"] = dict(Counter(decisions))
analysis["_ensemble_models"] = ensemble_models[:3]
else:
analysis = self.llm_service.safe_call_llm(
system_prompt, user_prompt, default_structure=default_struct, model=model
)
llm_time = int((time.time() - llm_start) * 1000)
logger.info(f"LLM call completed in {llm_time}ms")
@@ -932,6 +1168,50 @@ IMPORTANT:
score_based_decision = self._score_to_decision(objective_score["overall_score"], market=market)
llm_decision = str(analysis.get("decision", "HOLD") or "HOLD").upper()
# Horizon trend outlook for users (short/medium/long decision reference)
score_1d = float((objective_by_tf.get("1D") or {}).get("overall_score", objective_score.get("overall_score", 0.0)) or 0.0)
score_4h = float((objective_by_tf.get("4H") or {}).get("overall_score", score_1d) or score_1d)
score_1h = float((objective_by_tf.get("1H") or {}).get("overall_score", score_4h) or score_4h)
# ~24h: prefer 1H bar objective; fall back 4H -> 1D
score_24h = float(score_1h)
score_1w = float((objective_by_tf.get("1W") or {}).get("overall_score", score_1d) or score_1d)
score_3d = score_1d * 0.7 + score_4h * 0.3
score_1m = score_1w * 0.55 + float(objective_score.get("fundamental_score", 0.0)) * 0.30 + float(objective_score.get("macro_score", 0.0)) * 0.15
def _trend_strength(score_val: float) -> str:
a = abs(float(score_val))
if a >= 70:
return "strong"
if a >= 40:
return "moderate"
if a >= 20:
return "mild"
return "neutral"
trend_outlook = {
"next_24h": {
"score": round(score_24h, 2),
"trend": self._score_to_decision(score_24h, market=market),
"strength": _trend_strength(score_24h),
},
"next_3d": {
"score": round(score_3d, 2),
"trend": self._score_to_decision(score_3d, market=market),
"strength": _trend_strength(score_3d),
},
"next_1w": {
"score": round(score_1w, 2),
"trend": self._score_to_decision(score_1w, market=market),
"strength": _trend_strength(score_1w),
},
"next_1m": {
"score": round(score_1m, 2),
"trend": self._score_to_decision(score_1m, market=market),
"strength": _trend_strength(score_1m),
},
}
trend_outlook_summary = _build_trend_outlook_summary(trend_outlook, language)
# Consensus confidence:
consensus_conf = int(max(40, min(98, 50 + consensus_abs * 0.35)))
# Agreement boosts, disagreement reduces
@@ -942,6 +1222,9 @@ IMPORTANT:
cfg = self._get_ai_calibration(market=market)
min_abs_override = float(cfg.get("min_consensus_abs_override") or 15.0)
quality_hold_thr = float(cfg.get("quality_hold_threshold") or 0.7)
regime = self._detect_market_regime(data.get("indicators") or {})
if regime == "ranging":
min_abs_override *= 1.2
if consensus_abs >= min_abs_override:
final_decision = consensus_decision
@@ -953,11 +1236,21 @@ IMPORTANT:
analysis["decision"] = final_decision
analysis["confidence"] = consensus_conf
original_summary = analysis.get("summary", "")
level = "强烈" if consensus_abs >= 70 else "明显" if consensus_abs >= 40 else "轻微"
analysis["summary"] = (
f"{original_summary} [多周期客观共识:综合评分{consensus_score:.1f}分("
f"{level}{'利多' if consensus_score > 0 else '利空'}),建议{final_decision}]"
)
is_zh = str(language or "").lower().startswith("zh")
if is_zh:
level = "强烈" if consensus_abs >= 70 else "明显" if consensus_abs >= 40 else "轻微"
bias = "利多" if consensus_score > 0 else "利空"
consensus_note = (
f"[多周期客观共识:综合评分{consensus_score:.1f}分({level}{bias}),建议{final_decision}]"
)
else:
level = "strong" if consensus_abs >= 70 else "moderate" if consensus_abs >= 40 else "mild"
bias = "bullish" if consensus_score > 0 else "bearish"
consensus_note = (
f"[Multi-timeframe objective consensus: score {consensus_score:.1f} "
f"({level} {bias}), suggested decision {final_decision}]"
)
analysis["summary"] = f"{original_summary} {consensus_note}".strip()
else:
# Near-neutral: keep LLM but shrink confidence by quality and enforce HOLD if quality is poor
analysis["confidence"] = int(max(0, min(100, int(analysis.get("confidence", 50) or 50) * quality_multiplier)))
@@ -983,6 +1276,7 @@ IMPORTANT:
"consensus_abs": consensus_abs,
"agreement_ratio": agreement_ratio,
"quality_multiplier": quality_multiplier,
"market_regime": regime,
}
# Phase 5: Validate and constrain output (pass indicators for decision validation)
@@ -1014,6 +1308,17 @@ IMPORTANT:
except Exception:
# Keep model-provided position_size_pct
pass
# Confidence calibration: adjust by historical accuracy in bucket
if os.getenv("ENABLE_CONFIDENCE_CALIBRATION", "false").lower() == "true":
try:
from app.services.analysis_memory import get_analysis_memory
raw_conf = int(analysis.get("confidence", 50) or 50)
analysis["confidence"] = get_analysis_memory().get_adjusted_confidence(
raw_conf, market=market, symbol=symbol
)
except Exception as e:
logger.debug(f"Confidence calibration skipped: {e}")
# Build final result
total_time = int((time.time() - start_time) * 1000)
@@ -1041,6 +1346,15 @@ IMPORTANT:
"take_profit": analysis.get("take_profit"),
"position_size_pct": analysis.get("position_size_pct", 10),
"timeframe": analysis.get("timeframe", "medium"),
# camelCase + 语义别名:供私有前端/旧版组件绑定(勿用 indicators.trading_levels 充当计划)
"entryPrice": analysis.get("entry_price"),
"stopLoss": analysis.get("stop_loss"),
"takeProfit": analysis.get("take_profit"),
"positionSizePct": analysis.get("position_size_pct", 10),
"decision": str(analysis.get("decision", "HOLD") or "HOLD").upper(),
# 与 stop_loss / take_profit 数值相同;命名强调「亏损离场 / 盈利目标」避免与多单参考线混淆
"loss_exit_price": analysis.get("stop_loss"),
"profit_target_price": analysis.get("take_profit"),
},
"reasons": analysis.get("key_reasons", []),
"risks": analysis.get("risks", []),
@@ -1060,6 +1374,10 @@ IMPORTANT:
},
"indicators": data.get("indicators", {}),
"consensus": analysis.get("consensus", {}),
"trend_outlook": trend_outlook,
"trend_outlook_summary": trend_outlook_summary,
"trendOutlook": trend_outlook,
"trendOutlookSummary": trend_outlook_summary,
"analysis_time_ms": total_time,
"llm_time_ms": llm_time,
"data_collection_time_ms": data.get("collection_time_ms", 0),
@@ -1149,51 +1467,45 @@ IMPORTANT:
"""
检查是否有重大新闻事件。
重大新闻包括:监管变化、重大合作、丑闻、重大政策、地缘政治事件等。
地缘类使用词边界与分级,避免 toward/extension/us 等子串误判。
"""
if not news_data:
return False
# 检查新闻标题中的关键词(扩展了地缘政治相关关键词
# 子串关键词(较长词或中文,避免过短英文误匹配
major_keywords = [
# 监管和政策
"regulation", "regulatory", "ban", "approval", "policy", "government", "central bank",
"regulation", "regulatory", "approval", "policy", "government", "central bank",
"监管", "禁令", "批准", "政策", "政府", "央行",
# 商业事件
"partnership", "merger", "acquisition", "scandal", "lawsuit", "investigation",
"合作", "合并", "收购", "丑闻", "诉讼", "调查",
# 地缘政治事件(新增)
"war", "conflict", "military", "attack", "strike", "sanctions", "tension", "crisis",
"geopolitical", "iran", "israel", "russia", "ukraine", "china", "taiwan", "north korea",
"middle east", "gulf", "nato", "united states", "us", "usa", "america",
"战争", "冲突", "军事", "袭击", "打击", "制裁", "紧张", "危机",
"地缘政治", "伊朗", "以色列", "俄罗斯", "乌克兰", "中国", "台湾", "朝鲜",
"中东", "海湾", "北约", "美国"
"sanctions", "embargo", "制裁", "中东", "海湾", "北约",
"united states", "middle east",
]
for news in news_data[:10]: # 检查前10条最新新闻(增加检查范围)
title = (news.get("title") or news.get("headline") or "").lower()
summary = (news.get("summary") or "").lower()
# 短英文词用词边界匹配(不用裸子串)
major_short_patterns = [
re.compile(r"\b(?:ban|banned|banning)\b", re.I),
re.compile(r"\b(?:crisis|crises)\b", re.I),
re.compile(r"\b(?:catastrophe|meltdown)\b", re.I),
]
for news in news_data[:10]:
title = news.get("title") or news.get("headline") or ""
summary = news.get("summary") or ""
sentiment = news.get("sentiment", "neutral")
# 检查标题和摘要中是否包含重大关键词
text_to_check = f"{title} {summary}"
# 地缘政治事件通常很严重,即使情绪是中性也要识别
geopolitical_keywords = [
"war", "conflict", "military", "attack", "strike", "geopolitical",
"战争", "冲突", "军事", "袭击", "打击", "地缘政治"
]
# 如果是地缘政治相关,直接认为是重大新闻
if any(keyword in text_to_check for keyword in geopolitical_keywords):
logger.info(f"Detected major geopolitical event in news: {title[:60]}")
low = text_to_check.lower()
if _is_major_geopolitical_news_text(text_to_check):
logger.info(f"Detected major geopolitical event in news: {low[:80]}")
return True
# 其他重大关键词且情绪强烈(非中性),认为是重大新闻
if any(keyword in text_to_check for keyword in major_keywords) and sentiment != "neutral":
logger.info(f"Detected major news event: {title[:60]}")
if any(kw in low for kw in major_keywords) and sentiment != "neutral":
logger.info(f"Detected major news event: {low[:80]}")
return True
if sentiment != "neutral" and any(p.search(low) for p in major_short_patterns):
logger.info(f"Detected major news event (pattern): {low[:80]}")
return True
return False
def _has_macro_event(self, macro_data: Dict, market: str) -> bool:
@@ -1227,6 +1539,88 @@ IMPORTANT:
return False
def _finalize_trading_plan_for_decision(
self, analysis: Dict, current_price: float, indicators: Optional[Dict] = None
) -> Dict:
"""
After decision is final: force correct stop/take-profit geometry and mirror long levels for shorts.
BUY: stop_loss < current < take_profit
SELL: take_profit < current < stop_loss (short: stop above, TP below)
"""
if not current_price or current_price <= 0:
return analysis
indicators = indicators or {}
decision = str(analysis.get("decision", "HOLD")).upper()
if decision not in ("BUY", "SELL"):
return analysis
min_price = current_price * 0.90
max_price = current_price * 1.10
eps = max(abs(current_price) * 1e-6, 1e-8)
tl = indicators.get("trading_levels") or {}
sl_long = _safe_float_price(tl.get("suggested_stop_loss"))
tp_long = _safe_float_price(tl.get("suggested_take_profit"))
long_ok = (
sl_long is not None
and tp_long is not None
and sl_long < current_price - eps
and tp_long > current_price + eps
)
if decision == "SELL":
if long_ok:
mirrored_sl = round(2 * current_price - sl_long, 6)
mirrored_tp = round(2 * current_price - tp_long, 6)
mirrored_sl = min(max(mirrored_sl, current_price + eps), max_price)
mirrored_tp = max(min(mirrored_tp, current_price - eps), min_price)
if mirrored_sl > current_price and mirrored_tp < current_price:
analysis["stop_loss"] = mirrored_sl
analysis["take_profit"] = mirrored_tp
else:
analysis["stop_loss"] = round(min(max_price, current_price * 1.05), 6)
analysis["take_profit"] = round(max(min_price, current_price * 0.95), 6)
else:
sl_f = _safe_float_price(analysis.get("stop_loss"))
tp_f = _safe_float_price(analysis.get("take_profit"))
if sl_f is not None and tp_f is not None and tp_f < current_price < sl_f:
analysis["stop_loss"] = round(min(max(sl_f, current_price + eps), max_price), 6)
analysis["take_profit"] = round(max(min(tp_f, current_price - eps), min_price), 6)
else:
analysis["stop_loss"] = round(min(max_price, current_price * 1.05), 6)
analysis["take_profit"] = round(max(min_price, current_price * 0.95), 6)
else: # BUY
if long_ok:
sl = max(min(sl_long, current_price - eps), min_price)
tp = min(max(tp_long, current_price + eps), max_price)
analysis["stop_loss"] = round(sl, 6)
analysis["take_profit"] = round(tp, 6)
else:
sl_f = _safe_float_price(analysis.get("stop_loss"))
tp_f = _safe_float_price(analysis.get("take_profit"))
if sl_f is not None and tp_f is not None and sl_f < current_price < tp_f:
analysis["stop_loss"] = round(max(min(sl_f, current_price - eps), min_price), 6)
analysis["take_profit"] = round(min(max(tp_f, current_price + eps), max_price), 6)
else:
analysis["stop_loss"] = round(max(min_price, current_price * 0.95), 6)
analysis["take_profit"] = round(min(max_price, current_price * 1.05), 6)
# Last-resort: fix inverted or equal levels
sl_f = _safe_float_price(analysis.get("stop_loss"), current_price)
tp_f = _safe_float_price(analysis.get("take_profit"), current_price)
if sl_f is None or tp_f is None:
return analysis
if decision == "SELL":
if not (tp_f < current_price < sl_f):
analysis["stop_loss"] = round(min(max_price, current_price * 1.05), 6)
analysis["take_profit"] = round(max(min_price, current_price * 0.95), 6)
else:
if not (sl_f < current_price < tp_f):
analysis["stop_loss"] = round(max(min_price, current_price * 0.95), 6)
analysis["take_profit"] = round(min(max_price, current_price * 1.05), 6)
return analysis
def _validate_and_constrain(self, analysis: Dict, current_price: float, indicators: Dict = None,
has_major_news: bool = False, has_macro_event: bool = False) -> Dict:
"""
@@ -1239,22 +1633,45 @@ IMPORTANT:
# Price bounds
min_price = current_price * 0.90
max_price = current_price * 1.10
decision = str(analysis.get("decision", "HOLD")).upper()
# Constrain entry price
entry = analysis.get("entry_price", current_price)
if entry and (entry < min_price or entry > max_price):
entry = _safe_float_price(analysis.get("entry_price"), current_price)
if entry is not None and (entry < min_price or entry > max_price):
logger.warning(f"Entry price {entry} out of bounds, constraining to current price {current_price}")
analysis["entry_price"] = round(current_price, 6)
elif entry is not None:
analysis["entry_price"] = round(entry, 6)
# Constrain stop loss
stop_loss = analysis.get("stop_loss", current_price * 0.95)
if stop_loss and (stop_loss < min_price or stop_loss > current_price):
analysis["stop_loss"] = round(current_price * 0.95, 6)
# Constrain take profit
take_profit = analysis.get("take_profit", current_price * 1.05)
if take_profit and (take_profit < current_price or take_profit > max_price):
analysis["take_profit"] = round(current_price * 1.05, 6)
# Constrain stop loss / take profit by direction (numeric-safe).
# BUY: stop_loss < current < take_profit
# SELL: take_profit < current < stop_loss
if decision == "SELL":
stop_default = round(current_price * 1.05, 6)
tp_default = round(current_price * 0.95, 6)
stop_loss = _safe_float_price(analysis.get("stop_loss"), stop_default)
take_profit = _safe_float_price(analysis.get("take_profit"), tp_default)
if stop_loss is None or stop_loss <= current_price or stop_loss > max_price:
analysis["stop_loss"] = stop_default
else:
analysis["stop_loss"] = round(stop_loss, 6)
if take_profit is None or take_profit >= current_price or take_profit < min_price:
analysis["take_profit"] = tp_default
else:
analysis["take_profit"] = round(take_profit, 6)
else:
stop_default = round(current_price * 0.95, 6)
tp_default = round(current_price * 1.05, 6)
stop_loss = _safe_float_price(analysis.get("stop_loss"), stop_default)
take_profit = _safe_float_price(analysis.get("take_profit"), tp_default)
if stop_loss is None or stop_loss < min_price or stop_loss >= current_price:
analysis["stop_loss"] = stop_default
else:
analysis["stop_loss"] = round(stop_loss, 6)
if take_profit is None or take_profit <= current_price or take_profit > max_price:
analysis["take_profit"] = tp_default
else:
analysis["take_profit"] = round(take_profit, 6)
# Constrain confidence
confidence = analysis.get("confidence", 50)
@@ -1266,7 +1683,6 @@ IMPORTANT:
analysis[score_key] = max(0, min(100, int(score)))
# Validate decision
decision = str(analysis.get("decision", "HOLD")).upper()
if decision not in ["BUY", "SELL", "HOLD"]:
analysis["decision"] = "HOLD"
else:
@@ -1279,6 +1695,9 @@ IMPORTANT:
has_major_news=has_major_news,
has_macro_event=has_macro_event
)
# Final geometry after any decision change (e.g. forced HOLD skips finalize in caller — still safe)
analysis = self._finalize_trading_plan_for_decision(analysis, current_price, indicators)
return analysis
@@ -1729,71 +2148,64 @@ IMPORTANT:
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
"""
计算新闻情绪评分 (-100 to +100)
包含地缘政治事件的特殊处理
地缘/冲突类:词边界 + 分级惩罚,单条封顶,避免 extension/toward 等误判叠加。
"""
if not news:
return 0.0 # 无新闻,中性
positive_count = 0
negative_count = 0
neutral_count = 0
geopolitical_penalty = 0 # 地缘政治事件惩罚分数
geopolitical_count = 0 # 地缘政治事件数量
# 地缘政治关键词
geopolitical_keywords = [
"war", "conflict", "military", "attack", "strike", "sanctions",
"geopolitical", "crisis", "tension", "iran", "israel", "russia",
"ukraine", "middle east", "nato", "united states",
"战争", "冲突", "军事", "袭击", "制裁", "地缘政治", "危机"
]
for item in news[:15]: # 检查前15条新闻
title = (item.get("headline") or item.get("title") or "").lower()
summary = (item.get("summary") or "").lower()
geopolitical_penalty = 0
max_geo_total = int(os.getenv("SENTIMENT_GEO_PENALTY_CAP", "-55"))
for item in news[:15]:
title = item.get("headline") or item.get("title") or ""
summary = item.get("summary") or ""
text = f"{title} {summary}"
sentiment = item.get("sentiment", "neutral")
is_global_event = item.get("is_global_event", False)
# 检查是否是地缘政治事件
is_geopolitical = is_global_event or any(keyword in text for keyword in geopolitical_keywords)
if is_geopolitical:
geopolitical_count += 1
# 地缘政治事件通常是利空的,给予严重惩罚
if any(kw in text for kw in ["war", "conflict", "attack", "strike", "战争", "冲突", "袭击", "打击"]):
geopolitical_penalty -= 50 # 战争/冲突事件严重利空
elif any(kw in text for kw in ["sanctions", "crisis", "tension", "制裁", "危机", "紧张"]):
geopolitical_penalty -= 30 # 制裁/危机事件利空
else:
geopolitical_penalty -= 20 # 其他地缘政治事件利空
logger.info(f"Detected geopolitical event in sentiment scoring: {title[:60]}, penalty: {geopolitical_penalty}")
# 统计普通新闻情绪
level, tag = _geopolitical_match_level(text)
if is_global_event and level == "none":
level, tag = "moderate", "is_global_event"
if level != "none":
delta = _geopolitical_sentiment_penalty_delta(level)
new_total = geopolitical_penalty + delta
if new_total < max_geo_total:
delta = max_geo_total - geopolitical_penalty
geopolitical_penalty += delta
preview = (title or summary or "")[:72]
logger.info(
f"Geopolitical sentiment ({level}, {tag}): {preview!r}, "
f"delta={delta}, cumulative={geopolitical_penalty}"
)
if sentiment == "positive":
positive_count += 1
elif sentiment == "negative":
negative_count += 1
else:
neutral_count += 1
total = positive_count + negative_count + neutral_count
# 计算净情绪(普通新闻)
if total > 0:
net_sentiment = (positive_count - negative_count) / total
base_score = net_sentiment * 60 # 基础情绪分数(-60到+60
base_score = net_sentiment * 60
else:
base_score = 0
# 地缘政治事件惩罚(如果有地缘政治事件,直接应用惩罚)
if geopolitical_count > 0:
# 地缘政治事件的影响权重很高,直接叠加惩罚
if geopolitical_penalty != 0:
final_score = base_score + geopolitical_penalty
logger.info(f"Sentiment score: base={base_score:.1f}, geopolitical_penalty={geopolitical_penalty}, final={final_score:.1f}")
logger.info(
f"Sentiment score: base={base_score:.1f}, "
f"geopolitical_penalty={geopolitical_penalty}, final={final_score:.1f}"
)
else:
final_score = base_score
return max(-100, min(100, final_score))
def _calculate_macro_score(self, macro: Dict, market: str) -> float:
@@ -1908,6 +2320,14 @@ IMPORTANT:
return max(-100, min(100, score))
def _detect_market_regime(self, indicators: Dict) -> str:
"""Detect trending vs ranging from MA trend. trending | ranging"""
ma = indicators.get("moving_averages") or {}
trend = str(ma.get("trend", "sideways")).lower()
if "uptrend" in trend or "downtrend" in trend or "strong" in trend:
return "trending"
return "ranging"
def _score_to_decision(self, score: float, *, market: str = "Crypto") -> str:
"""
根据客观评分转换为决策
@@ -2072,7 +2492,11 @@ IMPORTANT:
decision = fast_result.get("decision", "HOLD")
confidence = fast_result.get("confidence", 50)
scores = fast_result.get("scores", {})
to_sum = (fast_result.get("trend_outlook_summary") or "").strip()
overview_report = fast_result.get("summary", "") or ""
if to_sum:
overview_report = f"{overview_report}\n\n【周期预判】{to_sum}" if overview_report.strip() else f"【周期预判】{to_sum}"
return {
"overview": {
"overallScore": scores.get("overall", 50),
@@ -2085,7 +2509,7 @@ IMPORTANT:
"sentiment": scores.get("sentiment", 50),
"risk": 100 - confidence, # Inverse of confidence
},
"report": fast_result.get("summary", ""),
"report": overview_report,
},
"fundamental": {
"score": scores.get("fundamental", 50),
@@ -2135,6 +2559,8 @@ IMPORTANT:
"recommendation": "\n".join(fast_result.get("reasons", [])),
},
"fast_analysis": fast_result, # Include new format for gradual migration
"trend_outlook": fast_result.get("trend_outlook"),
"trend_outlook_summary": fast_result.get("trend_outlook_summary"),
"error": None,
}