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
+9 -10
View File
@@ -177,20 +177,19 @@ POST /api/users/change-password - Change own password
```text
GET /api/health
GET /api/indicator/kline
POST /api/analysis/multi
POST /api/fast-analysis/analyze - Fast AI analysis (main entry)
GET /api/fast-analysis/history - Analysis history
GET /api/fast-analysis/similar-patterns - RAG similar patterns
POST /api/fast-analysis/feedback - User feedback on analysis
```
## AI memory augmentation
## AI analysis & memory
This backend includes a lightweight, privacy-first **memory-augmented multi-agent** system:
Uses **FastAnalysisService** (single LLM call, multi-factor):
- Memory DBs stored in PostgreSQL
- API hooks:
- `POST /api/analysis/multi` (main entry)
- `POST /api/analysis/reflect` (manual learn from post-trade outcomes)
- Controls in `.env`:
- `ENABLE_AGENT_MEMORY`, `AGENT_MEMORY_*`
- `ENABLE_REFLECTION_WORKER`, `REFLECTION_WORKER_INTERVAL_SEC`
- Memory: `qd_analysis_memory` in PostgreSQL
- API: `POST /api/fast-analysis/analyze` (main), `/history`, `/similar-patterns`, `/feedback`
- Calibration: `AICalibrationService` tunes BUY/SELL thresholds from validated outcomes
## Frontend integration
+6
View File
@@ -279,6 +279,12 @@ def create_app(config_name='default'):
start_ai_calibration_worker()
except Exception:
pass
# Reflection worker: validate past decisions, run calibration periodically.
try:
from app.services.reflection import start_reflection_worker
start_reflection_worker()
except Exception:
pass
restore_running_strategies()
return app
+28 -12
View File
@@ -4,7 +4,7 @@
"""
from abc import ABC, abstractmethod
from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from app.utils.logger import get_logger
@@ -136,19 +136,35 @@ class BaseDataSource(ABC):
klines: List[Dict[str, Any]],
timeframe: str
):
"""记录获取结果日志"""
"""记录获取结果日志
延迟判断:
- K 线 time 为 Unix 秒(UTC),与 datetime.now(UTC) 比较,避免本地时区误差。
- 日线/周线:最后一根通常是「上一交易日收盘」,周末/节假日可达 3~4 天,
原先用 2×86400s(48h)会在周一早盘误报;改为日线最多容忍约 5 个自然日,周线更宽。
"""
if klines:
latest_time = datetime.fromtimestamp(klines[-1]['time'])
time_diff = (datetime.now() - latest_time).total_seconds()
# logger.info(
# f"{self.name}: {symbol} 获取 {len(klines)} 条数据, "
# f"最新时间: {latest_time}, 延迟: {time_diff:.0f}秒"
# )
# 检查数据是否过旧
max_diff = TIMEFRAME_SECONDS.get(timeframe, 3600) * 2
latest_ts = int(klines[-1]["time"])
latest_utc = datetime.fromtimestamp(latest_ts, tz=timezone.utc)
now_utc = datetime.now(timezone.utc)
time_diff = (now_utc - latest_utc).total_seconds()
tf_sec = TIMEFRAME_SECONDS.get(timeframe, 3600)
if tf_sec < 86400:
# 分钟/小时级:超过约 2 根 K 未更新则告警
max_diff = tf_sec * 2
elif tf_sec == 86400:
# 日线:覆盖周末 + 短假期(约 5 个自然日)
max_diff = 5 * 86400
else:
# 周线:允许跨多周数据滞后
max_diff = max(tf_sec * 2, 21 * 86400)
if time_diff > max_diff:
logger.warning(f"Warning: {symbol} data is delayed ({time_diff:.0f}s)")
logger.warning(
f"Warning: {symbol} data is delayed ({time_diff:.0f}s, "
f"latest_bar_utc={latest_utc.isoformat()}, threshold={max_diff:.0f}s, tf={timeframe})"
)
else:
logger.warning(f"{self.name}: no data for {symbol}")
@@ -22,6 +22,72 @@ _analysis_inflight_lock = threading.Lock()
_analysis_inflight = {} # key -> expire_ts
def _try_refund_credits(user_id: int, amount: int, remark: str):
"""Best-effort async refund when task fails after pre-charge."""
try:
if int(amount or 0) <= 0:
return
billing = get_billing_service()
billing.add_credits(
user_id=int(user_id),
amount=int(amount),
action='refund',
remark=remark
)
except Exception as e:
logger.error(f"Async auto refund failed: {e}", exc_info=True)
def _run_async_analysis_task(task_memory_id: int, market: str, symbol: str, language: str,
model: str, timeframe: str, user_id: int, inflight_key: str,
credits_charged: int = 0):
"""
Background worker: execute analysis and update pending history record.
"""
try:
service = get_fast_analysis_service()
memory = get_analysis_memory()
result = service.analyze(
market=market,
symbol=symbol,
language=language,
model=model,
timeframe=timeframe,
user_id=user_id
)
memory.finalize_pending_task(task_memory_id, result)
if result.get("error"):
_try_refund_credits(
user_id=int(user_id),
amount=int(credits_charged or 0),
remark=f'Auto refund: async fast-analysis failed ({market}:{symbol}:{timeframe})'
)
# analyze() already stores a separate memory row; remove it to avoid duplicates.
auto_memory_id = result.get("memory_id")
if auto_memory_id and int(auto_memory_id) != int(task_memory_id):
try:
memory.delete_history(int(auto_memory_id), user_id=user_id)
except Exception:
pass
except Exception as e:
logger.error(f"Async analysis task failed: {e}", exc_info=True)
_try_refund_credits(
user_id=int(user_id),
amount=int(credits_charged or 0),
remark=f'Auto refund: async fast-analysis exception ({market}:{symbol}:{timeframe})'
)
try:
get_analysis_memory().fail_pending_task(task_memory_id, str(e))
except Exception:
pass
finally:
try:
_release_inflight(inflight_key)
except Exception:
pass
def _build_inflight_key(user_id: int, market: str, symbol: str, timeframe: str) -> str:
return f"{int(user_id)}|{str(market or '').strip().upper()}|{str(symbol or '').strip().upper()}|{str(timeframe or '').strip().upper()}"
@@ -70,6 +136,7 @@ def analyze():
language = data.get('language', 'en-US')
model = data.get('model')
timeframe = data.get('timeframe', '1D')
async_submit = bool(data.get('async_submit', False))
if not market or not symbol:
return jsonify({
@@ -134,6 +201,45 @@ def analyze():
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
service = get_fast_analysis_service()
# Async submit mode: record "processing" immediately and return task id.
if async_submit:
memory = get_analysis_memory()
pending_id = memory.create_pending_task(
market=market,
symbol=symbol,
language=language,
model=model or "",
timeframe=timeframe,
user_id=user_id
)
if not pending_id:
return jsonify({'code': 0, 'msg': 'Failed to create analysis task', 'data': None}), 500
t = threading.Thread(
target=_run_async_analysis_task,
args=(int(pending_id), market, symbol, language, model, timeframe, int(user_id), inflight_key, int(credits_charged or 0)),
daemon=True
)
t.start()
# worker owns inflight release
inflight_key = None
return jsonify({
'code': 1,
'msg': 'submitted',
'data': {
'task_id': int(pending_id),
'memory_id': int(pending_id),
'status': 'processing',
'market': market,
'symbol': symbol,
'timeframe': timeframe,
'credits_charged': credits_charged,
'remaining_credits': remaining_credits,
}
})
result = service.analyze(
market=market,
symbol=symbol,
+27 -2
View File
@@ -424,12 +424,37 @@ def _fetch_commodities() -> List[Dict[str, Any]]:
"category": "commodity"
})
elif len(hist) == 1:
current = _safe_float(hist["Close"].iloc[-1], 0)
change = 0.0
# Fallback: some futures symbols only return one row in short history windows.
try:
fast_info = getattr(ticker, "fast_info", {}) or {}
prev_close = _safe_float(fast_info.get("previousClose"), 0)
if prev_close > 0 and current > 0:
change = ((current - prev_close) / prev_close) * 100
except Exception:
pass
if change == 0.0:
try:
info = getattr(ticker, "info", {}) or {}
# yfinance may expose either regularMarketChangePercent (ratio)
# or regularMarketChange (absolute). Prefer percent when present.
rcp = info.get("regularMarketChangePercent")
if rcp is not None:
change = _safe_float(rcp, 0) * 100
else:
rmc = _safe_float(info.get("regularMarketChange"), 0)
prev_close = _safe_float(info.get("regularMarketPreviousClose"), 0)
if prev_close > 0:
change = (rmc / prev_close) * 100
except Exception:
pass
result.append({
"symbol": commodity["symbol"],
"name_cn": commodity["name_cn"],
"name_en": commodity["name_en"],
"price": round(hist["Close"].iloc[-1], 2),
"change": 0,
"price": round(current, 2),
"change": round(change, 2),
"unit": commodity["unit"],
"category": "commodity"
})
+12 -1
View File
@@ -698,7 +698,18 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
return content.strip() or _template_code()
def stream():
# 不扣任何 QDT:开源本地版直接生成/返回代码
from app.services.billing_service import get_billing_service
billing = get_billing_service()
ok, msg = billing.check_and_consume(
user_id=g.user_id,
feature='ai_code_gen',
reference_id=f"ai_code_gen_{g.user_id}_{int(time.time())}"
)
if not ok:
yield "data: " + json.dumps({"error": f"积分不足: {msg}"}, ensure_ascii=False) + "\n\n"
yield "data: [DONE]\n\n"
return
try:
code_text = _generate_code_via_llm()
except Exception as e:
+10 -13
View File
@@ -250,20 +250,17 @@ def add_position():
with get_db_connection() as db:
cur = db.cursor()
# Delete any existing positions for this symbol (regardless of side),
# ensuring only one position per symbol per user per group.
cur.execute(
"DELETE FROM qd_manual_positions WHERE user_id = ? AND market = ? AND symbol = ? AND group_name = ?",
(user_id, market, symbol, group_name)
)
cur.execute(
"""
INSERT INTO qd_manual_positions
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
ON CONFLICT(user_id, market, symbol, side, group_name) DO UPDATE SET
name = excluded.name,
quantity = excluded.quantity,
entry_price = excluded.entry_price,
entry_time = excluded.entry_time,
notes = excluded.notes,
tags = excluded.tags,
group_name = excluded.group_name,
updated_at = NOW()
""",
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name)
)
@@ -557,8 +554,8 @@ def add_monitor():
if monitor_type not in ('ai', 'price_alert', 'pnl_alert'):
monitor_type = 'ai'
# Calculate next_run_at based on interval
interval_minutes = int(config.get('interval_minutes') or 60)
# Calculate next_run_at based on interval (frontend sends run_interval_minutes)
interval_minutes = int(config.get('run_interval_minutes') or config.get('interval_minutes') or 60)
position_ids_json = json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False)
config_json = json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False)
@@ -616,8 +613,8 @@ def update_monitor(monitor_id):
updates.append('config = ?')
params.append(json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False))
# Recalculate next_run_at if interval changed (handled separately for PostgreSQL)
next_run_interval = int(config.get('interval_minutes') or 60)
# Recalculate next_run_at if interval changed
next_run_interval = int(config.get('run_interval_minutes') or config.get('interval_minutes') or 60)
if 'notification_config' in data:
notification_config = data.get('notification_config') or {}
+128 -29
View File
@@ -5,10 +5,12 @@ Admin-only endpoints for system configuration management.
"""
import os
import re
import importlib
from flask import Blueprint, request, jsonify
from app.utils.logger import get_logger
from app.utils.config_loader import clear_config_cache
from app.utils.auth import login_required, admin_required
from dotenv import load_dotenv
logger = get_logger(__name__)
@@ -17,6 +19,55 @@ settings_bp = Blueprint('settings', __name__)
# .env 文件路径
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
def _reload_runtime_env() -> None:
"""
Reload .env into current process so settings take effect immediately.
Priority keeps backend_api_python/.env over repo-root/.env.
"""
backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
root_dir = os.path.dirname(backend_dir)
# Load root first, then backend .env to keep backend file higher priority
load_dotenv(os.path.join(root_dir, '.env'), override=True)
load_dotenv(os.path.join(backend_dir, '.env'), override=True)
def _refresh_runtime_services() -> None:
"""
Reset singleton services so new env/config is picked up lazily
on next request without restarting the Python process.
"""
# Prefer dedicated reset function where available.
try:
search_mod = importlib.import_module('app.services.search')
if hasattr(search_mod, 'reset_search_service'):
search_mod.reset_search_service()
except Exception as e:
logger.warning(f"reset_search_service skipped: {e}")
# Generic singleton fields used across services.
singleton_fields = [
('app.services.fast_analysis', '_fast_analysis_service'),
('app.services.billing_service', '_billing_service'),
('app.services.security_service', '_security_service'),
('app.services.oauth_service', '_oauth_service'),
('app.services.user_service', '_user_service'),
('app.services.email_service', '_email_service'),
('app.services.community_service', '_community_service'),
('app.services.usdt_payment_service', '_svc'),
('app.services.usdt_payment_service', '_worker'),
('app.services.analysis_memory', '_memory_instance'),
]
for module_name, field_name in singleton_fields:
try:
mod = importlib.import_module(module_name)
if hasattr(mod, field_name):
setattr(mod, field_name, None)
except Exception as e:
logger.warning(f"Singleton reset skipped: {module_name}.{field_name}: {e}")
# 配置项定义(分组)- 按功能模块划分,每个配置项包含描述
# ---------------------------------------------------------------
# 精简原则:
@@ -439,19 +490,75 @@ CONFIG_SCHEMA = {
'icon': 'experiment',
'order': 7,
'items': [
{
'key': 'ENABLE_AGENT_MEMORY',
'label': 'Enable Agent Memory',
'type': 'boolean',
'default': 'True',
'description': 'Enable AI agent memory for learning from past trades'
},
{
'key': 'ENABLE_REFLECTION_WORKER',
'label': 'Enable Auto Reflection',
'type': 'boolean',
'default': 'False',
'description': 'Enable background worker for automatic trade reflection'
'description': 'Enable background worker for automatic trade reflection and calibration'
},
{
'key': 'REFLECTION_WORKER_INTERVAL_SEC',
'label': 'Reflection Interval (sec)',
'type': 'number',
'default': '86400',
'description': 'Reflection worker run interval in seconds (86400 = 1 day)'
},
{
'key': 'REFLECTION_MIN_AGE_DAYS',
'label': 'Min Age for Validation (days)',
'type': 'number',
'default': '7',
'description': 'Only validate analyses older than N days'
},
{
'key': 'REFLECTION_VALIDATE_LIMIT',
'label': 'Validation Batch Limit',
'type': 'number',
'default': '200',
'description': 'Max records to validate per reflection cycle'
},
{
'key': 'ENABLE_CONFIDENCE_CALIBRATION',
'label': 'Enable Confidence Calibration',
'type': 'boolean',
'default': 'False',
'description': 'Adjust confidence by historical accuracy in each bucket'
},
{
'key': 'ENABLE_AI_ENSEMBLE',
'label': 'Enable Multi-Model Voting',
'type': 'boolean',
'default': 'False',
'description': 'Use 2-3 models and majority vote for more stable decisions'
},
{
'key': 'AI_ENSEMBLE_MODELS',
'label': 'Ensemble Models',
'type': 'text',
'default': 'openai/gpt-4o,openai/gpt-4o-mini',
'description': 'Comma-separated model IDs for ensemble voting'
},
{
'key': 'AI_CALIBRATION_MARKETS',
'label': 'Calibration Markets',
'type': 'text',
'default': 'Crypto',
'description': 'Comma-separated markets to run threshold calibration'
},
{
'key': 'AI_CALIBRATION_LOOKBACK_DAYS',
'label': 'Calibration Lookback (days)',
'type': 'number',
'default': '30',
'description': 'Days of validated data for calibration'
},
{
'key': 'AI_CALIBRATION_MIN_SAMPLES',
'label': 'Calibration Min Samples',
'type': 'number',
'default': '80',
'description': 'Minimum validated samples required for calibration'
},
]
},
@@ -661,31 +768,17 @@ CONFIG_SCHEMA = {
},
{
'key': 'BILLING_COST_AI_ANALYSIS',
'label': 'AI Analysis Cost',
'label': 'AI Analysis Cost (per symbol)',
'type': 'number',
'default': '10',
'description': 'Credits per AI analysis request'
'description': 'Credits per symbol (instant analysis, AI filter, scheduled tasks all use this price)'
},
{
'key': 'BILLING_COST_STRATEGY_RUN',
'label': 'Strategy Run Cost',
'key': 'BILLING_COST_AI_CODE_GEN',
'label': 'AI Code Generation Cost',
'type': 'number',
'default': '5',
'description': 'Credits per strategy start'
},
{
'key': 'BILLING_COST_BACKTEST',
'label': 'Backtest Cost',
'type': 'number',
'default': '3',
'description': 'Credits per backtest run'
},
{
'key': 'BILLING_COST_PORTFOLIO_MONITOR',
'label': 'Portfolio Monitor Cost',
'type': 'number',
'default': '8',
'description': 'Credits per portfolio AI monitoring run'
'default': '30',
'description': 'Credits per AI strategy/indicator code generation (higher token usage)'
},
{
'key': 'CREDITS_REGISTER_BONUS',
@@ -873,13 +966,19 @@ def save_settings():
if write_env_file(current_env):
# 清除配置缓存
clear_config_cache()
# 热重载运行时环境变量(无需重启进程)
_reload_runtime_env()
# 重置依赖配置的服务单例(下次请求自动按新配置重建)
_refresh_runtime_services()
return jsonify({
'code': 1,
'msg': 'Settings saved successfully',
'data': {
'updated_keys': list(updates.keys()),
'requires_restart': True # 标记需要重启
'requires_restart': False,
'hot_reloaded': True,
'services_refreshed': True
}
})
else:
@@ -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()
+50 -51
View File
@@ -63,12 +63,8 @@ SMTP_USE_SSL=false
# =========================
# Proxy (optional)
# =========================
# Most users only need PROXY_URL.
# Example local:
# PROXY_URL=socks5h://127.0.0.1:10808
#
# Example Docker:
# PROXY_URL=socks5h://host.docker.internal:10808
# PROXY_URL=socks5h://127.0.0.1:10808 # local
# PROXY_URL=socks5h://host.docker.internal:10808 # Docker
PROXY_URL=
# =========================
@@ -86,23 +82,47 @@ GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URI=http://localhost:5000/api/auth/oauth/github/callback
# =========================
# Billing / payments (optional)
# Billing / payments
# =========================
BILLING_ENABLED=False
USDT_PAY_ENABLED=False
BILLING_ENABLED=false
# 积分单价
BILLING_COST_AI_ANALYSIS=10
BILLING_COST_AI_CODE_GEN=30
CREDITS_REGISTER_BONUS=100
CREDITS_REFERRAL_BONUS=50
# Membership plans
MEMBERSHIP_MONTHLY_PRICE_USD=19.9
MEMBERSHIP_YEARLY_PRICE_USD=199
MEMBERSHIP_LIFETIME_PRICE_USD=499
MEMBERSHIP_MONTHLY_CREDITS=500
MEMBERSHIP_YEARLY_CREDITS=8000
MEMBERSHIP_LIFETIME_MONTHLY_CREDITS=800
# USDT payment
USDT_PAY_ENABLED=false
USDT_PAY_CHAIN=TRC20
USDT_TRC20_XPUB=
USDT_TRC20_CONTRACT=TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj
TRONGRID_BASE_URL=https://api.trongrid.io
TRONGRID_API_KEY=
USDT_PAY_CONFIRM_SECONDS=30
USDT_PAY_EXPIRE_MINUTES=30
USDT_WORKER_POLL_INTERVAL=30
# =========================
# Advanced / rarely changed
# =========================
# The settings below are optional. Most users can leave them unchanged.
# Network / App tuning
PYTHON_API_HOST=0.0.0.0
PYTHON_API_PORT=5000
PYTHON_API_DEBUG=False
PYTHON_API_DEBUG=false
RATE_LIMIT=100
ENABLE_CACHE=False
ENABLE_REQUEST_LOG=True
ENABLE_CACHE=false
ENABLE_REQUEST_LOG=true
# Strategy / execution tuning
PENDING_ORDER_STALE_SEC=90
@@ -112,6 +132,7 @@ MAKER_OFFSET_BPS=2
STRATEGY_TICK_INTERVAL_SEC=10
PRICE_CACHE_TTL_SEC=10
K_LINE_HISTORY_GET_NUMBER=500
SIGNAL_NOTIFY_TIMEOUT_SEC=6
# LLM advanced tuning
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
@@ -119,7 +140,6 @@ OPENROUTER_TEMPERATURE=0.7
OPENROUTER_MAX_TOKENS=4000
OPENROUTER_TIMEOUT=300
OPENROUTER_CONNECT_TIMEOUT=30
OPENAI_BASE_URL=https://api.openai.com/v1
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
GROK_BASE_URL=https://api.x.ai/v1
@@ -128,32 +148,26 @@ GROK_BASE_URL=https://api.x.ai/v1
DATA_SOURCE_TIMEOUT=30
DATA_SOURCE_RETRY=3
DATA_SOURCE_RETRY_BACKOFF=0.5
FINNHUB_API_KEY=
FINNHUB_TIMEOUT=10
FINNHUB_RATE_LIMIT=60
CCXT_DEFAULT_EXCHANGE=coinbase
CCXT_TIMEOUT=10000
AKSHARE_TIMEOUT=30
YFINANCE_TIMEOUT=30
TIINGO_API_KEY=
TIINGO_TIMEOUT=10
# AI search / news providers
# AI search / news
SEARCH_PROVIDER=google
SEARCH_MAX_RESULTS=10
SEARCH_GOOGLE_API_KEY=
SEARCH_GOOGLE_CX=
SEARCH_BING_API_KEY=
TAVILY_API_KEYS=
SERPAPI_KEYS=
# SMS / phone notifications
# SMS / phone (optional)
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER=
@@ -162,42 +176,27 @@ TWILIO_FROM_NUMBER=
SECURITY_IP_MAX_ATTEMPTS=10
SECURITY_IP_WINDOW_MINUTES=5
SECURITY_IP_BLOCK_MINUTES=15
SECURITY_ACCOUNT_MAX_ATTEMPTS=5
SECURITY_ACCOUNT_WINDOW_MINUTES=60
SECURITY_ACCOUNT_BLOCK_MINUTES=30
VERIFICATION_CODE_EXPIRE_MINUTES=10
VERIFICATION_CODE_RATE_LIMIT=60
VERIFICATION_CODE_IP_HOURLY_LIMIT=10
VERIFICATION_CODE_MAX_ATTEMPTS=5
VERIFICATION_CODE_LOCK_MINUTES=30
# Billing / credits
BILLING_COST_AI_ANALYSIS=10
BILLING_COST_STRATEGY_RUN=5
BILLING_COST_BACKTEST=3
BILLING_COST_PORTFOLIO_MONITOR=8
BILLING_COST_POLYMARKET_DEEP_ANALYSIS=15
# AI analysis tuning
ENABLE_CONFIDENCE_CALIBRATION=false
ENABLE_AI_ENSEMBLE=false
AI_ENSEMBLE_MODELS=openai/gpt-4o,openai/gpt-4o-mini
ENABLE_REFLECTION_WORKER=false
REFLECTION_WORKER_INTERVAL_SEC=86400
REFLECTION_MIN_AGE_DAYS=7
REFLECTION_VALIDATE_LIMIT=200
AI_CALIBRATION_MARKETS=Crypto
AI_CALIBRATION_LOOKBACK_DAYS=30
AI_CALIBRATION_MIN_SAMPLES=80
AI_ANALYSIS_CONSENSUS_TIMEFRAMES=1D,4H
CREDITS_REGISTER_BONUS=100
CREDITS_REFERRAL_BONUS=50
# Membership plans
MEMBERSHIP_MONTHLY_PRICE_USD=19.9
MEMBERSHIP_YEARLY_PRICE_USD=199
MEMBERSHIP_LIFETIME_PRICE_USD=499
MEMBERSHIP_MONTHLY_CREDITS=500
MEMBERSHIP_YEARLY_CREDITS=8000
MEMBERSHIP_LIFETIME_MONTHLY_CREDITS=800
# USDT payment
USDT_PAY_CHAIN=TRC20
USDT_TRC20_XPUB=
USDT_TRC20_CONTRACT=TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj
TRONGRID_BASE_URL=https://api.trongrid.io
TRONGRID_API_KEY=
USDT_PAY_CONFIRM_SECONDS=30
USDT_PAY_EXPIRE_MINUTES=30
USDT_WORKER_POLL_INTERVAL=30
# Internal
INTERNAL_API_KEY=
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""
Run AI calibration manually (e.g. via cron).
Usage:
python scripts/run_calibration.py
AI_CALIBRATION_MARKET=Crypto python scripts/run_calibration.py
AI_CALIBRATION_MARKETS=Crypto,USStock python scripts/run_calibration.py
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from app.services.ai_calibration import AICalibrationService
def main():
markets = (os.getenv("AI_CALIBRATION_MARKETS") or os.getenv("AI_CALIBRATION_MARKET") or "Crypto").strip().split(",")
for m in markets:
m = m.strip()
if not m:
continue
print(f"Calibrating market: {m}")
svc = AICalibrationService()
r = svc.calibrate_market(market=m, validate_before=True)
if r:
print(f" OK: accuracy={r.best_accuracy:.1f}% threshold=±{r.buy_threshold:.1f} samples={r.sample_count}")
else:
print(" SKIP: not enough validated samples")
print("Done.")
if __name__ == "__main__":
main()
@@ -1,19 +1,31 @@
import sys
import os
from dotenv import load_dotenv
# 添加项目根目录到 Python 路径
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..'))
# 添加后端目录到 Python 路径(使得可以 import app.*
# 由于 app 包位于 backend_api_python/app 下,而脚本位于 backend_api_python/scripts
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.services.agents.reflection import ReflectionService
from app.services.reflection import ReflectionService
def main():
# Load backend envs for DATABASE_URL, reflection switches, etc.
# This script may be run locally, so we must load .env explicitly.
backend_env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '.env'))
root_env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
if os.path.exists(root_env_path):
load_dotenv(root_env_path, override=False)
if os.path.exists(backend_env_path):
load_dotenv(backend_env_path, override=False)
"""
运行自动反思验证任务
建议通过 cron 定时任务调度器 每天运行一次
"""
print("Running Automated Reflection Verification Task...")
service = ReflectionService()
service.run_verification_cycle()
stats = service.run_verification_cycle()
print("Reflection stats:", stats)
print("Task Completed.")
if __name__ == "__main__":