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
@@ -67,6 +67,9 @@ class AnalysisMemory:
consensus_abs DECIMAL(24, 8),
agreement_ratio DECIMAL(10, 6),
quality_multiplier DECIMAL(10, 6),
task_status VARCHAR(20) DEFAULT 'completed',
task_error TEXT,
updated_at TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW(),
validated_at TIMESTAMP,
actual_outcome VARCHAR(20),
@@ -124,6 +127,27 @@ class AnalysisMemory:
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN quality_multiplier DECIMAL(10, 6);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'task_status'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN task_status VARCHAR(20) DEFAULT 'completed';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'task_error'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN task_error TEXT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'updated_at'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN updated_at TIMESTAMP DEFAULT NOW();
END IF;
END $$;
""")
@@ -185,14 +209,16 @@ class AnalysisMemory:
INSERT INTO qd_analysis_memory (
user_id, market, symbol, decision, confidence,
price_at_analysis, summary, reasons, scores, indicators_snapshot, raw_result,
consensus_score, consensus_abs, agreement_ratio, quality_multiplier
consensus_score, consensus_abs, agreement_ratio, quality_multiplier,
task_status, task_error, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s)
%s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""", (
user_id, market, symbol, decision, confidence,
price, summary, reasons, scores, indicators, raw,
consensus_score, consensus_abs, agreement_ratio, quality_multiplier,
"completed", "",
))
# 使用 lastrowid 属性获取 IDexecute 内部已经处理了 RETURNING
@@ -227,7 +253,8 @@ class AnalysisMemory:
SELECT
id, decision, confidence, price_at_analysis,
summary, reasons, scores,
created_at, validated_at, was_correct, actual_return_pct
created_at, validated_at, was_correct, actual_return_pct,
task_status, task_error, updated_at
FROM qd_analysis_memory
WHERE market = %s AND symbol = %s
AND created_at > NOW() - INTERVAL '{int(days)} days'
@@ -248,7 +275,10 @@ class AnalysisMemory:
"summary": row['summary'],
"reasons": _safe_json_parse(row['reasons'], []),
"scores": _safe_json_parse(row['scores'], {}),
"status": row.get('task_status') or 'completed',
"error_message": row.get('task_error') or '',
"created_at": row['created_at'].isoformat() if row['created_at'] else None,
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
"was_correct": row['was_correct'],
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
})
@@ -292,7 +322,8 @@ class AnalysisMemory:
SELECT
id, market, symbol, decision, confidence, price_at_analysis,
summary, reasons, scores, indicators_snapshot, raw_result,
created_at, validated_at, was_correct, actual_return_pct
created_at, validated_at, was_correct, actual_return_pct,
task_status, task_error, updated_at
FROM qd_analysis_memory
{where_clause}
ORDER BY created_at DESC
@@ -316,7 +347,10 @@ class AnalysisMemory:
"scores": _safe_json_parse(row['scores'], {}),
"indicators": _safe_json_parse(row['indicators_snapshot'], {}),
"full_result": _safe_json_parse(row['raw_result'], None),
"status": row.get('task_status') or 'completed',
"error_message": row.get('task_error') or '',
"created_at": row['created_at'].isoformat() if row['created_at'] else None,
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
"was_correct": row['was_correct'],
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
})
@@ -358,27 +392,143 @@ class AnalysisMemory:
except Exception as e:
logger.error(f"Failed to delete memory {memory_id}: {e}")
return False
def create_pending_task(self, market: str, symbol: str, language: str, model: str, timeframe: str,
user_id: int = None) -> Optional[int]:
"""Create a processing record in history before long-running analysis starts."""
try:
with get_db_connection() as db:
cur = db.cursor()
summary = f"Analysis submitted ({timeframe})..."
reasons = json.dumps([])
scores = json.dumps({})
indicators = json.dumps({})
raw = json.dumps({
"market": market,
"symbol": symbol,
"language": language,
"model": model,
"timeframe": timeframe,
"task_status": "processing",
})
cur.execute("""
INSERT INTO qd_analysis_memory (
user_id, market, symbol, decision, confidence,
summary, reasons, scores, indicators_snapshot, raw_result,
task_status, task_error, updated_at, created_at
) VALUES (%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, NOW(), NOW())
RETURNING id
""", (
user_id, market, symbol, "HOLD", 0,
summary, reasons, scores, indicators, raw,
"processing", "",
))
# PostgresCursor.execute() 会在 INSERT 时提前 fetchone() 消耗 RETURNING 结果,
# 所以这里不要再 cur.fetchone(),直接取 lastrowid。
memory_id = cur.lastrowid
db.commit()
cur.close()
return memory_id
except Exception as e:
logger.error(f"Failed to create pending task: {e}")
return None
def finalize_pending_task(self, memory_id: int, result: Dict[str, Any]) -> bool:
"""Overwrite pending record with final analysis result."""
try:
consensus = result.get("consensus") or {}
with get_db_connection() as db:
cur = db.cursor()
cur.execute("""
UPDATE qd_analysis_memory
SET decision = %s,
confidence = %s,
price_at_analysis = %s,
summary = %s,
reasons = %s,
scores = %s,
indicators_snapshot = %s,
raw_result = %s,
consensus_score = %s,
consensus_abs = %s,
agreement_ratio = %s,
quality_multiplier = %s,
task_status = %s,
task_error = %s,
updated_at = NOW()
WHERE id = %s
""", (
result.get("decision"),
result.get("confidence"),
result.get("market_data", {}).get("current_price"),
result.get("summary"),
json.dumps(result.get("reasons", [])),
json.dumps(result.get("scores", {})),
json.dumps(result.get("indicators", {})),
json.dumps(result),
consensus.get("consensus_score"),
consensus.get("consensus_abs"),
consensus.get("agreement_ratio"),
consensus.get("quality_multiplier"),
"completed" if not result.get("error") else "failed",
str(result.get("error") or ""),
int(memory_id),
))
ok = cur.rowcount > 0
db.commit()
cur.close()
return ok
except Exception as e:
logger.error(f"Failed to finalize pending task {memory_id}: {e}")
return False
def fail_pending_task(self, memory_id: int, error_message: str) -> bool:
"""Mark pending task as failed."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("""
UPDATE qd_analysis_memory
SET task_status = 'failed',
task_error = %s,
summary = %s,
updated_at = NOW()
WHERE id = %s
""", (
str(error_message or "analysis failed"),
f"Analysis failed: {str(error_message or '')}",
int(memory_id),
))
ok = cur.rowcount > 0
db.commit()
cur.close()
return ok
except Exception as e:
logger.error(f"Failed to mark task failed {memory_id}: {e}")
return False
def get_similar_patterns(self, market: str, symbol: str,
current_indicators: Dict, limit: int = 3) -> List[Dict]:
"""
Find historical analyses with similar technical patterns.
This is a simplified version - can be enhanced with vector similarity later.
Currently matches based on:
- Same symbol
- Similar RSI range (±10)
- Same MACD signal direction
- Validated outcomes preferred
Multi-indicator weighted similarity:
- RSI: ±15 range, weighted 0.3
- MACD signal: exact match, weighted 0.3
- MA trend: exact match, weighted 0.25
- Volatility level: similar band, weighted 0.15
- Time decay: prefer recent validated outcomes
"""
try:
rsi = current_indicators.get("rsi", {}).get("value", 50)
macd_signal = current_indicators.get("macd", {}).get("signal", "neutral")
rsi = float(current_indicators.get("rsi", {}).get("value") or 50)
macd_signal = str(current_indicators.get("macd", {}).get("signal") or "neutral").lower()
ma_trend = str(current_indicators.get("moving_averages", {}).get("trend") or "sideways").lower()
vol_level = str(current_indicators.get("volatility", {}).get("level") or "normal").lower()
with get_db_connection() as db:
cur = db.cursor()
# Simple pattern matching query
cur.execute("""
SELECT
id, decision, confidence, price_at_analysis,
@@ -388,49 +538,50 @@ class AnalysisMemory:
WHERE market = %s AND symbol = %s
AND validated_at IS NOT NULL
AND was_correct IS NOT NULL
ORDER BY
CASE WHEN was_correct = true THEN 0 ELSE 1 END,
created_at DESC
ORDER BY validated_at DESC NULLS LAST, created_at DESC
LIMIT %s
""", (market, symbol, limit * 2)) # Get more for filtering
""", (market, symbol, limit * 5))
rows = cur.fetchall() or []
cur.close()
results = []
scored = []
for row in rows:
indicators = _safe_json_parse(row['indicators_snapshot'], {})
hist_rsi = indicators.get("rsi", {}).get("value", 50)
hist_macd = indicators.get("macd", {}).get("signal", "neutral")
ind = _safe_json_parse(row['indicators_snapshot'], {})
hist_rsi = float(ind.get("rsi", {}).get("value") or 50)
hist_macd = str(ind.get("macd", {}).get("signal") or "neutral").lower()
hist_ma = str(ind.get("moving_averages", {}).get("trend") or "sideways").lower()
hist_vol = str(ind.get("volatility", {}).get("level") or "normal").lower()
# Simple similarity check
rsi_similar = abs(hist_rsi - rsi) <= 15
macd_similar = hist_macd == macd_signal
rsi_diff = abs(hist_rsi - rsi)
rsi_score = max(0, 1 - rsi_diff / 30) * 0.3
macd_score = 0.3 if hist_macd == macd_signal else 0
ma_score = 0.25 if hist_ma == ma_trend else 0
vol_score = 0.15 if hist_vol == vol_level else (0.08 if _vol_bands_similar(vol_level, hist_vol) else 0)
if rsi_similar or macd_similar:
results.append({
"id": row['id'],
"decision": row['decision'],
"confidence": row['confidence'],
"price": float(row['price_at_analysis']) if row['price_at_analysis'] else None,
"summary": row['summary'],
"was_correct": row['was_correct'],
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
"similarity": {
"rsi_match": rsi_similar,
"macd_match": macd_similar,
}
})
if len(results) >= limit:
break
sim = rsi_score + macd_score + ma_score + vol_score
if sim < 0.25:
continue
bonus = 0.1 if row['was_correct'] else 0
scored.append((sim + bonus, {
"id": row['id'],
"decision": row['decision'],
"confidence": row['confidence'],
"price": float(row['price_at_analysis']) if row['price_at_analysis'] else None,
"summary": row['summary'],
"was_correct": row['was_correct'],
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
"similarity_score": round(sim + bonus, 3),
}))
return results
scored.sort(key=lambda x: -x[0])
return [p[1] for p in scored[:limit]]
except Exception as e:
logger.error(f"Failed to get similar patterns: {e}")
return []
def record_feedback(self, memory_id: int, feedback: str) -> bool:
"""
Record user feedback on an analysis.
@@ -493,8 +644,8 @@ class AnalysisMemory:
for row in rows:
try:
# Get current price using MarketDataCollector
current_price = collector._get_price(row['market'], row['symbol'])
price_data = collector._get_price(row['market'], row['symbol'])
current_price = float(price_data.get('price', 0)) if price_data else None
if not current_price or current_price <= 0:
continue
analysis_price = float(row['price_at_analysis'])
@@ -580,7 +731,8 @@ class AnalysisMemory:
for row in rows:
try:
current_price = collector._get_price(row["market"], row["symbol"])
price_data = collector._get_price(row["market"], row["symbol"])
current_price = float(price_data.get("price", 0)) if price_data else None
if not current_price or current_price <= 0:
continue
analysis_price = float(row.get("price_at_analysis") or 0.0)
@@ -625,6 +777,74 @@ class AnalysisMemory:
return stats
def get_confidence_accuracy_by_bucket(
self, market: str = None, symbol: str = None, days: int = 90
) -> Dict[str, float]:
"""
Compute actual accuracy by confidence bucket for calibration.
Buckets: (50,60), (60,70), (70,80), (80,90), (90,100).
Returns e.g. {"60_70": 0.58, "70_80": 0.62} - bucket_key -> accuracy.
"""
try:
with get_db_connection() as db:
cur = db.cursor()
where = ["validated_at IS NOT NULL", "was_correct IS NOT NULL", "confidence IS NOT NULL"]
params = []
if market:
where.append("market = %s")
params.append(market)
if symbol:
where.append("symbol = %s")
params.append(symbol)
where.append(f"created_at > NOW() - INTERVAL '{int(days)} days'")
params = tuple(params) if params else ()
cur.execute(f"""
SELECT confidence, was_correct
FROM qd_analysis_memory
WHERE {' AND '.join(where)}
""", params)
rows = cur.fetchall() or []
cur.close()
buckets = [(50, 60), (60, 70), (70, 80), (80, 90), (90, 101)]
out = {}
for lo, hi in buckets:
subset = [r for r in rows if lo <= (r.get("confidence") or 0) < hi]
if len(subset) < 5:
continue
correct = sum(1 for r in subset if r.get("was_correct"))
out[f"{lo}_{hi}"] = correct / len(subset)
return out
except Exception as e:
logger.warning(f"get_confidence_accuracy_by_bucket failed: {e}")
return {}
def get_adjusted_confidence(
self, raw_confidence: int, market: str = None, symbol: str = None
) -> int:
"""
Adjust confidence based on historical accuracy in that bucket.
If model is overconfident (low actual accuracy), dampen. Underconfident -> boost slightly.
"""
buckets = [(50, 60, "50_60"), (60, 70, "60_70"), (70, 80, "70_80"), (80, 90, "80_90"), (90, 101, "90_100")]
bucket_key = None
for lo, hi, key in buckets:
if lo <= raw_confidence < hi:
bucket_key = key
break
if not bucket_key:
return max(1, min(99, int(raw_confidence)))
acc_map = self.get_confidence_accuracy_by_bucket(market=market, symbol=symbol)
acc = acc_map.get(bucket_key)
if acc is None or acc <= 0:
return max(1, min(99, int(raw_confidence)))
expected = 0.5 + (raw_confidence - 50) / 100
if expected <= 0:
return raw_confidence
factor = acc / expected
adjusted = int(raw_confidence * factor)
return max(1, min(99, adjusted))
def get_performance_stats(self, market: str = None, symbol: str = None,
days: int = 30) -> Dict[str, Any]:
"""
@@ -705,6 +925,18 @@ class AnalysisMemory:
}
def _vol_bands_similar(a: str, b: str) -> bool:
"""Check if two volatility levels are in similar band."""
low = {"low", "normal", "normal_low"}
high = {"high", "elevated", "volatile", "very_high"}
a, b = a.lower(), b.lower()
if a in low and b in low:
return True
if a in high and b in high:
return True
return False
# Singleton
_memory_instance = None
@@ -30,21 +30,16 @@ DEFAULT_BILLING_CONFIG = {
'enabled': False, # 是否启用计费
# 各功能积分消耗(0表示免费)
'cost_ai_analysis': 10, # AI分析 每次消耗积分
'cost_strategy_run': 5, # 策略运行 每次消耗积分(启动时)
'cost_backtest': 3, # 回测 每次消耗积分
'cost_portfolio_monitor': 8, # Portfolio AI监控 每次消耗积分
'cost_indicator_create': 0, # 创建指标 免费
'cost_polymarket_deep_analysis': 15, # Polymarket深度分析 每次消耗积分
# ai_analysis 统一单价:即时分析 / AI过滤 / 定时任务 均按此单价 × 标的数扣费
'cost_ai_analysis': 10,
'cost_ai_code_gen': 30,
'cost_polymarket_deep_analysis': 15,
}
# Feature name mapping (for log recording)
FEATURE_NAMES = {
'ai_analysis': 'AI Analysis',
'strategy_run': 'Strategy Run',
'backtest': 'Backtest',
'portfolio_monitor': 'Portfolio Monitor',
'indicator_create': 'Indicator Create',
'ai_code_gen': 'AI Code Generation',
'polymarket_deep_analysis': 'Polymarket Deep Analysis',
}
@@ -458,7 +453,7 @@ class BillingService:
Args:
user_id: 用户ID
feature: 功能名称(ai_analysis/strategy_run/backtest/portfolio_monitor等
feature: 功能名称(ai_analysis / polymarket_deep_analysis
reference_id: 关联ID(可选)
Returns:
@@ -716,12 +711,10 @@ class BillingService:
'is_vip': is_vip,
'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None,
'billing_enabled': config.get('enabled', False),
# 功能费用(供前端显示)
'feature_costs': {
'ai_analysis': config.get('cost_ai_analysis', 0),
'strategy_run': config.get('cost_strategy_run', 0),
'backtest': config.get('cost_backtest', 0),
'portfolio_monitor': config.get('cost_portfolio_monitor', 0),
'ai_code_gen': config.get('cost_ai_code_gen', 0),
'polymarket_deep_analysis': config.get('cost_polymarket_deep_analysis', 0),
}
}
+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,
}
+44 -26
View File
@@ -82,11 +82,10 @@ class LLMService:
if provider_name:
try:
# Explicit selection should always be respected.
# API key validation happens later in call path.
selected = LLMProvider(provider_name.lower())
# Verify this provider has an API key configured
if self.get_api_key(selected):
return selected
logger.warning(f"LLM_PROVIDER={provider_name} but no API key configured, auto-detecting...")
return selected
except ValueError:
pass
@@ -184,31 +183,36 @@ class LLMService:
response = requests.post(url, headers=headers, json=data, timeout=timeout)
# Handle errors with detailed messages
if response.status_code == 403:
error_msg = "OpenRouter API 403 Forbidden"
# Handle non-2xx with provider/model-aware details
if response.status_code >= 400:
provider_name = "OpenRouter" if "openrouter" in (base_url or "").lower() else "LLM"
error_msg = f"{provider_name} API {response.status_code}"
err_text = ""
try:
error_data = response.json()
if "error" in error_data:
error_detail = error_data["error"]
if isinstance(error_detail, dict):
error_msg = f"OpenRouter API 403: {error_detail.get('message', 'Forbidden')}"
elif isinstance(error_detail, str):
error_msg = f"OpenRouter API 403: {error_detail}"
except:
pass
# Check if API key is configured
from app.config.api_keys import APIKeys
if not APIKeys.OPENROUTER_API_KEY:
error_msg += ". OPENROUTER_API_KEY 未配置,请在 backend_api_python/.env 中设置"
else:
error_msg += ". 可能的原因:1) API 密钥无效或过期 2) 账户余额不足 3) 没有权限访问该模型。请检查 https://openrouter.ai/keys"
error_data = response.json() or {}
error_detail = error_data.get("error")
if isinstance(error_detail, dict):
err_text = str(error_detail.get("message") or "").strip()
elif isinstance(error_detail, str):
err_text = error_detail.strip()
except Exception:
err_text = (response.text or "").strip()[:300]
if err_text:
error_msg = f"{error_msg}: {err_text}"
# OpenRouter targeted hints
if "openrouter" in (base_url or "").lower():
from app.config.api_keys import APIKeys
if not APIKeys.OPENROUTER_API_KEY:
error_msg += ". OPENROUTER_API_KEY 未配置,请在 backend_api_python/.env 中设置"
elif response.status_code == 403:
error_msg += ". 可能原因:API 密钥无效/过期、余额不足、或无模型权限。请检查 https://openrouter.ai/keys"
elif response.status_code == 404:
error_msg += ". 可能原因:模型不可用或账户隐私/数据策略限制。请检查 https://openrouter.ai/settings/privacy"
raise ValueError(error_msg)
response.raise_for_status()
result = response.json()
if "choices" in result and len(result["choices"]) > 0:
content = result["choices"][0]["message"]["content"]
@@ -369,9 +373,23 @@ class LLMService:
logger.debug(f"Auto-detected provider '{provider.value}' from model '{model}'")
p = provider or self.provider
cfg = load_addon_config()
explicit_provider_name = str(cfg.get('llm', {}).get('provider') or os.getenv('LLM_PROVIDER', '')).strip().lower()
explicit_provider = None
if explicit_provider_name:
try:
explicit_provider = LLMProvider(explicit_provider_name)
except ValueError:
explicit_provider = None
api_key = self.get_api_key(p)
if not api_key:
# If provider is explicitly configured by user, don't silently switch.
if explicit_provider is not None and p == explicit_provider:
raise ValueError(
f"API key not configured for explicit provider: {p.value}. "
f"Please set {p.value.upper()}_API_KEY in settings."
)
# If no API key for current provider, try to find any available provider
if try_alternative_providers:
for alt_provider in [LLMProvider.DEEPSEEK, LLMProvider.GROK, LLMProvider.OPENAI, LLMProvider.GOOGLE, LLMProvider.OPENROUTER]:
@@ -281,14 +281,12 @@ class MarketDataCollector:
"""
计算技术指标 (本地计算无外部依赖)
返回格式符合前端 FastAnalysisReport.vue 的期望
{
rsi: { value, signal },
macd: { signal, trend },
moving_averages: { ma5, ma10, ma20, trend },
levels: { support, resistance },
volatility: { level, pct }
}
返回格式符合前端 FastAnalysisReport.vue 的期望
口径说明与常见行情终端对齐
- RSI(14)Wilder 平滑首段均幅为前 14 期简单平均其后递推
- MACD收盘 EMA12/EMA26首值= N SMA信号线=MACD EMA9SMA 种子
- MASMA枢轴上一根 K H/L/C摆动高低 20 H/L 窗口极值
- 布林20 收盘 SMA ± 2×总体标准差ATR(14)Wilder ATR= 14 TR 简单平均其后递推
"""
if not klines or len(klines) < 5:
return {}
@@ -319,8 +317,8 @@ class MarketDataCollector:
'signal': rsi_signal,
}
# ========== MACD ==========
if len(closes) >= 26:
# ========== MACD(SMA 种子 EMA,与常见终端一致)==========
if len(closes) >= 34:
macd_raw = self._calc_macd(closes)
macd_val = macd_raw.get('MACD', 0)
macd_sig = macd_raw.get('MACD_signal', 0)
@@ -366,6 +364,11 @@ class MarketDataCollector:
'ma20': round(ma20, 6),
'trend': ma_trend,
}
# 先算布林带,供下方合成支撑/阻力使用(键名 BB_upper / BB_lower
bb_for_levels: Dict[str, Any] = {}
if len(closes) >= 20:
bb_for_levels = self._calc_bollinger(closes, 20, 2) or {}
# ========== 支撑/阻力位 (多种方法综合) ==========
# 方法1: 枢轴点 (Pivot Points) - 使用前一日数据
@@ -390,13 +393,13 @@ class MarketDataCollector:
swing_high = max(recent_highs) if recent_highs else current_price * 1.05
swing_low = min(recent_lows) if recent_lows else current_price * 0.95
# 方法3: 布林带中轨上下 (如果有)
bb_upper = indicators.get('bollinger', {}).get('upper', swing_high)
bb_lower = indicators.get('bollinger', {}).get('lower', swing_low)
# 方法3: 布林上下轨(与 _calc_bollinger 返回字段一致)
bb_upper = bb_for_levels.get('BB_upper', swing_high)
bb_lower = bb_for_levels.get('BB_lower', swing_low)
# 综合取值: 取多种方法的平均/加权
resistance = round((r1 + swing_high + bb_upper) / 3, 6) if bb_upper else round((r1 + swing_high) / 2, 6)
support = round((s1 + swing_low + bb_lower) / 3, 6) if bb_lower else round((s1 + swing_low) / 2, 6)
resistance = round((r1 + swing_high + bb_upper) / 3, 6)
support = round((s1 + swing_low + bb_lower) / 3, 6)
indicators['levels'] = {
'support': support,
@@ -411,20 +414,10 @@ class MarketDataCollector:
'method': 'pivot_swing_bb_avg' # 标注计算方法
}
# ========== ATR 和波动率 ==========
atr = 0
# ========== ATR 和波动率(Wilder ATR,全序列递推至最新一根)==========
atr = 0.0
if len(klines) >= 14:
# 真实波动幅度 ATR (True Range)
true_ranges = []
for i in range(-14, 0):
h = float(klines[i].get('high', 0))
l = float(klines[i].get('low', 0))
prev_c = float(klines[i-1].get('close', 0)) if i > -14 else h
if h > 0 and l > 0:
tr = max(h - l, abs(h - prev_c), abs(l - prev_c))
true_ranges.append(tr)
atr = sum(true_ranges) / len(true_ranges) if true_ranges else 0
atr = float(self._calc_atr_wilder(klines, period=14))
volatility_pct = (atr / current_price * 100) if current_price > 0 else 0
if volatility_pct > 5:
@@ -468,10 +461,9 @@ class MarketDataCollector:
'method': 'atr_support_resistance'
}
# ========== 布林带 (附加) ==========
if len(closes) >= 20:
bb_data = self._calc_bollinger(closes, 20, 2)
indicators['bollinger'] = bb_data
# ========== 布林带 (附加,与 bb_for_levels 同一次计算) ==========
if bb_for_levels:
indicators['bollinger'] = bb_for_levels
# ========== 成交量 (附加) ==========
if len(volumes) >= 20:
@@ -498,48 +490,109 @@ class MarketDataCollector:
return {}
def _calc_rsi(self, closes: List[float], period: int = 14) -> float:
"""计算RSI"""
"""Wilder RSI:首段均幅为前 period 期涨跌简单平均,之后按 Wilder 平滑递推。"""
if len(closes) < period + 1:
return 50.0
deltas = [closes[i] - closes[i-1] for i in range(1, len(closes))]
gains = [d if d > 0 else 0 for d in deltas]
losses = [-d if d < 0 else 0 for d in deltas]
avg_gain = sum(gains[-period:]) / period
avg_loss = sum(losses[-period:]) / period
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [d if d > 0 else 0.0 for d in deltas]
losses = [-d if d < 0 else 0.0 for d in deltas]
if len(gains) < period:
return 50.0
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
for i in range(period, len(gains)):
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return round(rsi, 2)
return round(100.0 - (100.0 / (1.0 + rs)), 2)
def _ema_series_sma_seed(self, data: List[float], period: int) -> List[Optional[float]]:
"""
标准 EMA首值 = period 根简单平均SMA之后 EMA_t = (P_t - EMA_{t-1}) * k + EMA_{t-1}k=2/(period+1)
period-1 根无定义返回 None
"""
n = len(data)
out: List[Optional[float]] = [None] * n
if n < period:
return out
k = 2.0 / (period + 1)
out[period - 1] = sum(data[:period]) / period
for i in range(period, n):
prev = out[i - 1]
if prev is None:
break
out[i] = (data[i] - prev) * k + prev
return out
def _calc_macd(self, closes: List[float]) -> Dict[str, float]:
"""计算MACD"""
def ema(data, period):
multiplier = 2 / (period + 1)
ema_values = [data[0]]
for i in range(1, len(data)):
ema_values.append((data[i] - ema_values[-1]) * multiplier + ema_values[-1])
return ema_values
ema12 = ema(closes, 12)
ema26 = ema(closes, 26)
macd_line = [ema12[i] - ema26[i] for i in range(len(closes))]
signal_line = ema(macd_line, 9)
histogram = [macd_line[i] - signal_line[i] for i in range(len(closes))]
"""
MACD(12,26,9)DIF = EMA12(close) EMA26(close)DEA = EMA9(DIF) = DIF DEA
EMA 均采用 SMA 种子DIF 自第 26 K 起有定义信号线对 DIF 子序列再算 EMA9
"""
n = len(closes)
ema12 = self._ema_series_sma_seed(closes, 12)
ema26 = self._ema_series_sma_seed(closes, 26)
if n < 26 or ema12[-1] is None or ema26[-1] is None:
return {'MACD': 0.0, 'MACD_signal': 0.0, 'MACD_histogram': 0.0}
macd_sub: List[float] = []
for i in range(25, n):
v12 = ema12[i]
v26 = ema26[i]
if v12 is not None and v26 is not None:
macd_sub.append(v12 - v26)
if not macd_sub:
return {'MACD': 0.0, 'MACD_signal': 0.0, 'MACD_histogram': 0.0}
sig_series = self._ema_series_sma_seed(macd_sub, 9)
last_macd = macd_sub[-1]
last_sig = sig_series[-1]
if last_sig is None:
last_sig = last_macd
return {
'MACD': round(macd_line[-1], 4),
'MACD_signal': round(signal_line[-1], 4),
'MACD_histogram': round(histogram[-1], 4)
'MACD': round(last_macd, 6),
'MACD_signal': round(last_sig, 6),
'MACD_histogram': round(last_macd - last_sig, 6),
}
def _true_ranges(self, klines: List[Dict[str, Any]]) -> List[float]:
"""每根 K 的 True Range(首根仅 HL)。"""
trs: List[float] = []
for i, k in enumerate(klines):
h = float(k.get('high', 0))
l = float(k.get('low', 0))
if h <= 0 or l <= 0:
trs.append(0.0)
continue
if i == 0:
trs.append(h - l)
else:
pc = float(klines[i - 1].get('close', 0))
trs.append(max(h - l, abs(h - pc), abs(l - pc)))
return trs
def _calc_atr_wilder(self, klines: List[Dict[str, Any]], period: int = 14) -> float:
"""Wilder ATR:首 ATR = 前 period 期 TR 简单平均,之后 ATR_t = (ATR_{t-1}*(period-1)+TR_t)/period。"""
trs = self._true_ranges(klines)
if len(trs) < period:
return 0.0
atr = sum(trs[:period]) / period
for i in range(period, len(trs)):
atr = (atr * (period - 1) + trs[i]) / period
return atr
def _calc_bollinger(self, closes: List[float], period: int = 20, std_dev: int = 2) -> Dict[str, float]:
"""计算布林带"""
"""布林带:中轨为 period 收盘 SMA,σ 为总体标准差(方差/period),上下轨=中轨±std_dev×σ。"""
if len(closes) < period:
return {}
@@ -723,58 +776,86 @@ class MarketDataCollector:
def _get_earnings_data(self, symbol: str) -> Optional[Dict[str, Any]]:
"""
获取盈利报告数据Earnings
包括历史盈利盈利预测盈利日期等
使用 quarterly_income_stmt 替代已弃用的 Ticker.earnings / quarterly_earnings
历史季度摘要从利润表推导盈利日历仍用 ticker.calendar若可用
"""
def _pick_float(stmt: pd.DataFrame, row_names: tuple, col) -> Optional[float]:
for name in row_names:
if name in stmt.index:
raw = stmt.loc[name, col]
if raw is None or (isinstance(raw, float) and pd.isna(raw)):
continue
try:
return float(raw)
except (TypeError, ValueError):
continue
return None
try:
ticker = yf.Ticker(symbol)
earnings_data = {}
# 历史盈利数据
earnings_data: Dict[str, Any] = {}
# 季度利润表(yfinance 推荐路径,避免 fundamentals.Ticker.earnings 弃用告警)
try:
earnings_history = ticker.earnings_history
if earnings_history is not None and not earnings_history.empty:
# 获取最近4个季度
recent_earnings = earnings_history.head(4)
earnings_data['history'] = []
for _, row in recent_earnings.iterrows():
earnings_data['history'].append({
'date': str(row.get('Date', '')),
'eps_actual': float(row.get('EPS Actual', 0)) if row.get('EPS Actual') is not None else None,
'eps_estimate': float(row.get('EPS Estimate', 0)) if row.get('EPS Estimate') is not None else None,
'surprise': float(row.get('Surprise(%)', 0)) if row.get('Surprise(%)') is not None else None,
q_inc = ticker.quarterly_income_stmt
if q_inc is not None and not q_inc.empty and len(q_inc.columns) > 0:
cols = list(q_inc.columns)[:4]
latest_q = cols[0]
rev = _pick_float(
q_inc,
("Total Revenue", "Revenue", "Total Revenues", "Net Sales"),
latest_q,
)
ni = _pick_float(
q_inc,
(
"Net Income",
"Net Income Common Stockholders",
"Net Income Continuous Operations",
"Net Income Including Noncontrolling Interests",
),
latest_q,
)
earnings_data["quarterly"] = {
"latest_quarter": str(latest_q),
"revenue": rev,
"earnings": ni,
}
# 最近若干季度 EPS(来自利润表行,非一致预期)
earnings_data["history"] = []
for col in cols:
eps = _pick_float(q_inc, ("Diluted EPS", "Basic EPS"), col)
earnings_data["history"].append({
"date": str(col),
"eps_actual": eps,
"eps_estimate": None,
"surprise": None,
})
except Exception as e:
logger.debug(f"Earnings history fetch failed for {symbol}: {e}")
# 盈利日历(未来盈利日期)
logger.debug(f"Quarterly income statement (earnings) fetch failed for {symbol}: {e}")
# 盈利日历(未来盈利日期与一致预期
try:
earnings_calendar = ticker.calendar
if earnings_calendar is not None and not earnings_calendar.empty:
earnings_data['upcoming'] = {
'next_earnings_date': str(earnings_calendar.index[0]) if len(earnings_calendar.index) > 0 else None,
'eps_estimate': float(earnings_calendar.loc[earnings_calendar.index[0], 'Earnings Estimate']) if len(earnings_calendar.index) > 0 and 'Earnings Estimate' in earnings_calendar.columns else None,
'revenue_estimate': float(earnings_calendar.loc[earnings_calendar.index[0], 'Revenue Estimate']) if len(earnings_calendar.index) > 0 and 'Revenue Estimate' in earnings_calendar.columns else None,
idx0 = earnings_calendar.index[0]
earnings_data["upcoming"] = {
"next_earnings_date": str(idx0),
"eps_estimate": float(earnings_calendar.loc[idx0, "Earnings Estimate"])
if "Earnings Estimate" in earnings_calendar.columns
else None,
"revenue_estimate": float(earnings_calendar.loc[idx0, "Revenue Estimate"])
if "Revenue Estimate" in earnings_calendar.columns
else None,
}
except Exception as e:
logger.debug(f"Earnings calendar fetch failed for {symbol}: {e}")
# 季度盈利数据
try:
quarterly_earnings = ticker.quarterly_earnings
if quarterly_earnings is not None and not quarterly_earnings.empty:
latest_q = quarterly_earnings.index[0] if len(quarterly_earnings.index) > 0 else None
if latest_q:
earnings_data['quarterly'] = {
'latest_quarter': str(latest_q),
'revenue': float(quarterly_earnings.loc[latest_q, 'Revenue']) if 'Revenue' in quarterly_earnings.columns else None,
'earnings': float(quarterly_earnings.loc[latest_q, 'Earnings']) if 'Earnings' in quarterly_earnings.columns else None,
}
except Exception as e:
logger.debug(f"Quarterly earnings fetch failed for {symbol}: {e}")
return earnings_data if earnings_data else None
except Exception as e:
logger.debug(f"Earnings data fetch failed for {symbol}: {e}")
return None
@@ -9,6 +9,7 @@ import json
import threading
import time
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from app.utils.db import get_db_connection
@@ -16,6 +17,7 @@ from app.utils.logger import get_logger
from app.services.fast_analysis import get_fast_analysis_service
from app.services.signal_notifier import SignalNotifier
from app.services.kline import KlineService
from app.services.billing_service import get_billing_service
logger = get_logger(__name__)
@@ -154,98 +156,92 @@ def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = No
return []
MAX_PARALLEL_ANALYSIS = 5
def _analyze_single_position(pos: Dict[str, Any], language: str) -> Dict[str, Any]:
"""Analyze a single position (designed to run inside a thread pool)."""
market = pos.get('market')
symbol = pos.get('symbol')
name = pos.get('name') or symbol
group_name = pos.get('group_name')
if not market or not symbol:
return {'market': market, 'symbol': symbol, 'name': name, 'error': 'missing market/symbol'}
try:
logger.info(f"Running fast AI analysis for {market}:{symbol}")
service = get_fast_analysis_service()
analysis_result = service.analyze(
market=market, symbol=symbol, language=language, timeframe='1D'
)
detailed = analysis_result.get('detailed_analysis', {})
trading_plan = analysis_result.get('trading_plan', {})
scores = analysis_result.get('scores', {})
risks = analysis_result.get('risks', [])
risk_report = '\n'.join([f"{r}" for r in risks]) if risks else ''
result = {
'market': market, 'symbol': symbol, 'name': name, 'group_name': group_name,
'entry_price': pos.get('entry_price'),
'current_price': pos.get('current_price') or analysis_result.get('market_data', {}).get('current_price'),
'pnl': pos.get('pnl'), 'pnl_percent': pos.get('pnl_percent'),
'quantity': pos.get('quantity'), 'side': pos.get('side'),
'final_decision': analysis_result.get('decision', 'HOLD'),
'confidence': analysis_result.get('confidence', 50),
'reasoning': analysis_result.get('summary', ''),
'trader_decision': analysis_result.get('decision', 'HOLD'),
'trader_reasoning': analysis_result.get('summary', ''),
'overview_report': detailed.get('technical', ''),
'fundamental_report': detailed.get('fundamental', ''),
'sentiment_report': detailed.get('sentiment', ''),
'risk_report': risk_report,
'suggested_entry': trading_plan.get('entry_price'),
'suggested_stop_loss': trading_plan.get('stop_loss'),
'suggested_take_profit': trading_plan.get('take_profit'),
'technical_score': scores.get('technical', 50),
'fundamental_score': scores.get('fundamental', 50),
'sentiment_score': scores.get('sentiment', 50),
'key_reasons': analysis_result.get('reasons', []),
'error': analysis_result.get('error')
}
logger.info(f"Fast analysis completed for {market}:{symbol}: {analysis_result.get('decision', 'N/A')}")
return result
except Exception as e:
logger.error(f"Failed to analyze {market}:{symbol}: {e}")
return {'market': market, 'symbol': symbol, 'name': name, 'error': str(e)}
def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) -> Dict[str, Any]:
"""
Run fast AI analysis on positions.
Uses the new FastAnalysisService (single LLM call, faster and more stable).
Run fast AI analysis on positions **in parallel** using a thread pool.
"""
try:
language = config.get('language', 'en-US')
custom_prompt = config.get('prompt', '')
# Get the fast analysis service
service = get_fast_analysis_service()
# Analyze each position
position_analyses = []
for pos in positions:
market = pos.get('market')
symbol = pos.get('symbol')
name = pos.get('name') or symbol
group_name = pos.get('group_name')
if not market or not symbol:
continue
try:
logger.info(f"Running fast AI analysis for {market}:{symbol}")
# Use the new FastAnalysisService (single LLM call)
analysis_result = service.analyze(
market=market,
symbol=symbol,
language=language,
timeframe='1D'
)
# Extract information from the new format
detailed = analysis_result.get('detailed_analysis', {})
trading_plan = analysis_result.get('trading_plan', {})
scores = analysis_result.get('scores', {})
# Build risk report from risks list
risks = analysis_result.get('risks', [])
risk_report = '\n'.join([f"{r}" for r in risks]) if risks else ''
position_analysis = {
'market': market,
'symbol': symbol,
'name': name,
'group_name': group_name,
'entry_price': pos.get('entry_price'),
'current_price': pos.get('current_price') or analysis_result.get('market_data', {}).get('current_price'),
'pnl': pos.get('pnl'),
'pnl_percent': pos.get('pnl_percent'),
'quantity': pos.get('quantity'),
'side': pos.get('side'),
# New fast analysis results
'final_decision': analysis_result.get('decision', 'HOLD'),
'confidence': analysis_result.get('confidence', 50),
'reasoning': analysis_result.get('summary', ''),
'trader_decision': analysis_result.get('decision', 'HOLD'), # Same as final for fast analysis
'trader_reasoning': analysis_result.get('summary', ''),
'overview_report': detailed.get('technical', ''),
'fundamental_report': detailed.get('fundamental', ''),
'sentiment_report': detailed.get('sentiment', ''),
'risk_report': risk_report,
# Trading plan
'suggested_entry': trading_plan.get('entry_price'),
'suggested_stop_loss': trading_plan.get('stop_loss'),
'suggested_take_profit': trading_plan.get('take_profit'),
# Scores
'technical_score': scores.get('technical', 50),
'fundamental_score': scores.get('fundamental', 50),
'sentiment_score': scores.get('sentiment', 50),
'key_reasons': analysis_result.get('reasons', []),
'error': analysis_result.get('error')
}
position_analyses.append(position_analysis)
logger.info(f"Fast analysis completed for {market}:{symbol}: {analysis_result.get('decision', 'N/A')}")
except Exception as e:
logger.error(f"Failed to analyze {market}:{symbol}: {e}")
position_analyses.append({
'market': market,
'symbol': symbol,
'name': name,
'error': str(e)
})
# Build comprehensive report
workers = min(len(positions), MAX_PARALLEL_ANALYSIS)
position_analyses: List[Dict[str, Any]] = [None] * len(positions)
with ThreadPoolExecutor(max_workers=workers) as executor:
future_to_idx = {
executor.submit(_analyze_single_position, pos, language): idx
for idx, pos in enumerate(positions)
}
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
position_analyses[idx] = future.result()
except Exception as e:
pos = positions[idx]
position_analyses[idx] = {
'market': pos.get('market'), 'symbol': pos.get('symbol'),
'name': pos.get('name') or pos.get('symbol'), 'error': str(e)
}
analysis_report = _build_comprehensive_report(positions, position_analyses, language, custom_prompt)
return {
'success': True,
'analysis': analysis_report,
@@ -254,15 +250,11 @@ def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) ->
'analyzed_count': len([p for p in position_analyses if not p.get('error')]),
'timestamp': _now_ts()
}
except Exception as e:
logger.error(f"_run_ai_analysis failed: {e}")
logger.error(traceback.format_exc())
return {
'success': False,
'error': str(e),
'timestamp': _now_ts()
}
return {'success': False, 'error': str(e), 'timestamp': _now_ts()}
def _build_comprehensive_report(
@@ -914,22 +906,67 @@ def run_single_monitor(monitor_id: int, override_language: str = None, user_id:
if override_language:
config['language'] = override_language
# Get positions for this user
# Resolve interval (frontend sends run_interval_minutes, legacy uses interval_minutes)
interval_minutes = int(
config.get('run_interval_minutes')
or config.get('interval_minutes')
or 60
)
# Get positions (or build from config.symbol if no position_ids)
positions = _get_positions_for_monitor(position_ids if position_ids else None, user_id=monitor_user_id)
# If monitor was created without positions but has symbol in config, build a virtual position
if not positions and config.get('symbol'):
positions = [{
'market': config.get('market', ''),
'symbol': config.get('symbol', ''),
'name': config.get('symbol', ''),
'side': 'long',
'quantity': 0,
'entry_price': 0,
'current_price': 0,
'pnl': 0,
'pnl_percent': 0,
}]
if not positions:
return {'success': False, 'error': 'No positions to analyze'}
# ── Billing: charge per symbol analyzed ──
billing = get_billing_service()
symbol_count = len(positions)
per_symbol_cost = billing.get_feature_cost('ai_analysis')
total_cost = per_symbol_cost * symbol_count
if total_cost > 0 and billing.is_billing_enabled():
user_credits = billing.get_user_credits(monitor_user_id)
if user_credits < total_cost:
logger.warning(
f"Monitor #{monitor_id} skipped: insufficient credits "
f"({user_credits} < {total_cost} for {symbol_count} symbols)"
)
return {
'success': False,
'error': f'Insufficient credits: need {total_cost}, have {user_credits}'
}
for i in range(symbol_count):
pos = positions[i]
ok, msg = billing.check_and_consume(
user_id=monitor_user_id,
feature='ai_analysis',
reference_id=f"monitor_{monitor_id}_{pos.get('symbol', '')}"
)
if not ok:
logger.warning(f"Monitor #{monitor_id} billing failed at symbol #{i+1}: {msg}")
break
# Run analysis based on type
if monitor_type == 'ai':
result = _run_ai_analysis(positions, config)
else:
# For other types, we can add price_alert, pnl_alert logic later
result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'}
# Update monitor record
interval_minutes = int(config.get('interval_minutes') or 60)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
@@ -0,0 +1,100 @@
"""
Reflection Service - Post-trade validation and learning.
Validates historical AI decisions against actual price outcomes,
updates qd_analysis_memory with was_correct/actual_return_pct,
and optionally triggers AI calibration.
"""
import os
import threading
import time
from typing import Dict, Any, Optional
from app.utils.logger import get_logger
from app.services.analysis_memory import get_analysis_memory
logger = get_logger(__name__)
_reflection_thread: Optional[threading.Thread] = None
_reflection_stop = threading.Event()
class ReflectionService:
"""
Runs verification cycle: validate unvalidated decisions, optionally run calibration.
"""
def run_verification_cycle(self) -> Dict[str, Any]:
"""
Run one verification cycle:
1. Validate unvalidated analysis records (older than min_age_days)
2. Optionally run AI calibration for configured markets
"""
memory = get_analysis_memory()
min_age_days = int(os.getenv("REFLECTION_MIN_AGE_DAYS", "7"))
limit = int(os.getenv("REFLECTION_VALIDATE_LIMIT", "200"))
stats = memory.validate_unvalidated_older_than(
min_age_days=min_age_days,
limit=limit,
)
logger.info(f"Reflection validation: {stats}")
if stats.get("validated", 0) > 0:
self._maybe_run_calibration()
else:
logger.debug("No new validations, skipping calibration")
return stats
def _maybe_run_calibration(self) -> None:
"""Run AI calibration if enabled."""
if os.getenv("ENABLE_OFFLINE_AI_CALIBRATION", "true").lower() != "true":
return
try:
from app.services.ai_calibration import AICalibrationService
svc = AICalibrationService()
markets = (os.getenv("AI_CALIBRATION_MARKETS", "Crypto") or "Crypto").strip().split(",")
for market in markets:
market = market.strip()
if not market:
continue
result = svc.calibrate_market(
market=market,
lookback_days=int(os.getenv("AI_CALIBRATION_LOOKBACK_DAYS", "30")),
min_samples=int(os.getenv("AI_CALIBRATION_MIN_SAMPLES", "80")),
validate_before=False,
)
if result:
logger.info(
f"[Reflection] Calibration updated for {market}: "
f"accuracy={result.best_accuracy:.1f}% thr=±{result.buy_threshold:.1f}"
)
except Exception as e:
logger.warning(f"Reflection calibration failed: {e}", exc_info=True)
def start_reflection_worker() -> None:
"""Start background reflection worker (validates + calibrates periodically)."""
global _reflection_thread
# Default to ON to reduce environment-specific configuration needs.
if os.getenv("ENABLE_REFLECTION_WORKER", "true").lower() != "true":
logger.info("Reflection worker disabled (ENABLE_REFLECTION_WORKER != true).")
return
interval_sec = int(os.getenv("REFLECTION_WORKER_INTERVAL_SEC", "86400"))
if _reflection_thread and _reflection_thread.is_alive():
return
def _run():
_reflection_stop.clear()
logger.info(f"Reflection worker started, interval={interval_sec}s")
while not _reflection_stop.is_set():
try:
ReflectionService().run_verification_cycle()
except Exception as e:
logger.error(f"Reflection cycle failed: {e}", exc_info=True)
_reflection_stop.wait(timeout=interval_sec)
logger.info("Reflection worker stopped.")
_reflection_thread = threading.Thread(target=_run, daemon=True)
_reflection_thread.start()
@@ -2263,8 +2263,33 @@ class TradingExecutor:
language = amc.get("language") or amc.get("lang") or tc.get("language") or "zh-CN"
language = str(language or "zh-CN")
# ── Billing: AI filter uses the same cost as ai_analysis ──
try:
from app.services.billing_service import get_billing_service
billing = get_billing_service()
if billing.is_billing_enabled():
user_id = 1
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
row = cur.fetchone()
cur.close()
user_id = int((row or {}).get('user_id') or 1)
except Exception:
pass
ok, msg = billing.check_and_consume(
user_id=user_id,
feature='ai_analysis',
reference_id=f"ai_filter_{strategy_id}_{symbol}"
)
if not ok:
logger.warning(f"AI filter billing failed for strategy {strategy_id}: {msg}")
return False, {"ai_decision": "", "reason": f"billing_failed:{msg}"}
except Exception as e:
logger.warning(f"AI filter billing check error: {e}")
try:
# 使用新的 FastAnalysisService (单次LLM调用,更快更稳定)
from app.services.fast_analysis import get_fast_analysis_service
service = get_fast_analysis_service()