@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user