Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-01-31 02:59:49 +08:00
parent 2853e83885
commit 0b37aa4a67
58 changed files with 11231 additions and 8698 deletions
+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,