Merge branch 'brokermr810:main' into main

This commit is contained in:
PengfaGuo
2026-01-31 10:26:27 +08:00
committed by GitHub
61 changed files with 11231 additions and 8745 deletions
+1 -37
View File
@@ -13,7 +13,6 @@ logger = get_logger(__name__)
# Global singletons (avoid duplicate strategy threads).
_trading_executor = None
_pending_order_worker = None
_reflection_worker = None
def get_trading_executor():
@@ -34,15 +33,6 @@ def get_pending_order_worker():
return _pending_order_worker
def get_reflection_worker():
"""Get the reflection verification worker singleton."""
global _reflection_worker
if _reflection_worker is None:
from app.services.agents.reflection_worker import ReflectionWorker
_reflection_worker = ReflectionWorker()
return _reflection_worker
def start_portfolio_monitor():
"""Start the portfolio monitor service if enabled.
@@ -67,30 +57,6 @@ def start_portfolio_monitor():
logger.error(f"Failed to start portfolio monitor: {e}")
def start_reflection_worker():
"""
Start the reflection worker if enabled.
To enable it, set ENABLE_REFLECTION_WORKER=true.
"""
import os
enabled = os.getenv("ENABLE_REFLECTION_WORKER", "false").lower() == "true"
if not enabled:
logger.info("Reflection worker is disabled. Set ENABLE_REFLECTION_WORKER=true to enable.")
return
# Avoid running twice with Flask reloader
debug = os.getenv("PYTHON_API_DEBUG", "false").lower() == "true"
if debug:
if os.environ.get("WERKZEUG_RUN_MAIN") != "true":
return
try:
get_reflection_worker().start()
except Exception as e:
logger.error(f"Failed to start reflection worker: {e}")
def start_pending_order_worker():
"""Start the pending order worker (disabled by default in paper mode).
@@ -244,8 +210,7 @@ def create_app(config_name='default'):
'/api/indicator/getIndicators', # Search indicators
'/api/market/klines', # Fetch K-lines (sometimes POST)
'/api/ai/chat', # AI Chat (generates response, doesn't mutate system state)
'/api/analysis/indicator', # Analysis request
'/api/analysis/ai_analysis' # AI Analysis request
'/api/fast-analysis/analyze', # Fast AI Analysis request
]
# Check if current path ends with any whitelist item
@@ -265,7 +230,6 @@ def create_app(config_name='default'):
# Startup hooks.
with app.app_context():
start_pending_order_worker()
start_reflection_worker()
start_portfolio_monitor()
restore_running_strategies()
@@ -473,6 +473,28 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
except Exception as e:
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
# 第三备选: Akshare
try:
import akshare as ak
# 使用 akshare 获取实时行情
df = ak.stock_zh_a_spot_em()
if df is not None and not df.empty:
# 在数据中查找对应股票
row = df[df['代码'] == symbol]
if not row.empty:
row = row.iloc[0]
return {
'last': float(row.get('最新价', 0) or 0),
'change': float(row.get('涨跌额', 0) or 0),
'changePercent': float(row.get('涨跌幅', 0) or 0),
'high': float(row.get('最高', 0) or 0),
'low': float(row.get('最低', 0) or 0),
'open': float(row.get('今开', 0) or 0),
'previousClose': float(row.get('昨收', 0) or 0)
}
except Exception as e:
logger.debug(f"Akshare ticker failed for {symbol}: {e}")
return {'last': 0, 'symbol': symbol}
@@ -776,4 +798,44 @@ class HShareDataSource(BaseDataSource, TencentDataMixin):
except Exception as e:
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
# 第三备选: yfinance
try:
import yfinance as yf
# 港股在 yfinance 中的格式是 XXXX.HK
yf_symbol = f"{symbol.zfill(4)}.HK"
ticker = yf.Ticker(yf_symbol)
# Try fast_info first (faster)
try:
info = ticker.fast_info
if hasattr(info, 'last_price') and info.last_price and info.last_price > 0:
return {
'last': float(info.last_price),
'change': float(info.last_price - info.previous_close) if hasattr(info, 'previous_close') and info.previous_close else 0,
'changePercent': float((info.last_price - info.previous_close) / info.previous_close * 100) if hasattr(info, 'previous_close') and info.previous_close else 0,
'high': float(info.day_high) if hasattr(info, 'day_high') and info.day_high else float(info.last_price),
'low': float(info.day_low) if hasattr(info, 'day_low') and info.day_low else float(info.last_price),
'open': float(info.open) if hasattr(info, 'open') and info.open else float(info.last_price),
'previousClose': float(info.previous_close) if hasattr(info, 'previous_close') and info.previous_close else 0
}
except Exception:
pass
# Fallback to history
hist = ticker.history(period="2d")
if hist is not None and not hist.empty:
current = float(hist['Close'].iloc[-1])
prev_close = float(hist['Close'].iloc[-2]) if len(hist) > 1 else current
return {
'last': current,
'change': current - prev_close,
'changePercent': (current - prev_close) / prev_close * 100 if prev_close else 0,
'high': float(hist['High'].iloc[-1]),
'low': float(hist['Low'].iloc[-1]),
'open': float(hist['Open'].iloc[-1]),
'previousClose': prev_close
}
except Exception as e:
logger.debug(f"yfinance ticker failed for {symbol}: {e}")
return {'last': 0, 'symbol': symbol}
+35 -3
View File
@@ -6,6 +6,7 @@ from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
import time
import requests
import threading
from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS
from app.utils.logger import get_logger
@@ -13,6 +14,11 @@ from app.config import TiingoConfig, APIKeys
logger = get_logger(__name__)
# 全局缓存 - 减少 Tiingo API 调用
_forex_cache: Dict[str, Dict[str, Any]] = {}
_forex_cache_lock = threading.Lock()
_FOREX_CACHE_TTL = 60 # 外汇价格缓存 60 秒 (Tiingo 免费 API 限制严格)
class ForexDataSource(BaseDataSource):
"""外汇数据源 (Tiingo)"""
@@ -60,6 +66,7 @@ class ForexDataSource(BaseDataSource):
获取外汇实时报价
使用 Tiingo FX Top-of-Book API 获取实时报价
带有 60 秒缓存以避免频繁触发 Tiingo 速率限制
Returns:
dict: {
@@ -75,6 +82,16 @@ class ForexDataSource(BaseDataSource):
logger.warning("Tiingo API key not configured")
return {'last': 0, 'symbol': symbol}
# 检查缓存
cache_key = f"ticker_{symbol}"
with _forex_cache_lock:
cached = _forex_cache.get(cache_key)
if cached:
cache_time = cached.get('_cache_time', 0)
if time.time() - cache_time < _FOREX_CACHE_TTL:
logger.debug(f"Using cached forex ticker for {symbol}")
return cached
try:
# 解析 symbol
tiingo_symbol = self.SYMBOL_MAP.get(symbol)
@@ -93,12 +110,20 @@ class ForexDataSource(BaseDataSource):
for attempt in range(3):
response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT)
if response.status_code == 429:
time.sleep(2 * (attempt + 1))
wait_time = 2 * (attempt + 1)
logger.warning(f"Tiingo rate limit (429), waiting {wait_time}s before retry ({attempt+1}/3)")
time.sleep(wait_time)
continue
break
if response.status_code == 429:
logger.warning("Tiingo rate limit exceeded for ticker request")
logger.info("Note: Tiingo 1-minute forex data requires a paid subscription")
# 返回缓存数据(如果有的话,即使已过期)
with _forex_cache_lock:
if cache_key in _forex_cache:
logger.info(f"Returning stale cache for {symbol} due to rate limit")
return _forex_cache[cache_key]
return {'last': 0, 'symbol': symbol}
response.raise_for_status()
@@ -144,15 +169,22 @@ class ForexDataSource(BaseDataSource):
except Exception:
pass # 涨跌计算失败不影响主要功能
return {
result = {
'last': round(last_price, 5),
'bid': round(bid, 5),
'ask': round(ask, 5),
'change': round(change, 5),
'changePercent': round(change_pct, 2),
'previousClose': round(prev_close, 5) if prev_close else 0
'previousClose': round(prev_close, 5) if prev_close else 0,
'_cache_time': time.time()
}
# 缓存结果
with _forex_cache_lock:
_forex_cache[cache_key] = result
return result
except Exception as e:
logger.error(f"Failed to get forex ticker for {symbol}: {e}")
+5 -3
View File
@@ -7,7 +7,6 @@ from flask import Flask
def register_routes(app: Flask):
"""Register all API route blueprints"""
from app.routes.kline import kline_bp
from app.routes.analysis import analysis_bp
from app.routes.backtest import backtest_bp
from app.routes.health import health_bp
from app.routes.market import market_bp
@@ -23,12 +22,13 @@ def register_routes(app: Flask):
from app.routes.mt5 import mt5_bp
from app.routes.user import user_bp
from app.routes.global_market import global_market_bp
from app.routes.community import community_bp
from app.routes.fast_analysis import fast_analysis_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
app.register_blueprint(user_bp, url_prefix='/api/users') # User management
app.register_blueprint(kline_bp, url_prefix='/api/indicator')
app.register_blueprint(analysis_bp, url_prefix='/api/analysis')
app.register_blueprint(backtest_bp, url_prefix='/api/indicator')
app.register_blueprint(market_bp, url_prefix='/api/market')
app.register_blueprint(ai_chat_bp, url_prefix='/api/ai')
@@ -40,4 +40,6 @@ def register_routes(app: Flask):
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')
app.register_blueprint(community_bp, url_prefix='/api/community')
app.register_blueprint(fast_analysis_bp, url_prefix='/api/fast-analysis')
-406
View File
@@ -1,406 +0,0 @@
"""
Analysis API routes (local-only).
Implements multi-dimensional analysis plus lightweight task/history APIs for the frontend.
"""
from flask import Blueprint, request, jsonify, Response, g
import json
import traceback
import time
from app.services.analysis import AnalysisService, reflect_analysis
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from app.utils.language import detect_request_language
from app.utils.auth import login_required
logger = get_logger(__name__)
analysis_bp = Blueprint('analysis', __name__)
def _now_ts() -> int:
return int(time.time())
def _normalize_symbol(symbol: str) -> str:
return (symbol or '').strip().upper()
def _store_task(user_id: int, market: str, symbol: str, model: str, language: str, status: str, result: dict = None, error_message: str = "") -> int:
"""Create a new task record. For pending tasks, completed_at is NULL."""
result_json = json.dumps(result or {}, ensure_ascii=False)
with get_db_connection() as db:
cur = db.cursor()
if status in ['completed', 'failed']:
cur.execute(
"""
INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
""",
(user_id, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '')
)
else:
cur.execute(
"""
INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
""",
(user_id, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '')
)
task_id = cur.lastrowid
db.commit()
cur.close()
return int(task_id)
def _update_task(task_id: int, status: str, result: dict = None, error_message: str = "") -> bool:
"""Update an existing task with result and status."""
try:
result_json = json.dumps(result or {}, ensure_ascii=False)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
UPDATE qd_analysis_tasks
SET status = ?, result_json = ?, error_message = ?, completed_at = NOW()
WHERE id = ?
""",
(status, result_json, error_message or '', task_id)
)
db.commit()
cur.close()
return True
except Exception as e:
logger.error(f"_update_task failed: {e}")
return False
def _get_task(task_id: int, user_id: int) -> dict:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT * FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, user_id))
row = cur.fetchone()
cur.close()
return row or None
def _parse_result_json(row: dict) -> dict:
if not row:
return {}
raw = row.get('result_json') or ''
try:
return json.loads(raw) if raw else {}
except Exception:
return {}
@analysis_bp.route('/multi', methods=['POST'])
@analysis_bp.route('/multiAnalysis', methods=['POST']) # compatibility with legacy naming
@login_required
def multi_analysis():
"""
Multi-dimensional analysis for the current user.
Request body:
market: Market (AShare, USStock, HShare, Crypto, Forex, Futures)
symbol: Symbol
language: Optional; if omitted we will detect from request headers (X-App-Lang / Accept-Language)
"""
task_id = None
user_id = None
market = ''
symbol = ''
model = None
language = 'en-US'
try:
user_id = g.user_id
data = request.get_json()
if not data:
return jsonify({
'code': 0,
'msg': 'Request body is required',
'data': None
}), 400
market = data.get('market', '')
symbol = data.get('symbol', '')
language = detect_request_language(request, body=data, default='en-US')
model = data.get('model', None)
use_multi_agent = data.get('use_multi_agent', None) # None -> use backend default
timeframe = data.get('timeframe', '1D')
if not symbol or not market:
return jsonify({
'code': 0,
'msg': 'Missing required parameters',
'data': None
}), 400
# Normalize/defend input for local-only mode.
market = str(market).strip()
symbol = _normalize_symbol(symbol)
language = str(language or 'en-US')
model = str(model) if model else None
logger.info(f"Analyze request: {market}:{symbol}, use_multi_agent={use_multi_agent}, model={model}")
# Step 0: Check billing (计费检查)
from app.services.billing_service import get_billing_service
billing_success, billing_msg = get_billing_service().check_and_consume(
user_id=user_id,
feature='ai_analysis',
reference_id=f'{market}:{symbol}'
)
if not billing_success:
if 'insufficient_credits' in billing_msg:
parts = billing_msg.split(':')
current = parts[1] if len(parts) > 1 else '0'
required = parts[2] if len(parts) > 2 else '?'
return jsonify({
'code': 0,
'msg': f'Insufficient credits. Current: {current}, Required: {required}',
'data': {'error_type': 'insufficient_credits', 'current': current, 'required': required}
}), 402
return jsonify({'code': 0, 'msg': billing_msg, 'data': None}), 400
# Step 1: Create a "pending" task record first (so user can see progress in history)
task_id = _store_task(user_id, market, symbol, model or '', language, 'pending', result={}, error_message='')
# Step 2: Run analysis in background thread (so user can navigate away)
import threading
def run_analysis_background(task_id_inner, market_inner, symbol_inner, language_inner, model_inner, timeframe_inner, use_multi_agent_inner):
"""Execute analysis in background and update task when done."""
try:
service = AnalysisService(use_multi_agent=use_multi_agent_inner)
result = service.analyze(market_inner, symbol_inner, language_inner, model=model_inner, timeframe=timeframe_inner)
_update_task(task_id_inner, 'completed', result=result, error_message='')
logger.info(f"Background analysis completed for task {task_id_inner}")
except Exception as e:
logger.error(f"Background analysis failed for task {task_id_inner}: {e}")
_update_task(task_id_inner, 'failed', result={}, error_message=str(e))
analysis_thread = threading.Thread(
target=run_analysis_background,
args=(task_id, market, symbol, language, model, timeframe, use_multi_agent),
daemon=False # Keep running even if main request thread ends
)
analysis_thread.start()
# Step 3: Return immediately with task_id (frontend will poll for results)
return jsonify({'code': 1, 'msg': 'success', 'data': {'task_id': task_id, 'status': 'pending'}})
except Exception as e:
logger.error(f"Analysis failed: {str(e)}")
logger.error(traceback.format_exc())
# Update existing task as "failed", or create a new failed record if task_id doesn't exist
try:
if task_id:
_update_task(task_id, 'failed', result={}, error_message=str(e))
elif user_id:
_store_task(user_id, market, symbol, model or '', language, 'failed', result={}, error_message=str(e))
except Exception:
pass
return jsonify({
'code': 0,
'msg': f'Analysis failed: {str(e)}',
'data': {'task_id': task_id} if task_id else None
}), 500
@analysis_bp.route('/getTaskStatus', methods=['GET'])
@login_required
def get_task_status():
"""Frontend compatibility: return task status + result by task_id for the current user."""
try:
user_id = g.user_id
task_id = int(request.args.get('task_id') or 0)
if not task_id:
return jsonify({'code': 0, 'msg': 'Missing task_id', 'data': None}), 400
row = _get_task(task_id, user_id)
if not row:
return jsonify({'code': 0, 'msg': 'Task not found', 'data': None}), 404
payload = {
'id': row.get('id'),
'market': row.get('market'),
'symbol': row.get('symbol'),
'status': row.get('status'),
'error_message': row.get('error_message') or '',
'result': _parse_result_json(row)
}
return jsonify({'code': 1, 'msg': 'success', 'data': payload})
except Exception as e:
logger.error(f"get_task_status failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@analysis_bp.route('/getHistoryList', methods=['GET'])
@login_required
def get_history_list():
"""Frontend compatibility: paginated analysis history for the current user."""
try:
user_id = g.user_id
page = int(request.args.get('page') or 1)
pagesize = int(request.args.get('pagesize') or 20)
page = max(page, 1)
pagesize = min(max(pagesize, 1), 100)
offset = (page - 1) * pagesize
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT COUNT(1) as cnt FROM qd_analysis_tasks WHERE user_id = ?", (user_id,))
total = int((cur.fetchone() or {}).get('cnt') or 0)
cur.execute(
"""
SELECT id, market, symbol, model, status, error_message, created_at, completed_at, result_json
FROM qd_analysis_tasks
WHERE user_id = ?
ORDER BY id DESC
LIMIT ? OFFSET ?
""",
(user_id, pagesize, offset)
)
rows = cur.fetchall() or []
cur.close()
out = []
for r in rows:
has_result = bool((r.get('result_json') or '').strip())
# Convert datetime to Unix timestamp for frontend compatibility
created_at = r.get('created_at')
completed_at = r.get('completed_at')
createtime = int(created_at.timestamp()) if created_at else 0
completetime = int(completed_at.timestamp()) if completed_at else None
out.append({
'id': r.get('id'),
'market': r.get('market'),
'symbol': r.get('symbol'),
'model': r.get('model') or '',
'status': r.get('status'),
'has_result': has_result,
'error_message': r.get('error_message') or '',
'createtime': createtime,
'completetime': completetime
})
return jsonify({'code': 1, 'msg': 'success', 'data': {'list': out, 'total': total}})
except Exception as e:
logger.error(f"get_history_list failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'list': [], 'total': 0}}), 500
@analysis_bp.route('/deleteTask', methods=['POST'])
@login_required
def delete_task():
"""Delete an analysis task by task_id for the current user."""
try:
user_id = g.user_id
data = request.get_json() or {}
task_id = int(data.get('task_id') or 0)
if not task_id:
return jsonify({'code': 0, 'msg': 'Missing task_id', 'data': None}), 400
# Verify task belongs to user
row = _get_task(task_id, user_id)
if not row:
return jsonify({'code': 0, 'msg': 'Task not found', 'data': None}), 404
# Delete the task
with get_db_connection() as db:
cur = db.cursor()
cur.execute("DELETE FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, user_id))
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'deleted_id': task_id}})
except Exception as e:
logger.error(f"delete_task failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@analysis_bp.route('/createTask', methods=['POST'])
@login_required
def create_task():
"""
Compatibility endpoint for legacy frontend.
In local-only mode we do not run a separate async worker; we create a completed task record immediately.
"""
try:
user_id = g.user_id
data = request.get_json() or {}
market = str((data.get('market') or '')).strip()
symbol = _normalize_symbol(data.get('symbol'))
language = detect_request_language(request, body=data, default='en-US')
model = data.get('model') or ''
if not market or not symbol:
return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400
# Create a placeholder "pending" task so frontend can show task_id if it needs it.
task_id = _store_task(user_id, market, symbol, str(model), language, 'pending', result={}, error_message='')
return jsonify({'code': 1, 'msg': 'success', 'data': {'task_id': task_id, 'status': 'pending'}})
except Exception as e:
logger.error(f"create_task failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@analysis_bp.route('/reflect', methods=['POST'])
@login_required
def reflect():
"""
Reflection API.
Learn from post-trade outcomes and update agent memory (local-only).
Body:
market: Market
symbol: Symbol
decision: BUY/SELL/HOLD
returns: Optional return percentage
result: Optional free-text outcome
"""
try:
data = request.get_json()
if not data:
return jsonify({
'code': 0,
'msg': 'Request body is required',
'data': None
}), 400
market = data.get('market', '')
symbol = data.get('symbol', '')
decision = data.get('decision', '')
returns = data.get('returns', None)
result = data.get('result', None)
if not symbol or not market or not decision:
return jsonify({
'code': 0,
'msg': 'Missing required parameters (market, symbol, decision)',
'data': None
}), 400
logger.info(f"Reflection: {market}:{symbol}, decision={decision}, returns={returns}")
reflect_analysis(market, symbol, decision, returns, result)
return jsonify({
'code': 1,
'msg': 'success',
'data': None
})
except Exception as e:
logger.error(f"Reflection failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'Reflection failed: {str(e)}',
'data': None
}), 500
+61 -12
View File
@@ -175,17 +175,27 @@ def login():
if user.get('status') == 'pending':
return jsonify({'code': 0, 'msg': 'Account is pending activation', 'data': None}), 403
# Step 4: Generate token
# Step 4: Increment token_version (invalidates old sessions for single-client login)
user_id = user.get('id') or user.get('user_id', 1)
try:
from app.services.user_service import get_user_service
new_token_version = get_user_service().increment_token_version(user_id)
except Exception as e:
logger.warning(f"Failed to increment token_version: {e}")
new_token_version = 1
# Step 5: Generate token with new token_version
token = generate_token(
user_id=user.get('id') or user.get('user_id', 1),
user_id=user_id,
username=user.get('username', username),
role=user.get('role', 'admin')
role=user.get('role', 'admin'),
token_version=new_token_version # 包含新的 token_version
)
if not token:
return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500
# Step 5: Record successful login
# Step 6: Record successful login
security.record_login_attempt(ip_address, 'ip', True, ip_address, user_agent)
security.record_login_attempt(username, 'account', True, ip_address, user_agent)
security.clear_login_attempts(ip_address, 'ip')
@@ -359,11 +369,19 @@ def login_with_code():
{'reason': 'account_disabled'})
return jsonify({'code': 0, 'msg': 'Account is disabled', 'data': None}), 403
# Generate token
# Increment token_version (invalidates old sessions for single-client login)
try:
new_token_version = user_service.increment_token_version(user['id'])
except Exception as e:
logger.warning(f"Failed to increment token_version: {e}")
new_token_version = 1
# Generate token with new token_version
token = generate_token(
user_id=user['id'],
username=user['username'],
role=user.get('role', 'user')
role=user.get('role', 'user'),
token_version=new_token_version
)
if not token:
@@ -632,8 +650,19 @@ def register():
security.log_security_event('register', user_id, ip_address, user_agent,
{'email': email, 'referred_by': referred_by})
# Auto login after registration
token = generate_token(user_id=user_id, username=username, role='user')
# Auto login after registration (get token_version for new user)
try:
new_token_version = user_service.get_token_version(user_id)
except Exception as e:
logger.warning(f"Failed to get token_version: {e}")
new_token_version = 1
token = generate_token(
user_id=user_id,
username=username,
role='user',
token_version=new_token_version
)
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
@@ -858,11 +887,21 @@ def oauth_google_callback():
error_msg = user_result.get('error', 'user_creation_failed')
return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg))
# Generate token
# Increment token_version (invalidates old sessions for single-client login)
from app.services.user_service import get_user_service
user_service = get_user_service()
try:
new_token_version = user_service.increment_token_version(user_result['id'])
except Exception as e:
logger.warning(f"Failed to increment token_version: {e}")
new_token_version = 1
# Generate token with new token_version
token = generate_token(
user_id=user_result['id'],
username=user_result['username'],
role=user_result.get('role', 'user')
role=user_result.get('role', 'user'),
token_version=new_token_version
)
# Log OAuth login
@@ -934,11 +973,21 @@ def oauth_github_callback():
error_msg = user_result.get('error', 'user_creation_failed')
return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg))
# Generate token
# Increment token_version (invalidates old sessions for single-client login)
from app.services.user_service import get_user_service
user_service = get_user_service()
try:
new_token_version = user_service.increment_token_version(user_result['id'])
except Exception as e:
logger.warning(f"Failed to increment token_version: {e}")
new_token_version = 1
# Generate token with new token_version
token = generate_token(
user_id=user_result['id'],
username=user_result['username'],
role=user_result.get('role', 'user')
role=user_result.get('role', 'user'),
token_version=new_token_version
)
# Log OAuth login
+424
View File
@@ -0,0 +1,424 @@
"""
Community APIs - 指标社区接口
提供指标市场购买评论等功能的 REST API
"""
from flask import Blueprint, jsonify, request, g
from app.utils.auth import login_required
from app.utils.logger import get_logger
from app.services.community_service import get_community_service
logger = get_logger(__name__)
community_bp = Blueprint("community", __name__)
# ==========================================
# 指标市场
# ==========================================
@community_bp.route("/indicators", methods=["GET"])
@login_required
def get_market_indicators():
"""
获取市场指标列表
Query params:
page: 页码 (default 1)
page_size: 每页数量 (default 12)
keyword: 搜索关键词
pricing_type: 'free' / 'paid' / (全部)
sort_by: 'newest' / 'hot' / 'price_asc' / 'price_desc' / 'rating'
"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 12))
keyword = request.args.get('keyword', '').strip()
pricing_type = request.args.get('pricing_type', '').strip() or None
sort_by = request.args.get('sort_by', 'newest').strip()
# 限制每页数量
page_size = min(max(page_size, 1), 50)
service = get_community_service()
result = service.get_market_indicators(
page=page,
page_size=page_size,
keyword=keyword if keyword else None,
pricing_type=pricing_type,
sort_by=sort_by,
user_id=g.user_id
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_market_indicators failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@community_bp.route("/indicators/<int:indicator_id>", methods=["GET"])
@login_required
def get_indicator_detail(indicator_id: int):
"""获取指标详情"""
try:
service = get_community_service()
result = service.get_indicator_detail(indicator_id, user_id=g.user_id)
if not result:
return jsonify({'code': 0, 'msg': 'indicator_not_found', 'data': None}), 404
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_indicator_detail failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==========================================
# 购买功能
# ==========================================
@community_bp.route("/indicators/<int:indicator_id>/purchase", methods=["POST"])
@login_required
def purchase_indicator(indicator_id: int):
"""
购买指标
会自动
1. 检查积分是否充足
2. 扣除买家积分增加卖家积分
3. 创建购买记录
4. 复制指标到买家账户
"""
try:
service = get_community_service()
success, message, data = service.purchase_indicator(
buyer_id=g.user_id,
indicator_id=indicator_id
)
if success:
return jsonify({'code': 1, 'msg': message, 'data': data})
else:
return jsonify({'code': 0, 'msg': message, 'data': data}), 400
except Exception as e:
logger.error(f"purchase_indicator failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@community_bp.route("/my-purchases", methods=["GET"])
@login_required
def get_my_purchases():
"""获取我购买的指标列表"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 20))
page_size = min(max(page_size, 1), 50)
service = get_community_service()
result = service.get_my_purchases(
user_id=g.user_id,
page=page,
page_size=page_size
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_my_purchases failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==========================================
# 评论功能
# ==========================================
@community_bp.route("/indicators/<int:indicator_id>/comments", methods=["GET"])
@login_required
def get_comments(indicator_id: int):
"""获取指标评论列表"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 20))
page_size = min(max(page_size, 1), 50)
service = get_community_service()
result = service.get_comments(
indicator_id=indicator_id,
page=page,
page_size=page_size
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_comments failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@community_bp.route("/indicators/<int:indicator_id>/comments", methods=["POST"])
@login_required
def add_comment(indicator_id: int):
"""
添加评论
Request body:
rating: 1-5 星评分
content: 评论内容可选最多500字
注意只有购买过的用户可以评论且只能评论一次
"""
try:
data = request.get_json() or {}
rating = int(data.get('rating', 5))
content = (data.get('content') or '').strip()
service = get_community_service()
success, message, result = service.add_comment(
user_id=g.user_id,
indicator_id=indicator_id,
rating=rating,
content=content
)
if success:
return jsonify({'code': 1, 'msg': message, 'data': result})
else:
return jsonify({'code': 0, 'msg': message, 'data': result}), 400
except Exception as e:
logger.error(f"add_comment failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@community_bp.route("/indicators/<int:indicator_id>/comments/<int:comment_id>", methods=["PUT"])
@login_required
def update_comment(indicator_id: int, comment_id: int):
"""
更新评论只能修改自己的评论
Request body:
rating: 1-5 星评分
content: 评论内容最多500字
"""
try:
data = request.get_json() or {}
rating = int(data.get('rating', 5))
content = (data.get('content') or '').strip()
service = get_community_service()
success, message, result = service.update_comment(
user_id=g.user_id,
comment_id=comment_id,
indicator_id=indicator_id,
rating=rating,
content=content
)
if success:
return jsonify({'code': 1, 'msg': message, 'data': result})
else:
return jsonify({'code': 0, 'msg': message, 'data': result}), 400
except Exception as e:
logger.error(f"update_comment failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@community_bp.route("/indicators/<int:indicator_id>/my-comment", methods=["GET"])
@login_required
def get_my_comment(indicator_id: int):
"""获取当前用户对指定指标的评论(用于编辑)"""
try:
service = get_community_service()
result = service.get_user_comment(
user_id=g.user_id,
indicator_id=indicator_id
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_my_comment failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==========================================
# 实盘表现
# ==========================================
@community_bp.route("/indicators/<int:indicator_id>/performance", methods=["GET"])
@login_required
def get_indicator_performance(indicator_id: int):
"""获取指标的实盘表现统计"""
try:
service = get_community_service()
result = service.get_indicator_performance(indicator_id)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_indicator_performance failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==========================================
# 管理员审核功能
# ==========================================
def _is_admin():
"""检查当前用户是否是管理员"""
role = getattr(g, 'user_role', None)
return role == 'admin'
@community_bp.route("/admin/pending-indicators", methods=["GET"])
@login_required
def get_pending_indicators():
"""
获取待审核的指标列表管理员专用
Query params:
page: 页码 (default 1)
page_size: 每页数量 (default 20)
review_status: 'pending' / 'approved' / 'rejected' / 'all'
"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 20))
review_status = request.args.get('review_status', 'pending').strip() or 'pending'
page_size = min(max(page_size, 1), 100)
service = get_community_service()
result = service.get_pending_indicators(
page=page,
page_size=page_size,
review_status=review_status
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_pending_indicators failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@community_bp.route("/admin/review-stats", methods=["GET"])
@login_required
def get_review_stats():
"""获取审核统计数据(管理员专用)"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
service = get_community_service()
result = service.get_review_stats()
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_review_stats failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@community_bp.route("/admin/indicators/<int:indicator_id>/review", methods=["POST"])
@login_required
def review_indicator(indicator_id: int):
"""
审核指标管理员专用
Request body:
action: 'approve' / 'reject'
note: 审核备注可选
"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
data = request.get_json() or {}
action = data.get('action', '').strip()
note = data.get('note', '').strip()
if action not in ('approve', 'reject'):
return jsonify({'code': 0, 'msg': 'invalid_action', 'data': None}), 400
service = get_community_service()
success, message = service.review_indicator(
admin_id=g.user_id,
indicator_id=indicator_id,
action=action,
note=note
)
if success:
return jsonify({'code': 1, 'msg': message, 'data': None})
else:
return jsonify({'code': 0, 'msg': message, 'data': None}), 400
except Exception as e:
logger.error(f"review_indicator failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@community_bp.route("/admin/indicators/<int:indicator_id>/unpublish", methods=["POST"])
@login_required
def unpublish_indicator(indicator_id: int):
"""
下架指标管理员专用
Request body:
note: 下架原因可选
"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
data = request.get_json() or {}
note = data.get('note', '').strip()
service = get_community_service()
success, message = service.unpublish_indicator(
admin_id=g.user_id,
indicator_id=indicator_id,
note=note
)
if success:
return jsonify({'code': 1, 'msg': message, 'data': None})
else:
return jsonify({'code': 0, 'msg': message, 'data': None}), 400
except Exception as e:
logger.error(f"unpublish_indicator failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@community_bp.route("/admin/indicators/<int:indicator_id>", methods=["DELETE"])
@login_required
def admin_delete_indicator(indicator_id: int):
"""删除指标(管理员专用)"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
service = get_community_service()
success, message = service.admin_delete_indicator(
admin_id=g.user_id,
indicator_id=indicator_id
)
if success:
return jsonify({'code': 1, 'msg': message, 'data': None})
else:
return jsonify({'code': 0, 'msg': message, 'data': None}), 400
except Exception as e:
logger.error(f"admin_delete_indicator failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@@ -40,6 +40,15 @@ def _safe_float(v: Any, default: float = 0.0) -> float:
return default
def _format_datetime(dt: Any) -> Any:
"""Convert datetime object to ISO format string for JSON serialization."""
if dt is None:
return None
if hasattr(dt, 'isoformat'):
return dt.isoformat()
return dt
def _safe_json_loads(value: Any, default: Any) -> Any:
if value is None:
return default
@@ -596,6 +605,12 @@ def pending_orders():
"exchange_display": exchange_display,
"notify_channels": notify_channels,
"market_type": market_type or (r.get("market_type") or ""),
# Format datetime fields for JSON serialization
"created_at": _format_datetime(r.get("created_at")),
"updated_at": _format_datetime(r.get("updated_at")),
"executed_at": _format_datetime(r.get("executed_at")),
"processed_at": _format_datetime(r.get("processed_at")),
"sent_at": _format_datetime(r.get("sent_at")),
}
)
@@ -0,0 +1,386 @@
"""
Fast Analysis API Routes
New high-performance analysis endpoints that replace the slow multi-agent system.
"""
from flask import Blueprint, request, jsonify, g
from app.utils.auth import login_required
from app.utils.logger import get_logger
from app.services.fast_analysis import get_fast_analysis_service
from app.services.analysis_memory import get_analysis_memory
logger = get_logger(__name__)
fast_analysis_bp = Blueprint('fast_analysis', __name__)
@fast_analysis_bp.route('/analyze', methods=['POST'])
@login_required
def analyze():
"""
Fast AI analysis for any symbol.
POST /api/fast-analysis/analyze
Body: {
"market": "Crypto" | "USStock" | "AShare" | "Forex" | ...,
"symbol": "BTC/USDT" | "AAPL" | ...,
"language": "zh-CN" | "en-US" (optional),
"model": "openai/gpt-4o" (optional),
"timeframe": "1D" (optional)
}
Returns:
Fast analysis result with actionable recommendations.
"""
try:
data = request.get_json() or {}
market = (data.get('market') or '').strip()
symbol = (data.get('symbol') or '').strip()
language = data.get('language', 'en-US')
model = data.get('model')
timeframe = data.get('timeframe', '1D')
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'market and symbol are required',
'data': None
}), 400
service = get_fast_analysis_service()
result = service.analyze(
market=market,
symbol=symbol,
language=language,
model=model,
timeframe=timeframe
)
if result.get('error'):
return jsonify({
'code': 0,
'msg': result['error'],
'data': result
}), 500
# memory_id is already set in service.analyze() -> _store_analysis_memory()
# No need to store again here (would create duplicates)
return jsonify({
'code': 1,
'msg': 'success',
'data': result
})
except Exception as e:
logger.error(f"Fast analysis API failed: {e}", exc_info=True)
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
@fast_analysis_bp.route('/analyze-legacy', methods=['POST'])
@login_required
def analyze_legacy():
"""
Fast analysis with legacy format output.
For backward compatibility with existing frontend.
POST /api/fast-analysis/analyze-legacy
Body: Same as /analyze
Returns:
Result in multi-agent format for frontend compatibility.
"""
try:
data = request.get_json() or {}
market = (data.get('market') or '').strip()
symbol = (data.get('symbol') or '').strip()
language = data.get('language', 'en-US')
model = data.get('model')
timeframe = data.get('timeframe', '1D')
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'market and symbol are required',
'data': None
}), 400
service = get_fast_analysis_service()
result = service.analyze_legacy_format(
market=market,
symbol=symbol,
language=language,
model=model,
timeframe=timeframe
)
if result.get('error'):
return jsonify({
'code': 0,
'msg': result['error'],
'data': result
}), 500
return jsonify({
'code': 1,
'msg': 'success',
'data': result
})
except Exception as e:
logger.error(f"Fast analysis legacy API failed: {e}", exc_info=True)
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
@fast_analysis_bp.route('/history', methods=['GET'])
@login_required
def get_history():
"""
Get analysis history for a symbol.
GET /api/fast-analysis/history?market=Crypto&symbol=BTC/USDT&days=7&limit=10
"""
try:
market = request.args.get('market', '').strip()
symbol = request.args.get('symbol', '').strip()
days = int(request.args.get('days', 7))
limit = min(int(request.args.get('limit', 10)), 50)
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'market and symbol are required',
'data': None
}), 400
memory = get_analysis_memory()
history = memory.get_recent(market, symbol, days, limit)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'items': history,
'total': len(history)
}
})
except Exception as e:
logger.error(f"Get history failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
@fast_analysis_bp.route('/history/all', methods=['GET'])
@login_required
def get_all_history():
"""
Get all analysis history with pagination.
GET /api/fast-analysis/history/all?page=1&pagesize=20
"""
try:
page = int(request.args.get('page', 1))
pagesize = min(int(request.args.get('pagesize', 20)), 50)
memory = get_analysis_memory()
result = memory.get_all_history(page=page, page_size=pagesize)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'list': result['items'],
'total': result['total'],
'page': result['page'],
'pagesize': result['page_size']
}
})
except Exception as e:
logger.error(f"Get all history failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
@fast_analysis_bp.route('/history/<int:memory_id>', methods=['DELETE'])
@login_required
def delete_history(memory_id: int):
"""
Delete a history record.
DELETE /api/fast-analysis/history/123
"""
try:
memory = get_analysis_memory()
success = memory.delete_history(memory_id)
if success:
return jsonify({
'code': 1,
'msg': 'Deleted successfully',
'data': None
})
else:
return jsonify({
'code': 0,
'msg': 'Record not found',
'data': None
}), 404
except Exception as e:
logger.error(f"Delete history failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
@fast_analysis_bp.route('/feedback', methods=['POST'])
@login_required
def submit_feedback():
"""
Submit user feedback on an analysis.
POST /api/fast-analysis/feedback
Body: {
"memory_id": 123,
"feedback": "helpful" | "not_helpful" | "accurate" | "inaccurate"
}
"""
try:
data = request.get_json() or {}
memory_id = int(data.get('memory_id', 0))
feedback = (data.get('feedback') or '').strip()
if not memory_id or not feedback:
return jsonify({
'code': 0,
'msg': 'memory_id and feedback are required',
'data': None
}), 400
valid_feedback = ['helpful', 'not_helpful', 'accurate', 'inaccurate']
if feedback not in valid_feedback:
return jsonify({
'code': 0,
'msg': f'feedback must be one of: {valid_feedback}',
'data': None
}), 400
memory = get_analysis_memory()
success = memory.record_feedback(memory_id, feedback)
return jsonify({
'code': 1 if success else 0,
'msg': 'success' if success else 'failed',
'data': None
})
except Exception as e:
logger.error(f"Submit feedback failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
@fast_analysis_bp.route('/performance', methods=['GET'])
@login_required
def get_performance():
"""
Get AI analysis performance statistics.
GET /api/fast-analysis/performance?market=Crypto&symbol=BTC/USDT&days=30
"""
try:
market = request.args.get('market', '').strip() or None
symbol = request.args.get('symbol', '').strip() or None
days = int(request.args.get('days', 30))
memory = get_analysis_memory()
stats = memory.get_performance_stats(market, symbol, days)
return jsonify({
'code': 1,
'msg': 'success',
'data': stats
})
except Exception as e:
logger.error(f"Get performance failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
@fast_analysis_bp.route('/similar-patterns', methods=['GET'])
@login_required
def get_similar_patterns():
"""
Get similar historical patterns for current market conditions.
GET /api/fast-analysis/similar-patterns?market=Crypto&symbol=BTC/USDT
"""
try:
market = request.args.get('market', '').strip()
symbol = request.args.get('symbol', '').strip()
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'market and symbol are required',
'data': None
}), 400
# Get current indicators
service = get_fast_analysis_service()
data = service._collect_market_data(market, symbol)
indicators = data.get('indicators', {})
# Find similar patterns
memory = get_analysis_memory()
patterns = memory.get_similar_patterns(market, symbol, indicators)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'patterns': patterns,
'current_indicators': {
'rsi': indicators.get('rsi', {}).get('value'),
'macd_signal': indicators.get('macd', {}).get('signal'),
'trend': indicators.get('moving_averages', {}).get('trend'),
}
}
})
except Exception as e:
logger.error(f"Get similar patterns failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
+208 -104
View File
@@ -38,15 +38,31 @@ logger = get_logger(__name__)
global_market_bp = Blueprint("global_market", __name__)
# Cache for market data (simple in-memory cache)
# 多用户场景下,合理的缓存可以大幅减少 API 请求
_cache: Dict[str, Dict[str, Any]] = {}
_cache_ttl = 30 # 30 seconds cache
_cache_ttl = 60 # Default 60 seconds cache
# 缓存时间配置(秒)
CACHE_TTL = {
"crypto_heatmap": 300, # 5分钟 - 加密货币变化快但热力图不需要实时
"forex_pairs": 120, # 2分钟 - 外汇日内波动较小
"stock_indices": 120, # 2分钟 - 指数变化较慢
"market_overview": 120, # 2分钟 - 概览数据
"market_heatmap": 120, # 2分钟 - 热力图
"commodities": 120, # 2分钟 - 大宗商品
"market_news": 180, # 3分钟 - 新闻
"economic_calendar": 3600, # 1小时 - 日历事件
"market_sentiment": 21600, # 6小时 - 宏观情绪变化缓慢
"trading_opportunities": 60, # 1分钟 - 交易机会需要较新
}
def _get_cached(key: str, ttl: int = None) -> Optional[Any]:
"""Get cached data if not expired."""
if key in _cache:
entry = _cache[key]
cache_ttl = ttl or entry.get("ttl", _cache_ttl)
# 优先使用传入的 ttl,然后是 CACHE_TTL 配置,最后是默认值
cache_ttl = ttl or CACHE_TTL.get(key, entry.get("ttl", _cache_ttl))
if time.time() - entry.get("ts", 0) < cache_ttl:
return entry.get("data")
return None
@@ -57,7 +73,7 @@ def _set_cached(key: str, data: Any, ttl: int = None):
_cache[key] = {
"ts": time.time(),
"data": data,
"ttl": ttl or _cache_ttl
"ttl": ttl or CACHE_TTL.get(key, _cache_ttl)
}
@@ -484,115 +500,173 @@ def _fetch_fear_greed_index() -> Dict[str, Any]:
def _fetch_vix() -> Dict[str, Any]:
"""Fetch VIX (CBOE Volatility Index)."""
"""Fetch VIX (CBOE Volatility Index) with multiple fallbacks."""
# 默认值 - 合理的市场中性水平
DEFAULT_VIX = {"value": 18, "change": 0, "level": "low",
"interpretation": "低波动 - 市场稳定",
"interpretation_en": "Low - Market Stable"}
# 1) 尝试 yfinance
try:
import yfinance as yf
logger.debug("Fetching VIX from yfinance")
ticker = yf.Ticker("^VIX")
hist = ticker.history(period="5d") # 获取更多天数以防周末
if len(hist) >= 2:
prev_close = hist["Close"].iloc[-2]
current = hist["Close"].iloc[-1]
change = ((current - prev_close) / prev_close) * 100
logger.info(f"VIX fetched: {current:.2f} (change: {change:.2f}%)")
elif len(hist) == 1:
current = hist["Close"].iloc[-1]
change = 0
logger.info(f"VIX fetched (single day): {current:.2f}")
try:
hist = ticker.history(period="5d")
except Exception as hist_err:
logger.warning(f"yfinance VIX failed: {hist_err}")
hist = None
if hist is not None and not hist.empty and len(hist) >= 1:
current = float(hist["Close"].iloc[-1])
if current > 0:
prev_close = float(hist["Close"].iloc[-2]) if len(hist) >= 2 else current
change = ((current - prev_close) / prev_close) * 100 if prev_close else 0
logger.info(f"VIX from yfinance: {current:.2f}")
else:
raise ValueError("VIX value is 0")
else:
logger.warning("VIX history is empty")
current = 0
change = 0
# VIX levels interpretation
if current < 12:
level = "very_low"
interpretation_cn = "极低波动 - 市场极度乐观"
interpretation_en = "Very Low - Extreme Optimism"
elif current < 20:
level = "low"
interpretation_cn = "低波动 - 市场稳定"
interpretation_en = "Low - Market Stable"
elif current < 25:
level = "moderate"
interpretation_cn = "中等波动 - 正常水平"
interpretation_en = "Moderate - Normal Level"
elif current < 30:
level = "high"
interpretation_cn = "高波动 - 市场担忧"
interpretation_en = "High - Market Concern"
else:
level = "very_high"
interpretation_cn = "极高波动 - 市场恐慌"
interpretation_en = "Very High - Market Panic"
return {
"value": round(current, 2),
"change": round(change, 2),
"level": level,
"interpretation": interpretation_cn,
"interpretation_en": interpretation_en
}
raise ValueError("VIX history empty")
except Exception as e:
logger.error(f"Failed to fetch VIX: {e}", exc_info=True)
return {"value": 0, "change": 0, "level": "unknown", "interpretation": "数据获取失败", "interpretation_en": "Data fetch failed"}
logger.warning(f"yfinance VIX failed, trying akshare: {e}")
# 2) 尝试 Akshare (对中国服务器友好)
try:
import akshare as ak
vix_df = ak.index_vix() # VIX指数
if vix_df is not None and len(vix_df) > 0:
current = float(vix_df.iloc[-1]['close'])
prev_close = float(vix_df.iloc[-2]['close']) if len(vix_df) >= 2 else current
change = ((current - prev_close) / prev_close) * 100 if prev_close else 0
logger.info(f"VIX from akshare: {current:.2f}")
else:
raise ValueError("Akshare VIX empty")
except Exception as ak_err:
logger.warning(f"Akshare VIX also failed: {ak_err}")
return DEFAULT_VIX
if current <= 0:
return DEFAULT_VIX
# VIX levels interpretation
if current < 12:
level = "very_low"
interpretation_cn = "极低波动 - 市场极度乐观"
interpretation_en = "Very Low - Extreme Optimism"
elif current < 20:
level = "low"
interpretation_cn = "低波动 - 市场稳定"
interpretation_en = "Low - Market Stable"
elif current < 25:
level = "moderate"
interpretation_cn = "中等波动 - 正常水平"
interpretation_en = "Moderate - Normal Level"
elif current < 30:
level = "high"
interpretation_cn = "高波动 - 市场担忧"
interpretation_en = "High - Market Concern"
else:
level = "very_high"
interpretation_cn = "极高波动 - 市场恐慌"
interpretation_en = "Very High - Market Panic"
return {
"value": round(current, 2),
"change": round(change, 2),
"level": level,
"interpretation": interpretation_cn,
"interpretation_en": interpretation_en
}
def _fetch_dollar_index() -> Dict[str, Any]:
"""Fetch US Dollar Index (DXY)."""
"""Fetch US Dollar Index (DXY) with multiple fallbacks."""
# 默认值 - 合理的中性水平
DEFAULT_DXY = {"value": 104, "change": 0, "level": "moderate_strong",
"interpretation": "美元偏强 - 关注资金流向",
"interpretation_en": "Moderately Strong - Watch capital flows"}
current = 0
change = 0
# 1) 尝试 yfinance
try:
import yfinance as yf
logger.debug("Fetching DXY from yfinance")
ticker = yf.Ticker("DX-Y.NYB")
hist = ticker.history(period="5d")
if len(hist) >= 2:
prev_close = hist["Close"].iloc[-2]
current = hist["Close"].iloc[-1]
change = ((current - prev_close) / prev_close) * 100
elif len(hist) == 1:
current = hist["Close"].iloc[-1]
change = 0
try:
hist = ticker.history(period="5d")
except Exception as hist_err:
logger.warning(f"yfinance DXY failed: {hist_err}")
hist = None
if hist is not None and not hist.empty and len(hist) >= 1:
current = float(hist["Close"].iloc[-1])
if current > 0:
prev_close = float(hist["Close"].iloc[-2]) if len(hist) >= 2 else current
change = ((current - prev_close) / prev_close) * 100 if prev_close else 0
logger.info(f"DXY from yfinance: {current:.2f}")
else:
raise ValueError("DXY value is 0")
else:
current = 0
change = 0
# DXY interpretation
if current > 105:
level = "strong"
interpretation_cn = "美元强势 - 利空大宗商品/新兴市场"
interpretation_en = "Strong USD - Bearish commodities/EM"
elif current > 100:
level = "moderate_strong"
interpretation_cn = "美元偏强 - 关注资金流向"
interpretation_en = "Moderately Strong - Watch capital flows"
elif current > 95:
level = "neutral"
interpretation_cn = "美元中性 - 市场均衡"
interpretation_en = "Neutral - Market balanced"
elif current > 90:
level = "moderate_weak"
interpretation_cn = "美元偏弱 - 利多风险资产"
interpretation_en = "Moderately Weak - Bullish risk assets"
else:
level = "weak"
interpretation_cn = "美元疲软 - 利多黄金/大宗商品"
interpretation_en = "Weak USD - Bullish gold/commodities"
logger.info(f"DXY fetched: {current:.2f} ({level})")
return {
"value": round(current, 2),
"change": round(change, 2),
"level": level,
"interpretation": interpretation_cn,
"interpretation_en": interpretation_en
}
raise ValueError("DXY history empty")
except Exception as e:
logger.error(f"Failed to fetch DXY: {e}", exc_info=True)
return {"value": 0, "change": 0, "level": "unknown", "interpretation": "数据获取失败", "interpretation_en": "Data fetch failed"}
logger.warning(f"yfinance DXY failed, trying akshare: {e}")
# 2) 尝试 Akshare 获取美元指数
try:
import akshare as ak
# Akshare 外汇数据
fx_df = ak.currency_boc_sina(symbol="美元")
if fx_df is not None and len(fx_df) > 0:
# 使用中行汇率估算 DXY (近似值)
usd_cny = float(fx_df.iloc[-1]['中行汇买价']) / 100
current = usd_cny * 14.5 # 大致换算
change = 0
logger.info(f"DXY estimated from akshare: {current:.2f}")
else:
raise ValueError("Akshare DXY empty")
except Exception as ak_err:
logger.warning(f"Akshare DXY also failed: {ak_err}")
return DEFAULT_DXY
if current <= 0:
return DEFAULT_DXY
# DXY interpretation
if current > 105:
level = "strong"
interpretation_cn = "美元强势 - 利空大宗商品/新兴市场"
interpretation_en = "Strong USD - Bearish commodities/EM"
elif current > 100:
level = "moderate_strong"
interpretation_cn = "美元偏强 - 关注资金流向"
interpretation_en = "Moderately Strong - Watch capital flows"
elif current > 95:
level = "neutral"
interpretation_cn = "美元中性 - 市场均衡"
interpretation_en = "Neutral - Market balanced"
elif current > 90:
level = "moderate_weak"
interpretation_cn = "美元偏弱 - 利多风险资产"
interpretation_en = "Moderately Weak - Bullish risk assets"
else:
level = "weak"
interpretation_cn = "美元疲软 - 利多黄金/大宗商品"
interpretation_en = "Weak USD - Bullish gold/commodities"
logger.info(f"DXY fetched: {current:.2f} ({level})")
return {
"value": round(current, 2),
"change": round(change, 2),
"level": level,
"interpretation": interpretation_cn,
"interpretation_en": interpretation_en
}
def _fetch_yield_curve() -> Dict[str, Any]:
@@ -602,12 +676,24 @@ def _fetch_yield_curve() -> Dict[str, Any]:
logger.debug("Fetching Treasury Yield Curve")
# 10-year and 2-year Treasury yields
tnx = yf.Ticker("^TNX") # 10-year
twoy = yf.Ticker("^IRX") # 3-month (IRX) - 2Y not directly available, use as proxy
# 10-year Treasury yield
tnx = yf.Ticker("^TNX")
# Try to get 2Y from another source or calculate spread differently
tnx_hist = tnx.history(period="5d")
# 使用 try-except 包裹 history 调用
try:
tnx_hist = tnx.history(period="5d")
except Exception as hist_err:
logger.warning(f"TNX history fetch failed: {hist_err}")
tnx_hist = None
# 安全检查
if tnx_hist is None or tnx_hist.empty:
logger.warning("TNX history is None or empty, returning default")
return {
"yield_10y": 4.2, "yield_2y": 4.0, "spread": 0.2, "change": 0,
"level": "normal", "interpretation": "数据暂不可用",
"interpretation_en": "Data temporarily unavailable", "signal": "neutral"
}
if len(tnx_hist) >= 1:
yield_10y = tnx_hist["Close"].iloc[-1]
@@ -1246,9 +1332,26 @@ def _generate_heatmap_data() -> Dict[str, Any]:
"crypto": [],
"sectors": [],
"forex": [],
"commodities": [], # 新增大宗商品热力图
"indices": []
}
# Commodities heatmap (黄金、白银、原油等)
commodities_data = _get_cached("commodities")
if not commodities_data:
commodities_data = _fetch_commodities()
_set_cached("commodities", commodities_data)
for comm in (commodities_data or []):
heatmap["commodities"].append({
"name": comm.get("name_cn", comm.get("name_en", "")),
"name_cn": comm.get("name_cn", ""),
"name_en": comm.get("name_en", ""),
"value": comm.get("change", 0),
"price": comm.get("price", 0),
"unit": comm.get("unit", "")
})
# Crypto heatmap
# Ensure mainstream coins by market cap appear first; also avoid blank symbols
crypto_sorted = sorted(
@@ -1477,10 +1580,11 @@ def market_sentiment():
Includes: Fear & Greed, VIX, DXY, Yield Curve, VXN, GVZ, VIX Term Structure.
"""
try:
# 缓存5分钟,因为这些数据不会频繁变化
cached = _get_cached("market_sentiment", 300)
# 缓存6小时 (21600秒),宏观数据变化缓慢,减少 API 调用
MACRO_CACHE_TTL = 21600 # 6 hours
cached = _get_cached("market_sentiment", MACRO_CACHE_TTL)
if cached:
logger.debug("Returning cached sentiment data")
logger.debug("Returning cached sentiment data (6h cache)")
return jsonify({"code": 1, "msg": "success", "data": cached})
logger.info("Fetching fresh sentiment data (comprehensive)")
@@ -1521,7 +1625,7 @@ def market_sentiment():
"timestamp": int(time.time())
}
_set_cached("market_sentiment", data, 300)
_set_cached("market_sentiment", data, 21600) # 6 hours cache
return jsonify({"code": 1, "msg": "success", "data": data})
+60 -13
View File
@@ -199,29 +199,76 @@ def save_indicator():
now = _now_ts() # For BIGINT fields (createtime, updatetime)
# 检查用户是否是管理员(管理员发布的指标自动通过审核)
user_role = getattr(g, 'user_role', 'user')
is_admin = user_role == 'admin'
with get_db_connection() as db:
cur = db.cursor()
if indicator_id and indicator_id > 0:
cur.execute(
"""
UPDATE qd_indicator_codes
SET name = ?, code = ?, description = ?,
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
updatetime = ?, updated_at = NOW()
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
""",
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id),
)
# 检查是否从未发布改为发布,需要设置审核状态
if publish_to_community:
cur.execute(
"SELECT publish_to_community, review_status FROM qd_indicator_codes WHERE id = ? AND user_id = ?",
(indicator_id, user_id)
)
existing = cur.fetchone()
was_published = existing and existing.get('publish_to_community')
# 如果之前未发布,现在发布,设置审核状态
# 管理员发布的直接通过,普通用户需要待审核
new_review_status = 'approved' if is_admin else 'pending'
if not was_published:
cur.execute(
"""
UPDATE qd_indicator_codes
SET name = ?, code = ?, description = ?,
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
review_status = ?, review_note = '', reviewed_at = NOW(), reviewed_by = ?,
updatetime = ?, updated_at = NOW()
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
""",
(name, code, description, publish_to_community, pricing_type, price, preview_image,
new_review_status, user_id if is_admin else None, now, indicator_id, user_id),
)
else:
# 已发布过的更新,保持原审核状态
cur.execute(
"""
UPDATE qd_indicator_codes
SET name = ?, code = ?, description = ?,
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
updatetime = ?, updated_at = NOW()
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
""",
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id),
)
else:
# 取消发布,清除审核状态
cur.execute(
"""
UPDATE qd_indicator_codes
SET name = ?, code = ?, description = ?,
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
review_status = NULL, review_note = '', reviewed_at = NULL, reviewed_by = NULL,
updatetime = ?, updated_at = NOW()
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
""",
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id),
)
else:
# 新建指标 - 管理员发布的直接通过,普通用户需要待审核
review_status = None
if publish_to_community:
review_status = 'approved' if is_admin else 'pending'
cur.execute(
"""
INSERT INTO qd_indicator_codes
(user_id, is_buy, end_time, name, code, description,
publish_to_community, pricing_type, price, preview_image,
publish_to_community, pricing_type, price, preview_image, review_status,
createtime, updatetime, created_at, updated_at)
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
""",
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, now, now),
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, review_status, now, now),
)
indicator_id = int(cur.lastrowid or 0)
db.commit()
+32 -16
View File
@@ -352,24 +352,40 @@ def get_watchlist_prices():
future = executor.submit(get_single_price, market, symbol)
futures[future] = (market, symbol)
# 收集结果(保持顺序
for future in as_completed(futures, timeout=30):
try:
result = future.result()
results.append(result)
except Exception as e:
market, symbol = futures[future]
logger.error(f"Price fetch timed out or failed: {market}:{symbol} - {str(e)}")
results.append({
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0
})
# 收集结果(带超时保护
completed_futures = set()
try:
for future in as_completed(futures, timeout=30):
completed_futures.add(future)
try:
result = future.result()
results.append(result)
except Exception as e:
market, symbol = futures[future]
logger.warning(f"Price fetch failed: {market}:{symbol} - {str(e)}")
results.append({
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0
})
except TimeoutError:
# 超时时,为未完成的任务添加默认结果
for future, (market, symbol) in futures.items():
if future not in completed_futures:
logger.warning(f"Price fetch timed out: {market}:{symbol}")
results.append({
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0,
'error': 'timeout'
})
success_count = sum(1 for r in results if r.get('price', 0) > 0)
# logger.info(f"批量获取完成,成功: {success_count}/{len(results)}")
logger.info(f"Watchlist prices: {success_count}/{len(results)} successful")
return jsonify({
'code': 1,
+2 -2
View File
@@ -2,9 +2,9 @@
业务服务层
"""
from app.services.kline import KlineService
from app.services.analysis import AnalysisService
from app.services.backtest import BacktestService
from app.services.strategy_compiler import StrategyCompiler
from app.services.fast_analysis import FastAnalysisService
__all__ = ['KlineService', 'AnalysisService', 'BacktestService', 'StrategyCompiler']
__all__ = ['KlineService', 'BacktestService', 'StrategyCompiler', 'FastAnalysisService']
@@ -1,148 +0,0 @@
# 多智能体分析系统
基于 TradingAgents 架构优化的多智能体股票分析系统。
## 架构特点
### 1. 多智能体协作
- **分析师团队**:市场分析师、基本面分析师、新闻分析师、情绪分析师、风险分析师
- **研究团队**:看涨研究员、看跌研究员
- **交易团队**:交易员、风险分析师(激进/中性/保守)
### 2. 工作流程
```
分析师团队分析 → 研究辩论 → 交易员决策 → 风险辩论 → 最终决策
```
### 3. 记忆系统
- 使用 SQLite 存储历史决策
- 基于文本相似度检索历史经验
- 支持从交易结果中学习
### 4. 工具调用
- 智能体可以主动获取数据
- 支持多数据源(yfinance, finnhub, ccxt, 腾讯接口)
## 使用方法
### 基本使用
```python
from app.services.analysis import AnalysisService
# 使用多智能体架构(默认)
service = AnalysisService(use_multi_agent=True)
result = service.analyze("USStock", "AAPL", "zh-CN")
# 使用传统架构(向后兼容)
service = AnalysisService(use_multi_agent=False)
result = service.analyze("USStock", "AAPL", "zh-CN")
```
### 环境变量配置
`.env` 文件中设置:
```bash
# 是否启用多智能体架构(默认:True)
USE_MULTI_AGENT=True
# 最大辩论轮数(默认:2
MAX_DEBATE_ROUNDS=2
```
### 反思学习
```python
from app.services.analysis import reflect_analysis
# 从交易结果中学习
reflect_analysis(
market="USStock",
symbol="AAPL",
decision="BUY",
returns=1000.0, # 收益
result="交易成功,收益 10%"
)
```
## 智能体说明
### 分析师智能体
- **MarketAnalyst**: 技术分析,计算技术指标
- **FundamentalAnalyst**: 基本面分析,财务数据
- **NewsAnalyst**: 新闻事件分析
- **SentimentAnalyst**: 市场情绪分析
- **RiskAnalyst**: 风险评估
### 研究员智能体
- **BullResearcher**: 构建看涨论据
- **BearResearcher**: 构建看跌论据
### 交易员智能体
- **TraderAgent**: 综合所有分析,做出交易决策
### 风险分析师智能体
- **RiskyAnalyst**: 激进风险分析
- **NeutralAnalyst**: 中性风险分析
- **SafeAnalyst**: 保守风险分析
## 返回结果格式
多智能体模式返回的完整结果包括:
```json
{
"overview": {
"overallScore": 75,
"recommendation": "BUY",
"confidence": 82,
"dimensionScores": {...},
"report": "..."
},
"fundamental": {...},
"technical": {...},
"news": {...},
"sentiment": {...},
"risk": {...},
"debate": {
"bull": {...},
"bear": {...},
"research_decision": "..."
},
"trader_decision": {
"decision": "BUY",
"confidence": 85,
"trading_plan": {...}
},
"risk_debate": {
"risky": {...},
"neutral": {...},
"safe": {...}
},
"final_decision": {
"decision": "BUY",
"confidence": 85,
"reasoning": "..."
}
}
```
## 优势
1. **多角度分析**:多个智能体从不同角度分析,减少盲点
2. **辩论机制**:看涨/看跌辩论,发现潜在问题
3. **风险控制**:多维度风险分析,提高决策质量
4. **持续学习**:记忆系统支持从历史经验中学习
5. **向后兼容**:可以切换到传统模式
## 注意事项
1. 多智能体模式会产生更多的 API 调用,成本较高
2. 分析时间会比传统模式长
3. 记忆系统需要 SQLite 数据库支持
4. 建议在生产环境中根据需求选择合适的模式
@@ -1,32 +0,0 @@
"""
多智能体分析系统
基于 TradingAgents 架构优化
"""
from .base_agent import BaseAgent
from .analyst_agents import (
MarketAnalyst,
FundamentalAnalyst,
NewsAnalyst,
SentimentAnalyst,
RiskAnalyst
)
from .researcher_agents import BullResearcher, BearResearcher
from .trader_agent import TraderAgent
from .risk_agents import RiskyAnalyst, NeutralAnalyst, SafeAnalyst
from .memory import AgentMemory
__all__ = [
'BaseAgent',
'MarketAnalyst',
'FundamentalAnalyst',
'NewsAnalyst',
'SentimentAnalyst',
'RiskAnalyst',
'BullResearcher',
'BearResearcher',
'TraderAgent',
'RiskyAnalyst',
'NeutralAnalyst',
'SafeAnalyst',
'AgentMemory',
]
@@ -1,440 +0,0 @@
"""
Analyst agents.
Includes: market/technical, fundamental, news, sentiment, risk analysts.
"""
import json
from typing import Dict, Any
from .base_agent import BaseAgent
from .tools import AgentTools
from app.services.llm import LLMService
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
class MarketAnalyst(BaseAgent):
"""Market / technical analyst."""
def __init__(self, memory=None):
super().__init__("MarketAnalyst", memory)
self.tools = AgentTools()
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Run technical analysis."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
base_data = context.get('base_data', {})
# Kline + current price
kline_data = base_data.get('kline_data') or self.tools.get_stock_data(market, symbol, days=30)
current_price = base_data.get('current_price') or self.tools.get_current_price(market, symbol)
# Technical indicators
indicators = {}
if kline_data:
indicators = self.tools.calculate_technical_indicators(kline_data)
# Prompts
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a professional technical analyst. Please analyze the technical trends of the stock or cryptocurrency, including:
{lang_instruction}
1. Technical Indicator Signals (MACD signals, RSI range, trend strength, important MA directions)
2. Technical Score (0-100). Be objective. Do not default to 75.
- 0-40: Bearish/Weak
- 41-60: Neutral
- 61-100: Bullish/Strong
3. Technical Analysis Report (about 300 words)
Please strictly return the result in JSON format as follows:
{{
"score": 75,
"indicators": {{
"MACD": "Golden Cross/Death Cross or Flat",
"RSI(14)": "75 (Overbought)",
"MA20": "Upward/Downward/Flat",
"Support/Resistance": "Support: 150.00, Resistance: 165.50"
}},
"report": "Technical analysis report content..."
}}"""
user_prompt = f"""Please perform technical analysis for {symbol} in {market} market.
**Current Price:**
{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'}
**Kline Data (Last 30 days):**
{json.dumps(kline_data[-10:] if kline_data else [], ensure_ascii=False, indent=2) if kline_data else 'No Data'}
**Calculated Technical Indicators:**
{json.dumps(indicators, ensure_ascii=False, indent=2) if indicators else 'No Data'}
Please analyze the short-term and medium-term trends based on the above Kline data and price movements."""
# LLM call
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"score": 50, "indicators": {}, "report": "Failed to parse technical analysis"},
model=model
)
return {
"type": "technical",
"data": result,
"indicators": indicators
}
def _get_language_instruction(self, language: str) -> str:
"""Return an English language instruction string for the LLM prompt."""
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
class FundamentalAnalyst(BaseAgent):
"""Fundamental analyst."""
def __init__(self, memory=None):
super().__init__("FundamentalAnalyst", memory)
self.tools = AgentTools()
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Run fundamental analysis."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
base_data = context.get('base_data', {})
# Fundamental data
fundamental_data = base_data.get('fundamental_data') or self.tools.get_fundamental_data(market, symbol)
company_data = base_data.get('company_data') or self.tools.get_company_data(market, symbol, language=language)
# Memory
situation = f"{market}:{symbol} fundamental analysis"
memory_meta = {
"market": market,
"symbol": symbol,
"timeframe": context.get("timeframe"),
"features": context.get("memory_features") or {},
}
memories = self.get_memories(situation, n_matches=None, metadata=memory_meta)
memory_prompt = self.format_memories_for_prompt(memories)
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a fundamental analyst. Please analyze the financial status and industry position of the stock, including:
{lang_instruction}
1. Financial Indicators (Select key P/E, P/B, ROE, Revenue Growth)
2. Fundamental Score (0-100). Be objective. Do not default to 80.
- 0-40: Poor/Overvalued
- 41-60: Fair/Neutral
- 61-100: Good/Undervalued
3. Fundamental Analysis Report (about 300 words)
{memory_prompt}
Please strictly return the result in JSON format as follows:
{{
"score": 80,
"financials": {{
"P/E": "25.3",
"P/B": "4.2",
"ROE": "18.5%",
"Market Cap": "1200.5 B"
}},
"report": "Fundamental analysis report content..."
}}"""
user_prompt = f"""Please perform fundamental analysis for {symbol} in {market} market.
**Basic Company Info:**
{json.dumps(company_data, ensure_ascii=False, indent=2) if company_data else 'No Data'}
**Raw Fundamental Indicators:**
{json.dumps(fundamental_data, ensure_ascii=False, indent=2) if fundamental_data else 'No Data'}
Please analyze based on the above data."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"score": 50, "financials": {}, "report": "Failed to parse fundamental analysis"},
model=model
)
return {
"type": "fundamental",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
class NewsAnalyst(BaseAgent):
"""News analyst."""
def __init__(self, memory=None):
super().__init__("NewsAnalyst", memory)
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Run news analysis."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
base_data = context.get('base_data', {})
company_data = base_data.get('company_data', {})
news_data = base_data.get('news_data', [])
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a professional news intelligence analyst. Your task is to extract key intelligence that has a major impact on stock prices from massive market information.
Analysis Requirements:
{lang_instruction}
1. **Filter Noise**: News data may contain internet results from search engines. Please carefully discriminate and ignore ads, duplicate content, or irrelevant noise. Prioritize authoritative financial media and official announcements.
2. **Timeliness**: Focus on breaking news within the last 48 hours. For old news, lower its weight unless there are new developments.
3. **Deep Interpretation**: Do not just repeat news titles. Analyze the logic behind the event and its specific impact on company fundamentals or market sentiment (e.g., Earnings Beat -> Profit Improvement -> Valuation Increase).
4. **Scoring**: News Score (0-100).
- 0-40: Major Negative (e.g., financial fraud, regulatory crackdown, core business damage)
- 41-59: Neutral or minor impact
- 60-100: Positive (e.g., strong earnings, major partnership, policy support)
- Higher scores indicate more positive news.
Please strictly return the result in JSON format as follows:
{{
"score": 70,
"events": [
{{
"title": "Event Title",
"impact": "Positive/Negative/Neutral",
"summary": "Event summary and deep impact analysis...",
"date": "2023-10-27"
}}
],
"report": "Comprehensive news analysis report, including overall judgment on recent market public opinion..."
}}"""
user_prompt = f"""Please perform in-depth news intelligence analysis for {symbol} in {market} market.
**Company Background:**
{json.dumps(company_data, ensure_ascii=False, indent=2) if company_data else 'No Data'}
**Latest Intelligence Sources (Contains professional financial news and web search results, please discriminate):**
{json.dumps(news_data, ensure_ascii=False, indent=2) if news_data else 'No directly related news'}
Please analyze based on the above intelligence. If the provided "Latest Intelligence Sources" contain no substantive content or only irrelevant noise, please explicitly state "No valid news available" and deduce logically based on the industry trends and macro market environment of the stock."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"score": 50, "events": [], "report": "Failed to parse news analysis"},
model=model
)
return {
"type": "news",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
class SentimentAnalyst(BaseAgent):
"""Sentiment analyst."""
def __init__(self, memory=None):
super().__init__("SentimentAnalyst", memory)
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Run sentiment analysis."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
base_data = context.get('base_data', {})
current_price = base_data.get('current_price', {})
kline_data = base_data.get('kline_data', [])
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a market sentiment analyst. Please analyze the market sentiment and popularity of the stock, including:
{lang_instruction}
1. Sentiment Heat Indicators (e.g., analyst ratings, social media discussion volume, put/call ratio)
2. Sentiment Score (0-100, higher means more optimistic). Be objective. Do not default to 65.
- 0-40: Bearish/Fearful
- 41-60: Neutral
- 61-100: Bullish/Greedy
3. Sentiment Analysis Report (about 300 words)
Please strictly return the result in JSON format as follows:
{{
"score": 65,
"scores": {{
"Analyst Rating": 90,
"Social Media Heat": 85,
"Market Sentiment Index": 70
}},
"report": "Sentiment analysis report content..."
}}
Note: All values in the 'scores' dictionary must be integers between 0-100 (pure numbers), representing optimism or heat, without any text or percentage signs."""
user_prompt = f"""Please perform sentiment analysis for {symbol} in {market} market.
Based on the current Kline trends and price fluctuations, combined with your existing knowledge, evaluate whether the market sentiment for this stock is bullish, bearish, or neutral.
**Current Price:**
{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'}
**Kline Data (Recent Trends):**
{json.dumps(kline_data[-5:] if kline_data else [], ensure_ascii=False, indent=2) if kline_data else 'No Data'}
Please evaluate market sentiment."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"score": 50, "scores": {"Analyst Rating": 50, "Social Media Heat": 50, "Market Sentiment Index": 50}, "report": "Failed to parse sentiment analysis"},
model=model
)
return {
"type": "sentiment",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
class RiskAnalyst(BaseAgent):
"""Risk analyst."""
def __init__(self, memory=None):
super().__init__("RiskAnalyst", memory)
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Run risk assessment."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
base_data = context.get('base_data', {})
current_price = base_data.get('current_price', {})
kline_data = base_data.get('kline_data', [])
fundamental_data = base_data.get('fundamental_data', {})
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a risk management analyst. Please evaluate the investment risks of the stock, including:
{lang_instruction}
1. Risk Indicators (Volatility, Liquidity, Concentration Risk, Systemic Risk Exposure)
2. Risk Score (0-100, higher score means lower risk/safer). Be objective. Do not default to 60.
- 0-40: High Risk (Dangerous)
- 41-60: Moderate Risk
- 61-100: Low Risk (Safe)
3. Risk Assessment Report (about 300 words)
Please strictly return the result in JSON format as follows:
{{
"score": 60,
"metrics": {{
"Volatility (Beta)": "1.2 (Higher than market)",
"Liquidity": "Good",
"Concentration Risk": "Low (Diversified business)"
}},
"report": "Risk assessment report content..."
}}"""
user_prompt = f"""Please perform risk assessment for {symbol} in {market} market.
**Current Price:**
{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'}
**Kline Data (Volatility Analysis):**
{json.dumps(kline_data, ensure_ascii=False, indent=2) if kline_data else 'No Data'}
**Fundamental Data (Debt Risk):**
{json.dumps(fundamental_data, ensure_ascii=False, indent=2) if fundamental_data else 'No Data'}
Please evaluate investment risks based on price volatility, fundamental data, etc."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"score": 50, "metrics": {}, "report": "Failed to parse risk assessment"},
model=model
)
return {
"type": "risk",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
@@ -1,96 +0,0 @@
"""
智能体基类
"""
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional, List
import os
from app.utils.logger import get_logger
logger = get_logger(__name__)
class BaseAgent(ABC):
"""智能体基类,所有分析智能体都继承此类"""
def __init__(self, name: str, memory: Optional[Any] = None):
"""
初始化智能体
Args:
name: 智能体名称
memory: 记忆系统实例可选
"""
self.name = name
self.memory = memory
self.logger = get_logger(f"{__name__}.{name}")
@abstractmethod
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""
执行分析任务
Args:
context: 分析上下文包含市场代码基础数据等
Returns:
分析结果字典
"""
pass
def get_memories(self, situation: str, n_matches: Optional[int] = None, metadata: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
"""
从记忆中检索相似情况
Args:
situation: 当前情况描述
n_matches: 返回的匹配数量
Returns:
匹配的历史记录列表
"""
if n_matches is None:
try:
n_matches = int(os.getenv("AGENT_MEMORY_TOP_K", "5") or 5)
except Exception:
n_matches = 5
if self.memory:
# New memory API supports metadata; older implementations will ignore extra args.
try:
return self.memory.get_memories(situation, n_matches=n_matches, metadata=metadata)
except TypeError:
return self.memory.get_memories(situation, n_matches=n_matches)
return []
def format_memories_for_prompt(self, memories: List[Dict[str, Any]]) -> str:
"""
格式化记忆为提示词
Args:
memories: 记忆列表
Returns:
格式化的字符串
"""
if not memories:
return "No prior experience available."
lines = ["Prior experience (most relevant first):"]
for i, mem in enumerate(memories, 1):
rec = mem.get("recommendation") or "N/A"
res = mem.get("result") or ""
ret = mem.get("returns")
created_at = mem.get("created_at")
# Keep created_at as-is (SQLite string), but include it for traceability.
meta_bits = []
if created_at:
meta_bits.append(f"at {created_at}")
if ret is not None and ret != "":
meta_bits.append(f"returns={ret}%")
meta_s = f" ({', '.join(meta_bits)})" if meta_bits else ""
if res:
lines.append(f"{i}. {rec}{meta_s}\n outcome: {res}")
else:
lines.append(f"{i}. {rec}{meta_s}")
return "\n".join(lines)
@@ -1,745 +0,0 @@
"""
Multi-agent coordinator.
Orchestrates agents and the overall analysis workflow.
"""
from typing import Dict, Any, List, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
from .analyst_agents import (
MarketAnalyst, FundamentalAnalyst, NewsAnalyst,
SentimentAnalyst, RiskAnalyst
)
from .researcher_agents import BullResearcher, BearResearcher
from .trader_agent import TraderAgent
from .risk_agents import RiskyAnalyst, NeutralAnalyst, SafeAnalyst
from .memory import AgentMemory
from .reflection import ReflectionService
from app.utils.logger import get_logger
logger = get_logger(__name__)
class AgentCoordinator:
"""Multi-agent coordinator."""
@staticmethod
def _is_zh(language: str) -> bool:
return str(language or "").lower().startswith("zh")
@classmethod
def _t(cls, language: str, en: str, zh: str) -> str:
"""Pick a localized string for user-facing fields (not logs)."""
return zh if cls._is_zh(language) else en
def __init__(self, enable_memory: bool = True, max_debate_rounds: int = 2):
"""
Args:
enable_memory: Enable memory/reflection
max_debate_rounds: Max debate rounds
"""
self.enable_memory = enable_memory
self.max_debate_rounds = max_debate_rounds
# Reflection service
self.reflection_service = ReflectionService() if enable_memory else None
# Memory stores
if enable_memory:
self.memories = {
'market': AgentMemory('market_analyst'),
'fundamental': AgentMemory('fundamental_analyst'),
'news': AgentMemory('news_analyst'),
'sentiment': AgentMemory('sentiment_analyst'),
'risk': AgentMemory('risk_analyst'),
'bull': AgentMemory('bull_researcher'),
'bear': AgentMemory('bear_researcher'),
'trader': AgentMemory('trader_agent'),
}
else:
self.memories = {}
# Initialize agents
self._init_agents()
def _init_agents(self):
"""Initialize all agents."""
# Analyst agents
self.market_analyst = MarketAnalyst(
memory=self.memories.get('market')
)
self.fundamental_analyst = FundamentalAnalyst(
memory=self.memories.get('fundamental')
)
self.news_analyst = NewsAnalyst(
memory=self.memories.get('news')
)
self.sentiment_analyst = SentimentAnalyst(
memory=self.memories.get('sentiment')
)
self.risk_analyst = RiskAnalyst(
memory=self.memories.get('risk')
)
# Researcher agents
self.bull_researcher = BullResearcher(
memory=self.memories.get('bull')
)
self.bear_researcher = BearResearcher(
memory=self.memories.get('bear')
)
# Trader agent
self.trader_agent = TraderAgent(
memory=self.memories.get('trader')
)
# Risk debate agents
self.risky_analyst = RiskyAnalyst()
self.neutral_analyst = NeutralAnalyst()
self.safe_analyst = SafeAnalyst()
def run_analysis_stream(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None, timeframe: str = "1D", on_progress=None):
"""
Run the full multi-agent analysis workflow with progress callbacks.
Args:
on_progress: Callback function that receives (agent_name: str, status: str, result: Optional[dict])
status can be: 'started', 'completed', 'error'
Yields:
Progress events as dicts: { 'agent': str, 'status': str, 'result': dict or None }
"""
logger.info(f"Multi-agent stream analysis start: {market}:{symbol}, model={model}, language={language}")
def emit_progress(agent: str, status: str, result: dict = None):
"""Emit progress event."""
if on_progress:
on_progress(agent, status, result)
# Build base context
from .tools import AgentTools
tools = AgentTools()
emit_progress('data_collection', 'started', None)
# 1) Base data
current_price = tools.get_current_price(market, symbol)
company_data = tools.get_company_data(market, symbol, language=language)
# Normalize timeframe
tf = (timeframe or "1D").strip()
# 2) Kline + fundamentals
kline_data = tools.get_stock_data(market, symbol, days=30, timeframe=tf)
fundamental_data = tools.get_fundamental_data(market, symbol)
indicators = tools.calculate_technical_indicators(kline_data or [])
# 3) News (Finnhub + web search)
company_name = company_data.get('name', symbol) if company_data else symbol
news_data = tools.get_news(market, symbol, days=7, company_name=company_name)
base_data = {
"market": market,
"symbol": symbol,
"current_price": current_price,
"kline_data": kline_data,
"fundamental_data": fundamental_data,
"company_data": company_data,
"news_data": news_data,
"indicators": indicators,
}
context = {
"market": market,
"symbol": symbol,
"language": language,
"model": model,
"timeframe": tf,
"memory_features": {
"timeframe": tf,
"price": (current_price or {}).get("price"),
"changePercent": (current_price or {}).get("changePercent"),
"indicators": indicators,
},
"base_data": base_data
}
emit_progress('data_collection', 'completed', None)
# Phase 1: Analysts (parallel but report individually)
logger.info("Phase 1: Analyst team")
# Fundamental Analyst
emit_progress('fundamental', 'started', None)
fundamental_report = self.fundamental_analyst.analyze(context)
emit_progress('fundamental', 'completed', fundamental_report.get('data', {}))
# Technical Analyst (market_analyst)
emit_progress('technical', 'started', None)
market_report = self.market_analyst.analyze(context)
emit_progress('technical', 'completed', market_report.get('data', {}))
# News Analyst
emit_progress('news', 'started', None)
news_report = self.news_analyst.analyze(context)
emit_progress('news', 'completed', news_report.get('data', {}))
# Sentiment Analyst
emit_progress('sentiment', 'started', None)
sentiment_report = self.sentiment_analyst.analyze(context)
emit_progress('sentiment', 'completed', sentiment_report.get('data', {}))
# Risk Analyst
emit_progress('risk', 'started', None)
risk_report = self.risk_analyst.analyze(context)
emit_progress('risk', 'completed', risk_report.get('data', {}))
# Update context with analyst outputs
context.update({
"market_report": market_report,
"fundamental_report": fundamental_report,
"news_report": news_report,
"sentiment_report": sentiment_report,
"risk_report": risk_report,
})
# Phase 2: Bull/Bear debate
logger.info("Phase 2: Research debate")
emit_progress('debate_bull', 'started', None)
bull_argument = self.bull_researcher.analyze(context)
emit_progress('debate_bull', 'completed', bull_argument.get('data', {}))
emit_progress('debate_bear', 'started', None)
bear_argument = self.bear_researcher.analyze(context)
emit_progress('debate_bear', 'completed', bear_argument.get('data', {}))
context["bull_argument"] = bull_argument
context["bear_argument"] = bear_argument
# Research manager decision
emit_progress('debate_research', 'started', None)
research_decision = self._make_research_decision(bull_argument, bear_argument, context)
context["research_decision"] = research_decision
emit_progress('debate_research', 'completed', {'research_decision': research_decision})
# Phase 3: Trader decision
logger.info("Phase 3: Trader decision")
emit_progress('trader_decision', 'started', None)
trader_result = self.trader_agent.analyze(context)
trader_plan = trader_result.get('data', {}).get('trading_plan', {})
context["trader_plan"] = trader_plan
emit_progress('trader_decision', 'completed', trader_result.get('data', {}))
# Phase 4: Risk debate
logger.info("Phase 4: Risk debate")
emit_progress('risk_debate_risky', 'started', None)
risky_result = self.risky_analyst.analyze(context)
emit_progress('risk_debate_risky', 'completed', risky_result.get('data', {}))
emit_progress('risk_debate_neutral', 'started', None)
neutral_result = self.neutral_analyst.analyze(context)
emit_progress('risk_debate_neutral', 'completed', neutral_result.get('data', {}))
emit_progress('risk_debate_safe', 'started', None)
safe_result = self.safe_analyst.analyze(context)
emit_progress('risk_debate_safe', 'completed', safe_result.get('data', {}))
# Final decision
logger.info("Phase 5: Final decision")
emit_progress('final_decision', 'started', None)
final_decision = self._make_risk_decision(risky_result, neutral_result, safe_result, trader_result, context)
emit_progress('final_decision', 'completed', final_decision)
# Generate overview
emit_progress('overview', 'started', None)
overview = self._generate_overview(context, final_decision)
emit_progress('overview', 'completed', overview)
# Record for reflection
if self.reflection_service and final_decision.get('decision') in ['BUY', 'SELL', 'HOLD']:
try:
self.reflection_service.record_analysis(
market=market,
symbol=symbol,
price=base_data.get('current_price', {}).get('price'),
decision=final_decision.get('decision'),
confidence=final_decision.get('confidence', 50),
reasoning=final_decision.get('reasoning', ''),
check_days=7
)
except Exception as e:
logger.warning(f"Record reflection failed: {e}")
# Build final result
debate_data = {
"bull": bull_argument.get('data', {}) if bull_argument.get('data') else {},
"bear": bear_argument.get('data', {}) if bear_argument.get('data') else {},
"research_decision": research_decision if research_decision else "Analyzing..."
}
trader_decision_data = trader_result.get('data', {}) if trader_result.get('data') else {
"decision": "HOLD",
"confidence": 50,
"reasoning": "Analyzing...",
"trading_plan": {},
"report": "Analyzing..."
}
risk_debate_data = {
"risky": risky_result.get('data', {}) if risky_result.get('data') else {},
"neutral": neutral_result.get('data', {}) if neutral_result.get('data') else {},
"safe": safe_result.get('data', {}) if safe_result.get('data') else {}
}
if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0):
final_decision = {
"decision": "HOLD",
"confidence": 50,
"reasoning": "Analyzing...",
"risk_summary": {},
"recommendation": "Analyzing..."
}
result = {
"overview": overview,
"fundamental": fundamental_report.get('data', {}),
"technical": market_report.get('data', {}),
"news": news_report.get('data', {}),
"sentiment": sentiment_report.get('data', {}),
"risk": risk_report.get('data', {}),
"debate": debate_data,
"trader_decision": trader_decision_data,
"risk_debate": risk_debate_data,
"final_decision": final_decision,
"error": None
}
logger.info(f"Multi-agent stream analysis completed: {market}:{symbol}")
return result
def run_analysis(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
"""
Run the full multi-agent analysis workflow.
"""
logger.info(f"Multi-agent analysis start: {market}:{symbol}, model={model}, language={language}")
# Build base context
from .tools import AgentTools
tools = AgentTools()
# 1) Base data
current_price = tools.get_current_price(market, symbol)
company_data = tools.get_company_data(market, symbol, language=language)
# Normalize timeframe
tf = (timeframe or "1D").strip()
# 2) Kline + fundamentals
kline_data = tools.get_stock_data(market, symbol, days=30, timeframe=tf)
fundamental_data = tools.get_fundamental_data(market, symbol)
indicators = tools.calculate_technical_indicators(kline_data or [])
# 3) News (Finnhub + web search)
company_name = company_data.get('name', symbol) if company_data else symbol
news_data = tools.get_news(market, symbol, days=7, company_name=company_name)
base_data = {
"market": market,
"symbol": symbol,
"current_price": current_price,
"kline_data": kline_data,
"fundamental_data": fundamental_data,
"company_data": company_data,
"news_data": news_data,
"indicators": indicators,
}
context = {
"market": market,
"symbol": symbol,
"language": language,
"model": model,
"timeframe": tf,
# Compact structured features for memory retrieval.
"memory_features": {
"timeframe": tf,
"price": (current_price or {}).get("price"),
"changePercent": (current_price or {}).get("changePercent"),
"indicators": indicators,
},
"base_data": base_data
}
# Phase 1: Analysts (parallel)
logger.info("Phase 1: Analyst team (parallel)")
with ThreadPoolExecutor(max_workers=5) as executor:
future_market = executor.submit(self.market_analyst.analyze, context)
future_fundamental = executor.submit(self.fundamental_analyst.analyze, context)
future_news = executor.submit(self.news_analyst.analyze, context)
future_sentiment = executor.submit(self.sentiment_analyst.analyze, context)
future_risk = executor.submit(self.risk_analyst.analyze, context)
market_report = future_market.result()
fundamental_report = future_fundamental.result()
news_report = future_news.result()
sentiment_report = future_sentiment.result()
risk_report = future_risk.result()
# Update context with analyst outputs
context.update({
"market_report": market_report,
"fundamental_report": fundamental_report,
"news_report": news_report,
"sentiment_report": sentiment_report,
"risk_report": risk_report,
})
# Phase 2: Bull/Bear debate (parallel)
logger.info("Phase 2: Research debate (parallel)")
with ThreadPoolExecutor(max_workers=2) as executor:
future_bull = executor.submit(self.bull_researcher.analyze, context)
future_bear = executor.submit(self.bear_researcher.analyze, context)
bull_argument = future_bull.result()
bear_argument = future_bear.result()
context["bull_argument"] = bull_argument
context["bear_argument"] = bear_argument
# Research manager decision (lightweight, based on debate)
research_decision = self._make_research_decision(
bull_argument, bear_argument, context
)
context["research_decision"] = research_decision
# Phase 3: Trader decision
logger.info("Phase 3: Trader decision")
trader_result = self.trader_agent.analyze(context)
trader_plan = trader_result.get('data', {}).get('trading_plan', {})
context["trader_plan"] = trader_plan
# Phase 4: Risk debate (parallel)
logger.info("Phase 4: Risk debate (parallel)")
with ThreadPoolExecutor(max_workers=3) as executor:
future_risky = executor.submit(self.risky_analyst.analyze, context)
future_neutral = executor.submit(self.neutral_analyst.analyze, context)
future_safe = executor.submit(self.safe_analyst.analyze, context)
risky_result = future_risky.result()
neutral_result = future_neutral.result()
safe_result = future_safe.result()
# Risk manager final decision
final_decision = self._make_risk_decision(
risky_result, neutral_result, safe_result,
trader_result, context
)
# Record analysis result for later reflection/validation
if self.reflection_service and final_decision.get('decision') in ['BUY', 'SELL', 'HOLD']:
try:
self.reflection_service.record_analysis(
market=market,
symbol=symbol,
price=base_data.get('current_price', {}).get('price'),
decision=final_decision.get('decision'),
confidence=final_decision.get('confidence', 50),
reasoning=final_decision.get('reasoning', ''),
check_days=7 # Validate after 7 days by default
)
except Exception as e:
logger.warning(f"Record reflection failed: {e}")
# Build final result (defensive defaults to keep frontend stable)
debate_data = {
"bull": bull_argument.get('data', {}) if bull_argument.get('data') else {},
"bear": bear_argument.get('data', {}) if bear_argument.get('data') else {},
"research_decision": research_decision if research_decision else "Analyzing..."
}
trader_decision_data = trader_result.get('data', {}) if trader_result.get('data') else {
"decision": "HOLD",
"confidence": 50,
"reasoning": "Analyzing...",
"trading_plan": {},
"report": "Analyzing..."
}
risk_debate_data = {
"risky": risky_result.get('data', {}) if risky_result.get('data') else {},
"neutral": neutral_result.get('data', {}) if neutral_result.get('data') else {},
"safe": safe_result.get('data', {}) if safe_result.get('data') else {}
}
# Ensure final_decision is present
if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0):
final_decision = {
"decision": "HOLD",
"confidence": 50,
"reasoning": "Analyzing...",
"risk_summary": {},
"recommendation": "Analyzing..."
}
result = {
"overview": self._generate_overview(context, final_decision),
"fundamental": fundamental_report.get('data', {}),
"technical": market_report.get('data', {}),
"news": news_report.get('data', {}),
"sentiment": sentiment_report.get('data', {}),
"risk": risk_report.get('data', {}),
"debate": debate_data,
"trader_decision": trader_decision_data,
"risk_debate": risk_debate_data,
"final_decision": final_decision,
"error": None
}
logger.info(f"Multi-agent analysis completed: {market}:{symbol}")
logger.info(
"Result fields - debate=%s, trader_decision=%s, risk_debate=%s, final_decision=%s",
bool(result.get('debate')),
bool(result.get('trader_decision')),
bool(result.get('risk_debate')),
bool(result.get('final_decision')),
)
return result
def _make_research_decision(self, bull: Dict, bear: Dict, context: Dict) -> str:
"""Research manager decision (rule-based, lightweight)."""
try:
language = context.get("language", "en-US")
bull_confidence = bull.get('data', {}).get('confidence', 50)
bear_confidence = bear.get('data', {}).get('confidence', 50)
# Tie-breaker when scores are close (<= 10)
score_diff = bull_confidence - bear_confidence
if abs(score_diff) <= 10:
# Use technical + sentiment as a bias signal
market_score = context.get('market_report', {}).get('data', {}).get('score', 50)
sentiment_score = context.get('sentiment_report', {}).get('data', {}).get('score', 50)
market_bias = (market_score + sentiment_score) / 2
if market_bias > 60:
return self._t(
language,
en=(
f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), "
f"but technical/sentiment are optimistic (avg {market_bias:.1f}), slightly leaning bullish."
),
zh=(
f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%),"
f"但鉴于技术面和市场情绪偏乐观(平均分 {market_bias:.1f}),稍微倾向于看涨。"
),
)
elif market_bias < 40:
return self._t(
language,
en=(
f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), "
f"but technical/sentiment are pessimistic (avg {market_bias:.1f}), slightly leaning bearish."
),
zh=(
f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%),"
f"但鉴于技术面和市场情绪偏悲观(平均分 {market_bias:.1f}),稍微倾向于看跌。"
),
)
else:
return self._t(
language,
en=(
f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), "
"and market bias is unclear. Prefer neutral / wait-and-see."
),
zh=(
f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%),"
"且市场情绪不明朗,建议保持中立/观望。"
),
)
elif score_diff > 10:
return self._t(
language,
en=(
f"Research decision: bullish case (confidence {bull_confidence}%) is clearly stronger than bearish "
f"(confidence {bear_confidence}%). Lean bullish."
),
zh=(
f"研究经理决策:基于看涨论据(置信度 {bull_confidence}%)明显强于看跌论据(置信度 {bear_confidence}%),明确倾向于看涨。"
),
)
else: # score_diff < -10
return self._t(
language,
en=(
f"Research decision: bearish case (confidence {bear_confidence}%) is clearly stronger than bullish "
f"(confidence {bull_confidence}%). Lean bearish."
),
zh=(
f"研究经理决策:基于看跌论据(置信度 {bear_confidence}%)明显强于看涨论据(置信度 {bull_confidence}%),明确倾向于看跌。"
),
)
except Exception as e:
logger.error(f"Research decision failed: {e}")
language = context.get("language", "en-US") if isinstance(context, dict) else "en-US"
return self._t(language, en="Research decision: unable to reach a clear conclusion.", zh="研究经理决策:无法做出明确判断。")
def _make_risk_decision(self, risky: Dict, neutral: Dict, safe: Dict,
trader: Dict, context: Dict) -> Dict[str, Any]:
"""Risk manager final decision (lightweight)."""
try:
language = context.get("language", "en-US")
trader_decision = trader.get('data', {}).get('decision', 'HOLD')
trader_confidence = trader.get('data', {}).get('confidence', 50)
# Risk debate summary
risk_summary = {
"risky_view": risky.get('data', {}).get('recommendation', ''),
"neutral_view": neutral.get('data', {}).get('recommendation', ''),
"safe_view": safe.get('data', {}).get('recommendation', ''),
}
# Final decision (use trader decision + risk debate context)
final_decision = {
"decision": trader_decision,
"confidence": trader_confidence,
"reasoning": self._t(
language,
en=f"Final decision is based on trader analysis ({trader_decision}, confidence {trader_confidence}%) and the risk debate.",
zh=f"基于交易员分析({trader_decision},置信度 {trader_confidence}%)和风险辩论,做出最终决策。",
),
"risk_summary": risk_summary,
"recommendation": trader.get('data', {}).get('report', '')
}
return final_decision
except Exception as e:
logger.error(f"Risk decision failed: {e}")
language = context.get("language", "en-US") if isinstance(context, dict) else "en-US"
return {
"decision": "HOLD",
"confidence": 50,
"reasoning": self._t(language, en="Risk decision failed", zh="风险决策失败"),
"risk_summary": {},
"recommendation": ""
}
def _generate_overview(self, context: Dict, final_decision: Dict) -> Dict[str, Any]:
"""Generate overview section (lightweight, deterministic)."""
try:
language = context.get("language", "en-US")
# Extract dimension scores
technical_data = context.get('market_report', {}).get('data', {})
fundamental_data = context.get('fundamental_report', {}).get('data', {})
news_data = context.get('news_report', {}).get('data', {})
sentiment_data = context.get('sentiment_report', {}).get('data', {})
risk_data = context.get('risk_report', {}).get('data', {})
technical_score = technical_data.get('score', 50)
fundamental_score = fundamental_data.get('score', 50)
news_score = news_data.get('score', 50)
sentiment_score = sentiment_data.get('score', 50)
risk_score = risk_data.get('score', 50)
# Generate an overall score using weighted dimensions + decision/confidence adjustment
decision = final_decision.get('decision', 'HOLD')
confidence = final_decision.get('confidence', 50)
# 1) Base score: weighted average (tech 30%, fundamental 25%, news 15%, sentiment 15%, risk 15%)
weighted_score = (
technical_score * 0.3 +
fundamental_score * 0.25 +
news_score * 0.15 +
sentiment_score * 0.15 +
risk_score * 0.15
)
# 2) Decision adjustment: BUY pushes toward 60-100, SELL toward 0-40, HOLD toward 50
if decision == 'BUY':
target_score = 60 + (confidence / 100 * 40) # Map to 60-100
overall_score = (weighted_score * 0.4) + (target_score * 0.6)
elif decision == 'SELL':
target_score = 40 - (confidence / 100 * 40) # Map to 0-40
overall_score = (weighted_score * 0.4) + (target_score * 0.6)
else:
overall_score = (weighted_score * 0.6) + (50 * 0.4)
# Clamp to 0..100
overall_score = max(0, min(100, int(overall_score)))
return {
"overallScore": overall_score,
"recommendation": decision,
"confidence": confidence,
"dimensionScores": {
"fundamental": fundamental_score,
"technical": technical_score,
"news": news_score,
"sentiment": sentiment_score,
"risk": risk_score
},
"report": final_decision.get(
'reasoning',
self._t(language, en="Overview generated.", zh="综合分析完成"),
)
}
except Exception as e:
logger.error(f"Generate overview failed: {e}")
language = context.get("language", "en-US") if isinstance(context, dict) else "en-US"
return {
"overallScore": 50,
"recommendation": "HOLD",
"confidence": 50,
"dimensionScores": {
"fundamental": 50,
"technical": 50,
"news": 50,
"sentiment": 50,
"risk": 50
},
"report": self._t(language, en="Failed to generate overview.", zh="综合分析生成失败")
}
def reflect_and_learn(self, market: str, symbol: str, decision: str,
returns: Optional[float] = None, result: Optional[str] = None):
"""
Reflection hook: store post-trade outcomes into memory (local-only).
Args:
market: Market
symbol: Symbol
decision: BUY/SELL/HOLD
returns: Return percentage
result: Free-text outcome
"""
if not self.enable_memory:
return
try:
situation = f"{market}:{symbol} trading decision"
recommendation = f"Decision: {decision}, returns: {returns if returns is not None else 'N/A'}"
# Update trader memory
if 'trader' in self.memories:
self.memories['trader'].add_memory(
situation,
recommendation,
result,
returns,
metadata={
"market": market,
"symbol": symbol,
"timeframe": None,
"features": {
"source": "manual_reflect",
}
},
)
logger.info(f"Reflection completed: {market}:{symbol}, decision={decision}, returns={returns}")
except Exception as e:
logger.error(f"Reflection failed: {e}")
@@ -1,91 +0,0 @@
"""
Lightweight embedding utilities for local-only deployments.
We intentionally avoid heavyweight ML deps (torch/sentence-transformers) and external services.
This module provides a deterministic "hashed embedding" (similar to feature hashing):
- Tokenize text
- Hash tokens into a fixed-size dense vector
- L2 normalize
It is not as semantically strong as modern transformer embeddings, but it enables:
- Vector storage in SQLite
- Cosine similarity retrieval
- Recency/return weighted ranking
"""
from __future__ import annotations
import math
import os
import re
import struct
import hashlib
from typing import List, Optional
_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+", re.UNICODE)
def _tokenize(text: str) -> List[str]:
t = (text or "").lower()
return _TOKEN_RE.findall(t)
class EmbeddingService:
"""Deterministic local embedding service."""
def __init__(self, dim: Optional[int] = None):
self.dim = int(dim or os.getenv("AGENT_MEMORY_EMBEDDING_DIM", "256") or 256)
if self.dim <= 0:
self.dim = 256
def embed(self, text: str) -> List[float]:
"""
Return a dense, L2-normalized embedding vector.
"""
vec = [0.0] * self.dim
tokens = _tokenize(text)
if not tokens:
return vec
# Feature hashing with signed counts
for tok in tokens:
h = hashlib.blake2b(tok.encode("utf-8"), digest_size=8).digest()
# Use first 8 bytes as unsigned int
v = int.from_bytes(h, "little", signed=False)
idx = v % self.dim
sign = -1.0 if ((v >> 63) & 1) else 1.0
vec[idx] += sign
# L2 normalize
norm = math.sqrt(sum(x * x for x in vec)) or 1.0
return [x / norm for x in vec]
def to_bytes(self, vec: List[float]) -> bytes:
"""
Pack float vector into little-endian float32 bytes for SQLite BLOB storage.
"""
if not vec:
return b""
return struct.pack("<" + "f" * len(vec), *[float(x) for x in vec])
def from_bytes(self, blob: bytes) -> List[float]:
if not blob:
return []
n = len(blob) // 4
if n <= 0:
return []
return list(struct.unpack("<" + "f" * n, blob[: n * 4]))
def cosine_sim(a: List[float], b: List[float]) -> float:
"""
Cosine similarity for L2-normalized vectors.
If vectors are not normalized, this becomes a scaled dot product.
"""
if not a or not b:
return 0.0
n = min(len(a), len(b))
return float(sum(a[i] * b[i] for i in range(n)))
@@ -1,319 +0,0 @@
"""
Agent memory system (PostgreSQL).
This module stores agent experiences in PostgreSQL and retrieves relevant past cases
to inject into prompts (RAG-style). It does NOT finetune model weights.
Retrieval (configurable):
- Vector similarity via deterministic local embeddings (default)
- Fallback to difflib text similarity when embeddings are missing
Ranking combines:
- similarity
- recency decay (half-life)
- optional returns weight
"""
import json
import os
import math
from typing import List, Dict, Any, Optional
from datetime import datetime, timezone
import difflib
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from .embedding import EmbeddingService, cosine_sim
logger = get_logger(__name__)
class AgentMemory:
"""Agent memory system using PostgreSQL"""
def __init__(self, agent_name: str, db_path: Optional[str] = None):
"""
Initialize memory system.
Args:
agent_name: Agent identifier (e.g., 'trader_agent', 'risk_analyst')
db_path: Deprecated parameter, kept for backward compatibility
"""
self.agent_name = agent_name
self.embedder = EmbeddingService()
self.enable_vector = os.getenv("AGENT_MEMORY_ENABLE_VECTOR", "true").lower() == "true"
self.candidate_limit = int(os.getenv("AGENT_MEMORY_CANDIDATE_LIMIT", "500") or 500)
self.half_life_days = float(os.getenv("AGENT_MEMORY_HALF_LIFE_DAYS", "30") or 30)
self.w_sim = float(os.getenv("AGENT_MEMORY_W_SIM", "0.75") or 0.75)
self.w_recency = float(os.getenv("AGENT_MEMORY_W_RECENCY", "0.20") or 0.20)
self.w_returns = float(os.getenv("AGENT_MEMORY_W_RETURNS", "0.05") or 0.05)
def _now_utc(self) -> datetime:
return datetime.now(timezone.utc)
def _parse_ts(self, ts_val: Any) -> Optional[datetime]:
if ts_val is None:
return None
if isinstance(ts_val, datetime):
return ts_val
s = str(ts_val)
try:
return datetime.fromisoformat(s.replace("Z", ""))
except Exception:
return None
def _recency_score(self, created_at: Any) -> float:
dt = self._parse_ts(created_at)
if not dt:
return 0.0
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
age_days = max(0.0, (self._now_utc() - dt).total_seconds() / 86400.0)
hl = max(0.1, float(self.half_life_days or 30.0))
return float(math.exp(-math.log(2.0) * (age_days / hl)))
def _returns_score(self, returns: Any) -> float:
try:
r = float(returns)
except Exception:
return 0.0
return float(math.tanh(r / 10.0))
def _build_embed_text(self, situation: str, recommendation: str, result: Optional[str], features_json: Optional[str]) -> str:
return "\n".join([
f"situation: {situation or ''}",
f"recommendation: {recommendation or ''}",
f"result: {result or ''}",
f"features: {features_json or ''}",
])
def add_memory(
self,
situation: str,
recommendation: str,
result: Optional[str] = None,
returns: Optional[float] = None,
metadata: Optional[Dict[str, Any]] = None,
):
"""
Add a memory entry.
Args:
situation: Situation description
recommendation: Decision/recommendation made
result: Outcome description (optional)
returns: Return percentage (optional)
metadata: Optional structured metadata (market/symbol/timeframe/features...)
"""
try:
meta = metadata or {}
market = (meta.get("market") or "").strip() or None
symbol = (meta.get("symbol") or "").strip() or None
timeframe = (meta.get("timeframe") or "").strip() or None
features = meta.get("features") if isinstance(meta, dict) else None
try:
features_json = json.dumps(features, ensure_ascii=False) if features is not None else None
except Exception:
features_json = None
embedding_blob = None
if self.enable_vector:
text = self._build_embed_text(situation, recommendation, result, features_json)
vec = self.embedder.embed(text)
embedding_blob = self.embedder.to_bytes(vec)
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO qd_agent_memories
(agent_name, situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(self.agent_name, situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding_blob)
)
conn.commit()
cur.close()
logger.info(f"{self.agent_name} added new memory")
except Exception as e:
logger.error(f"Failed to add memory: {e}")
def get_memories(self, current_situation: str, n_matches: int = 5, metadata: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
"""
Retrieve similar memories.
Args:
current_situation: Current situation description
n_matches: Number of matches to return
metadata: Optional metadata for filtering/weighting
Returns:
List of matching memory entries
"""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
"""
SELECT id, situation, recommendation, result, returns, created_at,
market, symbol, timeframe, features_json, embedding
FROM qd_agent_memories
WHERE agent_name = ?
ORDER BY created_at DESC
LIMIT ?
""",
(self.agent_name, int(self.candidate_limit))
)
all_memories = cur.fetchall() or []
cur.close()
if not all_memories:
return []
meta = metadata or {}
tf = (meta.get("timeframe") or "").strip()
features = meta.get("features") if isinstance(meta, dict) else None
try:
q_features_json = json.dumps(features, ensure_ascii=False) if features is not None else None
except Exception:
q_features_json = None
query_vec = []
if self.enable_vector:
query_text = self._build_embed_text(current_situation, "", "", q_features_json)
query_vec = self.embedder.embed(query_text)
ranked = []
for row in all_memories:
mem_id = row['id']
situation = row['situation']
recommendation = row['recommendation']
result = row['result']
returns = row['returns']
created_at = row['created_at']
market = row['market']
symbol = row['symbol']
timeframe = row['timeframe']
features_json = row['features_json']
embedding_blob = row['embedding']
sim = 0.0
if self.enable_vector and embedding_blob:
try:
# Handle memoryview/bytes from PostgreSQL
if isinstance(embedding_blob, memoryview):
embedding_blob = bytes(embedding_blob)
mem_vec = self.embedder.from_bytes(embedding_blob)
sim = cosine_sim(query_vec, mem_vec)
except Exception:
sim = 0.0
else:
sim = difflib.SequenceMatcher(None, (current_situation or "").lower(), (situation or "").lower()).ratio()
rec = self._recency_score(created_at)
ret = self._returns_score(returns)
score = (self.w_sim * sim) + (self.w_recency * rec) + (self.w_returns * ret)
if tf and timeframe and str(timeframe).strip() != tf:
score -= 0.15
ranked.append({
'id': mem_id,
'matched_situation': situation,
'recommendation': recommendation,
'result': result,
'returns': returns,
'created_at': created_at,
'market': market,
'symbol': symbol,
'timeframe': timeframe,
'features_json': features_json,
'score': float(score),
'sim': float(sim),
'recency': float(rec),
})
ranked.sort(key=lambda x: x.get('score', 0.0), reverse=True)
return ranked[: max(0, int(n_matches or 0))]
except Exception as e:
logger.error(f"Failed to retrieve memories: {e}")
return []
def update_memory_result(self, memory_id: int, result: str, returns: Optional[float] = None):
"""
Update memory result.
Args:
memory_id: Memory ID
result: Outcome description
returns: Return percentage
"""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
"""
UPDATE qd_agent_memories
SET result = ?, returns = ?, updated_at = NOW()
WHERE id = ? AND agent_name = ?
""",
(result, returns, memory_id, self.agent_name)
)
conn.commit()
cur.close()
logger.info(f"{self.agent_name} updated memory {memory_id}")
except Exception as e:
logger.error(f"Failed to update memory: {e}")
def get_statistics(self) -> Dict[str, Any]:
"""Get memory statistics for this agent."""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
'SELECT COUNT(*) as cnt FROM qd_agent_memories WHERE agent_name = ?',
(self.agent_name,)
)
total = cur.fetchone()['cnt']
cur.execute(
'SELECT AVG(returns) as avg_ret FROM qd_agent_memories WHERE agent_name = ? AND returns IS NOT NULL',
(self.agent_name,)
)
avg_returns = cur.fetchone()['avg_ret'] or 0
cur.execute(
'SELECT COUNT(*) as cnt FROM qd_agent_memories WHERE agent_name = ? AND returns > 0',
(self.agent_name,)
)
positive = cur.fetchone()['cnt']
cur.close()
return {
'total_memories': total,
'average_returns': round(avg_returns, 2),
'positive_decisions': positive,
'success_rate': round(positive / total * 100, 2) if total > 0 else 0
}
except Exception as e:
logger.error(f"Failed to get statistics: {e}")
return {}
def clear_memories(self):
"""Clear all memories for this agent (use with caution)."""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
'DELETE FROM qd_agent_memories WHERE agent_name = ?',
(self.agent_name,)
)
conn.commit()
cur.close()
logger.warning(f"{self.agent_name} cleared all memories")
except Exception as e:
logger.error(f"Failed to clear memories: {e}")
@@ -1,249 +0,0 @@
"""
Auto-reflection and verification service (PostgreSQL).
Records analysis predictions and auto-verifies results in the future
to achieve closed-loop learning for AI agents.
"""
import os
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from .memory import AgentMemory
from .tools import AgentTools
logger = get_logger(__name__)
class ReflectionService:
"""Reflection service: manages storage and verification of analysis records."""
def __init__(self, db_path: Optional[str] = None):
"""
Initialize reflection service.
Args:
db_path: Deprecated parameter, kept for backward compatibility
"""
self.tools = AgentTools()
def record_analysis(
self,
market: str,
symbol: str,
price: float,
decision: str,
confidence: int,
reasoning: str,
check_days: int = 7
):
"""
Record an analysis for future verification.
Args:
market: Market type
symbol: Symbol code
price: Current price
decision: Decision (BUY/SELL/HOLD)
confidence: Confidence level (0-100)
reasoning: Reasoning text
check_days: Days until verification (default 7)
"""
try:
target_date = datetime.now() + timedelta(days=check_days)
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO qd_reflection_records
(market, symbol, initial_price, decision, confidence, reasoning, target_check_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(market, symbol, price, decision, confidence, reasoning, target_date)
)
conn.commit()
cur.close()
logger.info(f"Recorded analysis for reflection: {market}:{symbol}, will verify after {check_days} day(s)")
except Exception as e:
logger.error(f"Failed to record analysis: {e}")
def run_verification_cycle(self):
"""
Execute verification cycle: check due records, verify results, and write to memory.
"""
logger.info("Starting auto-reflection verification cycle...")
try:
with get_db_connection() as conn:
cur = conn.cursor()
# 1. Find all due and pending records
cur.execute(
"""
SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date
FROM qd_reflection_records
WHERE status = 'PENDING' AND target_check_date <= NOW()
"""
)
records = cur.fetchall() or []
if not records:
logger.info("No records to verify")
cur.close()
return
logger.info(f"Found {len(records)} records to verify")
# Initialize memory system for writing verification results
trader_memory = AgentMemory('trader_agent')
for record in records:
record_id = record['id']
market = record['market']
symbol = record['symbol']
initial_price = record['initial_price']
decision = record['decision']
confidence = record['confidence']
reasoning = record['reasoning']
analysis_date = record['analysis_date']
try:
# 2. Get current price
current_price_data = self.tools.get_current_price(market, symbol)
current_price = current_price_data.get('price')
if not current_price:
logger.warning(f"Cannot get current price for {market}:{symbol}, skipping")
continue
# 3. Calculate return and result
if not initial_price or initial_price == 0:
actual_return = 0.0
else:
actual_return = (current_price - initial_price) / initial_price * 100
# Evaluate result
result_desc = ""
is_good_prediction = False
if decision == "BUY":
if actual_return > 2.0:
result_desc = "Correct: price rose after BUY"
is_good_prediction = True
elif actual_return < -2.0:
result_desc = "Wrong: price fell after BUY"
else:
result_desc = "Neutral: limited price movement"
elif decision == "SELL":
if actual_return < -2.0:
result_desc = "Correct: price fell after SELL"
is_good_prediction = True
elif actual_return > 2.0:
result_desc = "Wrong: price rose after SELL"
else:
result_desc = "Neutral: limited price movement"
else: # HOLD
if -2.0 <= actual_return <= 2.0:
result_desc = "Correct: limited movement during HOLD"
is_good_prediction = True
else:
result_desc = f"Deviated: large movement during HOLD ({actual_return:.2f}%)"
# 4. Write to memory system (agent learning)
memory_situation = f"{market}:{symbol} auto-verified (analysis_date: {analysis_date})"
memory_recommendation = f"Decision: {decision} (confidence {confidence}), reasoning: {(reasoning or '')[:120]}"
memory_result = f"Verification: {result_desc}; return={actual_return:.2f}% (initial {initial_price} -> final {current_price})"
trader_memory.add_memory(
memory_situation,
memory_recommendation,
memory_result,
actual_return,
metadata={
"market": market,
"symbol": symbol,
"timeframe": "1D",
"features": {
"source": "auto_verify",
"decision": decision,
"confidence": confidence,
"initial_price": initial_price,
"final_price": current_price,
"analysis_date": str(analysis_date),
"result_desc": result_desc,
"is_good_prediction": bool(is_good_prediction),
},
}
)
# 5. Update record status
cur.execute(
"""
UPDATE qd_reflection_records
SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ?
WHERE id = ?
""",
(current_price, actual_return, result_desc, record_id)
)
conn.commit()
logger.info(f"Verification completed {market}:{symbol}: {result_desc}")
except Exception as inner_e:
logger.error(f"Failed to process record {record_id}: {inner_e}")
# Optionally mark as failed to avoid repeated processing
# cur.execute("UPDATE qd_reflection_records SET status = 'FAILED' WHERE id = ?", (record_id,))
# conn.commit()
cur.close()
logger.info("Reflection verification cycle completed")
except Exception as e:
logger.error(f"Failed to execute verification cycle: {e}")
def get_pending_count(self) -> int:
"""Get count of pending verification records."""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'PENDING'")
count = cur.fetchone()['cnt']
cur.close()
return count
except Exception as e:
logger.error(f"Failed to get pending count: {e}")
return 0
def get_statistics(self) -> Dict[str, Any]:
"""Get reflection statistics."""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records")
total = cur.fetchone()['cnt']
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'PENDING'")
pending = cur.fetchone()['cnt']
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'COMPLETED'")
completed = cur.fetchone()['cnt']
cur.execute(
"SELECT AVG(actual_return) as avg_ret FROM qd_reflection_records WHERE status = 'COMPLETED' AND actual_return IS NOT NULL"
)
avg_return = cur.fetchone()['avg_ret'] or 0
cur.close()
return {
'total_records': total,
'pending_records': pending,
'completed_records': completed,
'average_return': round(avg_return, 2)
}
except Exception as e:
logger.error(f"Failed to get statistics: {e}")
return {}
@@ -1,67 +0,0 @@
"""
Background worker for automated reflection verification.
This replaces the need for an external cron job in local deployments.
It periodically runs ReflectionService.run_verification_cycle().
Controls (env):
- ENABLE_REFLECTION_WORKER=true/false (default: false)
- REFLECTION_WORKER_INTERVAL_SEC (default: 86400)
"""
from __future__ import annotations
import os
import time
import threading
from typing import Optional
from app.utils.logger import get_logger
from .reflection import ReflectionService
logger = get_logger(__name__)
class ReflectionWorker:
def __init__(self):
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
def start(self):
if self._thread and self._thread.is_alive():
return
interval = int(os.getenv("REFLECTION_WORKER_INTERVAL_SEC", "86400") or 86400)
interval = max(60, interval) # at least 1 minute
def _run():
logger.info(f"Reflection worker started (interval={interval}s)")
svc = ReflectionService()
# Initial small delay to avoid fighting startup spikes
time.sleep(3)
while not self._stop.is_set():
try:
svc.run_verification_cycle()
except Exception as e:
logger.error(f"Reflection worker cycle failed: {e}")
# Sleep in small steps to react to stop quickly
remaining = interval
while remaining > 0 and not self._stop.is_set():
step = min(5, remaining)
time.sleep(step)
remaining -= step
logger.info("Reflection worker stopped")
self._thread = threading.Thread(target=_run, name="ReflectionWorker", daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
t = self._thread
if t and t.is_alive():
try:
t.join(timeout=5)
except Exception:
pass
@@ -1,204 +0,0 @@
"""
Researcher agents.
Includes: bull researcher and bear researcher.
"""
import json
from typing import Dict, Any
from .base_agent import BaseAgent
from app.services.llm import LLMService
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
class BullResearcher(BaseAgent):
"""Bullish researcher."""
def __init__(self, memory=None):
super().__init__("BullResearcher", memory)
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Construct the bull case."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
# Inputs
market_report = context.get('market_report', {})
fundamental_report = context.get('fundamental_report', {})
news_report = context.get('news_report', {})
sentiment_report = context.get('sentiment_report', {})
# Memory
situation = f"{market}:{symbol} bull case"
memory_meta = {
"market": market,
"symbol": symbol,
"timeframe": context.get("timeframe"),
"features": context.get("memory_features") or {},
}
memories = self.get_memories(situation, n_matches=None, metadata=memory_meta)
memory_prompt = self.format_memories_for_prompt(memories)
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a Bullish Analyst, constructing a bullish argument for an investment decision. Your tasks are:
{lang_instruction}
1. Highlight growth potential, competitive advantages, and positive market indicators.
2. Use the provided research and data to build a strong argument.
3. Effectively address/counter bearish viewpoints.
4. Learn from historical experience: {memory_prompt}
5. **Confidence Score**: Evaluate your confidence in the bullish case (0-100). Be realistic. If the data is mixed or weak, lower your confidence. Do NOT default to 75.
Please return in JSON format as follows:
{{
"argument": "Detailed bullish argument...",
"key_points": ["Point 1", "Point 2", "Point 3"],
"confidence": 75
}}"""
user_prompt = f"""Based on the following analysis reports, construct a bullish argument for {symbol} in {market} market:
**Market Technical Analysis:**
{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'}
**Fundamental Analysis:**
{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'}
**News Analysis:**
{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'}
**Sentiment Analysis:**
{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'}
Please construct a strong bullish argument, emphasizing growth potential, competitive advantages, and positive indicators."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"argument": "", "key_points": [], "confidence": 50},
model=model
)
return {
"type": "bull",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
class BearResearcher(BaseAgent):
"""Bearish researcher."""
def __init__(self, memory=None):
super().__init__("BearResearcher", memory)
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Construct the bear case."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
# Inputs
market_report = context.get('market_report', {})
fundamental_report = context.get('fundamental_report', {})
news_report = context.get('news_report', {})
sentiment_report = context.get('sentiment_report', {})
risk_report = context.get('risk_report', {})
# Bull argument (if present)
bull_argument = context.get('bull_argument', '')
# Memory
situation = f"{market}:{symbol} bear case"
memory_meta = {
"market": market,
"symbol": symbol,
"timeframe": context.get("timeframe"),
"features": context.get("memory_features") or {},
}
memories = self.get_memories(situation, n_matches=None, metadata=memory_meta)
memory_prompt = self.format_memories_for_prompt(memories)
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a Bearish Analyst, constructing a bearish argument for an investment decision. Your tasks are:
{lang_instruction}
1. Identify risks, challenges, and negative indicators.
2. Use the provided research and data to build a strong argument.
3. Effectively address/counter bullish viewpoints.
4. Learn from historical experience: {memory_prompt}
5. **Confidence Score**: Evaluate your confidence in the bearish case (0-100). Be realistic. If the data is mixed or weak, lower your confidence. Do NOT default to 75.
Please return in JSON format as follows:
{{
"argument": "Detailed bearish argument...",
"key_points": ["Point 1", "Point 2", "Point 3"],
"confidence": 75
}}"""
# Bull argument section (avoid backslashes in f-string expression)
bull_argument_section = f"**Bullish Argument (Needs Rebuttal):**\n{bull_argument}" if bull_argument else ""
user_prompt = f"""Based on the following analysis reports, construct a bearish argument for {symbol} in {market} market:
**Market Technical Analysis:**
{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'}
**Fundamental Analysis:**
{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'}
**News Analysis:**
{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'}
**Sentiment Analysis:**
{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'}
**Risk Analysis:**
{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'}
{bull_argument_section}
Please construct a strong bearish argument, emphasizing risks, challenges, and negative indicators."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"argument": "", "key_points": [], "confidence": 50},
model=model
)
return {
"type": "bear",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
@@ -1,206 +0,0 @@
"""
Risk debate agents.
Includes: aggressive / neutral / conservative risk analysts.
"""
import json
from typing import Dict, Any
from .base_agent import BaseAgent
from app.services.llm import LLMService
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
class RiskyAnalyst(BaseAgent):
"""Aggressive risk analyst."""
def __init__(self, memory=None):
super().__init__("RiskyAnalyst", memory)
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Analyze risk from an aggressive perspective."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
trader_plan = context.get('trader_plan', {})
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are an Aggressive Risk Analyst. You tend to:
{lang_instruction}
1. Emphasize high return potential, even with higher risks.
2. Believe current risks are controllable and worth taking.
3. Support aggressive trading strategies.
Please return in JSON format as follows:
{{
"argument": "Aggressive risk analysis argument...",
"risk_assessment": "Risk controllable, high return potential",
"recommendation": "Support trading plan"
}}"""
user_prompt = f"""Perform aggressive risk analysis for {symbol} in {market} market.
**Trading Plan:**
{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'}
Please analyze risk from an aggressive perspective, emphasizing return potential."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"argument": "", "risk_assessment": "", "recommendation": ""},
model=model
)
return {
"type": "risky",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
class NeutralAnalyst(BaseAgent):
"""Neutral risk analyst."""
def __init__(self, memory=None):
super().__init__("NeutralAnalyst", memory)
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Analyze risk from a neutral perspective."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
trader_plan = context.get('trader_plan', {})
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a Neutral Risk Analyst. You tend to:
{lang_instruction}
1. Balance risk and return.
2. Objectively evaluate various possibilities.
3. Provide neutral risk advice.
Please return in JSON format as follows:
{{
"argument": "Neutral risk analysis argument...",
"risk_assessment": "Balance between risk and return",
"recommendation": "Cautiously execute trading plan"
}}"""
user_prompt = f"""Perform neutral risk analysis for {symbol} in {market} market.
**Trading Plan:**
{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'}
Please analyze risk from a neutral perspective, balancing risk and return."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"argument": "", "risk_assessment": "", "recommendation": ""},
model=model
)
return {
"type": "neutral",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
class SafeAnalyst(BaseAgent):
"""Conservative risk analyst."""
def __init__(self, memory=None):
super().__init__("SafeAnalyst", memory)
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Analyze risk from a conservative perspective."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
trader_plan = context.get('trader_plan', {})
risk_report = context.get('risk_report', {})
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a Conservative Risk Analyst. You tend to:
{lang_instruction}
1. Emphasize risk control, prioritizing capital protection.
2. Identify potential risk points.
3. Suggest cautious or conservative trading strategies.
Please return in JSON format as follows:
{{
"argument": "Conservative risk analysis argument...",
"risk_assessment": "High risk exists, suggest caution",
"recommendation": "Suggest reducing position or suspending trading"
}}"""
user_prompt = f"""Perform conservative risk analysis for {symbol} in {market} market.
**Trading Plan:**
{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'}
**Risk Analysis Report:**
{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'}
Please analyze risk from a conservative perspective, emphasizing risk control."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{"argument": "", "risk_assessment": "", "recommendation": ""},
model=model
)
return {
"type": "safe",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
@@ -1,626 +0,0 @@
"""
Agent tools.
Provides data fetching helpers for the multi-agent analysis pipeline.
All docstrings/log messages in this module are English. Output language of AI reports
is controlled by the `language` value passed through the analysis context.
"""
from typing import Dict, Any, Optional, List
from datetime import datetime, timedelta
import os
import time
import pandas as pd
import yfinance as yf
import finnhub
import ccxt
import requests
from app.utils.logger import get_logger
from app.config import APIKeys
from app.services.search import SearchService
logger = get_logger(__name__)
class AgentTools:
"""A thin wrapper around various public data sources used by agents."""
def __init__(self):
self.search_service = SearchService()
self.finnhub_client = None
if APIKeys.is_configured('FINNHUB_API_KEY'):
try:
self.finnhub_client = finnhub.Client(api_key=APIKeys.FINNHUB_API_KEY)
except Exception as e:
# Safe logging to avoid cascading errors during exception handling
try:
logger.warning(f"Finnhub init failed: {e}")
except Exception:
# Fallback to print if logging fails
print(f"Warning: Finnhub init failed: {e}")
# Optional dependency: akshare (A-share fundamentals/company info)
try:
import akshare as ak # type: ignore
self._ak = ak
self._has_akshare = True
except Exception:
self._ak = None
self._has_akshare = False
# AShare spot cache (avoid fetching the full market list repeatedly)
self._ashare_spot_cache = None
self._ashare_spot_cache_ts = 0
self._ashare_spot_cache_ttl = 300 # seconds
def _get_ashare_spot_df(self):
"""Cached AShare spot dataframe via akshare (may be heavy on first load)."""
if not self._akshare_required():
return None
now = int(time.time())
if self._ashare_spot_cache is not None and (now - int(self._ashare_spot_cache_ts)) < int(self._ashare_spot_cache_ttl):
return self._ashare_spot_cache
ak = self._ak
if ak is None or not hasattr(ak, "stock_zh_a_spot_em"):
return None
df = ak.stock_zh_a_spot_em()
self._ashare_spot_cache = df
self._ashare_spot_cache_ts = now
return df
def _ccxt_exchange(self):
"""Create a CCXT exchange client (Binance) with optional proxy support."""
cfg: Dict[str, Any] = {'timeout': 5000, 'enableRateLimit': True}
# Keep proxy behavior consistent with data sources (.env PROXY_* is supported)
from app.config import CCXTConfig
proxy = (CCXTConfig.PROXY or '').strip()
if proxy:
cfg['proxies'] = {'http': proxy, 'https': proxy}
return ccxt.binance(cfg)
def _akshare_required(self) -> bool:
"""Whether akshare is available at runtime."""
return bool(self._has_akshare and self._ak is not None)
def get_stock_data(self, market: str, symbol: str, days: int = 30, timeframe: str = "1d") -> Optional[List[Dict[str, Any]]]:
"""
Get daily Kline data for recent days (best-effort).
Args:
market: Market
symbol: Symbol
days: Days (for daily) / candle count hint (best-effort for intraday)
timeframe: Kline timeframe (best-effort). Common values: 1d, 1h, 4h, 1w
Returns:
List of OHLCV dicts or None
"""
try:
klines = []
tf = (timeframe or "1d").strip().lower()
# Normalize common UI values
tf_map = {
"1d": "1d",
"1day": "1d",
"d": "1d",
"1h": "1h",
"60m": "1h",
"4h": "4h",
"240m": "4h",
"1w": "1wk",
"1wk": "1wk",
"w": "1wk",
}
tf_yf = tf_map.get(tf, "1d")
if market == 'USStock':
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
ticker = yf.Ticker(symbol)
# yfinance supports limited intervals. Fallback to 1d when unsupported.
interval = tf_yf if tf_yf in ["1d", "1h", "1wk"] else "1d"
df = ticker.history(start=start_date, end=end_date, interval=interval)
if not df.empty:
df = df.tail(days).reset_index()
for _, row in df.iterrows():
klines.append({
"time": row['Date'].strftime('%Y-%m-%d'),
"open": round(row['Open'], 4),
"high": round(row['High'], 4),
"low": round(row['Low'], 4),
"close": round(row['Close'], 4),
"volume": int(row['Volume'])
})
return klines
elif market == 'Crypto':
exchange = self._ccxt_exchange()
# Handle symbol format: ETH/USDT -> ETH/USDT, ETH -> ETH/USDT
symbol_pair = symbol if '/' in symbol else f'{symbol}/USDT'
start_time = int((datetime.now() - timedelta(days=days)).timestamp())
# CCXT timeframes: 1d, 1h, 4h ...
ccxt_tf = tf if tf in ["1d", "1h", "4h"] else "1d"
ohlcv = exchange.fetch_ohlcv(symbol_pair, ccxt_tf, since=start_time * 1000, limit=days)
if ohlcv:
for candle in ohlcv:
klines.append({
"time": datetime.fromtimestamp(candle[0] / 1000).strftime('%Y-%m-%d'),
"open": candle[1],
"high": candle[2],
"low": candle[3],
"close": candle[4],
"volume": candle[5]
})
return klines
# CN/HK stocks
if market in ('AShare', 'HShare'):
# Prefer akshare for AShare (requested), fall back to yfinance.
if market == 'AShare' and self._akshare_required():
try:
ak = self._ak
start_date = (datetime.now() - timedelta(days=days + 10)).strftime('%Y%m%d')
end_date = datetime.now().strftime('%Y%m%d')
# akshare returns a dataframe with Chinese column names.
df = ak.stock_zh_a_hist(symbol=symbol, period="daily", start_date=start_date, end_date=end_date, adjust="qfq")
if df is not None and not df.empty:
df = df.tail(days)
for _, row in df.iterrows():
dt = row.get('日期')
# dt can be datetime/date/str
if hasattr(dt, "strftime"):
t = dt.strftime('%Y-%m-%d')
else:
t = str(dt)[:10]
klines.append({
"time": t,
"open": float(row.get('开盘', 0) or 0),
"high": float(row.get('最高', 0) or 0),
"low": float(row.get('最低', 0) or 0),
"close": float(row.get('收盘', 0) or 0),
"volume": float(row.get('成交量', 0) or 0),
})
return klines
except Exception as e:
logger.warning(f"akshare AShare kline failed ({symbol}): {e}")
# yfinance fallback (daily)
if market == 'AShare':
yf_symbol = f"{symbol}.SS" if symbol.startswith('6') else f"{symbol}.SZ"
else:
yf_symbol = f"{symbol.zfill(4)}.HK"
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
ticker = yf.Ticker(yf_symbol)
interval = tf_yf if tf_yf in ["1d", "1h", "1wk"] else "1d"
df = ticker.history(start=start_date, end=end_date, interval=interval)
if not df.empty:
df = df.tail(days).reset_index()
for _, row in df.iterrows():
klines.append({
"time": row['Date'].strftime('%Y-%m-%d'),
"open": round(row['Open'], 4),
"high": round(row['High'], 4),
"low": round(row['Low'], 4),
"close": round(row['Close'], 4),
"volume": int(row['Volume'])
})
return klines
except Exception as e:
logger.error(f"Failed to fetch kline data {market}:{symbol}: {e}")
return None
def get_current_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""Get current price (best-effort)."""
try:
if market == 'USStock' and self.finnhub_client:
quote = self.finnhub_client.quote(symbol)
if quote and quote.get('c'):
return {
"price": quote.get('c', 0),
"change": quote.get('d', 0),
"changePercent": quote.get('dp', 0),
"high": quote.get('h', 0),
"low": quote.get('l', 0),
"open": quote.get('o', 0),
"previousClose": quote.get('pc', 0)
}
elif market == 'Crypto':
exchange = self._ccxt_exchange()
# Handle symbol format: ETH/USDT -> ETH/USDT, ETH -> ETH/USDT
symbol_pair = symbol if '/' in symbol else f'{symbol}/USDT'
ticker = exchange.fetch_ticker(symbol_pair)
if ticker:
return {
"price": ticker.get('last', 0),
"change": ticker.get('change', 0),
"changePercent": ticker.get('percentage', 0),
"high": ticker.get('high', 0),
"low": ticker.get('low', 0),
"open": ticker.get('open', 0),
"volume": ticker.get('quoteVolume', 0)
}
# CN/HK stocks: prefer akshare for AShare (requested)
if market in ('AShare', 'HShare'):
if market == 'AShare' and self._akshare_required():
try:
ak = self._ak
df = self._get_ashare_spot_df()
if df is not None and not df.empty:
row = df[df['代码'] == symbol].iloc[0]
price = float(row.get('最新价', 0) or 0)
change = float(row.get('涨跌额', 0) or 0)
change_pct = float(row.get('涨跌幅', 0) or 0)
high = float(row.get('最高', 0) or 0)
low = float(row.get('最低', 0) or 0)
open_p = float(row.get('今开', 0) or 0)
prev_close = float(row.get('昨收', 0) or 0)
return {
"price": price,
"change": change,
"changePercent": change_pct,
"high": high,
"low": low,
"open": open_p,
"previousClose": prev_close
}
except Exception as e:
logger.warning(f"akshare AShare spot failed ({symbol}): {e}")
# Do not use Tencent for AShare by default (requested). If akshare is not available,
# return None and let the LLM report degrade gracefully.
if market == 'AShare':
if not self._akshare_required():
logger.warning("akshare is not installed; AShare spot price is unavailable.")
return None
# HShare fallback: Tencent quote
symbol_code = f'hk{symbol}'
url = f"http://qt.gtimg.cn/q={symbol_code}"
resp = requests.get(url, timeout=10)
content = resp.content.decode('gbk', errors='ignore')
if '="' in content:
data_str = content.split('="')[1].strip('";\n')
if data_str:
parts = data_str.split('~')
if len(parts) > 32:
return {
"price": float(parts[3]) if parts[3] else 0,
"change": float(parts[31]) if parts[31] else 0,
"changePercent": float(parts[32]) if parts[32] else 0,
"high": float(parts[33]) if len(parts) > 33 and parts[33] else 0,
"low": float(parts[34]) if len(parts) > 34 and parts[34] else 0,
"open": float(parts[5]) if len(parts) > 5 and parts[5] else 0,
"previousClose": float(parts[4]) if parts[4] else 0
}
except Exception as e:
logger.error(f"Failed to fetch current price {market}:{symbol}: {e}")
return None
def get_fundamental_data(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""Get fundamental data (best-effort)."""
try:
if market == 'USStock' and self.finnhub_client:
metrics = self.finnhub_client.company_basic_financials(symbol, 'all')
profile = self.finnhub_client.company_profile2(symbol=symbol)
return {
"metrics": metrics.get('metric', {}),
"profile_metrics": {
"marketCapitalization": profile.get('marketCapitalization', 0),
"currency": profile.get('currency', 'USD'),
"finnhubIndustry": profile.get('finnhubIndustry', ''),
}
}
# AShare fundamentals via akshare (requested)
if market == 'AShare' and self._akshare_required():
ak = self._ak
out: Dict[str, Any] = {"metrics": {}, "profile_metrics": {}}
# 1) Use spot list (fast) for valuation/market cap
try:
df = self._get_ashare_spot_df()
if df is not None and not df.empty:
row = df[df['代码'] == symbol].iloc[0]
out["metrics"].update({
"pe_ttm": row.get('市盈率-动态'),
"pb": row.get('市净率'),
"turnoverRate": row.get('换手率'),
})
out["profile_metrics"].update({
"marketCapitalization": row.get('总市值'),
"floatMarketCap": row.get('流通市值'),
"currency": "CNY",
})
except Exception as e:
logger.debug(f"akshare spot metrics unavailable ({symbol}): {e}")
# 2) Try akshare indicator endpoints (optional, may be slower / may change)
try:
if hasattr(ak, "stock_a_lg_indicator"):
ind_df = ak.stock_a_lg_indicator(symbol=symbol)
if ind_df is not None and not ind_df.empty:
last = ind_df.iloc[-1].to_dict()
out["metrics"].update(last)
except Exception as e:
logger.debug(f"akshare indicator fetch failed ({symbol}): {e}")
return out
except Exception as e:
logger.error(f"Failed to fetch fundamental data {market}:{symbol}: {e}")
return None
def get_company_data(self, market: str, symbol: str, language: str = "en-US") -> Optional[Dict[str, Any]]:
"""Get basic company/project info (best-effort)."""
try:
# 1) Finnhub (mainly for US stocks)
if market == 'USStock' and self.finnhub_client:
profile = self.finnhub_client.company_profile2(symbol=symbol)
if profile:
return {
"name": profile.get('name', symbol),
"ticker": profile.get('ticker', symbol),
"exchange": profile.get('exchange', ''),
"industry": profile.get('finnhubIndustry', ''),
"website": profile.get('weburl', ''),
"marketCapitalization": profile.get('marketCapitalization', 0),
"description": f"Sector: {profile.get('finnhubIndustry', '')}, Country: {profile.get('country', '')}"
}
# 2) Basic info for AShare / HShare / Crypto
elif market in ('AShare', 'HShare', 'Crypto'):
name = symbol
if market == 'AShare':
# Prefer akshare for AShare (requested)
if self._akshare_required():
try:
ak = self._ak
# 1) Individual info (more structured)
if hasattr(ak, "stock_individual_info_em"):
df = ak.stock_individual_info_em(symbol=symbol)
if df is not None and not df.empty and 'item' in df.columns and 'value' in df.columns:
info = {str(r['item']).strip(): r['value'] for _, r in df.iterrows()}
# common keys: 股票简称, 所属行业, 上市时间, 总市值 ...
name = str(info.get('股票简称') or info.get('证券简称') or symbol).strip()
industry = str(info.get('所属行业') or '').strip()
website = str(info.get('公司网址') or '').strip()
market_cap = info.get('总市值') or info.get('总市值(元)') or 0
return {
"name": name or symbol,
"ticker": symbol,
"market": market,
"industry": industry,
"website": website,
"marketCapitalization": market_cap,
"description": f"Industry: {industry}" if industry else ""
}
# 2) Spot list for name
df2 = ak.stock_zh_a_spot_em()
if df2 is not None and not df2.empty:
row = df2[df2['代码'] == symbol].iloc[0]
name = str(row.get('名称') or symbol).strip()
except Exception as e:
logger.debug(f"akshare company info failed ({symbol}): {e}")
# Do not use Tencent for AShare by default (requested).
if not self._akshare_required():
logger.warning("akshare is not installed; AShare company info is limited.")
elif market == 'Crypto':
name = f"{symbol} Cryptocurrency"
# Enrich description via web search (best-effort)
# Query language should follow UI language when possible.
if str(language).lower().startswith('zh'):
search_query = f"{name} {symbol} 公司 简介" if market != 'Crypto' else f"{symbol} 加密 项目 介绍"
else:
search_query = f"{name} {symbol} company profile" if market != 'Crypto' else f"{symbol} crypto project info"
search_results = self.search_service.search(search_query, num_results=1)
description = ""
if search_results:
description = search_results[0].get('snippet', '')
return {
"name": name,
"ticker": symbol,
"market": market,
"description": description
}
except Exception as e:
logger.error(f"Failed to fetch company data {market}:{symbol}: {e}")
return None
def _fetch_page_content(self, url: str) -> str:
"""
Fetch readable page content via Jina Reader.
Args:
url: Target URL
Returns:
Extracted content (markdown-ish), truncated
"""
try:
jina_url = f"https://r.jina.ai/{url}"
# Use a slightly longer timeout for content extraction
response = requests.get(jina_url, timeout=15)
if response.status_code == 200:
content = response.text
# Truncate to avoid huge prompts
if len(content) > 3000:
content = content[:3000] + "..."
return content
return ""
except Exception as e:
logger.warning(f"Jina Reader content fetch failed {url}: {e}")
return ""
def get_news(self, market: str, symbol: str, days: int = 7, company_name: str = None) -> List[Dict[str, Any]]:
"""
Get news items (Finnhub + search engine) and optionally enrich via Jina Reader.
Args:
market: Market
symbol: Symbol/pair
days: Lookback days
company_name: Optional company/project name to improve search
Returns:
List of news items
"""
news_list = []
# 1) Finnhub news (if available)
try:
if self.finnhub_client:
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
raw_news = []
if market == 'USStock':
raw_news = self.finnhub_client.company_news(symbol, _from=start_date, to=end_date)
elif market == 'Crypto':
crypto_symbol = symbol.split('/')[0] if '/' in symbol else symbol
raw_news = self.finnhub_client.crypto_news(crypto_symbol)
else:
raw_news = self.finnhub_client.general_news('general', min_id=0)
if raw_news:
for item in raw_news:
if not item.get('headline') or not item.get('summary'):
continue
news_list.append({
"id": str(item.get('id', '')),
"datetime": datetime.fromtimestamp(item.get('datetime', 0)).strftime('%Y-%m-%d %H:%M'),
"headline": item.get('headline', ''),
"summary": item.get('summary', ''),
"source": f"Finnhub ({item.get('source', '')})",
"url": item.get('url', '')
})
except Exception as e:
logger.warning(f"Finnhub news fetch failed: {e}")
# 2) Supplement with search engine results (useful for non-US markets or specific events)
try:
# Build search query (use company name to improve relevance)
search_query = ""
search_name = company_name if company_name else symbol
# Time restriction for Google CSE
date_restrict = f"d{days}"
if market == 'AShare':
# AShare CN keywords
search_query = f'{search_name} {symbol} (利好 OR 利空 OR 财报 OR 公告 OR 业绩) after:{datetime.now().year-1}'
elif market == 'HShare':
search_query = f'{search_name} {symbol} (港股 OR 股价 OR 业绩) after:{datetime.now().year-1}'
elif market == 'Crypto':
search_query = f'{search_name} {symbol} crypto news analysis'
else:
search_query = f'{search_name} {symbol} stock news'
logger.info(f"Running news search: {search_query}")
# Google CSE uses `dateRestrict` as a separate param; SearchService supports it.
search_results = self.search_service.search(search_query, num_results=10, date_restrict=date_restrict)
for i, item in enumerate(search_results):
# Default: use snippet as summary
summary = f"{item.get('snippet', '')} (Source: {item.get('source', '')})"
# Jina Reader: deep-read only first 2 items to avoid slowdowns
if i < 2 and item.get('link'):
logger.info(f"Deep reading: {item.get('title')}")
full_content = self._fetch_page_content(item.get('link'))
if full_content:
summary = f"Deep content:\n{full_content}\n(Source: {item.get('source', '')})"
news_list.append({
"id": item.get('link', ''), # Use link as a stable id
"datetime": item.get('published', datetime.now().strftime('%Y-%m-%d')), # Fallback to today if missing
"headline": item.get('title', ''),
"summary": summary,
"source": f"Search ({item.get('source', '')})",
"url": item.get('link', '')
})
except Exception as e:
logger.warning(f"Search news failed: {e}")
# Sort by time desc and keep latest items (best-effort; time formats may vary)
news_list.sort(key=lambda x: x.get('datetime', ''), reverse=True)
return news_list[:20]
def calculate_technical_indicators(self, kline_data: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Calculate basic technical indicators from kline data.
Args:
kline_data: List of OHLCV dicts
Returns:
Indicators dict
"""
if not kline_data or len(kline_data) < 20:
return {}
try:
df = pd.DataFrame(kline_data)
df['close'] = pd.to_numeric(df['close'], errors='coerce')
df['high'] = pd.to_numeric(df['high'], errors='coerce')
df['low'] = pd.to_numeric(df['low'], errors='coerce')
df['volume'] = pd.to_numeric(df['volume'], errors='coerce')
indicators = {}
# Moving averages
if len(df) >= 20:
indicators['MA20'] = round(df['close'].tail(20).mean(), 4)
if len(df) >= 50:
indicators['MA50'] = round(df['close'].tail(50).mean(), 4)
# RSI
if len(df) >= 14:
delta = df['close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
indicators['RSI'] = round(rsi.iloc[-1], 2) if not rsi.empty else None
# MACD
if len(df) >= 26:
exp1 = df['close'].ewm(span=12, adjust=False).mean()
exp2 = df['close'].ewm(span=26, adjust=False).mean()
macd = exp1 - exp2
signal = macd.ewm(span=9, adjust=False).mean()
indicators['MACD'] = round(macd.iloc[-1], 4) if not macd.empty else None
indicators['MACD_Signal'] = round(signal.iloc[-1], 4) if not signal.empty else None
indicators['MACD_Histogram'] = round((macd - signal).iloc[-1], 4) if not (macd - signal).empty else None
# Bollinger bands
if len(df) >= 20:
sma = df['close'].rolling(window=20).mean()
std = df['close'].rolling(window=20).std()
indicators['BB_Upper'] = round((sma + 2 * std).iloc[-1], 4) if not sma.empty else None
indicators['BB_Middle'] = round(sma.iloc[-1], 4) if not sma.empty else None
indicators['BB_Lower'] = round((sma - 2 * std).iloc[-1], 4) if not sma.empty else None
return indicators
except Exception as e:
logger.error(f"Failed to calculate technical indicators: {e}")
return {}
@@ -1,135 +0,0 @@
"""
Trader agent.
Synthesizes all analysis outputs and produces a final trading decision.
"""
import json
from typing import Dict, Any
from .base_agent import BaseAgent
from app.services.llm import LLMService
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
class TraderAgent(BaseAgent):
"""Trader agent."""
def __init__(self, memory=None):
super().__init__("TraderAgent", memory)
self.llm_service = LLMService()
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Make a final trading decision."""
market = context.get('market')
symbol = context.get('symbol')
language = context.get('language', 'zh-CN')
model = context.get('model')
# Inputs
market_report = context.get('market_report', {})
fundamental_report = context.get('fundamental_report', {})
news_report = context.get('news_report', {})
sentiment_report = context.get('sentiment_report', {})
risk_report = context.get('risk_report', {})
# Debate outputs
bull_argument = context.get('bull_argument', {})
bear_argument = context.get('bear_argument', {})
research_decision = context.get('research_decision', '')
# Memory
situation = f"{market}:{symbol} trading decision"
memory_meta = {
"market": market,
"symbol": symbol,
"timeframe": context.get("timeframe"),
"features": context.get("memory_features") or {},
}
memories = self.get_memories(situation, n_matches=None, metadata=memory_meta)
memory_prompt = self.format_memories_for_prompt(memories)
lang_instruction = self._get_language_instruction(language)
system_prompt = f"""You are a Trader, needing to make a final trading decision based on all analysis results.
{lang_instruction}
Your tasks:
1. Synthesize analysis results from all dimensions.
2. Consider both bullish and bearish arguments.
3. Make a clear trading decision: BUY, SELL, or HOLD.
4. Provide a detailed trading plan.
5. Learn from historical experience: {memory_prompt}
6. **Confidence Score**: Evaluate your confidence in the decision (0-100). Be realistic. If the signals are mixed, confidence should be lower (e.g., 40-60). Only use high confidence (>80) for very clear strong signals. Do NOT default to 85.
Please return in JSON format as follows:
{{
"decision": "BUY/SELL/HOLD",
"confidence": 85,
"reasoning": "Reason for decision...",
"trading_plan": {{
"entry_price": "Suggested entry price",
"stop_loss": "Stop loss price",
"take_profit": "Take profit price",
"position_size": "Suggested position size"
}},
"report": "Detailed trading plan report..."
}}"""
user_prompt = f"""Based on all the following analyses, make a trading decision for {symbol} in {market} market:
**Market Technical Analysis:**
{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'}
**Fundamental Analysis:**
{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'}
**News Analysis:**
{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'}
**Sentiment Analysis:**
{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'}
**Risk Analysis:**
{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'}
**Bullish Argument:**
{json.dumps(bull_argument.get('data', {}), ensure_ascii=False, indent=2) if bull_argument else 'No Data'}
**Bearish Argument:**
{json.dumps(bear_argument.get('data', {}), ensure_ascii=False, indent=2) if bear_argument else 'No Data'}
**Research Manager Decision:**
{research_decision if research_decision else 'No Data'}
Please make a clear trading decision (BUY/SELL/HOLD) and provide a detailed trading plan."""
result = self.llm_service.safe_call_llm(
system_prompt,
user_prompt,
{
"decision": "HOLD",
"confidence": 50,
"reasoning": "",
"trading_plan": {},
"report": "Failed to parse trader decision"
},
model=model
)
return {
"type": "trader",
"data": result
}
def _get_language_instruction(self, language: str) -> str:
language_map = {
'zh-CN': 'Answer in Simplified Chinese.',
'zh-TW': 'Answer in Traditional Chinese.',
'en-US': 'Answer in English.',
'ja-JP': 'Answer in Japanese.',
'ko-KR': 'Answer in Korean.',
'vi-VN': 'Answer in Vietnamese.',
'th-TH': 'Answer in Thai.',
'ar-SA': 'Answer in Arabic.',
'fr-FR': 'Answer in French.',
'de-DE': 'Answer in German.'
}
return language_map.get(language, 'Answer in English.')
-170
View File
@@ -1,170 +0,0 @@
"""
Multi-dimensional analysis service.
Uses OpenRouter via the internal LLMService and the multi-agent coordinator.
Local-only: this project does not implement any paid/credit system itself.
"""
import json
import traceback
from typing import Dict, Any, Optional
from app.utils.logger import get_logger
logger = get_logger(__name__)
class AnalysisService:
"""Multi-dimensional analyzer powered by agent coordinator."""
# Class-level guard to avoid circular-init recursion
_initializing = False
def __init__(self, use_multi_agent: bool = None):
"""
Args:
use_multi_agent: Deprecated; kept for frontend compatibility
"""
# Avoid circular-init recursion
if AnalysisService._initializing:
logger.warning("AnalysisService is initializing; skipping duplicate initialization")
self.coordinator = None
return
self.coordinator = None
try:
# Mark initializing
AnalysisService._initializing = True
# Lazy import to avoid circular imports
from app.services.agents.coordinator import AgentCoordinator
import os
enable_memory = os.getenv("ENABLE_AGENT_MEMORY", "true").lower() == "true"
self.coordinator = AgentCoordinator(
enable_memory=enable_memory,
max_debate_rounds=2
)
logger.info("Multi-agent coordinator initialized")
except Exception as e:
logger.error(f"Coordinator init failed: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
self.coordinator = None
finally:
AnalysisService._initializing = False
def analyze(self, market: str, symbol: str, language: str = 'en-US', model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
"""
Args:
market: Market (AShare, USStock, HShare, Crypto, Forex, Futures)
symbol: Symbol
language: Output language tag (e.g. en-US, zh-CN, zh-TW)
model: Optional OpenRouter model id
Returns:
Result dict
"""
logger.info(f"Starting analysis {market}:{symbol}, language={language}, mode=multi-agent")
# Default result structure (keeps frontend compatible even when coordinator fails).
result = {
"overview": {"report": "Initializing..."},
"fundamental": {"report": "Initializing..."},
"technical": {"report": "Initializing..."},
"news": {"report": "Initializing..."},
"sentiment": {"report": "Initializing..."},
"risk": {"report": "Initializing..."},
"error": None
}
if not self.coordinator:
result["error"] = "Analysis service is not ready (coordinator init failed)"
return result
try:
logger.info(f"Run coordinator: {market}:{symbol}")
agent_result = self.coordinator.run_analysis(market, symbol, language, model=model, timeframe=timeframe)
logger.info(f"Coordinator result keys: {list(agent_result.keys())}")
# Validate expected keys (defensive)
debate = agent_result.get("debate", {})
trader_decision = agent_result.get("trader_decision", {})
risk_debate = agent_result.get("risk_debate", {})
final_decision = agent_result.get("final_decision", {})
# Keep frontend-compatible shape and fill defaults if empty
if "debate" in agent_result and "trader_decision" in agent_result and "risk_debate" in agent_result and "final_decision" in agent_result:
if not debate or (isinstance(debate, dict) and len(debate) == 0):
logger.warning("debate is empty; using defaults")
agent_result["debate"] = {"bull": {}, "bear": {}, "research_decision": "Analyzing..."}
if not trader_decision or (isinstance(trader_decision, dict) and len(trader_decision) == 0):
logger.warning("trader_decision is empty; using defaults")
agent_result["trader_decision"] = {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}
if not risk_debate or (isinstance(risk_debate, dict) and len(risk_debate) == 0):
logger.warning("risk_debate is empty; using defaults")
agent_result["risk_debate"] = {"risky": {}, "neutral": {}, "safe": {}}
if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0):
logger.warning("final_decision is empty; using defaults")
agent_result["final_decision"] = {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}
return agent_result
else:
logger.warning("Coordinator result format is incomplete; filling defaults")
return {
"overview": agent_result.get("overview", {"report": "Analyzing..."}),
"fundamental": agent_result.get("fundamental", {"report": "Analyzing..."}),
"technical": agent_result.get("technical", {"report": "Analyzing..."}),
"news": agent_result.get("news", {"report": "Analyzing..."}),
"sentiment": agent_result.get("sentiment", {"report": "Analyzing..."}),
"risk": agent_result.get("risk", {"report": "Analyzing..."}),
"debate": agent_result.get("debate", {"bull": {}, "bear": {}, "research_decision": "Analyzing..."}),
"trader_decision": agent_result.get("trader_decision", {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}),
"risk_debate": agent_result.get("risk_debate", {"risky": {}, "neutral": {}, "safe": {}}),
"final_decision": agent_result.get("final_decision", {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}),
"error": agent_result.get("error")
}
except Exception as e:
error_msg = str(e)
logger.error(f"Analysis failed {market}:{symbol} - {error_msg}")
# If OpenRouter returns 402, it's an upstream billing/credit issue (not a QuantDinger fee).
if "402" in error_msg or "Payment Required" in error_msg:
result["error"] = f"OpenRouter returned 402 (billing/credits). Please check your OpenRouter account. Details: {error_msg}"
else:
result["error"] = f"Analysis failed: {error_msg}"
return result
def multi_analysis(market: str, symbol: str, language: str = 'en-US', use_multi_agent: bool = None) -> Dict[str, Any]:
"""
Convenience entrypoint for multi-dimensional analysis.
Args:
market: Market (AShare, USStock, HShare, Crypto, Forex, Futures)
symbol: Symbol
language: Output language tag
use_multi_agent: Deprecated; kept for compatibility
"""
analyzer = AnalysisService()
return analyzer.analyze(market, symbol, language)
def reflect_analysis(market: str, symbol: str, decision: str, returns: float = None, result: str = None):
"""
Reflection hook: learn from post-trade outcomes (local-only).
Args:
market: Market
symbol: Symbol
decision: Decision (BUY/SELL/HOLD)
returns: Return percentage
result: Free-text outcome
"""
try:
analyzer = AnalysisService()
if analyzer.coordinator:
analyzer.coordinator.reflect_and_learn(market, symbol, decision, returns, result)
logger.info(f"Reflection completed: {market}:{symbol}")
except Exception as e:
logger.error(f"Reflection failed: {e}")
@@ -0,0 +1,555 @@
"""
Analysis Memory System 2.0
Simplified memory for fast analysis service.
Features:
1. Store analysis decisions with market context
2. Retrieve similar historical patterns
3. Track decision outcomes for learning
"""
import json
import time
import hashlib
from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
logger = get_logger(__name__)
def _safe_json_parse(val, default=None):
"""安全解析 JSON - 处理已是 Python 对象或字符串的情况"""
if val is None:
return default
if isinstance(val, (dict, list)):
return val # 已经是 Python 对象 (PostgreSQL JSONB 自动转换)
if isinstance(val, str):
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
return default
return default
class AnalysisMemory:
"""
Simple but effective memory system for AI analysis.
Uses PostgreSQL for persistence.
"""
def __init__(self):
self._ensure_table()
def _ensure_table(self):
"""Create memory table if not exists."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS qd_analysis_memory (
id SERIAL PRIMARY KEY,
market VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
decision VARCHAR(10) NOT NULL,
confidence INT DEFAULT 50,
price_at_analysis DECIMAL(24, 8),
entry_price DECIMAL(24, 8),
stop_loss DECIMAL(24, 8),
take_profit DECIMAL(24, 8),
summary TEXT,
reasons JSONB,
risks JSONB,
scores JSONB,
indicators_snapshot JSONB,
created_at TIMESTAMP DEFAULT NOW(),
validated_at TIMESTAMP,
actual_outcome VARCHAR(20),
actual_return_pct DECIMAL(10, 4),
was_correct BOOLEAN,
user_feedback VARCHAR(20),
feedback_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_symbol
ON qd_analysis_memory(market, symbol);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_created
ON qd_analysis_memory(created_at DESC);
""")
db.commit()
cur.close()
except Exception as e:
logger.warning(f"Memory table creation skipped: {e}")
def store(self, analysis_result: Dict[str, Any]) -> Optional[int]:
"""
Store an analysis result for future reference.
Args:
analysis_result: Result from FastAnalysisService.analyze()
Returns:
Memory ID or None if failed
"""
try:
with get_db_connection() as db:
cur = db.cursor()
# 准备数据
market = analysis_result.get("market")
symbol = analysis_result.get("symbol")
decision = analysis_result.get("decision")
confidence = analysis_result.get("confidence")
price = analysis_result.get("market_data", {}).get("current_price")
entry = analysis_result.get("trading_plan", {}).get("entry_price")
stop = analysis_result.get("trading_plan", {}).get("stop_loss")
take = analysis_result.get("trading_plan", {}).get("take_profit")
summary = analysis_result.get("summary")
reasons = json.dumps(analysis_result.get("reasons", []))
risks = json.dumps(analysis_result.get("risks", []))
scores = json.dumps(analysis_result.get("scores", {}))
indicators = json.dumps(analysis_result.get("indicators", {}))
raw = json.dumps(analysis_result)
cur.execute("""
INSERT INTO qd_analysis_memory (
market, symbol, decision, confidence,
price_at_analysis, entry_price, stop_loss, take_profit,
summary, reasons, risks, scores, indicators_snapshot, raw_result
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (market, symbol, decision, confidence, price, entry, stop, take,
summary, reasons, risks, scores, indicators, raw))
# 使用 lastrowid 属性获取 IDexecute 内部已经处理了 RETURNING
memory_id = cur.lastrowid
db.commit()
cur.close()
logger.info(f"Stored analysis memory #{memory_id} for {symbol}")
return memory_id
except Exception as e:
logger.error(f"Failed to store analysis memory: {e}", exc_info=True)
return None
def get_recent(self, market: str, symbol: str, days: int = 7, limit: int = 5) -> List[Dict]:
"""
Get recent analysis history for a symbol.
Args:
market: Market type
symbol: Symbol
days: Look back period
limit: Max results
Returns:
List of historical analyses
"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(f"""
SELECT
id, decision, confidence, price_at_analysis,
summary, reasons, scores,
created_at, validated_at, was_correct, actual_return_pct
FROM qd_analysis_memory
WHERE market = %s AND symbol = %s
AND created_at > NOW() - INTERVAL '{int(days)} days'
ORDER BY created_at DESC
LIMIT %s
""", (market, symbol, limit))
rows = cur.fetchall() or []
cur.close()
results = []
for row in rows:
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'],
"reasons": _safe_json_parse(row['reasons'], []),
"scores": _safe_json_parse(row['scores'], {}),
"created_at": row['created_at'].isoformat() if row['created_at'] else None,
"was_correct": row['was_correct'],
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
})
return results
except Exception as e:
logger.error(f"Failed to get recent memories: {e}")
return []
def get_all_history(self, user_id: int = None, page: int = 1, page_size: int = 20) -> Dict:
"""
Get all analysis history with pagination.
Args:
user_id: Optional user ID filter (not used currently, for future)
page: Page number (1-indexed)
page_size: Items per page
Returns:
Dict with items list and total count
"""
try:
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# Get total count
cur.execute("SELECT COUNT(*) as cnt FROM qd_analysis_memory")
total_row = cur.fetchone()
total = total_row['cnt'] if total_row else 0
# Get paginated results
cur.execute("""
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
FROM qd_analysis_memory
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""", (page_size, offset))
rows = cur.fetchall() or []
cur.close()
items = []
for row in rows:
items.append({
"id": row['id'],
"market": row['market'],
"symbol": row['symbol'],
"decision": row['decision'],
"confidence": row['confidence'],
"price": float(row['price_at_analysis']) if row['price_at_analysis'] else None,
"summary": row['summary'],
"reasons": _safe_json_parse(row['reasons'], []),
"scores": _safe_json_parse(row['scores'], {}),
"indicators": _safe_json_parse(row['indicators_snapshot'], {}),
"full_result": _safe_json_parse(row['raw_result'], None),
"created_at": row['created_at'].isoformat() if row['created_at'] else None,
"was_correct": row['was_correct'],
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
})
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size
}
except Exception as e:
logger.error(f"Failed to get all history: {e}")
return {"items": [], "total": 0, "page": page, "page_size": page_size}
def delete_history(self, memory_id: int) -> bool:
"""
Delete a history record by ID.
Args:
memory_id: The ID of the analysis memory to delete
Returns:
True if deleted successfully, False otherwise
"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("DELETE FROM qd_analysis_memory WHERE id = %s", (memory_id,))
db.commit()
affected = cur.rowcount
cur.close()
return affected > 0
except Exception as e:
logger.error(f"Failed to delete memory {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
"""
try:
rsi = current_indicators.get("rsi", {}).get("value", 50)
macd_signal = current_indicators.get("macd", {}).get("signal", "neutral")
with get_db_connection() as db:
cur = db.cursor()
# Simple pattern matching query
cur.execute("""
SELECT
id, decision, confidence, price_at_analysis,
summary, reasons, indicators_snapshot,
created_at, was_correct, actual_return_pct
FROM qd_analysis_memory
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
LIMIT %s
""", (market, symbol, limit * 2)) # Get more for filtering
rows = cur.fetchall() or []
cur.close()
results = []
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")
# Simple similarity check
rsi_similar = abs(hist_rsi - rsi) <= 15
macd_similar = hist_macd == macd_signal
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
return results
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.
Args:
memory_id: Analysis memory ID
feedback: 'helpful' | 'not_helpful' | 'accurate' | 'inaccurate'
"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("""
UPDATE qd_analysis_memory
SET user_feedback = %s, feedback_at = NOW()
WHERE id = %s
""", (feedback, memory_id))
db.commit()
cur.close()
return True
except Exception as e:
logger.error(f"Failed to record feedback: {e}")
return False
def validate_past_decisions(self, days_ago: int = 7) -> Dict[str, Any]:
"""
Validate historical decisions by comparing with actual price movements.
Run this periodically (e.g., daily) to build learning data.
Args:
days_ago: Validate decisions from N days ago
Returns:
Validation statistics
"""
from app.services.market_data_collector import MarketDataCollector
collector = MarketDataCollector()
stats = {
"validated": 0,
"correct": 0,
"incorrect": 0,
"errors": 0,
}
try:
with get_db_connection() as db:
cur = db.cursor()
# Get unvalidated decisions from N days ago
cur.execute(f"""
SELECT id, market, symbol, decision, price_at_analysis
FROM qd_analysis_memory
WHERE validated_at IS NULL
AND created_at < NOW() - INTERVAL '{int(days_ago)} days'
AND created_at > NOW() - INTERVAL '{int(days_ago + 1)} days'
LIMIT 50
""")
rows = cur.fetchall() or []
for row in rows:
try:
# Get current price using MarketDataCollector
current_price = collector._get_price(row['market'], row['symbol'])
if not current_price or current_price <= 0:
continue
analysis_price = float(row['price_at_analysis'])
if analysis_price <= 0:
continue
# Calculate return
return_pct = ((current_price - analysis_price) / analysis_price) * 100
# Determine if decision was correct
decision = row['decision']
was_correct = False
if decision == 'BUY' and return_pct > 2: # 2% threshold
was_correct = True
elif decision == 'SELL' and return_pct < -2:
was_correct = True
elif decision == 'HOLD' and abs(return_pct) <= 5:
was_correct = True
# Update record
cur.execute("""
UPDATE qd_analysis_memory
SET validated_at = NOW(),
actual_return_pct = %s,
was_correct = %s
WHERE id = %s
""", (return_pct, was_correct, row['id']))
stats["validated"] += 1
if was_correct:
stats["correct"] += 1
else:
stats["incorrect"] += 1
except Exception as e:
logger.warning(f"Failed to validate memory {row['id']}: {e}")
stats["errors"] += 1
db.commit()
cur.close()
except Exception as e:
logger.error(f"Validation batch failed: {e}")
accuracy = (stats["correct"] / stats["validated"] * 100) if stats["validated"] > 0 else 0
stats["accuracy_pct"] = round(accuracy, 2)
logger.info(f"Validation completed: {stats}")
return stats
def get_performance_stats(self, market: str = None, symbol: str = None,
days: int = 30) -> Dict[str, Any]:
"""
Get AI performance statistics.
Returns:
Performance metrics for display
"""
try:
with get_db_connection() as db:
cur = db.cursor()
where_clauses = ["validated_at IS NOT NULL"]
params = []
if market:
where_clauses.append("market = %s")
params.append(market)
if symbol:
where_clauses.append("symbol = %s")
params.append(symbol)
# Use f-string for interval since psycopg2 doesn't support placeholder in INTERVAL
where_clauses.append(f"created_at > NOW() - INTERVAL '{int(days)} days'")
where_sql = " AND ".join(where_clauses)
cur.execute(f"""
SELECT
COUNT(*) as total,
SUM(CASE WHEN was_correct = true THEN 1 ELSE 0 END) as correct,
AVG(actual_return_pct) as avg_return,
SUM(CASE WHEN decision = 'BUY' THEN 1 ELSE 0 END) as buy_count,
SUM(CASE WHEN decision = 'SELL' THEN 1 ELSE 0 END) as sell_count,
SUM(CASE WHEN decision = 'HOLD' THEN 1 ELSE 0 END) as hold_count,
SUM(CASE WHEN user_feedback = 'helpful' THEN 1 ELSE 0 END) as helpful_count,
SUM(CASE WHEN user_feedback IS NOT NULL THEN 1 ELSE 0 END) as feedback_count
FROM qd_analysis_memory
WHERE {where_sql}
""", tuple(params) if params else None)
row = cur.fetchone()
cur.close()
if not row or not row['total']:
return {
"total_analyses": 0,
"accuracy_pct": 0,
"avg_return_pct": 0,
"user_satisfaction_pct": 0,
}
total = row['total']
correct = row['correct'] or 0
return {
"total_analyses": total,
"accuracy_pct": round((correct / total * 100) if total > 0 else 0, 2),
"avg_return_pct": round(float(row['avg_return'] or 0), 2),
"decision_distribution": {
"buy": row['buy_count'] or 0,
"sell": row['sell_count'] or 0,
"hold": row['hold_count'] or 0,
},
"user_satisfaction_pct": round(
(row['helpful_count'] / row['feedback_count'] * 100)
if row['feedback_count'] and row['feedback_count'] > 0 else 0, 2
),
"period_days": days,
}
except Exception as e:
logger.error(f"Failed to get performance stats: {e}")
return {
"total_analyses": 0,
"accuracy_pct": 0,
"error": str(e),
}
# Singleton
_memory_instance = None
def get_analysis_memory() -> AnalysisMemory:
"""Get singleton AnalysisMemory instance."""
global _memory_instance
if _memory_instance is None:
_memory_instance = AnalysisMemory()
return _memory_instance
@@ -0,0 +1,941 @@
"""
Community Service - 指标社区服务
处理指标市场购买评论等功能
"""
import time
from decimal import Decimal
from typing import Dict, Any, List, Optional, Tuple
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.services.billing_service import get_billing_service
logger = get_logger(__name__)
class CommunityService:
"""指标社区服务类"""
def __init__(self):
self.billing = get_billing_service()
# ==========================================
# 指标市场
# ==========================================
def get_market_indicators(
self,
page: int = 1,
page_size: int = 12,
keyword: str = None,
pricing_type: str = None, # 'free' / 'paid' / None(all)
sort_by: str = 'newest', # 'newest' / 'hot' / 'price_asc' / 'price_desc' / 'rating'
user_id: int = None # 当前用户ID,用于判断是否已购买
) -> Dict[str, Any]:
"""获取市场上已发布的指标列表"""
offset = (page - 1) * page_size
try:
with get_db_connection() as db:
cur = db.cursor()
# 构建查询条件 - 只显示已发布且审核通过的指标
where_clauses = ["i.publish_to_community = 1", "(i.review_status = 'approved' OR i.review_status IS NULL)"]
params = []
if keyword and keyword.strip():
where_clauses.append("(i.name ILIKE ? OR i.description ILIKE ?)")
search_term = f"%{keyword.strip()}%"
params.extend([search_term, search_term])
if pricing_type == 'free':
where_clauses.append("(i.pricing_type = 'free' OR i.price <= 0)")
elif pricing_type == 'paid':
where_clauses.append("(i.pricing_type != 'free' AND i.price > 0)")
where_sql = " AND ".join(where_clauses)
# 排序
order_map = {
'newest': 'i.created_at DESC',
'hot': 'i.purchase_count DESC, i.view_count DESC',
'price_asc': 'i.price ASC, i.created_at DESC',
'price_desc': 'i.price DESC, i.created_at DESC',
'rating': 'i.avg_rating DESC, i.rating_count DESC'
}
order_sql = order_map.get(sort_by, 'i.created_at DESC')
# 获取总数
count_sql = f"""
SELECT COUNT(*) as count
FROM qd_indicator_codes i
WHERE {where_sql}
"""
cur.execute(count_sql, tuple(params))
total = cur.fetchone()['count']
# 获取列表(联表查询作者信息)
query_sql = f"""
SELECT
i.id, i.name, i.description, i.pricing_type, i.price,
i.preview_image, i.purchase_count, i.avg_rating, i.rating_count,
i.view_count, i.created_at, i.updated_at,
u.id as author_id, u.username as author_username,
u.nickname as author_nickname, u.avatar as author_avatar
FROM qd_indicator_codes i
LEFT JOIN qd_users u ON i.user_id = u.id
WHERE {where_sql}
ORDER BY {order_sql}
LIMIT ? OFFSET ?
"""
cur.execute(query_sql, tuple(params + [page_size, offset]))
rows = cur.fetchall() or []
# 如果有当前用户,查询已购买的指标
purchased_ids = set()
if user_id:
indicator_ids = [r['id'] for r in rows]
if indicator_ids:
placeholders = ','.join(['?'] * len(indicator_ids))
cur.execute(
f"SELECT indicator_id FROM qd_indicator_purchases WHERE buyer_id = ? AND indicator_id IN ({placeholders})",
tuple([user_id] + indicator_ids)
)
purchased_ids = {r['indicator_id'] for r in (cur.fetchall() or [])}
cur.close()
# 格式化返回数据
items = []
for row in rows:
items.append({
'id': row['id'],
'name': row['name'],
'description': row['description'][:200] if row['description'] else '',
'pricing_type': row['pricing_type'] or 'free',
'price': float(row['price'] or 0),
'preview_image': row['preview_image'] or '',
'purchase_count': row['purchase_count'] or 0,
'avg_rating': float(row['avg_rating'] or 0),
'rating_count': row['rating_count'] or 0,
'view_count': row['view_count'] or 0,
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
'author': {
'id': row['author_id'],
'username': row['author_username'],
'nickname': row['author_nickname'] or row['author_username'],
'avatar': row['author_avatar'] or '/avatar2.jpg'
},
'is_purchased': row['id'] in purchased_ids,
'is_own': row['author_id'] == user_id
})
return {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size if total > 0 else 0
}
except Exception as e:
logger.error(f"get_market_indicators failed: {e}")
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
def get_indicator_detail(self, indicator_id: int, user_id: int = None) -> Optional[Dict[str, Any]]:
"""获取指标详情"""
try:
with get_db_connection() as db:
cur = db.cursor()
# 获取指标信息
cur.execute("""
SELECT
i.id, i.name, i.description, i.pricing_type, i.price,
i.preview_image, i.purchase_count, i.avg_rating, i.rating_count,
i.view_count, i.publish_to_community, i.created_at, i.updated_at,
i.user_id,
u.id as author_id, u.username as author_username,
u.nickname as author_nickname, u.avatar as author_avatar
FROM qd_indicator_codes i
LEFT JOIN qd_users u ON i.user_id = u.id
WHERE i.id = ?
""", (indicator_id,))
row = cur.fetchone()
if not row:
cur.close()
return None
# 检查是否已发布到社区(或者是自己的指标)
if not row['publish_to_community'] and row['user_id'] != user_id:
cur.close()
return None
# 检查是否已购买
is_purchased = False
if user_id:
cur.execute(
"SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?",
(indicator_id, user_id)
)
is_purchased = cur.fetchone() is not None
# 增加浏览次数
cur.execute(
"UPDATE qd_indicator_codes SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?",
(indicator_id,)
)
db.commit()
cur.close()
return {
'id': row['id'],
'name': row['name'],
'description': row['description'] or '',
'pricing_type': row['pricing_type'] or 'free',
'price': float(row['price'] or 0),
'preview_image': row['preview_image'] or '',
'purchase_count': row['purchase_count'] or 0,
'avg_rating': float(row['avg_rating'] or 0),
'rating_count': row['rating_count'] or 0,
'view_count': (row['view_count'] or 0) + 1,
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
'updated_at': row['updated_at'].isoformat() if row['updated_at'] else None,
'author': {
'id': row['author_id'],
'username': row['author_username'],
'nickname': row['author_nickname'] or row['author_username'],
'avatar': row['author_avatar'] or '/avatar2.jpg'
},
'is_purchased': is_purchased,
'is_own': row['user_id'] == user_id
}
except Exception as e:
logger.error(f"get_indicator_detail failed: {e}")
return None
# ==========================================
# 购买功能
# ==========================================
def purchase_indicator(self, buyer_id: int, indicator_id: int) -> Tuple[bool, str, Dict[str, Any]]:
"""
购买指标
Returns:
(success, message, data)
"""
try:
with get_db_connection() as db:
cur = db.cursor()
# 1. 获取指标信息
cur.execute("""
SELECT id, user_id, name, code, description, pricing_type, price,
preview_image, is_encrypted
FROM qd_indicator_codes
WHERE id = ? AND publish_to_community = 1
""", (indicator_id,))
indicator = cur.fetchone()
if not indicator:
cur.close()
return False, 'indicator_not_found', {}
seller_id = indicator['user_id']
price = float(indicator['price'] or 0)
pricing_type = indicator['pricing_type'] or 'free'
# 2. 检查是否购买自己的指标
if seller_id == buyer_id:
cur.close()
return False, 'cannot_buy_own', {}
# 3. 检查是否已购买
cur.execute(
"SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?",
(indicator_id, buyer_id)
)
if cur.fetchone():
cur.close()
return False, 'already_purchased', {}
# 4. 如果是付费指标,检查并扣除积分
if pricing_type != 'free' and price > 0:
buyer_credits = self.billing.get_user_credits(buyer_id)
if buyer_credits < price:
cur.close()
return False, 'insufficient_credits', {
'required': price,
'current': float(buyer_credits)
}
# 扣除买家积分
new_buyer_balance = buyer_credits - Decimal(str(price))
cur.execute(
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
(float(new_buyer_balance), buyer_id)
)
# 记录买家积分日志
cur.execute("""
INSERT INTO qd_credits_log
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
VALUES (?, 'indicator_purchase', ?, ?, 'indicator_purchase', ?, ?, NOW())
""", (buyer_id, -price, float(new_buyer_balance), str(indicator_id),
f"购买指标: {indicator['name']}"))
# 给卖家增加积分(可配置抽成比例,这里先100%给卖家)
seller_credits = self.billing.get_user_credits(seller_id)
new_seller_balance = seller_credits + Decimal(str(price))
cur.execute(
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
(float(new_seller_balance), seller_id)
)
# 记录卖家积分日志
cur.execute("""
INSERT INTO qd_credits_log
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
VALUES (?, 'indicator_sale', ?, ?, 'indicator_sale', ?, ?, NOW())
""", (seller_id, price, float(new_seller_balance), str(indicator_id),
f"出售指标: {indicator['name']}"))
# 5. 创建购买记录
cur.execute("""
INSERT INTO qd_indicator_purchases
(indicator_id, buyer_id, seller_id, price, created_at)
VALUES (?, ?, ?, ?, NOW())
""", (indicator_id, buyer_id, seller_id, price))
# 6. 复制指标到买家账户
now_ts = int(time.time())
cur.execute("""
INSERT INTO qd_indicator_codes
(user_id, is_buy, end_time, name, code, description,
publish_to_community, pricing_type, price, is_encrypted, preview_image,
createtime, updatetime, created_at, updated_at)
VALUES (?, 1, 0, ?, ?, ?, 0, 'free', 0, ?, ?, ?, ?, NOW(), NOW())
""", (
buyer_id,
indicator['name'],
indicator['code'],
indicator['description'],
indicator['is_encrypted'] or 0,
indicator['preview_image'],
now_ts, now_ts
))
# 7. 更新指标购买次数
cur.execute("""
UPDATE qd_indicator_codes
SET purchase_count = COALESCE(purchase_count, 0) + 1
WHERE id = ?
""", (indicator_id,))
db.commit()
cur.close()
logger.info(f"User {buyer_id} purchased indicator {indicator_id} for {price} credits")
return True, 'success', {'indicator_name': indicator['name'], 'price': price}
except Exception as e:
logger.error(f"purchase_indicator failed: {e}")
return False, f'error: {str(e)}', {}
def get_my_purchases(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""获取用户购买的指标列表"""
offset = (page - 1) * page_size
try:
with get_db_connection() as db:
cur = db.cursor()
# 获取总数
cur.execute(
"SELECT COUNT(*) as count FROM qd_indicator_purchases WHERE buyer_id = ?",
(user_id,)
)
total = cur.fetchone()['count']
# 获取列表
cur.execute("""
SELECT
p.id as purchase_id, p.price as purchase_price, p.created_at as purchase_time,
i.id, i.name, i.description, i.preview_image, i.avg_rating,
u.nickname as seller_nickname, u.avatar as seller_avatar
FROM qd_indicator_purchases p
LEFT JOIN qd_indicator_codes i ON p.indicator_id = i.id
LEFT JOIN qd_users u ON p.seller_id = u.id
WHERE p.buyer_id = ?
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?
""", (user_id, page_size, offset))
rows = cur.fetchall() or []
cur.close()
items = []
for row in rows:
items.append({
'purchase_id': row['purchase_id'],
'purchase_price': float(row['purchase_price'] or 0),
'purchase_time': row['purchase_time'].isoformat() if row['purchase_time'] else None,
'indicator': {
'id': row['id'],
'name': row['name'],
'description': row['description'][:100] if row['description'] else '',
'preview_image': row['preview_image'] or '',
'avg_rating': float(row['avg_rating'] or 0)
},
'seller': {
'nickname': row['seller_nickname'],
'avatar': row['seller_avatar'] or '/avatar2.jpg'
}
})
return {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size if total > 0 else 0
}
except Exception as e:
logger.error(f"get_my_purchases failed: {e}")
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
# ==========================================
# 评论功能
# ==========================================
def get_comments(self, indicator_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""获取指标评论列表"""
offset = (page - 1) * page_size
try:
with get_db_connection() as db:
cur = db.cursor()
# 获取总数(只统计一级评论)
cur.execute("""
SELECT COUNT(*) as count FROM qd_indicator_comments
WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0
""", (indicator_id,))
total = cur.fetchone()['count']
# 获取评论列表
cur.execute("""
SELECT
c.id, c.rating, c.content, c.created_at,
u.id as user_id, u.nickname, u.avatar
FROM qd_indicator_comments c
LEFT JOIN qd_users u ON c.user_id = u.id
WHERE c.indicator_id = ? AND c.parent_id IS NULL AND c.is_deleted = 0
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?
""", (indicator_id, page_size, offset))
rows = cur.fetchall() or []
cur.close()
items = []
for row in rows:
items.append({
'id': row['id'],
'rating': row['rating'],
'content': row['content'],
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
'user': {
'id': row['user_id'],
'nickname': row['nickname'],
'avatar': row['avatar'] or '/avatar2.jpg'
}
})
return {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size if total > 0 else 0
}
except Exception as e:
logger.error(f"get_comments failed: {e}")
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
def add_comment(
self,
user_id: int,
indicator_id: int,
rating: int,
content: str
) -> Tuple[bool, str, Dict[str, Any]]:
"""
添加评论只有购买过的用户可以评论且只能评论一次
"""
try:
# 验证评分范围
rating = max(1, min(5, int(rating)))
content = (content or '').strip()[:500] # 限制500字
with get_db_connection() as db:
cur = db.cursor()
# 检查指标是否存在
cur.execute(
"SELECT id, user_id FROM qd_indicator_codes WHERE id = ? AND publish_to_community = 1",
(indicator_id,)
)
indicator = cur.fetchone()
if not indicator:
cur.close()
return False, 'indicator_not_found', {}
# 不能评论自己的指标
if indicator['user_id'] == user_id:
cur.close()
return False, 'cannot_comment_own', {}
# 检查是否已购买(免费指标也需要"获取"才能评论)
cur.execute(
"SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?",
(indicator_id, user_id)
)
if not cur.fetchone():
cur.close()
return False, 'not_purchased', {}
# 检查是否已评论
cur.execute(
"SELECT id FROM qd_indicator_comments WHERE indicator_id = ? AND user_id = ? AND parent_id IS NULL",
(indicator_id, user_id)
)
if cur.fetchone():
cur.close()
return False, 'already_commented', {}
# 添加评论
cur.execute("""
INSERT INTO qd_indicator_comments
(indicator_id, user_id, rating, content, created_at, updated_at)
VALUES (?, ?, ?, ?, NOW(), NOW())
""", (indicator_id, user_id, rating, content))
comment_id = cur.lastrowid
# 更新指标的评分统计
cur.execute("""
UPDATE qd_indicator_codes
SET
rating_count = COALESCE(rating_count, 0) + 1,
avg_rating = (
SELECT AVG(rating) FROM qd_indicator_comments
WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0
)
WHERE id = ?
""", (indicator_id, indicator_id))
db.commit()
cur.close()
logger.info(f"User {user_id} commented on indicator {indicator_id} with rating {rating}")
return True, 'success', {'comment_id': comment_id}
except Exception as e:
logger.error(f"add_comment failed: {e}")
return False, f'error: {str(e)}', {}
def update_comment(
self,
user_id: int,
comment_id: int,
indicator_id: int,
rating: int,
content: str
) -> Tuple[bool, str, Dict[str, Any]]:
"""
更新评论只能修改自己的评论
"""
try:
rating = max(1, min(5, int(rating)))
content = (content or '').strip()[:500]
with get_db_connection() as db:
cur = db.cursor()
# 检查评论是否存在且属于当前用户
cur.execute("""
SELECT id, rating as old_rating FROM qd_indicator_comments
WHERE id = ? AND user_id = ? AND indicator_id = ? AND is_deleted = 0
""", (comment_id, user_id, indicator_id))
comment = cur.fetchone()
if not comment:
cur.close()
return False, 'comment_not_found', {}
old_rating = comment['old_rating']
# 更新评论
cur.execute("""
UPDATE qd_indicator_comments
SET rating = ?, content = ?, updated_at = NOW()
WHERE id = ?
""", (rating, content, comment_id))
# 如果评分变了,更新指标的平均评分
if old_rating != rating:
cur.execute("""
UPDATE qd_indicator_codes
SET avg_rating = (
SELECT AVG(rating) FROM qd_indicator_comments
WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0
)
WHERE id = ?
""", (indicator_id, indicator_id))
db.commit()
cur.close()
logger.info(f"User {user_id} updated comment {comment_id}")
return True, 'success', {'comment_id': comment_id}
except Exception as e:
logger.error(f"update_comment failed: {e}")
return False, f'error: {str(e)}', {}
def get_user_comment(self, user_id: int, indicator_id: int) -> Optional[Dict[str, Any]]:
"""获取用户对某个指标的评论"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("""
SELECT id, rating, content, created_at, updated_at
FROM qd_indicator_comments
WHERE user_id = ? AND indicator_id = ? AND parent_id IS NULL AND is_deleted = 0
""", (user_id, indicator_id))
row = cur.fetchone()
cur.close()
if not row:
return None
return {
'id': row['id'],
'rating': row['rating'],
'content': row['content'],
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
'updated_at': row['updated_at'].isoformat() if row['updated_at'] else None
}
except Exception as e:
logger.error(f"get_user_comment failed: {e}")
return None
# ==========================================
# 管理员审核功能
# ==========================================
def get_pending_indicators(
self,
page: int = 1,
page_size: int = 20,
review_status: str = 'pending' # 'pending' / 'approved' / 'rejected' / 'all'
) -> Dict[str, Any]:
"""获取待审核的指标列表(管理员用)"""
offset = (page - 1) * page_size
try:
with get_db_connection() as db:
cur = db.cursor()
# 构建查询条件
where_clauses = ["i.publish_to_community = 1"]
params = []
if review_status and review_status != 'all':
where_clauses.append("i.review_status = ?")
params.append(review_status)
where_sql = " AND ".join(where_clauses)
# 获取总数
count_sql = f"""
SELECT COUNT(*) as count
FROM qd_indicator_codes i
WHERE {where_sql}
"""
cur.execute(count_sql, tuple(params))
total = cur.fetchone()['count']
# 获取列表
query_sql = f"""
SELECT
i.id, i.name, i.description, i.pricing_type, i.price,
i.preview_image, i.code, i.review_status, i.review_note,
i.reviewed_at, i.reviewed_by, i.created_at,
u.id as author_id, u.username as author_username,
u.nickname as author_nickname, u.avatar as author_avatar,
r.username as reviewer_username
FROM qd_indicator_codes i
LEFT JOIN qd_users u ON i.user_id = u.id
LEFT JOIN qd_users r ON i.reviewed_by = r.id
WHERE {where_sql}
ORDER BY i.created_at DESC
LIMIT ? OFFSET ?
"""
cur.execute(query_sql, tuple(params + [page_size, offset]))
rows = cur.fetchall() or []
cur.close()
items = []
for row in rows:
items.append({
'id': row['id'],
'name': row['name'],
'description': row['description'][:300] if row['description'] else '',
'pricing_type': row['pricing_type'] or 'free',
'price': float(row['price'] or 0),
'preview_image': row['preview_image'] or '',
'code': row['code'] or '', # 管理员可以看代码
'review_status': row['review_status'] or 'pending',
'review_note': row['review_note'] or '',
'reviewed_at': row['reviewed_at'].isoformat() if row['reviewed_at'] else None,
'reviewer_username': row['reviewer_username'],
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
'author': {
'id': row['author_id'],
'username': row['author_username'],
'nickname': row['author_nickname'] or row['author_username'],
'avatar': row['author_avatar'] or '/avatar2.jpg'
}
})
return {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size if total > 0 else 0
}
except Exception as e:
logger.error(f"get_pending_indicators failed: {e}")
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
def review_indicator(
self,
admin_id: int,
indicator_id: int,
action: str, # 'approve' / 'reject'
note: str = ''
) -> Tuple[bool, str]:
"""审核指标"""
try:
new_status = 'approved' if action == 'approve' else 'rejected'
note = (note or '').strip()[:500]
with get_db_connection() as db:
cur = db.cursor()
# 检查指标是否存在且已发布到社区
cur.execute("""
SELECT id, name, user_id FROM qd_indicator_codes
WHERE id = ? AND publish_to_community = 1
""", (indicator_id,))
indicator = cur.fetchone()
if not indicator:
cur.close()
return False, 'indicator_not_found'
# 更新审核状态
cur.execute("""
UPDATE qd_indicator_codes
SET review_status = ?, review_note = ?, reviewed_at = NOW(), reviewed_by = ?
WHERE id = ?
""", (new_status, note, admin_id, indicator_id))
db.commit()
cur.close()
logger.info(f"Admin {admin_id} {action}d indicator {indicator_id}")
return True, 'success'
except Exception as e:
logger.error(f"review_indicator failed: {e}")
return False, f'error: {str(e)}'
def unpublish_indicator(self, admin_id: int, indicator_id: int, note: str = '') -> Tuple[bool, str]:
"""下架指标(取消发布)"""
try:
note = (note or '').strip()[:500]
with get_db_connection() as db:
cur = db.cursor()
# 检查指标是否存在
cur.execute("""
SELECT id, name FROM qd_indicator_codes WHERE id = ?
""", (indicator_id,))
indicator = cur.fetchone()
if not indicator:
cur.close()
return False, 'indicator_not_found'
# 下架(取消发布)
cur.execute("""
UPDATE qd_indicator_codes
SET publish_to_community = 0, review_status = 'rejected',
review_note = ?, reviewed_at = NOW(), reviewed_by = ?
WHERE id = ?
""", (f"下架: {note}" if note else "管理员下架", admin_id, indicator_id))
db.commit()
cur.close()
logger.info(f"Admin {admin_id} unpublished indicator {indicator_id}")
return True, 'success'
except Exception as e:
logger.error(f"unpublish_indicator failed: {e}")
return False, f'error: {str(e)}'
def admin_delete_indicator(self, admin_id: int, indicator_id: int) -> Tuple[bool, str]:
"""管理员删除指标"""
try:
with get_db_connection() as db:
cur = db.cursor()
# 检查指标是否存在
cur.execute("SELECT id, name FROM qd_indicator_codes WHERE id = ?", (indicator_id,))
indicator = cur.fetchone()
if not indicator:
cur.close()
return False, 'indicator_not_found'
# 删除关联的评论
cur.execute("DELETE FROM qd_indicator_comments WHERE indicator_id = ?", (indicator_id,))
# 删除关联的购买记录
cur.execute("DELETE FROM qd_indicator_purchases WHERE indicator_id = ?", (indicator_id,))
# 删除指标
cur.execute("DELETE FROM qd_indicator_codes WHERE id = ?", (indicator_id,))
db.commit()
cur.close()
logger.info(f"Admin {admin_id} deleted indicator {indicator_id}")
return True, 'success'
except Exception as e:
logger.error(f"admin_delete_indicator failed: {e}")
return False, f'error: {str(e)}'
def get_review_stats(self) -> Dict[str, int]:
"""获取审核统计"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("""
SELECT
COUNT(*) FILTER (WHERE review_status = 'pending' OR review_status IS NULL) as pending_count,
COUNT(*) FILTER (WHERE review_status = 'approved') as approved_count,
COUNT(*) FILTER (WHERE review_status = 'rejected') as rejected_count
FROM qd_indicator_codes
WHERE publish_to_community = 1
""")
row = cur.fetchone()
cur.close()
return {
'pending': row['pending_count'] or 0,
'approved': row['approved_count'] or 0,
'rejected': row['rejected_count'] or 0
}
except Exception as e:
logger.error(f"get_review_stats failed: {e}")
return {'pending': 0, 'approved': 0, 'rejected': 0}
# ==========================================
# 实盘表现(聚合回测数据)
# ==========================================
def get_indicator_performance(self, indicator_id: int) -> Dict[str, Any]:
"""
获取指标的实盘表现统计
目前基于回测数据统计未来可扩展为实盘交易数据
"""
default_result = {
'strategy_count': 0,
'trade_count': 0,
'win_rate': 0,
'total_profit': 0,
'avg_return': 0,
'max_drawdown': 0
}
try:
with get_db_connection() as db:
cur = db.cursor()
# 首先检查回测记录表是否存在
cur.execute("""
SELECT COUNT(*) as cnt FROM information_schema.tables
WHERE table_name = 'qd_backtest_runs'
""")
table_exists = cur.fetchone()
if not table_exists or table_exists['cnt'] == 0:
cur.close()
return default_result
# 从回测记录中统计该指标的表现
# 使用 indicator_id 字段匹配
cur.execute("""
SELECT
COUNT(*) as run_count,
AVG(CASE WHEN total_return IS NOT NULL THEN total_return ELSE 0 END) as avg_return,
AVG(CASE WHEN win_rate IS NOT NULL THEN win_rate ELSE 0 END) as avg_win_rate,
AVG(CASE WHEN max_drawdown IS NOT NULL THEN max_drawdown ELSE 0 END) as avg_drawdown,
SUM(CASE WHEN trade_count IS NOT NULL THEN trade_count ELSE 0 END) as total_trades
FROM qd_backtest_runs
WHERE indicator_id = ?
""", (indicator_id,))
row = cur.fetchone()
cur.close()
if not row or row['run_count'] == 0:
return default_result
return {
'strategy_count': row['run_count'] or 0,
'trade_count': row['total_trades'] or 0,
'win_rate': round(float(row['avg_win_rate'] or 0), 2),
'total_profit': round(float(row['avg_return'] or 0), 2),
'avg_return': round(float(row['avg_return'] or 0), 2),
'max_drawdown': round(float(row['avg_drawdown'] or 0), 2)
}
except Exception as e:
logger.error(f"get_indicator_performance failed: {e}")
return default_result
# 全局单例
_community_service = None
def get_community_service() -> CommunityService:
"""获取社区服务单例"""
global _community_service
if _community_service is None:
_community_service = CommunityService()
return _community_service
@@ -0,0 +1,785 @@
"""
Fast Analysis Service 3.0
系统性重构版本 - 使用统一的数据采集器
核心改进
1. 数据源统一 - 使用 MarketDataCollector与K线模块自选列表完全一致
2. 宏观数据 - 新增美元指数VIX利率等宏观经济指标
3. 多维新闻 - 使用结构化API无需深度阅读
4. 单次LLM调用 - 强约束prompt输出结构化分析
"""
import json
import time
from typing import Dict, Any, Optional, List
from decimal import Decimal, ROUND_HALF_UP
from app.utils.logger import get_logger
from app.services.llm import LLMService
from app.services.market_data_collector import get_market_data_collector
logger = get_logger(__name__)
class FastAnalysisService:
"""
快速分析服务 3.0
架构
1. 数据采集层 - MarketDataCollector (统一数据源)
2. 分析层 - 单次LLM调用 (强约束prompt)
3. 记忆层 - 分析历史存储和检索
"""
def __init__(self):
self.llm_service = LLMService()
self.data_collector = get_market_data_collector()
self._memory_db = None # Lazy init
# ==================== Data Collection Layer ====================
def _collect_market_data(self, market: str, symbol: str, timeframe: str = "1D") -> Dict[str, Any]:
"""
使用统一的数据采集器收集市场数据
数据层次
1. 核心数据: 价格K线技术指标
2. 基本面: 公司信息财务数据
3. 宏观数据: DXYVIXTNX黄金等
4. 情绪数据: 新闻市场情绪
"""
return self.data_collector.collect_all(
market=market,
symbol=symbol,
timeframe=timeframe,
include_macro=True,
include_news=True,
timeout=30
)
def _calculate_indicators(self, kline_data: List[Dict]) -> Dict[str, Any]:
"""
Calculate technical indicators using rules (no LLM).
Returns actionable signals, not raw numbers.
"""
if not kline_data or len(kline_data) < 5:
return {"error": "Insufficient data"}
try:
# Use tools' built-in calculation
raw_indicators = self.tools.calculate_technical_indicators(kline_data)
# Extract key values
closes = [float(k.get("close", 0)) for k in kline_data if k.get("close")]
if not closes:
return {"error": "No close prices"}
current_price = closes[-1]
# RSI interpretation
rsi = raw_indicators.get("RSI", 50)
if rsi < 30:
rsi_signal = "oversold"
rsi_action = "potential_buy"
elif rsi > 70:
rsi_signal = "overbought"
rsi_action = "potential_sell"
else:
rsi_signal = "neutral"
rsi_action = "hold"
# MACD interpretation
macd = raw_indicators.get("MACD", 0)
macd_signal_line = raw_indicators.get("MACD_Signal", 0)
macd_hist = raw_indicators.get("MACD_Hist", 0)
if macd > macd_signal_line and macd_hist > 0:
macd_signal = "bullish"
macd_trend = "golden_cross" if macd_hist > 0 and len(kline_data) > 1 else "bullish"
elif macd < macd_signal_line and macd_hist < 0:
macd_signal = "bearish"
macd_trend = "death_cross" if macd_hist < 0 and len(kline_data) > 1 else "bearish"
else:
macd_signal = "neutral"
macd_trend = "consolidating"
# Moving averages
ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else current_price
ma10 = sum(closes[-10:]) / 10 if len(closes) >= 10 else current_price
ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else current_price
if current_price > ma5 > ma10 > ma20:
ma_trend = "strong_uptrend"
elif current_price > ma20:
ma_trend = "uptrend"
elif current_price < ma5 < ma10 < ma20:
ma_trend = "strong_downtrend"
elif current_price < ma20:
ma_trend = "downtrend"
else:
ma_trend = "sideways"
# Support/Resistance (simple: recent highs/lows)
recent_highs = [float(k.get("high", 0)) for k in kline_data[-14:] if k.get("high")]
recent_lows = [float(k.get("low", 0)) for k in kline_data[-14:] if k.get("low")]
resistance = max(recent_highs) if recent_highs else current_price * 1.05
support = min(recent_lows) if recent_lows else current_price * 0.95
# Volatility (ATR-like)
if len(kline_data) >= 14:
ranges = []
for k in kline_data[-14:]:
h = float(k.get("high", 0))
l = float(k.get("low", 0))
if h > 0 and l > 0:
ranges.append(h - l)
atr = sum(ranges) / len(ranges) if ranges else 0
volatility_pct = (atr / current_price * 100) if current_price > 0 else 0
if volatility_pct > 5:
volatility = "high"
elif volatility_pct > 2:
volatility = "medium"
else:
volatility = "low"
else:
volatility = "unknown"
volatility_pct = 0
return {
"current_price": round(current_price, 6),
"rsi": {
"value": round(rsi, 2),
"signal": rsi_signal,
"action": rsi_action,
},
"macd": {
"value": round(macd, 6),
"signal_line": round(macd_signal_line, 6),
"histogram": round(macd_hist, 6),
"signal": macd_signal,
"trend": macd_trend,
},
"moving_averages": {
"ma5": round(ma5, 6),
"ma10": round(ma10, 6),
"ma20": round(ma20, 6),
"trend": ma_trend,
},
"levels": {
"support": round(support, 6),
"resistance": round(resistance, 6),
},
"volatility": {
"level": volatility,
"pct": round(volatility_pct, 2),
},
"raw": raw_indicators,
}
except Exception as e:
logger.error(f"Indicator calculation failed: {e}")
return {"error": str(e)}
def _format_news_summary(self, news_data: List[Dict], max_items: int = 5) -> str:
"""Format news into a concise summary for the prompt."""
if not news_data:
return "No recent news available."
summaries = []
for item in news_data[:max_items]:
title = item.get("title", item.get("headline", ""))
sentiment = item.get("sentiment", "neutral")
date = item.get("date", item.get("datetime", ""))[:10] if item.get("date") or item.get("datetime") else ""
if title:
summaries.append(f"- [{sentiment}] {title} ({date})")
return "\n".join(summaries) if summaries else "No recent news available."
# ==================== Memory Layer ====================
def _get_memory_context(self, market: str, symbol: str, current_indicators: Dict) -> str:
"""
Retrieve relevant historical analysis for similar market conditions.
"""
try:
from app.services.analysis_memory import get_analysis_memory
memory = get_analysis_memory()
# Get similar patterns
patterns = memory.get_similar_patterns(market, symbol, current_indicators, limit=3)
if not patterns:
return "No similar historical patterns found in memory."
context_lines = ["Historical patterns with similar conditions:"]
for p in patterns:
outcome = ""
if p.get("was_correct") is not None:
outcome = f" (Outcome: {'Correct' if p['was_correct'] else 'Incorrect'}"
if p.get("actual_return_pct"):
outcome += f", Return: {p['actual_return_pct']:.2f}%"
outcome += ")"
context_lines.append(
f"- Decision: {p['decision']} at ${p.get('price', 'N/A')}{outcome}"
)
return "\n".join(context_lines)
except Exception as e:
logger.warning(f"Memory retrieval failed: {e}")
return "Memory retrieval failed."
# ==================== Prompt Engineering ====================
def _build_analysis_prompt(self, data: Dict[str, Any], language: str) -> tuple:
"""
Build the single, comprehensive analysis prompt.
Key: Strong constraints to prevent absurd recommendations.
"""
price_data = data.get("price") or {}
current_price = price_data.get("price", 0) if price_data else 0
change_24h = price_data.get("changePercent", 0) if price_data else 0
# Ensure all data fields have safe defaults (may be None from failed fetches)
indicators = data.get("indicators") or {}
fundamental = data.get("fundamental") or {}
company = data.get("company") or {}
news_summary = self._format_news_summary(data.get("news") or [])
# Language instruction - MUST be enforced strictly
lang_map = {
'zh-CN': '⚠️ 重要:你必须用简体中文回答所有内容,包括summary、key_reasons、risks等所有文本字段。不要使用英文。',
'zh-TW': '⚠️ 重要:你必須用繁體中文回答所有內容,包括summary、key_reasons、risks等所有文本字段。不要使用英文。',
'en-US': '⚠️ IMPORTANT: You MUST answer ALL content in English, including summary, key_reasons, risks, and all text fields. Do NOT use Chinese.',
'ja-JP': '⚠️ 重要:すべての内容を日本語で回答してください。summary、key_reasons、risksなど、すべてのテキストフィールドを日本語で記述してください。',
}
lang_instruction = lang_map.get(language, '⚠️ IMPORTANT: Answer ALL content in English.')
# Get pre-calculated trading levels from technical analysis
levels = indicators.get("levels", {})
trading_levels = indicators.get("trading_levels", {})
volatility = indicators.get("volatility", {})
support = levels.get("support", current_price * 0.95)
resistance = levels.get("resistance", current_price * 1.05)
pivot = levels.get("pivot", current_price)
# Use ATR-based suggestions if available, otherwise use percentage
atr = volatility.get("atr", current_price * 0.02)
suggested_stop_loss = trading_levels.get("suggested_stop_loss", current_price - 2 * atr)
suggested_take_profit = trading_levels.get("suggested_take_profit", current_price + 3 * atr)
risk_reward_ratio = trading_levels.get("risk_reward_ratio", 1.5)
# Price bounds (still enforce max 10% deviation)
if current_price > 0:
price_lower_bound = round(max(suggested_stop_loss, current_price * 0.90), 6)
price_upper_bound = round(min(suggested_take_profit, current_price * 1.10), 6)
entry_range_low = round(current_price * 0.98, 6)
entry_range_high = round(current_price * 1.02, 6)
else:
price_lower_bound = price_upper_bound = entry_range_low = entry_range_high = 0
system_prompt = f"""You are QuantDinger's Senior Financial Analyst with 20+ years of experience.
Provide professional, detailed analysis like a Wall Street analyst report.
{lang_instruction}
📐 TECHNICAL LEVELS (Pre-calculated from chart data):
- Support: ${support} | Resistance: ${resistance} | Pivot: ${pivot}
- ATR (14-day): ${atr:.4f} ({volatility.get('pct', 0)}% volatility)
- Suggested Stop Loss: ${suggested_stop_loss:.4f} (based on 2x ATR below support)
- Suggested Take Profit: ${suggested_take_profit:.4f} (based on 3x ATR above resistance)
- Risk/Reward Ratio: {risk_reward_ratio}
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!
📊 YOUR ANALYSIS MUST INCLUDE:
1. **Technical Analysis**: Interpret the indicators, explain why support/resistance levels matter
2. **Fundamental Analysis**: Evaluate valuation, growth if data available
3. **Sentiment Analysis**: Assess market mood, news impact, macro factors
4. **Risk Assessment**: Explain why the stop loss level is appropriate
5. **Clear Recommendation**: BUY/SELL/HOLD with entry, stop loss (near suggested), take profit (near suggested)
Output ONLY valid JSON (do NOT include word counts or format hints in your actual response):
{{
"decision": "BUY" | "SELL" | "HOLD",
"confidence": 0-100,
"summary": "Executive summary in 2-3 sentences",
"analysis": {{
"technical": "Your detailed technical analysis here - interpret RSI, MACD, MA, support/resistance",
"fundamental": "Your fundamental assessment here - valuation, growth, competitive position",
"sentiment": "Your market sentiment analysis here - news impact, macro factors, mood"
}},
"entry_price": number,
"stop_loss": number,
"take_profit": number,
"position_size_pct": 1-100,
"timeframe": "short" | "medium" | "long",
"key_reasons": ["First key reason for this decision", "Second key reason", "Third key reason"],
"risks": ["Primary risk with potential impact", "Secondary risk"],
"technical_score": 0-100,
"fundamental_score": 0-100,
"sentiment_score": 0-100
}}
IMPORTANT: The analysis fields should contain your ACTUAL analysis text, NOT the format description above."""
# Format indicator data for prompt (ensure safe defaults)
rsi_data = indicators.get("rsi") or {}
macd_data = indicators.get("macd") or {}
ma_data = indicators.get("moving_averages") or {}
vol_data = indicators.get("volatility") or {}
levels = indicators.get("levels") or {}
# Format macro data
macro = data.get("macro") or {}
macro_summary = self._format_macro_summary(macro, data.get("market", ""))
user_prompt = f"""Analyze {data['symbol']} in {data['market']} market.
📊 REAL-TIME DATA:
- Current Price: ${current_price}
- 24h Change: {change_24h}%
- Support: ${support}
- Resistance: ${resistance}
📈 TECHNICAL INDICATORS:
- RSI(14): {rsi_data.get('value', 'N/A')} ({rsi_data.get('signal', 'N/A')})
- MACD: {macd_data.get('signal', 'N/A')} ({macd_data.get('trend', 'N/A')})
- MA Trend: {ma_data.get('trend', 'N/A')}
- Volatility: {vol_data.get('level', 'N/A')} ({vol_data.get('pct', 0)}%)
- Trend: {indicators.get('trend', 'N/A')}
- Price Position (20d): {indicators.get('price_position', 'N/A')}%
🌐 MACRO ENVIRONMENT:
{macro_summary}
📰 MARKET NEWS ({len(data.get('news') or [])} items):
{news_summary}
💼 FUNDAMENTALS:
- Company: {company.get('name', data['symbol'])}
- Industry: {company.get('industry', 'N/A')}
- P/E Ratio: {fundamental.get('pe_ratio', 'N/A')}
- P/B Ratio: {fundamental.get('pb_ratio', 'N/A')}
- Market Cap: {fundamental.get('market_cap', 'N/A')}
- 52W High/Low: {fundamental.get('52w_high', 'N/A')} / {fundamental.get('52w_low', 'N/A')}
- ROE: {fundamental.get('roe', 'N/A')}
IMPORTANT: Consider the macro environment (especially DXY, VIX, rates) when making your recommendation.
Provide your analysis now. Remember: all prices must be within 10% of ${current_price}."""
return system_prompt, user_prompt
def _format_macro_summary(self, macro: Dict[str, Any], market: str) -> str:
"""格式化宏观数据摘要"""
if not macro:
return "宏观数据暂不可用"
lines = []
# 美元指数
if 'DXY' in macro:
dxy = macro['DXY']
direction = "" if dxy.get('change', 0) > 0 else ""
lines.append(f"- {dxy.get('name', 'USD Index')}: {dxy.get('price', 'N/A')} ({direction}{abs(dxy.get('changePercent', 0)):.2f}%)")
# 美元强弱对不同资产的影响
if market == 'Crypto':
impact = "利空加密货币" if dxy.get('change', 0) > 0 else "利好加密货币"
lines.append(f" ⚠️ 美元{direction} {impact}")
elif market == 'Forex':
lines.append(f" ⚠️ 美元{direction} 直接影响外汇走势")
# VIX恐慌指数
if 'VIX' in macro:
vix = macro['VIX']
vix_value = vix.get('price', 0)
if vix_value > 30:
level = "极度恐慌 (>30)"
elif vix_value > 20:
level = "较高恐慌 (20-30)"
elif vix_value > 15:
level = "正常 (15-20)"
else:
level = "低波动 (<15)"
lines.append(f"- {vix.get('name', 'VIX')}: {vix_value:.2f} - {level}")
# 美债收益率
if 'TNX' in macro:
tnx = macro['TNX']
direction = "" if tnx.get('change', 0) > 0 else ""
lines.append(f"- {tnx.get('name', '10Y Treasury')}: {tnx.get('price', 'N/A'):.3f}% ({direction})")
if tnx.get('price', 0) > 4.5:
lines.append(" ⚠️ 高利率环境,对估值不利")
# 黄金
if 'GOLD' in macro:
gold = macro['GOLD']
direction = "" if gold.get('change', 0) > 0 else ""
lines.append(f"- {gold.get('name', 'Gold')}: ${gold.get('price', 'N/A'):.2f} ({direction}{abs(gold.get('changePercent', 0)):.2f}%)")
# 标普500
if 'SPY' in macro:
spy = macro['SPY']
direction = "" if spy.get('change', 0) > 0 else ""
lines.append(f"- {spy.get('name', 'S&P 500')}: ${spy.get('price', 'N/A'):.2f} ({direction}{abs(spy.get('changePercent', 0)):.2f}%)")
# 比特币 (作为风险指标)
if 'BTC' in macro and market != 'Crypto':
btc = macro['BTC']
direction = "" if btc.get('change', 0) > 0 else ""
lines.append(f"- {btc.get('name', 'BTC')}: ${btc.get('price', 'N/A'):,.0f} ({direction}{abs(btc.get('changePercent', 0)):.2f}%) [风险偏好指标]")
return "\n".join(lines) if lines else "宏观数据暂不可用"
# ==================== Main Analysis ====================
def analyze(self, market: str, symbol: str, language: str = 'en-US',
model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
"""
Run fast single-call analysis.
Returns:
Complete analysis result with actionable recommendations.
"""
start_time = time.time()
result = {
"market": market,
"symbol": symbol,
"language": language,
"timeframe": timeframe,
"analysis_time_ms": 0,
"error": None,
}
try:
# Phase 1: Data collection (parallel)
logger.info(f"Fast analysis starting: {market}:{symbol}")
data = self._collect_market_data(market, symbol, timeframe)
# Validate we have essential data - with fallback to indicators
current_price = None
# 优先从 price 数据获取
if data.get("price") and data["price"].get("price"):
current_price = data["price"]["price"]
# Fallback: 从 indicators 获取 (如果 K 线成功计算了)
if not current_price and data.get("indicators"):
current_price = data["indicators"].get("current_price")
if current_price:
logger.info(f"Using price from indicators: ${current_price}")
# 构建简化的 price 数据
data["price"] = {
"price": current_price,
"change": 0,
"changePercent": 0,
"source": "indicators_fallback"
}
# Fallback: 从 kline 最后一根获取
if not current_price and data.get("kline"):
klines = data["kline"]
if klines and len(klines) > 0:
current_price = float(klines[-1].get("close", 0))
if current_price > 0:
logger.info(f"Using price from kline: ${current_price}")
prev_close = float(klines[-2].get("close", current_price)) if len(klines) > 1 else current_price
change = current_price - prev_close
change_pct = (change / prev_close * 100) if prev_close > 0 else 0
data["price"] = {
"price": current_price,
"change": round(change, 6),
"changePercent": round(change_pct, 2),
"source": "kline_fallback"
}
if not current_price or current_price <= 0:
result["error"] = "Failed to fetch current price from all sources"
logger.error(f"Price fetch failed for {market}:{symbol}, all sources exhausted")
return result
# 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...")
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
)
llm_time = int((time.time() - llm_start) * 1000)
logger.info(f"LLM call completed in {llm_time}ms")
# Phase 4: Validate and constrain output
analysis = self._validate_and_constrain(analysis, current_price)
# Build final result
total_time = int((time.time() - start_time) * 1000)
# Extract detailed analysis sections
detailed_analysis = analysis.get("analysis", {})
if isinstance(detailed_analysis, str):
# If AI returned a string instead of dict, use it as technical analysis
detailed_analysis = {"technical": detailed_analysis, "fundamental": "", "sentiment": ""}
result.update({
"decision": analysis.get("decision", "HOLD"),
"confidence": analysis.get("confidence", 50),
"summary": analysis.get("summary", ""),
"detailed_analysis": {
"technical": detailed_analysis.get("technical", ""),
"fundamental": detailed_analysis.get("fundamental", ""),
"sentiment": detailed_analysis.get("sentiment", ""),
},
"trading_plan": {
"entry_price": analysis.get("entry_price"),
"stop_loss": analysis.get("stop_loss"),
"take_profit": analysis.get("take_profit"),
"position_size_pct": analysis.get("position_size_pct", 10),
"timeframe": analysis.get("timeframe", "medium"),
},
"reasons": analysis.get("key_reasons", []),
"risks": analysis.get("risks", []),
"scores": {
"technical": analysis.get("technical_score", 50),
"fundamental": analysis.get("fundamental_score", 50),
"sentiment": analysis.get("sentiment_score", 50),
"overall": self._calculate_overall_score(analysis),
},
"market_data": {
"current_price": current_price,
"change_24h": data["price"].get("changePercent", 0),
"support": data["indicators"].get("levels", {}).get("support"),
"resistance": data["indicators"].get("levels", {}).get("resistance"),
},
"indicators": data.get("indicators", {}),
"analysis_time_ms": total_time,
"llm_time_ms": llm_time,
"data_collection_time_ms": data.get("collection_time_ms", 0),
})
# Store in memory for future retrieval and get memory_id for feedback
memory_id = self._store_analysis_memory(result)
if memory_id:
result["memory_id"] = memory_id
logger.info(f"Fast analysis completed in {total_time}ms: {market}:{symbol} -> {result['decision']} (memory_id={memory_id})")
except Exception as e:
logger.error(f"Fast analysis failed: {e}", exc_info=True)
result["error"] = str(e)
return result
def _validate_and_constrain(self, analysis: Dict, current_price: float) -> Dict:
"""
Validate LLM output and constrain prices to reasonable ranges.
This prevents absurd recommendations like "BTC at 95000, buy at 75000".
"""
if not current_price or current_price <= 0:
return analysis
# Price bounds
min_price = current_price * 0.90
max_price = current_price * 1.10
# Constrain entry price
entry = analysis.get("entry_price", current_price)
if entry 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)
# 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 confidence
confidence = analysis.get("confidence", 50)
analysis["confidence"] = max(0, min(100, int(confidence)))
# Constrain scores
for score_key in ["technical_score", "fundamental_score", "sentiment_score"]:
score = analysis.get(score_key, 50)
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:
analysis["decision"] = decision
return analysis
def _calculate_overall_score(self, analysis: Dict) -> int:
"""Calculate weighted overall score."""
tech = analysis.get("technical_score", 50)
fund = analysis.get("fundamental_score", 50)
sent = analysis.get("sentiment_score", 50)
# Weights: technical 40%, fundamental 35%, sentiment 25%
overall = tech * 0.40 + fund * 0.35 + sent * 0.25
# Adjust based on decision
decision = analysis.get("decision", "HOLD")
confidence = analysis.get("confidence", 50)
if decision == "BUY":
overall = overall * 0.6 + (50 + confidence * 0.5) * 0.4
elif decision == "SELL":
overall = overall * 0.6 + (50 - confidence * 0.5) * 0.4
return max(0, min(100, int(overall)))
def _store_analysis_memory(self, result: Dict) -> Optional[int]:
"""Store analysis result for future learning. Returns memory_id."""
try:
from app.services.analysis_memory import get_analysis_memory
memory = get_analysis_memory()
memory_id = memory.store(result)
return memory_id
except Exception as e:
logger.warning(f"Memory storage failed: {e}")
return None
# ==================== Backward Compatibility ====================
def analyze_legacy_format(self, market: str, symbol: str, language: str = 'en-US',
model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
"""
Returns analysis in legacy multi-agent format for backward compatibility.
"""
fast_result = self.analyze(market, symbol, language, model, timeframe)
if fast_result.get("error"):
return {
"overview": {"report": f"Analysis failed: {fast_result['error']}"},
"fundamental": {"report": "N/A"},
"technical": {"report": "N/A"},
"news": {"report": "N/A"},
"sentiment": {"report": "N/A"},
"risk": {"report": "N/A"},
"error": fast_result["error"],
}
# Convert to legacy format
decision = fast_result.get("decision", "HOLD")
confidence = fast_result.get("confidence", 50)
scores = fast_result.get("scores", {})
return {
"overview": {
"overallScore": scores.get("overall", 50),
"recommendation": decision,
"confidence": confidence,
"dimensionScores": {
"fundamental": scores.get("fundamental", 50),
"technical": scores.get("technical", 50),
"news": scores.get("sentiment", 50),
"sentiment": scores.get("sentiment", 50),
"risk": 100 - confidence, # Inverse of confidence
},
"report": fast_result.get("summary", ""),
},
"fundamental": {
"score": scores.get("fundamental", 50),
"report": f"Fundamental score: {scores.get('fundamental', 50)}/100",
},
"technical": {
"score": scores.get("technical", 50),
"report": f"Technical score: {scores.get('technical', 50)}/100",
"indicators": fast_result.get("indicators", {}),
},
"news": {
"score": scores.get("sentiment", 50),
"report": "See sentiment analysis",
},
"sentiment": {
"score": scores.get("sentiment", 50),
"report": f"Sentiment score: {scores.get('sentiment', 50)}/100",
},
"risk": {
"score": 100 - confidence,
"report": "\n".join(fast_result.get("risks", [])),
},
"debate": {
"bull": {"confidence": confidence if decision == "BUY" else 50},
"bear": {"confidence": confidence if decision == "SELL" else 50},
"research_decision": fast_result.get("summary", ""),
},
"trader_decision": {
"decision": decision,
"confidence": confidence,
"reasoning": fast_result.get("summary", ""),
"trading_plan": fast_result.get("trading_plan", {}),
"report": "\n".join(fast_result.get("reasons", [])),
},
"risk_debate": {
"risky": {"recommendation": ""},
"neutral": {"recommendation": fast_result.get("summary", "")},
"safe": {"recommendation": ""},
},
"final_decision": {
"decision": decision,
"confidence": confidence,
"reasoning": fast_result.get("summary", ""),
"risk_summary": {
"risks": fast_result.get("risks", []),
},
"recommendation": "\n".join(fast_result.get("reasons", [])),
},
"fast_analysis": fast_result, # Include new format for gradual migration
"error": None,
}
# Singleton instance
_fast_analysis_service = None
def get_fast_analysis_service() -> FastAnalysisService:
"""Get singleton FastAnalysisService instance."""
global _fast_analysis_service
if _fast_analysis_service is None:
_fast_analysis_service = FastAnalysisService()
return _fast_analysis_service
def fast_analyze(market: str, symbol: str, language: str = 'en-US',
model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
"""Convenience function for fast analysis."""
service = get_fast_analysis_service()
return service.analyze(market, symbol, language, model, timeframe)
@@ -155,6 +155,7 @@ class DeepcoinClient(BaseRestClient):
"DC-ACCESS-TIMESTAMP": iso_time,
"DC-ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json",
"appid": "200103",
}
return headers
@@ -0,0 +1,976 @@
"""
市场数据采集服务 - AI分析专用
设计理念
1. 数据为王 - 先把数据获取做好做稳定
2. 统一数据源 - 完全复用 DataSourceFactory kline_service
3. 复用全球金融板块 - 宏观数据情绪数据复用 global_market.py 的缓存
4. 快速稳定 - 不依赖慢速外部服务如Jina Reader
数据源映射
- 价格/K线: DataSourceFactory (已验证与K线模块自选列表一致)
- 宏观数据: 复用 global_market.py (VIX, DXY, TNX, Fear&Greed等带缓存)
- 新闻: Finnhub API (结构化数据无需深度阅读)
- 基本面: Finnhub (美股) / akshare (A股) / 固定描述 (加密)
"""
import time
from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
import yfinance as yf
from app.data_sources import DataSourceFactory
from app.services.kline import KlineService
from app.utils.logger import get_logger
from app.config import APIKeys
logger = get_logger(__name__)
class MarketDataCollector:
"""
市场数据采集器
职责为AI分析提供完整准确及时的市场数据
数据层次
1. 核心数据 (必须成功): 价格K线
2. 分析数据 (增强): 技术指标基本面
3. 宏观数据 (可选): 复用 global_market.py (VIX, DXY, TNX, Fear&Greed等)
4. 情绪数据 (可选): 新闻市场情绪
"""
def __init__(self):
self.kline_service = KlineService()
self._finnhub_client = None
self._ak = None
self._init_clients()
def _init_clients(self):
"""初始化外部API客户端"""
# Finnhub
finnhub_key = APIKeys.FINNHUB_API_KEY
if finnhub_key:
try:
import finnhub
self._finnhub_client = finnhub.Client(api_key=finnhub_key)
except Exception as e:
logger.warning(f"Finnhub client init failed: {e}")
# akshare
try:
import akshare as ak
self._ak = ak
except ImportError:
logger.info("akshare not installed, A-share data will be limited")
def collect_all(
self,
market: str,
symbol: str,
timeframe: str = "1D",
include_macro: bool = True,
include_news: bool = True,
timeout: int = 30
) -> Dict[str, Any]:
"""
采集所有市场数据
Args:
market: 市场类型 (USStock, Crypto, AShare, HShare, Forex, Futures)
symbol: 标的代码
timeframe: K线周期
include_macro: 是否包含宏观数据
include_news: 是否包含新闻
timeout: 总超时时间()
Returns:
完整的市场数据字典
"""
start_time = time.time()
data = {
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"collected_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
# 核心数据
"price": None,
"kline": None,
"indicators": {},
# 基本面
"fundamental": {},
"company": {},
# 宏观
"macro": {},
# 情绪
"news": [],
"sentiment": {},
# 元数据
"_meta": {
"success_items": [],
"failed_items": [],
"duration_ms": 0
}
}
# === 阶段1: 核心数据 (并行获取) ===
with ThreadPoolExecutor(max_workers=4) as executor:
core_futures = {
executor.submit(self._get_price, market, symbol): "price",
executor.submit(self._get_kline, market, symbol, timeframe, 60): "kline",
}
# 如果需要基本面,也并行获取
if market in ('USStock', 'AShare', 'HShare'):
core_futures[executor.submit(self._get_fundamental, market, symbol)] = "fundamental"
core_futures[executor.submit(self._get_company, market, symbol)] = "company"
elif market == 'Crypto':
# 加密货币的"基本面"是固定描述
core_futures[executor.submit(self._get_crypto_info, symbol)] = "fundamental"
try:
for future in as_completed(core_futures, timeout=15):
key = core_futures[future]
try:
result = future.result(timeout=3)
if result:
data[key] = result
data["_meta"]["success_items"].append(key)
else:
data["_meta"]["failed_items"].append(key)
except Exception as e:
logger.warning(f"Core data fetch failed ({key}): {e}")
data["_meta"]["failed_items"].append(key)
except TimeoutError:
logger.warning(f"Core data fetch timed out for {market}:{symbol}")
# 计算技术指标 (本地计算,不需要外部API)
if data.get("kline"):
data["indicators"] = self._calculate_indicators(data["kline"])
data["_meta"]["success_items"].append("indicators")
# === 阶段2: 宏观数据 (如果需要) ===
if include_macro:
try:
data["macro"] = self._get_macro_data(market, timeout=10)
if data["macro"]:
data["_meta"]["success_items"].append("macro")
except Exception as e:
logger.warning(f"Macro data fetch failed: {e}")
data["_meta"]["failed_items"].append("macro")
# === 阶段3: 新闻/情绪 (如果需要) ===
if include_news:
try:
# 获取公司名称以改善搜索
company_name = None
if data.get("company"):
company_name = data["company"].get("name")
news_result = self._get_news(market, symbol, company_name, timeout=8)
data["news"] = news_result.get("news", [])
data["sentiment"] = news_result.get("sentiment", {})
if data["news"]:
data["_meta"]["success_items"].append("news")
except Exception as e:
logger.warning(f"News fetch failed: {e}")
data["_meta"]["failed_items"].append("news")
# 记录总耗时
data["_meta"]["duration_ms"] = int((time.time() - start_time) * 1000)
logger.info(f"Market data collection completed for {market}:{symbol} in {data['_meta']['duration_ms']}ms")
logger.info(f" Success: {data['_meta']['success_items']}")
logger.info(f" Failed: {data['_meta']['failed_items']}")
return data
# ==================== 核心数据获取 ====================
def _get_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""
获取实时价格 - 使用 kline_service (与自选列表一致)
"""
try:
price_data = self.kline_service.get_realtime_price(market, symbol, force_refresh=True)
if price_data and price_data.get('price', 0) > 0:
# 安全转换为 float,处理 None 值
def safe_float(val, default=0.0):
if val is None:
return default
try:
return float(val)
except (ValueError, TypeError):
return default
price = safe_float(price_data.get('price'))
return {
"price": price,
"change": safe_float(price_data.get('change')),
"changePercent": safe_float(price_data.get('changePercent')),
"high": safe_float(price_data.get('high'), price),
"low": safe_float(price_data.get('low'), price),
"open": safe_float(price_data.get('open'), price),
"previousClose": safe_float(price_data.get('previousClose'), price),
"source": price_data.get('source', 'unknown')
}
except Exception as e:
logger.warning(f"Price fetch failed for {market}:{symbol}: {e}")
# 如果 kline_service 失败,尝试从 K 线最后一根获取价格
try:
klines = DataSourceFactory.get_kline(market, symbol, "1D", 2)
if klines and len(klines) > 0:
latest = klines[-1]
price = float(latest.get('close', 0))
if price > 0:
prev_close = float(klines[-2].get('close', price)) if len(klines) > 1 else price
change = price - prev_close
change_pct = (change / prev_close * 100) if prev_close > 0 else 0
logger.info(f"Price fetched from K-line fallback for {market}:{symbol}: ${price}")
return {
"price": price,
"change": round(change, 6),
"changePercent": round(change_pct, 2),
"high": float(latest.get('high', price)),
"low": float(latest.get('low', price)),
"open": float(latest.get('open', price)),
"previousClose": prev_close,
"source": "kline_fallback"
}
except Exception as e:
logger.warning(f"K-line fallback price fetch also failed for {market}:{symbol}: {e}")
return None
def _get_kline(
self, market: str, symbol: str, timeframe: str, limit: int = 60
) -> Optional[List[Dict[str, Any]]]:
"""
获取K线数据 - 使用 DataSourceFactory (与K线模块一致)
"""
try:
klines = DataSourceFactory.get_kline(market, symbol, timeframe, limit)
if klines and len(klines) > 0:
return klines
except Exception as e:
logger.warning(f"Kline fetch failed for {market}:{symbol}: {e}")
return None
def _calculate_indicators(self, klines: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
计算技术指标 (本地计算无外部依赖)
返回格式符合前端 FastAnalysisReport.vue 的期望
{
rsi: { value, signal },
macd: { signal, trend },
moving_averages: { ma5, ma10, ma20, trend },
levels: { support, resistance },
volatility: { level, pct }
}
"""
if not klines or len(klines) < 5:
return {}
try:
closes = [float(k.get('close', 0)) for k in klines]
highs = [float(k.get('high', 0)) for k in klines]
lows = [float(k.get('low', 0)) for k in klines]
volumes = [float(k.get('volume', 0)) for k in klines]
if not closes:
return {}
current_price = closes[-1]
indicators = {}
# ========== RSI ==========
if len(closes) >= 15:
rsi_value = self._calc_rsi(closes, 14)
if rsi_value < 30:
rsi_signal = "oversold"
elif rsi_value > 70:
rsi_signal = "overbought"
else:
rsi_signal = "neutral"
indicators['rsi'] = {
'value': round(rsi_value, 2),
'signal': rsi_signal,
}
# ========== MACD ==========
if len(closes) >= 26:
macd_raw = self._calc_macd(closes)
macd_val = macd_raw.get('MACD', 0)
macd_sig = macd_raw.get('MACD_signal', 0)
macd_hist = macd_raw.get('MACD_histogram', 0)
if macd_val > macd_sig and macd_hist > 0:
macd_signal = "bullish"
macd_trend = "golden_cross" if macd_hist > 0 else "bullish"
elif macd_val < macd_sig and macd_hist < 0:
macd_signal = "bearish"
macd_trend = "death_cross" if macd_hist < 0 else "bearish"
else:
macd_signal = "neutral"
macd_trend = "consolidating"
indicators['macd'] = {
'value': round(macd_val, 6),
'signal_line': round(macd_sig, 6),
'histogram': round(macd_hist, 6),
'signal': macd_signal,
'trend': macd_trend,
}
# ========== 移动平均线 ==========
ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else current_price
ma10 = sum(closes[-10:]) / 10 if len(closes) >= 10 else current_price
ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else current_price
if current_price > ma5 > ma10 > ma20:
ma_trend = "strong_uptrend"
elif current_price > ma20:
ma_trend = "uptrend"
elif current_price < ma5 < ma10 < ma20:
ma_trend = "strong_downtrend"
elif current_price < ma20:
ma_trend = "downtrend"
else:
ma_trend = "sideways"
indicators['moving_averages'] = {
'ma5': round(ma5, 6),
'ma10': round(ma10, 6),
'ma20': round(ma20, 6),
'trend': ma_trend,
}
# ========== 支撑/阻力位 (多种方法综合) ==========
# 方法1: 枢轴点 (Pivot Points) - 使用前一日数据
if len(klines) >= 2:
prev_high = float(klines[-2].get('high', highs[-2]) if len(highs) >= 2 else current_price * 1.02)
prev_low = float(klines[-2].get('low', lows[-2]) if len(lows) >= 2 else current_price * 0.98)
prev_close = float(klines[-2].get('close', closes[-2]) if len(closes) >= 2 else current_price)
pivot = (prev_high + prev_low + prev_close) / 3
r1 = 2 * pivot - prev_low # 阻力位1
s1 = 2 * pivot - prev_high # 支撑位1
r2 = pivot + (prev_high - prev_low) # 阻力位2
s2 = pivot - (prev_high - prev_low) # 支撑位2
else:
pivot = current_price
r1 = r2 = current_price * 1.02
s1 = s2 = current_price * 0.98
# 方法2: 近期高低点
recent_highs = highs[-20:] if len(highs) >= 20 else highs
recent_lows = lows[-20:] if len(lows) >= 20 else lows
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)
# 综合取值: 取多种方法的平均/加权
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)
indicators['levels'] = {
'support': support,
'resistance': resistance,
'pivot': round(pivot, 6),
's1': round(s1, 6),
'r1': round(r1, 6),
's2': round(s2, 6),
'r2': round(r2, 6),
'swing_high': round(swing_high, 6),
'swing_low': round(swing_low, 6),
'method': 'pivot_swing_bb_avg' # 标注计算方法
}
# ========== ATR 和波动率 ==========
atr = 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
volatility_pct = (atr / current_price * 100) if current_price > 0 else 0
if volatility_pct > 5:
volatility_level = "high"
elif volatility_pct > 2:
volatility_level = "medium"
else:
volatility_level = "low"
else:
volatility_level = "unknown"
volatility_pct = 0
indicators['volatility'] = {
'level': volatility_level,
'pct': round(volatility_pct, 2),
'atr': round(atr, 6), # 添加 ATR 绝对值
}
# ========== 止盈止损建议 (基于 ATR 和支撑/阻力) ==========
# 止损: 基于 2x ATR 或支撑位,取更保守的
atr_stop_loss = current_price - (2 * atr) if atr > 0 else current_price * 0.95
support_stop = indicators['levels']['support']
suggested_stop_loss = max(atr_stop_loss, support_stop * 0.99) # 略低于支撑位
# 止盈: 基于 3x ATR 或阻力位,考虑风险回报比
atr_take_profit = current_price + (3 * atr) if atr > 0 else current_price * 1.05
resistance_tp = indicators['levels']['resistance']
suggested_take_profit = min(atr_take_profit, resistance_tp * 1.01) # 略高于阻力位
# 风险回报比
risk = current_price - suggested_stop_loss
reward = suggested_take_profit - current_price
risk_reward_ratio = round(reward / risk, 2) if risk > 0 else 0
indicators['trading_levels'] = {
'suggested_stop_loss': round(suggested_stop_loss, 6),
'suggested_take_profit': round(suggested_take_profit, 6),
'risk_reward_ratio': risk_reward_ratio,
'atr_multiplier_sl': 2.0, # 止损使用 2x ATR
'atr_multiplier_tp': 3.0, # 止盈使用 3x ATR
'method': 'atr_support_resistance'
}
# ========== 布林带 (附加) ==========
if len(closes) >= 20:
bb_data = self._calc_bollinger(closes, 20, 2)
indicators['bollinger'] = bb_data
# ========== 成交量 (附加) ==========
if len(volumes) >= 20:
avg_vol = sum(volumes[-20:]) / 20
indicators['volume_ratio'] = round(volumes[-1] / avg_vol, 2) if avg_vol > 0 else 1.0
# ========== 价格位置 (附加) ==========
if len(closes) >= 20:
high_20 = max(highs[-20:])
low_20 = min(lows[-20:])
if high_20 > low_20:
indicators['price_position'] = round((current_price - low_20) / (high_20 - low_20) * 100, 1)
else:
indicators['price_position'] = 50.0
# ========== 整体趋势 (附加) ==========
indicators['trend'] = ma_trend
indicators['current_price'] = round(current_price, 6)
return indicators
except Exception as e:
logger.warning(f"Indicator calculation failed: {e}")
return {}
def _calc_rsi(self, closes: List[float], period: int = 14) -> float:
"""计算RSI"""
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
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return round(rsi, 2)
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))]
return {
'MACD': round(macd_line[-1], 4),
'MACD_signal': round(signal_line[-1], 4),
'MACD_histogram': round(histogram[-1], 4)
}
def _calc_bollinger(self, closes: List[float], period: int = 20, std_dev: int = 2) -> Dict[str, float]:
"""计算布林带"""
if len(closes) < period:
return {}
recent = closes[-period:]
middle = sum(recent) / period
variance = sum((x - middle) ** 2 for x in recent) / period
std = variance ** 0.5
return {
'BB_upper': round(middle + std_dev * std, 4),
'BB_middle': round(middle, 4),
'BB_lower': round(middle - std_dev * std, 4),
'BB_width': round((std_dev * std * 2) / middle * 100, 2) if middle > 0 else 0
}
# ==================== 基本面数据 ====================
def _get_fundamental(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""获取基本面数据"""
try:
if market == 'USStock':
return self._get_us_fundamental(symbol)
elif market == 'AShare':
return self._get_ashare_fundamental(symbol)
elif market == 'HShare':
return self._get_hshare_fundamental(symbol)
except Exception as e:
logger.warning(f"Fundamental data fetch failed for {market}:{symbol}: {e}")
return None
def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
"""美股基本面 - Finnhub + yfinance"""
result = {}
# Finnhub
if self._finnhub_client:
try:
metrics = self._finnhub_client.company_basic_financials(symbol, 'all')
if metrics and metrics.get('metric'):
m = metrics['metric']
result.update({
'pe_ratio': m.get('peBasicExclExtraTTM'),
'pb_ratio': m.get('pbQuarterly'),
'ps_ratio': m.get('psTTM'),
'market_cap': m.get('marketCapitalization'),
'dividend_yield': m.get('dividendYieldIndicatedAnnual'),
'beta': m.get('beta'),
'52w_high': m.get('52WeekHigh'),
'52w_low': m.get('52WeekLow'),
'roe': m.get('roeTTM'),
'eps': m.get('epsBasicExclExtraItemsTTM'),
'revenue_growth': m.get('revenueGrowthTTMYoy'),
})
except Exception as e:
logger.debug(f"Finnhub fundamental failed for {symbol}: {e}")
# yfinance 补充
if not result:
try:
ticker = yf.Ticker(symbol)
info = ticker.info or {}
result.update({
'pe_ratio': info.get('trailingPE') or info.get('forwardPE'),
'pb_ratio': info.get('priceToBook'),
'market_cap': info.get('marketCap'),
'dividend_yield': info.get('dividendYield'),
'beta': info.get('beta'),
'52w_high': info.get('fiftyTwoWeekHigh'),
'52w_low': info.get('fiftyTwoWeekLow'),
'roe': info.get('returnOnEquity'),
'eps': info.get('trailingEps'),
})
except Exception as e:
logger.debug(f"yfinance fundamental failed for {symbol}: {e}")
return result if result else None
def _get_ashare_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
"""A股基本面 - akshare"""
if not self._ak:
return None
try:
# 个股指标
df = self._ak.stock_individual_info_em(symbol=symbol)
if df is not None and not df.empty:
result = {}
for _, row in df.iterrows():
item = row.get('item', '')
value = row.get('value', '')
if '市盈率' in item:
result['pe_ratio'] = value
elif '市净率' in item:
result['pb_ratio'] = value
elif '总市值' in item:
result['market_cap'] = value
elif 'ROE' in item or '净资产收益率' in item:
result['roe'] = value
elif '每股收益' in item:
result['eps'] = value
return result if result else None
except Exception as e:
logger.debug(f"akshare fundamental failed for {symbol}: {e}")
return None
def _get_hshare_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
"""港股基本面 - yfinance"""
try:
# 港股在yfinance的格式: 0700.HK, 9988.HK
yf_symbol = f"{symbol}.HK"
ticker = yf.Ticker(yf_symbol)
info = ticker.info or {}
return {
'pe_ratio': info.get('trailingPE'),
'pb_ratio': info.get('priceToBook'),
'market_cap': info.get('marketCap'),
'dividend_yield': info.get('dividendYield'),
'52w_high': info.get('fiftyTwoWeekHigh'),
'52w_low': info.get('fiftyTwoWeekLow'),
}
except Exception as e:
logger.debug(f"yfinance HShare fundamental failed for {symbol}: {e}")
return None
def _get_crypto_info(self, symbol: str) -> Optional[Dict[str, Any]]:
"""加密货币信息 (固定描述为主)"""
# 常见加密货币的描述
crypto_info = {
'BTC': {
'name': 'Bitcoin',
'description': '比特币,数字黄金,市值第一的加密货币,作为价值存储和避险资产',
'category': 'Store of Value',
},
'ETH': {
'name': 'Ethereum',
'description': '以太坊,智能合约平台,DeFi和NFT生态的基础设施',
'category': 'Smart Contract Platform',
},
'BNB': {
'name': 'Binance Coin',
'description': '币安币,全球最大交易所的平台代币',
'category': 'Exchange Token',
},
'SOL': {
'name': 'Solana',
'description': '高性能公链,主打高TPS和低Gas费',
'category': 'Smart Contract Platform',
},
'XRP': {
'name': 'Ripple',
'description': '瑞波币,专注跨境支付解决方案',
'category': 'Payment',
},
'DOGE': {
'name': 'Dogecoin',
'description': '狗狗币,Meme币代表,社区驱动',
'category': 'Meme',
},
}
# 提取基础代币名
base = symbol.split('/')[0] if '/' in symbol else symbol
base = base.upper()
if base in crypto_info:
return crypto_info[base]
return {
'name': base,
'description': f'{base} 是一种加密货币',
'category': 'Unknown',
}
def _get_company(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""获取公司信息"""
try:
if market == 'USStock' and self._finnhub_client:
profile = self._finnhub_client.company_profile2(symbol=symbol)
if profile:
return {
'name': profile.get('name'),
'industry': profile.get('finnhubIndustry'),
'country': profile.get('country'),
'exchange': profile.get('exchange'),
'ipo_date': profile.get('ipo'),
'market_cap': profile.get('marketCapitalization'),
'website': profile.get('weburl'),
}
elif market == 'AShare' and self._ak:
df = self._ak.stock_individual_info_em(symbol=symbol)
if df is not None and not df.empty:
result = {}
for _, row in df.iterrows():
item = row.get('item', '')
value = row.get('value', '')
if '名称' in item or '简称' in item:
result['name'] = value
elif '行业' in item:
result['industry'] = value
return result if result else None
except Exception as e:
logger.debug(f"Company info fetch failed for {market}:{symbol}: {e}")
return None
# ==================== 宏观数据 (复用全球金融板块) ====================
def _get_macro_data(self, market: str, timeout: int = 10) -> Dict[str, Any]:
"""
获取宏观经济数据 - 复用 global_market.py 的函数和缓存
优势
1. 数据与全球金融页面一致
2. 复用30秒/5分钟缓存降低API调用
3. 已有完整的数据解读和级别判断
"""
try:
# 复用 global_market.py 的市场情绪数据 (有5分钟缓存)
from app.routes.global_market import (
_fetch_vix, _fetch_dollar_index, _fetch_yield_curve,
_fetch_fear_greed_index,
_get_cached, _set_cached
)
result = {}
# 1) 尝试从缓存获取 (global_market 的缓存, 6小时有效)
MACRO_CACHE_TTL = 21600 # 6 hours
cached_sentiment = _get_cached("market_sentiment", MACRO_CACHE_TTL)
if cached_sentiment:
logger.info("Using cached sentiment data from global_market (6h cache)")
# 转换格式
if cached_sentiment.get('vix'):
vix = cached_sentiment['vix']
result['VIX'] = {
'name': 'VIX恐慌指数',
'description': vix.get('interpretation', ''),
'price': vix.get('value', 0),
'change': vix.get('change', 0),
'changePercent': vix.get('change', 0),
'level': vix.get('level', 'unknown'),
}
if cached_sentiment.get('dxy'):
dxy = cached_sentiment['dxy']
result['DXY'] = {
'name': '美元指数',
'description': dxy.get('interpretation', ''),
'price': dxy.get('value', 0),
'change': dxy.get('change', 0),
'changePercent': dxy.get('change', 0),
'level': dxy.get('level', 'unknown'),
}
if cached_sentiment.get('yield_curve'):
yc = cached_sentiment['yield_curve']
result['TNX'] = {
'name': '美债10年收益率',
'description': yc.get('interpretation', ''),
'price': yc.get('yield_10y', 0),
'change': yc.get('change', 0),
'changePercent': 0,
'spread': yc.get('spread', 0),
'level': yc.get('level', 'unknown'),
}
if cached_sentiment.get('fear_greed'):
fg = cached_sentiment['fear_greed']
result['FEAR_GREED'] = {
'name': '恐惧贪婪指数',
'description': fg.get('classification', 'Neutral'),
'price': fg.get('value', 50),
'change': 0,
'changePercent': 0,
}
if result:
return result
# 2) 如果没有缓存,快速并行获取关键指标
logger.info("Fetching macro data from global_market functions")
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(_fetch_vix): "VIX",
executor.submit(_fetch_dollar_index): "DXY",
executor.submit(_fetch_yield_curve): "TNX",
executor.submit(_fetch_fear_greed_index): "FEAR_GREED",
}
try:
for future in as_completed(futures, timeout=timeout):
key = futures[future]
try:
data = future.result(timeout=5)
if data:
# 转换为统一格式
if key == 'VIX':
result[key] = {
'name': 'VIX恐慌指数',
'description': data.get('interpretation', ''),
'price': data.get('value', 0),
'change': data.get('change', 0),
'changePercent': data.get('change', 0),
'level': data.get('level', 'unknown'),
}
elif key == 'DXY':
result[key] = {
'name': '美元指数',
'description': data.get('interpretation', ''),
'price': data.get('value', 0),
'change': data.get('change', 0),
'changePercent': data.get('change', 0),
'level': data.get('level', 'unknown'),
}
elif key == 'TNX':
result[key] = {
'name': '美债10年收益率',
'description': data.get('interpretation', ''),
'price': data.get('yield_10y', 0),
'change': data.get('change', 0),
'changePercent': 0,
'spread': data.get('spread', 0),
'level': data.get('level', 'unknown'),
}
elif key == 'FEAR_GREED':
result[key] = {
'name': '恐惧贪婪指数',
'description': data.get('classification', 'Neutral'),
'price': data.get('value', 50),
'change': 0,
'changePercent': 0,
}
except Exception as e:
logger.debug(f"Macro indicator {key} fetch failed: {e}")
except TimeoutError:
logger.warning("Macro data fetch timed out")
# 注:黄金等大宗商品数据不再作为宏观指标获取
# 原因:1) 如果分析的是黄金,价格已在 _get_price 中获取
# 2) 减少 API 调用,提高稳定性
pass
return result
except ImportError as e:
logger.warning(f"Could not import from global_market: {e}")
return {}
except Exception as e:
logger.error(f"_get_macro_data failed: {e}")
return {}
# ==================== 新闻/情绪数据 ====================
def _get_news(
self, market: str, symbol: str, company_name: str = None, timeout: int = 8
) -> Dict[str, Any]:
"""
获取新闻和情绪数据
策略
1. 使用结构化API (Finnhub) - 无需深度阅读
2. 只获取标题和摘要 - 不读取全文
3. 多来源聚合 - Finnhub + 市场特定来源
"""
news_list = []
sentiment = {}
# 1) Finnhub 新闻 (最可靠)
if self._finnhub_client:
try:
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
raw_news = []
if market == 'USStock':
raw_news = self._finnhub_client.company_news(symbol, _from=start_date, to=end_date)
else:
# 通用新闻
raw_news = self._finnhub_client.general_news('general', min_id=0)
if raw_news:
for item in raw_news[:10]: # 最多10条
if not item.get('headline'):
continue
news_list.append({
"datetime": datetime.fromtimestamp(item.get('datetime', 0)).strftime('%Y-%m-%d %H:%M'),
"headline": item.get('headline', ''),
"summary": item.get('summary', '')[:300] if item.get('summary') else '', # 截断摘要
"source": item.get('source', 'Finnhub'),
"url": item.get('url', ''),
"sentiment": item.get('sentiment', 'neutral'), # Finnhub有时提供情绪
})
except Exception as e:
logger.debug(f"Finnhub news fetch failed: {e}")
# 2) Finnhub 情绪分数 (如果可用)
if self._finnhub_client and market == 'USStock':
try:
# Finnhub 提供社交媒体情绪
social = self._finnhub_client.stock_social_sentiment(symbol)
if social:
sentiment['reddit'] = social.get('reddit', {})
sentiment['twitter'] = social.get('twitter', {})
except Exception as e:
logger.debug(f"Finnhub sentiment fetch failed: {e}")
# 3) A股特定新闻 (akshare)
if market == 'AShare' and self._ak:
try:
# 个股新闻
df = self._ak.stock_news_em(symbol=symbol)
if df is not None and not df.empty:
for _, row in df.head(10).iterrows():
news_list.append({
"datetime": str(row.get('发布时间', ''))[:16],
"headline": row.get('新闻标题', ''),
"summary": row.get('新闻内容', '')[:200] if row.get('新闻内容') else '',
"source": row.get('文章来源', 'eastmoney'),
"url": row.get('新闻链接', ''),
"sentiment": 'neutral',
})
except Exception as e:
logger.debug(f"akshare news fetch failed: {e}")
# 按时间排序
news_list.sort(key=lambda x: x.get('datetime', ''), reverse=True)
return {
"news": news_list[:15], # 最多15条
"sentiment": sentiment,
}
# 全局实例
_collector: Optional[MarketDataCollector] = None
def get_market_data_collector() -> MarketDataCollector:
"""获取市场数据采集器单例"""
global _collector
if _collector is None:
_collector = MarketDataCollector()
return _collector
@@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.services.analysis import AnalysisService
from app.services.fast_analysis import get_fast_analysis_service
from app.services.signal_notifier import SignalNotifier
from app.services.kline import KlineService
@@ -156,14 +156,17 @@ def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = No
def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) -> Dict[str, Any]:
"""
Run full multi-agent AI analysis on positions.
Uses the same 13-agent analysis flow as the AI Analysis page.
Run fast AI analysis on positions.
Uses the new FastAnalysisService (single LLM call, faster and more stable).
"""
try:
language = config.get('language', 'en-US')
custom_prompt = config.get('prompt', '')
# Analyze each position using the full agent analysis flow
# Get the fast analysis service
service = get_fast_analysis_service()
# Analyze each position
position_analyses = []
for pos in positions:
@@ -176,21 +179,24 @@ def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) ->
continue
try:
logger.info(f"Running multi-agent analysis for {market}:{symbol}")
logger.info(f"Running fast AI analysis for {market}:{symbol}")
# Use the full AnalysisService (13-agent flow)
analysis_result = AnalysisService().analyze(
# Use the new FastAnalysisService (single LLM call)
analysis_result = service.analyze(
market=market,
symbol=symbol,
language=language,
timeframe='1D'
)
# Extract key information from the analysis
final_decision = analysis_result.get('final_decision', {})
trader_decision = analysis_result.get('trader_decision', {})
overview = analysis_result.get('overview', {})
risk_report = analysis_result.get('risk', {})
# 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,
@@ -198,24 +204,35 @@ def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) ->
'name': name,
'group_name': group_name,
'entry_price': pos.get('entry_price'),
'current_price': pos.get('current_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'),
# Multi-agent analysis results
'final_decision': final_decision.get('decision', 'HOLD'),
'confidence': final_decision.get('confidence', 50),
'reasoning': final_decision.get('reasoning', ''),
'trader_decision': trader_decision.get('decision', 'HOLD'),
'trader_reasoning': trader_decision.get('reasoning', ''),
'overview_report': overview.get('report', ''),
'risk_report': risk_report.get('report', ''),
# 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"Analysis completed for {market}:{symbol}: {final_decision.get('decision', 'N/A')}")
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}")
@@ -283,7 +300,7 @@ def _build_html_report(
# Text translations
texts = {
'title': '投资组合AI分析报告' if is_zh else 'Portfolio AI Analysis Report',
'subtitle': '由 QuantDinger 多智能体分析系统生成' if is_zh else 'Generated by QuantDinger Multi-Agent Analysis System',
'subtitle': '由 QuantDinger AI 快速分析引擎生成' if is_zh else 'Generated by QuantDinger Fast AI Analysis Engine',
'overview': '组合概览' if is_zh else 'Portfolio Overview',
'positions': '持仓数量' if is_zh else 'Positions',
'total_value': '总市值' if is_zh else 'Total Value',
@@ -555,6 +555,11 @@ class TradingExecutor:
trade_direction = 'long' # 现货只能做多
logger.info(f"Strategy {strategy_id} spot trading; force trade_direction=long")
# 获取市场类别(Crypto, USStock, Forex, Futures, AShare, HShare
# 这决定了使用哪个数据源来获取价格和K线数据
market_category = (strategy.get('market_category') or 'Crypto').strip()
logger.info(f"Strategy {strategy_id} market_category: {market_category}")
# 初始化交易所连接(信号模式下无需真实连接)
exchange = None
@@ -611,7 +616,7 @@ class TradingExecutor:
# ============================================
# logger.info(f"策略 {strategy_id} 初始化:获取历史K线数据...")
history_limit = int(os.getenv('K_LINE_HISTORY_GET_NUMBER', 500))
klines = self._fetch_latest_kline(symbol, timeframe, limit=history_limit)
klines = self._fetch_latest_kline(symbol, timeframe, limit=history_limit, market_category=market_category)
if not klines or len(klines) < 2:
logger.error(f"Strategy {strategy_id} failed to fetch K-lines")
return
@@ -719,16 +724,16 @@ class TradingExecutor:
# ============================================
# 1. Fetch current price once per tick
# ============================================
current_price = self._fetch_current_price(exchange, symbol, market_type=market_type)
current_price = self._fetch_current_price(exchange, symbol, market_type=market_type, market_category=market_category)
if current_price is None:
logger.warning(f"Strategy {strategy_id} failed to fetch current price")
logger.warning(f"Strategy {strategy_id} failed to fetch current price for {market_category}:{symbol}")
continue
# ============================================
# 2. 检查是否需要更新K线(每个K线周期更新一次,从API拉取)
# ============================================
if current_time - last_kline_update_time >= kline_update_interval:
klines = self._fetch_latest_kline(symbol, timeframe, limit=history_limit)
klines = self._fetch_latest_kline(symbol, timeframe, limit=history_limit, market_category=market_category)
if klines and len(klines) >= 2:
df = self._klines_to_dataframe(klines)
if len(df) > 0:
@@ -978,6 +983,7 @@ class TradingExecutor:
leverage=leverage,
initial_capital=initial_capital,
market_type=market_type,
market_category=market_category,
execution_mode=execution_mode,
notification_config=notification_config,
trading_config=trading_config,
@@ -1041,7 +1047,8 @@ class TradingExecutor:
id, strategy_name, strategy_type, status,
initial_capital, leverage, decide_interval,
execution_mode, notification_config,
indicator_config, exchange_config, trading_config, ai_model_config
indicator_config, exchange_config, trading_config, ai_model_config,
market_category
FROM qd_strategies_trading
WHERE id = %s
"""
@@ -1104,25 +1111,39 @@ class TradingExecutor:
"""(Mock) 信号模式不需要真实交易所连接"""
return None
def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500) -> List[Dict[str, Any]]:
"""获取最新K线数据(优先从缓存获取)"""
def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500, market_category: str = 'Crypto') -> List[Dict[str, Any]]:
"""获取最新K线数据(优先从缓存获取)
Args:
symbol: 交易对/代码
timeframe: 时间周期
limit: 数据条数
market_category: 市场类型 (Crypto, USStock, Forex, Futures, AShare, HShare)
"""
try:
# 使用 KlineService 获取K线数据(自动处理缓存)
return self.kline_service.get_kline(
market='Crypto',
market=market_category,
symbol=symbol,
timeframe=timeframe,
limit=limit,
before_time=int(time.time())
)
except Exception as e:
logger.error(f"Failed to fetch K-lines: {str(e)}")
logger.error(f"Failed to fetch K-lines for {market_category}:{symbol}: {str(e)}")
return []
def _fetch_current_price(self, exchange: Any, symbol: str, market_type: str = None) -> Optional[float]:
"""获取当前价格 (改用 DataSource)"""
def _fetch_current_price(self, exchange: Any, symbol: str, market_type: str = None, market_category: str = 'Crypto') -> Optional[float]:
"""获取当前价格 (根据 market_category 选择正确的数据源)
Args:
exchange: 交易所实例信号模式下为 None
symbol: 交易对/代码
market_type: 交易类型 (swap/spot)
market_category: 市场类型 (Crypto, USStock, Forex, Futures, AShare, HShare)
"""
# Local in-memory cache first
cache_key = (symbol or "").strip().upper()
cache_key = f"{market_category}:{(symbol or '').strip().upper()}"
if cache_key and self._price_cache_ttl_sec > 0:
now = time.time()
try:
@@ -1138,12 +1159,9 @@ class TradingExecutor:
pass
try:
# 默认使用 binance 获取价格 (或者根据配置)
# 简单起见,这里硬编码或使用 generic source
ds = DataSourceFactory.get_data_source('binance')
# normalized symbol handling is tricky without exchange object.
# But usually DataSource expects standard 'BTC/USDT'
ticker = ds.get_ticker(symbol)
# 根据 market_category 选择正确的数据源
# 支持: Crypto, USStock, Forex, Futures, AShare, HShare
ticker = DataSourceFactory.get_ticker(market_category, symbol)
if ticker:
price = float(ticker.get('last') or ticker.get('close') or 0)
if price > 0:
@@ -1155,7 +1173,7 @@ class TradingExecutor:
pass
return price
except Exception as e:
logger.warning(f"Failed to fetch price: {e}")
logger.warning(f"Failed to fetch price for {market_category}:{symbol}: {e}")
return None
@@ -1887,6 +1905,7 @@ class TradingExecutor:
leverage: int,
initial_capital: float,
market_type: str = 'swap',
market_category: str = 'Crypto',
margin_mode: str = 'cross',
stop_loss_price: float = None,
take_profit_price: float = None,
@@ -2013,6 +2032,7 @@ class TradingExecutor:
amount=amount,
ref_price=float(current_price or 0.0),
market_type=market_type,
market_category=market_category,
leverage=leverage,
execution_mode=execution_mode,
notification_config=notification_config,
@@ -2049,16 +2069,28 @@ class TradingExecutor:
)
elif sig.startswith("reduce_"):
# Partial scale-out: reduce position size, keep entry price unchanged.
self._record_trade(
strategy_id=strategy_id, symbol=symbol, type=signal_type,
price=current_price, amount=amount, value=amount*current_price
)
# 信号模式下计算部分平仓盈亏
side = 'short' if 'short' in signal_type else 'long'
old_pos = next((p for p in current_positions if p.get('side') == side), None)
if not old_pos:
return True
old_size = float(old_pos.get('size') or 0.0)
old_entry = float(old_pos.get('entry_price') or 0.0)
# 计算减仓部分的盈亏(信号模式下,不含手续费)
reduce_profit = None
if old_entry > 0 and amount > 0:
if side == 'long':
reduce_profit = (current_price - old_entry) * amount
else:
reduce_profit = (old_entry - current_price) * amount
self._record_trade(
strategy_id=strategy_id, symbol=symbol, type=signal_type,
price=current_price, amount=amount, value=amount*current_price,
profit=reduce_profit
)
new_size = max(0.0, old_size - float(amount or 0.0))
if new_size <= old_size * 0.001:
self._close_position(strategy_id, symbol, side)
@@ -2068,11 +2100,25 @@ class TradingExecutor:
size=new_size, entry_price=old_entry, current_price=current_price
)
elif 'close' in sig:
# 信号模式下计算平仓盈亏
side = 'short' if 'short' in signal_type else 'long'
old_pos = next((p for p in current_positions if p.get('side') == side), None)
# 计算盈亏(信号模式下,不含手续费)
close_profit = None
if old_pos:
entry_price = float(old_pos.get('entry_price') or 0)
if entry_price > 0 and amount > 0:
if side == 'long':
close_profit = (current_price - entry_price) * amount
else:
close_profit = (entry_price - current_price) * amount
self._record_trade(
strategy_id=strategy_id, symbol=symbol, type=signal_type,
price=current_price, amount=amount, value=amount*current_price
price=current_price, amount=amount, value=amount*current_price,
profit=close_profit
)
side = 'short' if 'short' in signal_type else 'long'
self._close_position(strategy_id, symbol, side)
return True
@@ -2145,25 +2191,29 @@ class TradingExecutor:
language = str(language or "zh-CN")
try:
# Lazy import to avoid circular deps + heavy init unless the filter is enabled and entry signal happens.
from app.services.analysis import AnalysisService
# 使用新的 FastAnalysisService (单次LLM调用,更快更稳定)
from app.services.fast_analysis import get_fast_analysis_service
service = AnalysisService()
service = get_fast_analysis_service()
result = service.analyze(market, symbol, language, model=model)
if isinstance(result, dict) and result.get("error"):
return False, {"ai_decision": "", "reason": "analysis_error", "analysis_error": str(result.get("error") or "")}
ai_dec = self._extract_ai_trade_decision(result)
if not ai_dec:
return False, {"ai_decision": "", "reason": "missing_ai_decision"}
# FastAnalysisService 直接返回 decision 字段
ai_dec = str(result.get("decision", "")).strip().upper()
if not ai_dec or ai_dec not in ("BUY", "SELL", "HOLD"):
return False, {"ai_decision": ai_dec, "reason": "missing_ai_decision"}
expected = "BUY" if signal_type == "open_long" else "SELL"
confidence = result.get("confidence", 50)
summary = result.get("summary", "")
if ai_dec == expected:
return True, {"ai_decision": ai_dec, "reason": "match"}
return True, {"ai_decision": ai_dec, "reason": "match", "confidence": confidence, "summary": summary}
if ai_dec == "HOLD":
return False, {"ai_decision": ai_dec, "reason": "ai_hold"}
return False, {"ai_decision": ai_dec, "reason": "direction_mismatch"}
return False, {"ai_decision": ai_dec, "reason": "ai_hold", "confidence": confidence, "summary": summary}
return False, {"ai_decision": ai_dec, "reason": "direction_mismatch", "confidence": confidence, "summary": summary}
except Exception as e:
return False, {"ai_decision": "", "reason": "analysis_exception", "analysis_error": str(e)}
@@ -2262,6 +2312,7 @@ class TradingExecutor:
amount: float,
ref_price: Optional[float] = None,
market_type: str = 'swap',
market_category: str = 'Crypto',
leverage: float = 1.0,
margin_mode: str = 'cross',
stop_loss_price: float = None,
@@ -2291,7 +2342,7 @@ class TradingExecutor:
try:
# Reference price at enqueue time: use current tick price if provided to avoid extra fetch.
if ref_price is None:
ref_price = self._fetch_current_price(None, symbol) or 0.0
ref_price = self._fetch_current_price(None, symbol, market_category=market_category) or 0.0
ref_price = float(ref_price or 0.0)
extra_payload = {
@@ -183,6 +183,72 @@ class UserService:
user.pop('password_hash', None)
return user
def get_token_version(self, user_id: int) -> int:
"""
获取用户当前的 token 版本号
Args:
user_id: 用户ID
Returns:
当前 token 版本号默认为 1
"""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT token_version FROM qd_users WHERE id = ?",
(user_id,)
)
row = cur.fetchone()
cur.close()
if row:
return int(row.get('token_version') or 1)
return 1
except Exception as e:
logger.error(f"get_token_version failed: {e}")
return 1
def increment_token_version(self, user_id: int) -> int:
"""
递增用户的 token 版本号使旧的 token 失效
用于实现单一客户端登录踢出其他设备
Args:
user_id: 用户ID
Returns:
新的 token 版本号
"""
try:
with get_db_connection() as db:
cur = db.cursor()
# 递增 token_version
cur.execute(
"""
UPDATE qd_users
SET token_version = COALESCE(token_version, 0) + 1, updated_at = NOW()
WHERE id = ?
""",
(user_id,)
)
db.commit()
# 获取新的 token_version
cur.execute(
"SELECT token_version FROM qd_users WHERE id = ?",
(user_id,)
)
row = cur.fetchone()
cur.close()
new_version = int(row.get('token_version') or 1) if row else 1
logger.info(f"Incremented token_version for user_id={user_id} to {new_version}")
return new_version
except Exception as e:
logger.error(f"increment_token_version failed: {e}")
return 1
def create_user(self, data: Dict[str, Any] = None, **kwargs) -> Optional[int]:
"""
Create a new user.
+48 -1
View File
@@ -15,7 +15,7 @@ from app.utils.logger import get_logger
logger = get_logger(__name__)
def generate_token(user_id: int, username: str, role: str = 'user') -> str:
def generate_token(user_id: int, username: str, role: str = 'user', token_version: int = 1) -> str:
"""
Generate JWT token with user information.
@@ -23,6 +23,7 @@ def generate_token(user_id: int, username: str, role: str = 'user') -> str:
user_id: User ID
username: Username
role: User role (admin/manager/user/viewer)
token_version: Token version for single-client enforcement
Returns:
JWT token string
@@ -34,6 +35,7 @@ def generate_token(user_id: int, username: str, role: str = 'user') -> str:
'sub': username,
'user_id': user_id,
'role': role,
'token_version': token_version, # 用于单一客户端登录控制
}
return jwt.encode(
payload,
@@ -57,6 +59,17 @@ def verify_token(token: str) -> dict:
"""
try:
payload = jwt.decode(token, Config.SECRET_KEY, algorithms=['HS256'])
# 验证 token_version(单一客户端登录控制)
user_id = payload.get('user_id')
token_version = payload.get('token_version')
if user_id and token_version is not None:
# 检查数据库中的 token_version 是否匹配
if not _verify_token_version(user_id, token_version):
logger.debug(f"Token version mismatch for user {user_id}: expected current, got {token_version}")
return None
return payload
except jwt.ExpiredSignatureError:
logger.debug("Token expired")
@@ -66,6 +79,40 @@ def verify_token(token: str) -> dict:
return None
def _verify_token_version(user_id: int, token_version: int) -> bool:
"""
验证 token 版本是否与数据库中存储的版本匹配
用于实现单一客户端登录踢出重复登录
Args:
user_id: 用户ID
token_version: Token中的版本号
Returns:
True if version matches, False otherwise
"""
try:
from app.utils.db import get_db_connection
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT token_version FROM qd_users WHERE id = ?",
(user_id,)
)
row = cur.fetchone()
cur.close()
if not row:
return False
db_token_version = row.get('token_version') or 1
return int(token_version) == int(db_token_version)
except Exception as e:
logger.error(f"_verify_token_version failed: {e}")
# 如果验证失败,为了安全起见,返回 False
return False
def get_current_user_id() -> int:
"""Get current user ID from flask.g context"""
return getattr(g, 'user_id', None)
+130
View File
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS qd_users (
email_verified BOOLEAN DEFAULT FALSE, -- 邮箱是否已验证
referred_by INTEGER, -- 邀请人ID
notification_settings TEXT DEFAULT '', -- 用户通知配置 JSON (telegram_chat_id, default_channels等)
token_version INTEGER DEFAULT 1, -- Token版本号,用于单一客户端登录控制
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
@@ -287,6 +288,11 @@ CREATE TABLE IF NOT EXISTS qd_indicator_codes (
price DECIMAL(10,2) NOT NULL DEFAULT 0,
is_encrypted INTEGER NOT NULL DEFAULT 0,
preview_image VARCHAR(500) DEFAULT '',
-- Review status fields (for admin review feature)
review_status VARCHAR(20) DEFAULT 'approved', -- pending/approved/rejected
review_note TEXT DEFAULT '',
reviewed_at TIMESTAMP,
reviewed_by INTEGER,
createtime BIGINT,
updatetime BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
@@ -294,6 +300,7 @@ CREATE TABLE IF NOT EXISTS qd_indicator_codes (
);
CREATE INDEX IF NOT EXISTS idx_indicator_codes_user_id ON qd_indicator_codes(user_id);
CREATE INDEX IF NOT EXISTS idx_indicator_review_status ON qd_indicator_codes(review_status);
-- =============================================================================
-- 8. AI Decisions
@@ -616,6 +623,129 @@ CREATE TABLE IF NOT EXISTS qd_reflection_records (
CREATE INDEX IF NOT EXISTS idx_reflection_status ON qd_reflection_records(status, target_check_date);
CREATE INDEX IF NOT EXISTS idx_reflection_market ON qd_reflection_records(market, symbol);
-- =============================================================================
-- 19.5. Analysis Memory (Fast AI Analysis Memory System)
-- =============================================================================
-- Stores AI analysis results for history, feedback, and learning.
CREATE TABLE IF NOT EXISTS qd_analysis_memory (
id SERIAL PRIMARY KEY,
market VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
decision VARCHAR(10) NOT NULL,
confidence INT DEFAULT 50,
price_at_analysis DECIMAL(24, 8),
entry_price DECIMAL(24, 8),
stop_loss DECIMAL(24, 8),
take_profit DECIMAL(24, 8),
summary TEXT,
reasons JSONB,
risks JSONB,
scores JSONB,
indicators_snapshot JSONB,
raw_result JSONB, -- Full analysis result for history replay
created_at TIMESTAMP DEFAULT NOW(),
validated_at TIMESTAMP,
actual_outcome VARCHAR(20),
actual_return_pct DECIMAL(10, 4),
was_correct BOOLEAN,
user_feedback VARCHAR(20), -- helpful/not_helpful
feedback_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_symbol ON qd_analysis_memory(market, symbol);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_created ON qd_analysis_memory(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_validated ON qd_analysis_memory(validated_at) WHERE validated_at IS NOT NULL;
-- =============================================================================
-- 20. Migration: Add token_version for single-client login
-- =============================================================================
-- This migration adds token_version column for enforcing single-client login.
-- When a user logs in from a new device, the token_version is incremented,
-- invalidating all previous tokens and forcing other sessions to logout.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_users' AND column_name = 'token_version'
) THEN
ALTER TABLE qd_users ADD COLUMN token_version INTEGER DEFAULT 1;
RAISE NOTICE 'Added token_version column to qd_users table';
END IF;
END $$;
-- =============================================================================
-- 21. Indicator Community Tables
-- =============================================================================
-- Indicator Purchases (购买记录)
CREATE TABLE IF NOT EXISTS qd_indicator_purchases (
id SERIAL PRIMARY KEY,
indicator_id INTEGER NOT NULL REFERENCES qd_indicator_codes(id) ON DELETE CASCADE,
buyer_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
seller_id INTEGER NOT NULL REFERENCES qd_users(id),
price DECIMAL(10,2) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(indicator_id, buyer_id)
);
CREATE INDEX IF NOT EXISTS idx_purchases_indicator ON qd_indicator_purchases(indicator_id);
CREATE INDEX IF NOT EXISTS idx_purchases_buyer ON qd_indicator_purchases(buyer_id);
CREATE INDEX IF NOT EXISTS idx_purchases_seller ON qd_indicator_purchases(seller_id);
-- Indicator Comments (评论)
CREATE TABLE IF NOT EXISTS qd_indicator_comments (
id SERIAL PRIMARY KEY,
indicator_id INTEGER NOT NULL REFERENCES qd_indicator_codes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
rating INTEGER DEFAULT 5 CHECK (rating >= 1 AND rating <= 5),
content TEXT DEFAULT '',
parent_id INTEGER REFERENCES qd_indicator_comments(id) ON DELETE CASCADE,
is_deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comments_indicator ON qd_indicator_comments(indicator_id);
CREATE INDEX IF NOT EXISTS idx_comments_user ON qd_indicator_comments(user_id);
-- Add community stats columns to qd_indicator_codes
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_indicator_codes' AND column_name = 'purchase_count'
) THEN
ALTER TABLE qd_indicator_codes ADD COLUMN purchase_count INTEGER DEFAULT 0;
RAISE NOTICE 'Added purchase_count column to qd_indicator_codes';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_indicator_codes' AND column_name = 'avg_rating'
) THEN
ALTER TABLE qd_indicator_codes ADD COLUMN avg_rating DECIMAL(3,2) DEFAULT 0;
RAISE NOTICE 'Added avg_rating column to qd_indicator_codes';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_indicator_codes' AND column_name = 'rating_count'
) THEN
ALTER TABLE qd_indicator_codes ADD COLUMN rating_count INTEGER DEFAULT 0;
RAISE NOTICE 'Added rating_count column to qd_indicator_codes';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_indicator_codes' AND column_name = 'view_count'
) THEN
ALTER TABLE qd_indicator_codes ADD COLUMN view_count INTEGER DEFAULT 0;
RAISE NOTICE 'Added view_count column to qd_indicator_codes';
END IF;
END $$;
-- =============================================================================
-- Completion Notice
-- =============================================================================