feat: Multi-user system with PostgreSQL - WIP temporary save
This commit is contained in:
@@ -21,9 +21,12 @@ def register_routes(app: Flask):
|
||||
from app.routes.portfolio import portfolio_bp
|
||||
from app.routes.ibkr import ibkr_bp
|
||||
from app.routes.mt5 import mt5_bp
|
||||
from app.routes.user import user_bp
|
||||
from app.routes.strategy_code import strategy_code_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/user')
|
||||
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')
|
||||
@@ -37,4 +40,4 @@ 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(strategy_code_bp, url_prefix='/api/strategy-code')
|
||||
|
||||
@@ -32,7 +32,7 @@ def chat_message():
|
||||
})
|
||||
|
||||
|
||||
@ai_chat_bp.route('/chat/history', methods=['POST'])
|
||||
@ai_chat_bp.route('/chat/history', methods=['GET'])
|
||||
def get_chat_history():
|
||||
"""Return empty history (compatibility stub)."""
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': []})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Analysis API routes (local-only).
|
||||
Implements multi-dimensional analysis plus lightweight task/history APIs for the frontend.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
from flask import Blueprint, request, jsonify, Response, g
|
||||
import json
|
||||
import traceback
|
||||
import time
|
||||
@@ -11,11 +11,11 @@ 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__)
|
||||
DEFAULT_USER_ID = 1
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
@@ -23,27 +23,58 @@ def _now_ts() -> int:
|
||||
def _normalize_symbol(symbol: str) -> str:
|
||||
return (symbol or '').strip().upper()
|
||||
|
||||
def _store_task(market: str, symbol: str, model: str, language: str, status: str, result: dict = None, error_message: str = "") -> int:
|
||||
now = _now_ts()
|
||||
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()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(DEFAULT_USER_ID, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '', now, now if status in ['completed', 'failed'] else None)
|
||||
)
|
||||
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 _get_task(task_id: int) -> dict:
|
||||
|
||||
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, DEFAULT_USER_ID))
|
||||
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
|
||||
@@ -60,16 +91,25 @@ def _parse_result_json(row: dict) -> dict:
|
||||
|
||||
@analysis_bp.route('/multi', methods=['POST'])
|
||||
@analysis_bp.route('/multiAnalysis', methods=['POST']) # compatibility with legacy naming
|
||||
@login_required
|
||||
def multi_analysis():
|
||||
"""
|
||||
Multi-dimensional 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({
|
||||
@@ -100,49 +140,64 @@ def multi_analysis():
|
||||
|
||||
logger.info(f"Analyze request: {market}:{symbol}, use_multi_agent={use_multi_agent}, model={model}")
|
||||
|
||||
# Create analysis service instance (local-only; no paid credits)
|
||||
service = AnalysisService(use_multi_agent=use_multi_agent)
|
||||
result = service.analyze(market, symbol, language, model=model, timeframe=timeframe)
|
||||
# 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()
|
||||
|
||||
# Persist as "completed" history (no paid credits in local mode).
|
||||
task_id = _store_task(market, symbol, model or '', language, 'completed', result=result, error_message='')
|
||||
|
||||
# Keep frontend compatible: if it expects task polling, it can still use the id.
|
||||
result_payload = dict(result or {})
|
||||
result_payload['task_id'] = task_id
|
||||
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': result_payload})
|
||||
# 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:
|
||||
market = (data or {}).get('market', '') if 'data' in locals() else ''
|
||||
symbol = (data or {}).get('symbol', '') if 'data' in locals() else ''
|
||||
language = detect_request_language(request, body=(data or {}), default='en-US')
|
||||
model = (data or {}).get('model', '') if 'data' in locals() else ''
|
||||
market = str(market).strip()
|
||||
symbol = _normalize_symbol(symbol)
|
||||
_store_task(market, symbol, model, language, 'failed', result={}, error_message=str(e))
|
||||
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': None
|
||||
'data': {'task_id': task_id} if task_id else None
|
||||
}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/getTaskStatus', methods=['POST'])
|
||||
@analysis_bp.route('/getTaskStatus', methods=['GET'])
|
||||
@login_required
|
||||
def get_task_status():
|
||||
"""Frontend compatibility: return task status + result by task_id."""
|
||||
"""Frontend compatibility: return task status + result by task_id for the current user."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
task_id = int(data.get('task_id') or 0)
|
||||
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)
|
||||
row = _get_task(task_id, user_id)
|
||||
if not row:
|
||||
return jsonify({'code': 0, 'msg': 'Task not found', 'data': None}), 404
|
||||
|
||||
@@ -161,20 +216,21 @@ def get_task_status():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/getHistoryList', methods=['POST'])
|
||||
@analysis_bp.route('/getHistoryList', methods=['GET'])
|
||||
@login_required
|
||||
def get_history_list():
|
||||
"""Frontend compatibility: paginated analysis history for the single user."""
|
||||
"""Frontend compatibility: paginated analysis history for the current user."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
page = int(data.get('page') or 1)
|
||||
pagesize = int(data.get('pagesize') or 20)
|
||||
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 = ?", (DEFAULT_USER_ID,))
|
||||
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(
|
||||
"""
|
||||
@@ -184,7 +240,7 @@ def get_history_list():
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(DEFAULT_USER_ID, pagesize, offset)
|
||||
(user_id, pagesize, offset)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -192,6 +248,11 @@ def get_history_list():
|
||||
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'),
|
||||
@@ -200,8 +261,8 @@ def get_history_list():
|
||||
'status': r.get('status'),
|
||||
'has_result': has_result,
|
||||
'error_message': r.get('error_message') or '',
|
||||
'createtime': int(r.get('created_at') or 0),
|
||||
'completetime': int(r.get('completed_at') or 0) if r.get('completed_at') else None
|
||||
'createtime': createtime,
|
||||
'completetime': completetime
|
||||
})
|
||||
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'list': out, 'total': total}})
|
||||
@@ -211,13 +272,46 @@ def get_history_list():
|
||||
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'))
|
||||
@@ -228,7 +322,7 @@ def create_task():
|
||||
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(market, symbol, str(model), language, 'pending', result={}, error_message='')
|
||||
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)}")
|
||||
@@ -236,47 +330,8 @@ def create_task():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/stream', methods=['POST'])
|
||||
def stream_analysis():
|
||||
"""Streaming analysis (SSE)."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 0, 'msg': 'Request body is required'}), 400
|
||||
|
||||
market = data.get('market', '')
|
||||
symbol = data.get('symbol', '')
|
||||
language = detect_request_language(request, body=data, default='en-US')
|
||||
use_multi_agent = data.get('use_multi_agent', None)
|
||||
timeframe = data.get('timeframe', '1D')
|
||||
|
||||
def generate():
|
||||
try:
|
||||
yield f"data: {json.dumps({'status': 'started', 'message': 'Analysis started'})}\n\n"
|
||||
|
||||
service = AnalysisService(use_multi_agent=use_multi_agent)
|
||||
result = service.analyze(market, symbol, language, timeframe=timeframe)
|
||||
|
||||
yield f"data: {json.dumps({'status': 'completed', 'data': result})}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'status': 'error', 'message': str(e)})}\n\n"
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Streaming analysis failed: {str(e)}")
|
||||
return jsonify({'code': 0, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/reflect', methods=['POST'])
|
||||
@login_required
|
||||
def reflect():
|
||||
"""
|
||||
Reflection API.
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
"""
|
||||
Authentication API Routes
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
Handles login, logout, and user info retrieval.
|
||||
Supports both multi-user (database) and single-user (legacy) modes.
|
||||
"""
|
||||
import os
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app.config.settings import Config
|
||||
from app.utils.auth import generate_token
|
||||
from app.utils.auth import generate_token, login_required, authenticate_legacy
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
def _is_single_user_mode() -> bool:
|
||||
"""Check if system is in single-user (legacy) mode"""
|
||||
return os.getenv('SINGLE_USER_MODE', 'false').lower() == 'true'
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""Login (single-user, env-configured credentials)."""
|
||||
"""
|
||||
User login endpoint.
|
||||
|
||||
Request body:
|
||||
username: str
|
||||
password: str
|
||||
|
||||
Returns:
|
||||
token: JWT token
|
||||
userinfo: User information
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
@@ -20,49 +43,135 @@ def login():
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'code': 400, 'msg': 'Missing username or password', 'data': None}), 400
|
||||
|
||||
# Validate credentials from environment / settings
|
||||
if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD:
|
||||
token = generate_token(username)
|
||||
if token:
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Login successful',
|
||||
'data': {
|
||||
'token': token,
|
||||
'userinfo': {
|
||||
'username': username,
|
||||
'nickname': 'Admin',
|
||||
'avatar': ''
|
||||
}
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500
|
||||
else:
|
||||
|
||||
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
|
||||
user = None
|
||||
|
||||
# Try multi-user authentication first
|
||||
if not _is_single_user_mode():
|
||||
try:
|
||||
from app.services.user_service import get_user_service
|
||||
user = get_user_service().authenticate(username, password)
|
||||
except Exception as e:
|
||||
logger.warning(f"Multi-user auth failed, trying legacy: {e}")
|
||||
|
||||
# Fallback to legacy single-user mode
|
||||
if not user:
|
||||
user = authenticate_legacy(username, password)
|
||||
|
||||
if not user:
|
||||
return jsonify({'code': 0, 'msg': 'Invalid credentials', 'data': None}), 401
|
||||
|
||||
# Generate token
|
||||
token = generate_token(
|
||||
user_id=user.get('id') or user.get('user_id', 1),
|
||||
username=user.get('username', username),
|
||||
role=user.get('role', 'admin')
|
||||
)
|
||||
|
||||
if not token:
|
||||
return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500
|
||||
|
||||
# Build user info for frontend
|
||||
userinfo = {
|
||||
'id': user.get('id') or user.get('user_id', 1),
|
||||
'username': user.get('username', username),
|
||||
'nickname': user.get('nickname', 'User') + (' (Demo)' if is_demo else ''),
|
||||
'avatar': user.get('avatar', '/avatar2.jpg'),
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': user.get('role', 'admin'),
|
||||
'permissions': _get_permissions(user.get('role', 'admin'))
|
||||
}
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Login successful',
|
||||
'data': {
|
||||
'token': token,
|
||||
'userinfo': userinfo
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {e}")
|
||||
return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
"""Logout (client removes token; server is stateless)."""
|
||||
return jsonify({'code': 1, 'msg': 'Logout successful', 'data': None})
|
||||
|
||||
@auth_bp.route('/info', methods=['GET'])
|
||||
def get_user_info():
|
||||
"""Get user info (single-user mock)."""
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Success',
|
||||
'data': {
|
||||
'id': 1,
|
||||
'username': Config.ADMIN_USER,
|
||||
'nickname': 'Admin',
|
||||
'avatar': '/avatar2.jpg',
|
||||
'role': {'id': 'admin', 'permissions': ['dashboard', 'exception', 'account']}
|
||||
}
|
||||
})
|
||||
|
||||
@auth_bp.route('/info', methods=['GET'])
|
||||
@login_required
|
||||
def get_user_info():
|
||||
"""Get current user info."""
|
||||
try:
|
||||
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
|
||||
|
||||
user_id = getattr(g, 'user_id', 1)
|
||||
username = getattr(g, 'user', Config.ADMIN_USER)
|
||||
role = getattr(g, 'user_role', 'admin')
|
||||
|
||||
# Try to get full user info from database
|
||||
user_data = None
|
||||
if not _is_single_user_mode():
|
||||
try:
|
||||
from app.services.user_service import get_user_service
|
||||
user_data = get_user_service().get_user_by_id(user_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get user from database: {e}")
|
||||
|
||||
if user_data:
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Success',
|
||||
'data': {
|
||||
'id': user_data.get('id'),
|
||||
'username': user_data.get('username'),
|
||||
'nickname': user_data.get('nickname', 'User') + (' (Demo)' if is_demo else ''),
|
||||
'email': user_data.get('email'),
|
||||
'avatar': user_data.get('avatar', '/avatar2.jpg'),
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': user_data.get('role', 'user'),
|
||||
'permissions': _get_permissions(user_data.get('role', 'user'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Fallback for legacy mode
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Success',
|
||||
'data': {
|
||||
'id': user_id,
|
||||
'username': username,
|
||||
'nickname': 'Admin' + (' (Demo)' if is_demo else ''),
|
||||
'avatar': '/avatar2.jpg',
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': role,
|
||||
'permissions': _get_permissions(role)
|
||||
}
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_info error: {e}")
|
||||
return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
def _get_permissions(role: str) -> list:
|
||||
"""Get permissions list for a role"""
|
||||
try:
|
||||
from app.services.user_service import get_user_service
|
||||
return get_user_service().get_user_permissions(role)
|
||||
except Exception:
|
||||
# Default permissions for admin
|
||||
if role == 'admin':
|
||||
return ['dashboard', 'view', 'indicator', 'backtest', 'strategy',
|
||||
'portfolio', 'settings', 'user_manage', 'credentials']
|
||||
return ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Backtest API routes
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from datetime import datetime
|
||||
import traceback
|
||||
import json
|
||||
@@ -11,6 +11,7 @@ import os
|
||||
from app.services.backtest import BacktestService
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.auth import login_required
|
||||
import requests
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -82,12 +83,12 @@ def _normalize_lang(lang: str | None) -> str:
|
||||
return l2 if l2 in supported else "zh-CN"
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest/precision-info', methods=['POST'])
|
||||
@backtest_bp.route('/backtest/precision-info', methods=['GET'])
|
||||
def get_precision_info():
|
||||
"""
|
||||
获取回测精度信息(用于前端提示)
|
||||
|
||||
Params:
|
||||
Params (Query String):
|
||||
market: 市场类型
|
||||
startDate: 开始日期 (YYYY-MM-DD)
|
||||
endDate: 结束日期 (YYYY-MM-DD)
|
||||
@@ -96,13 +97,10 @@ def get_precision_info():
|
||||
精度信息,包含推荐的执行时间框架和预估K线数量
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 0, 'msg': 'Request body is required'}), 400
|
||||
|
||||
market = data.get('market', 'crypto')
|
||||
start_date_str = data.get('startDate', '')
|
||||
end_date_str = data.get('endDate', '')
|
||||
# Use request.args for GET params
|
||||
market = request.args.get('market', 'crypto')
|
||||
start_date_str = request.args.get('startDate', '')
|
||||
end_date_str = request.args.get('endDate', '')
|
||||
|
||||
if not start_date_str or not end_date_str:
|
||||
return jsonify({'code': 0, 'msg': 'startDate and endDate are required'}), 400
|
||||
@@ -123,9 +121,10 @@ def get_precision_info():
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest', methods=['POST'])
|
||||
@login_required
|
||||
def run_backtest():
|
||||
"""
|
||||
Run indicator backtest
|
||||
Run indicator backtest for the current user.
|
||||
|
||||
Params:
|
||||
indicatorId: Indicator ID (optional)
|
||||
@@ -148,8 +147,8 @@ def run_backtest():
|
||||
'data': None
|
||||
}), 400
|
||||
|
||||
# Extract params
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
# Extract params - use current user's ID
|
||||
user_id = g.user_id
|
||||
indicator_code = data.get('indicatorCode', '')
|
||||
indicator_id = data.get('indicatorId')
|
||||
symbol = data.get('symbol', '')
|
||||
@@ -267,7 +266,6 @@ def run_backtest():
|
||||
# Persist backtest run for AI optimization / history
|
||||
run_id = None
|
||||
try:
|
||||
now_ts = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -276,7 +274,7 @@ def run_backtest():
|
||||
(user_id, indicator_id, market, symbol, timeframe, start_date, end_date,
|
||||
initial_capital, commission, slippage, leverage, trade_direction,
|
||||
strategy_config, status, error_message, result_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
@@ -294,8 +292,7 @@ def run_backtest():
|
||||
json.dumps(strategy_config or {}, ensure_ascii=False),
|
||||
'success',
|
||||
'',
|
||||
json.dumps(result or {}, ensure_ascii=False),
|
||||
now_ts
|
||||
json.dumps(result or {}, ensure_ascii=False)
|
||||
)
|
||||
)
|
||||
run_id = cur.lastrowid
|
||||
@@ -327,9 +324,8 @@ def run_backtest():
|
||||
# Best-effort persist failed run (if we have enough context)
|
||||
try:
|
||||
data = data if isinstance(data, dict) else {}
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
user_id = g.user_id
|
||||
indicator_id = data.get('indicatorId')
|
||||
now_ts = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -338,7 +334,7 @@ def run_backtest():
|
||||
(user_id, indicator_id, market, symbol, timeframe, start_date, end_date,
|
||||
initial_capital, commission, slippage, leverage, trade_direction,
|
||||
strategy_config, status, error_message, result_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
@@ -356,8 +352,7 @@ def run_backtest():
|
||||
json.dumps(data.get('strategyConfig') or {}, ensure_ascii=False),
|
||||
'failed',
|
||||
str(e),
|
||||
'',
|
||||
now_ts
|
||||
''
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
@@ -371,13 +366,13 @@ def run_backtest():
|
||||
}), 500
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest/history', methods=['POST'])
|
||||
@backtest_bp.route('/backtest/history', methods=['GET'])
|
||||
@login_required
|
||||
def get_backtest_history():
|
||||
"""
|
||||
Get backtest run history (saved in SQLite).
|
||||
Get backtest run history for the current user.
|
||||
|
||||
Params:
|
||||
userid: User ID (default 1)
|
||||
Params (Query String):
|
||||
limit: Page size (default 50, max 200)
|
||||
offset: Offset (default 0)
|
||||
indicatorId: Optional indicator id filter
|
||||
@@ -386,17 +381,17 @@ def get_backtest_history():
|
||||
timeframe: Optional timeframe filter
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
limit = int(data.get('limit') or 50)
|
||||
offset = int(data.get('offset') or 0)
|
||||
# Use current user's ID
|
||||
user_id = g.user_id
|
||||
limit = int(request.args.get('limit') or 50)
|
||||
offset = int(request.args.get('offset') or 0)
|
||||
limit = max(1, min(limit, 200))
|
||||
offset = max(0, offset)
|
||||
|
||||
indicator_id = data.get('indicatorId')
|
||||
symbol = (data.get('symbol') or '').strip()
|
||||
market = (data.get('market') or '').strip()
|
||||
timeframe = (data.get('timeframe') or '').strip()
|
||||
indicator_id = request.args.get('indicatorId')
|
||||
symbol = (request.args.get('symbol') or '').strip()
|
||||
market = (request.args.get('market') or '').strip()
|
||||
timeframe = (request.args.get('timeframe') or '').strip()
|
||||
|
||||
where = ["user_id = ?"]
|
||||
params = [user_id]
|
||||
@@ -449,19 +444,18 @@ def get_backtest_history():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest/get', methods=['POST'])
|
||||
@backtest_bp.route('/backtest/get', methods=['GET'])
|
||||
@login_required
|
||||
def get_backtest_run():
|
||||
"""
|
||||
Get a backtest run detail by run id (includes result_json).
|
||||
Get a backtest run detail by run id for the current user.
|
||||
|
||||
Params:
|
||||
userid: User ID (default 1)
|
||||
Params (Query String):
|
||||
runId: Backtest run id (required)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
run_id = int(data.get('runId') or 0)
|
||||
user_id = g.user_id
|
||||
run_id = int(request.args.get('runId') or 0)
|
||||
if not run_id:
|
||||
return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400
|
||||
|
||||
@@ -716,17 +710,18 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest/aiAnalyze', methods=['POST'])
|
||||
@login_required
|
||||
def ai_analyze_backtest_runs():
|
||||
"""
|
||||
AI analyze selected backtest runs and provide strategy_config tuning suggestions.
|
||||
AI analyze selected backtest runs and provide strategy_config tuning suggestions
|
||||
for the current user.
|
||||
|
||||
Params:
|
||||
userid: User ID (default 1)
|
||||
runIds: list[int] (required)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
user_id = g.user_id
|
||||
lang = _normalize_lang(data.get('lang'))
|
||||
run_ids = data.get('runIds') or []
|
||||
if not isinstance(run_ids, list) or not run_ids:
|
||||
|
||||
@@ -9,17 +9,16 @@ Local deployment notes:
|
||||
import time
|
||||
import traceback
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.auth import login_required
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
credentials_bp = Blueprint('credentials', __name__)
|
||||
|
||||
DEFAULT_USER_ID = 1
|
||||
|
||||
|
||||
def _api_key_hint(api_key: str) -> str:
|
||||
if not api_key:
|
||||
@@ -31,9 +30,11 @@ def _api_key_hint(api_key: str) -> str:
|
||||
|
||||
|
||||
@credentials_bp.route('/list', methods=['GET'])
|
||||
@login_required
|
||||
def list_credentials():
|
||||
"""List all credentials for the current user."""
|
||||
try:
|
||||
user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID
|
||||
user_id = g.user_id
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -57,10 +58,12 @@ def list_credentials():
|
||||
|
||||
|
||||
@credentials_bp.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_credential():
|
||||
"""Create a new credential for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get('user_id') or DEFAULT_USER_ID)
|
||||
name = (data.get('name') or '').strip()
|
||||
exchange_id = (data.get('exchange_id') or '').strip()
|
||||
api_key = (data.get('api_key') or '').strip()
|
||||
@@ -78,16 +81,15 @@ def create_credential():
|
||||
'secret_key': secret_key,
|
||||
'passphrase': passphrase
|
||||
}, ensure_ascii=False)
|
||||
now = int(time.time())
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_exchange_credentials (user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config, now, now)
|
||||
(user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config)
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
db.commit()
|
||||
@@ -101,10 +103,12 @@ def create_credential():
|
||||
|
||||
|
||||
@credentials_bp.route('/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_credential():
|
||||
"""Delete a credential for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
cred_id = request.args.get('id', type=int)
|
||||
user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID
|
||||
if not cred_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
|
||||
|
||||
@@ -125,14 +129,14 @@ def delete_credential():
|
||||
|
||||
|
||||
@credentials_bp.route('/get', methods=['GET'])
|
||||
@login_required
|
||||
def get_credential():
|
||||
"""
|
||||
Return decrypted credential for form auto-fill.
|
||||
NOTE: In a production system, this must be protected by strong authentication/authorization.
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
cred_id = request.args.get('id', type=int)
|
||||
user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID
|
||||
if not cred_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
|
||||
|
||||
|
||||
@@ -15,10 +15,11 @@ import json
|
||||
import time
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.auth import login_required
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -255,19 +256,24 @@ def _compute_strategy_stats(trades: List[Dict[str, Any]], strategies: List[Dict[
|
||||
|
||||
|
||||
@dashboard_bp.route("/summary", methods=["GET"])
|
||||
@login_required
|
||||
def summary():
|
||||
"""
|
||||
Return dashboard summary used by `quantdinger_vue/src/views/dashboard/index.vue`.
|
||||
"""
|
||||
try:
|
||||
# Strategy counts
|
||||
user_id = g.user_id
|
||||
|
||||
# Strategy counts (filtered by user_id)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, strategy_name, strategy_type, status, initial_capital, trading_config
|
||||
FROM qd_strategies_trading
|
||||
"""
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
strategies = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -292,7 +298,7 @@ def summary():
|
||||
if isinstance(tc, dict) and _truthy(tc.get("enable_ai_filter")):
|
||||
ai_enabled_strategy_count += 1
|
||||
|
||||
# Positions (best-effort)
|
||||
# Positions (best-effort, filtered by user_id)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -300,8 +306,10 @@ def summary():
|
||||
SELECT p.*, s.strategy_name, s.initial_capital, s.leverage, s.market_type
|
||||
FROM qd_strategy_positions p
|
||||
LEFT JOIN qd_strategies_trading s ON s.id = p.strategy_id
|
||||
WHERE p.user_id = ?
|
||||
ORDER BY p.updated_at DESC
|
||||
"""
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -332,7 +340,7 @@ def summary():
|
||||
}
|
||||
)
|
||||
|
||||
# Recent trades (best-effort)
|
||||
# Recent trades (best-effort, filtered by user_id)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -340,9 +348,11 @@ def summary():
|
||||
SELECT t.*, s.strategy_name
|
||||
FROM qd_strategy_trades t
|
||||
LEFT JOIN qd_strategies_trading s ON s.id = t.strategy_id
|
||||
WHERE t.user_id = ?
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 500
|
||||
"""
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
recent_trades = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -497,18 +507,20 @@ def summary():
|
||||
|
||||
|
||||
@dashboard_bp.route("/pendingOrders", methods=["GET"])
|
||||
@login_required
|
||||
def pending_orders():
|
||||
"""
|
||||
Return pending orders list for dashboard page.
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
page_size = max(1, min(200, _safe_int(request.args.get("pageSize"), 20)))
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT COUNT(1) AS cnt FROM pending_orders")
|
||||
cur.execute("SELECT COUNT(1) AS cnt FROM pending_orders WHERE user_id = ?", (user_id,))
|
||||
total = int((cur.fetchone() or {}).get("cnt") or 0)
|
||||
cur.close()
|
||||
|
||||
@@ -525,10 +537,11 @@ def pending_orders():
|
||||
s.execution_mode AS strategy_execution_mode
|
||||
FROM pending_orders o
|
||||
LEFT JOIN qd_strategies_trading s ON s.id = o.strategy_id
|
||||
WHERE o.user_id = ?
|
||||
ORDER BY o.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(int(page_size), int(offset)),
|
||||
(user_id, int(page_size), int(offset)),
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -607,18 +620,21 @@ def pending_orders():
|
||||
|
||||
|
||||
@dashboard_bp.route("/pendingOrders/<int:order_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_pending_order(order_id: int):
|
||||
"""
|
||||
Delete a pending order record (dashboard operation).
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
oid = int(order_id or 0)
|
||||
if oid <= 0:
|
||||
return jsonify({"code": 0, "msg": "invalid_id", "data": None}), 400
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT id, status FROM pending_orders WHERE id = %s", (oid,))
|
||||
# Verify the order belongs to current user
|
||||
cur.execute("SELECT id, status FROM pending_orders WHERE id = ? AND user_id = ?", (oid, user_id))
|
||||
row = cur.fetchone() or {}
|
||||
if not row:
|
||||
cur.close()
|
||||
@@ -627,7 +643,7 @@ def delete_pending_order(order_id: int):
|
||||
if st == "processing":
|
||||
cur.close()
|
||||
return jsonify({"code": 0, "msg": "cannot_delete_processing", "data": None}), 400
|
||||
cur.execute("DELETE FROM pending_orders WHERE id = %s", (oid,))
|
||||
cur.execute("DELETE FROM pending_orders WHERE id = ? AND user_id = ?", (oid, user_id))
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
@@ -17,12 +17,13 @@ import time
|
||||
import traceback
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
from flask import Blueprint, Response, jsonify, request, g
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.auth import login_required
|
||||
import requests
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -115,24 +116,21 @@ def _generate_mock_df(length=200):
|
||||
return df
|
||||
|
||||
|
||||
@indicator_bp.route("/getIndicators", methods=["POST"])
|
||||
@indicator_bp.route("/getIndicators", methods=["GET"])
|
||||
@login_required
|
||||
def get_indicators():
|
||||
"""
|
||||
Get indicator list for a user.
|
||||
|
||||
Request:
|
||||
{ userid: number }
|
||||
Get indicator list for the current user.
|
||||
|
||||
Response:
|
||||
{ code: 1, data: [ ... ] }
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get("userid") or 1)
|
||||
user_id = g.user_id
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# Local mode: "我的指标" should include both purchased and custom indicators.
|
||||
# Get user's own indicators (both purchased and custom).
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -156,13 +154,13 @@ def get_indicators():
|
||||
|
||||
|
||||
@indicator_bp.route("/saveIndicator", methods=["POST"])
|
||||
@login_required
|
||||
def save_indicator():
|
||||
"""
|
||||
Create or update an indicator.
|
||||
Create or update an indicator for the current user.
|
||||
|
||||
Request (frontend sends many extra fields; we store only the essentials):
|
||||
{
|
||||
userid: number,
|
||||
id: number (0 for create),
|
||||
name: string,
|
||||
code: string,
|
||||
@@ -172,7 +170,7 @@ def save_indicator():
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get("userid") or 1)
|
||||
user_id = g.user_id
|
||||
indicator_id = int(data.get("id") or 0)
|
||||
code = data.get("code") or ""
|
||||
name = (data.get("name") or "").strip()
|
||||
@@ -199,7 +197,7 @@ def save_indicator():
|
||||
if not name:
|
||||
name = "Custom Indicator"
|
||||
|
||||
now = _now_ts()
|
||||
now = _now_ts() # For BIGINT fields (createtime, updatetime)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -209,10 +207,10 @@ def save_indicator():
|
||||
UPDATE qd_indicator_codes
|
||||
SET name = ?, code = ?, description = ?,
|
||||
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
|
||||
updatetime = ?, updated_at = ?
|
||||
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, now, indicator_id, user_id),
|
||||
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
@@ -221,9 +219,9 @@ def save_indicator():
|
||||
(user_id, is_buy, end_time, name, code, description,
|
||||
publish_to_community, pricing_type, price, preview_image,
|
||||
createtime, updatetime, created_at, updated_at)
|
||||
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, now, now, now, now),
|
||||
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, now, now),
|
||||
)
|
||||
indicator_id = int(cur.lastrowid or 0)
|
||||
db.commit()
|
||||
@@ -236,11 +234,12 @@ def save_indicator():
|
||||
|
||||
|
||||
@indicator_bp.route("/deleteIndicator", methods=["POST"])
|
||||
@login_required
|
||||
def delete_indicator():
|
||||
"""Delete an indicator by id."""
|
||||
"""Delete an indicator by id for the current user."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get("userid") or 1)
|
||||
user_id = g.user_id
|
||||
indicator_id = int(data.get("id") or 0)
|
||||
if not indicator_id:
|
||||
return jsonify({"code": 0, "msg": "id is required", "data": None}), 400
|
||||
@@ -261,6 +260,7 @@ def delete_indicator():
|
||||
|
||||
|
||||
@indicator_bp.route("/verifyCode", methods=["POST"])
|
||||
@login_required
|
||||
def verify_code():
|
||||
"""
|
||||
Verify/Dry-run indicator code with mock data.
|
||||
@@ -372,6 +372,7 @@ def verify_code():
|
||||
|
||||
|
||||
@indicator_bp.route("/aiGenerate", methods=["POST"])
|
||||
@login_required
|
||||
def ai_generate():
|
||||
"""
|
||||
SSE endpoint to generate indicator code.
|
||||
|
||||
@@ -14,7 +14,7 @@ kline_bp = Blueprint('kline', __name__)
|
||||
kline_service = KlineService()
|
||||
|
||||
|
||||
@kline_bp.route('/kline', methods=['GET', 'POST'])
|
||||
@kline_bp.route('/kline', methods=['GET'])
|
||||
def get_kline():
|
||||
"""
|
||||
获取K线数据
|
||||
@@ -27,17 +27,12 @@ def get_kline():
|
||||
before_time: 获取此时间之前的数据 (可选,Unix时间戳)
|
||||
"""
|
||||
try:
|
||||
# 支持 GET 和 POST
|
||||
if request.method == 'POST':
|
||||
data = request.get_json() or {}
|
||||
else:
|
||||
data = request.args
|
||||
|
||||
market = data.get('market', 'USStock')
|
||||
symbol = data.get('symbol', '')
|
||||
timeframe = data.get('timeframe', '1D')
|
||||
limit = int(data.get('limit', 300))
|
||||
before_time = data.get('before_time') or data.get('beforeTime')
|
||||
# 强制 GET, 使用 request.args
|
||||
market = request.args.get('market', 'USStock')
|
||||
symbol = request.args.get('symbol', '')
|
||||
timeframe = request.args.get('timeframe', '1D')
|
||||
limit = int(request.args.get('limit', 300))
|
||||
before_time = request.args.get('before_time') or request.args.get('beforeTime')
|
||||
|
||||
if before_time:
|
||||
before_time = int(before_time)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Market API routes (local-only).
|
||||
Provides watchlist, market metadata, symbol search, and pricing helpers for the frontend.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
import traceback
|
||||
import json
|
||||
import time
|
||||
@@ -13,6 +13,7 @@ from app.utils.logger import get_logger
|
||||
from app.utils.cache import CacheManager
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.config_loader import load_addon_config
|
||||
from app.utils.auth import login_required
|
||||
from app.data.market_symbols_seed import (
|
||||
get_hot_symbols as seed_get_hot_symbols,
|
||||
search_symbols as seed_search_symbols,
|
||||
@@ -26,11 +27,9 @@ market_bp = Blueprint('market', __name__)
|
||||
kline_service = KlineService()
|
||||
cache = CacheManager()
|
||||
|
||||
# 线程池用于并行获取价格
|
||||
# Thread pool for parallel price fetching
|
||||
executor = ThreadPoolExecutor(max_workers=10)
|
||||
|
||||
DEFAULT_USER_ID = 1
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
@@ -125,7 +124,7 @@ def get_market_types():
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': data})
|
||||
|
||||
|
||||
@market_bp.route('/menuFooterConfig', methods=['POST'])
|
||||
@market_bp.route('/menuFooterConfig', methods=['GET'])
|
||||
def get_menu_footer_config():
|
||||
"""
|
||||
Compatibility stub for old PHP `getMenuFooterConfig`.
|
||||
@@ -150,17 +149,16 @@ def get_menu_footer_config():
|
||||
}
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': data})
|
||||
|
||||
@market_bp.route('/symbols/search', methods=['POST'])
|
||||
@market_bp.route('/symbols/search', methods=['GET'])
|
||||
def search_symbols():
|
||||
"""
|
||||
Lightweight symbol search.
|
||||
In local-only mode we keep this simple; frontend allows manual input when no results.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
market = (data.get('market') or '').strip()
|
||||
keyword = (data.get('keyword') or '').strip().upper()
|
||||
limit = int(data.get('limit') or 20)
|
||||
market = (request.args.get('market') or '').strip()
|
||||
keyword = (request.args.get('keyword') or '').strip().upper()
|
||||
limit = int(request.args.get('limit') or 20)
|
||||
|
||||
if not market or not keyword:
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': []})
|
||||
@@ -172,29 +170,30 @@ def search_symbols():
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
|
||||
|
||||
@market_bp.route('/symbols/hot', methods=['POST'])
|
||||
@market_bp.route('/symbols/hot', methods=['GET'])
|
||||
def get_hot_symbols():
|
||||
"""Return a small curated hot list per market (local-only)."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
market = (data.get('market') or '').strip()
|
||||
limit = int(data.get('limit') or 10)
|
||||
market = (request.args.get('market') or '').strip()
|
||||
limit = int(request.args.get('limit') or 10)
|
||||
hot = seed_get_hot_symbols(market=market, limit=limit)
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': hot})
|
||||
except Exception as e:
|
||||
logger.error(f"get_hot_symbols failed: {str(e)}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
|
||||
|
||||
@market_bp.route('/watchlist/get', methods=['POST'])
|
||||
@market_bp.route('/watchlist/get', methods=['GET'])
|
||||
@login_required
|
||||
def get_watchlist():
|
||||
"""Get local watchlist for the single user."""
|
||||
"""Get watchlist for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
_ensure_watchlist_table()
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, market, symbol, name FROM qd_watchlist WHERE user_id = ? ORDER BY id DESC",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
|
||||
@@ -213,8 +212,8 @@ def get_watchlist():
|
||||
if resolved and resolved != current_name:
|
||||
row['name'] = resolved
|
||||
cur.execute(
|
||||
"UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?",
|
||||
(resolved, _now_ts(), DEFAULT_USER_ID, market, symbol)
|
||||
"UPDATE qd_watchlist SET name = ?, updated_at = NOW() WHERE user_id = ? AND market = ? AND symbol = ?",
|
||||
(resolved, user_id, market, symbol)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -227,9 +226,11 @@ def get_watchlist():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
|
||||
|
||||
@market_bp.route('/watchlist/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_watchlist():
|
||||
"""Add a symbol to local watchlist (no credits/fees in local mode)."""
|
||||
"""Add a symbol to watchlist for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
market = (data.get('market') or '').strip()
|
||||
symbol = _normalize_symbol(data.get('symbol'))
|
||||
@@ -241,18 +242,18 @@ def add_watchlist():
|
||||
resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol)
|
||||
name = name_in or resolved or symbol
|
||||
|
||||
now = _now_ts()
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# Insert or ignore duplicates.
|
||||
# Insert or update (PostgreSQL UPSERT)
|
||||
cur.execute(
|
||||
"INSERT OR IGNORE INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(DEFAULT_USER_ID, market, symbol, name, now, now)
|
||||
)
|
||||
# If already exists, keep it fresh and sync name.
|
||||
cur.execute(
|
||||
"UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?",
|
||||
(name, now, DEFAULT_USER_ID, market, symbol)
|
||||
"""
|
||||
INSERT INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NOW(), NOW())
|
||||
ON CONFLICT(user_id, market, symbol) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(user_id, market, symbol, name)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -264,9 +265,11 @@ def add_watchlist():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
@market_bp.route('/watchlist/remove', methods=['POST'])
|
||||
@login_required
|
||||
def remove_watchlist():
|
||||
"""Remove a symbol from watchlist. Frontend only passes symbol, so we delete across markets."""
|
||||
"""Remove a symbol from watchlist for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
symbol = _normalize_symbol(data.get('symbol'))
|
||||
if not symbol:
|
||||
@@ -276,7 +279,7 @@ def remove_watchlist():
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_watchlist WHERE user_id = ? AND symbol = ?",
|
||||
(DEFAULT_USER_ID, symbol)
|
||||
(user_id, symbol)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -290,51 +293,17 @@ def remove_watchlist():
|
||||
def get_single_price(market: str, symbol: str) -> dict:
|
||||
"""获取单个标的的价格数据"""
|
||||
try:
|
||||
# 先尝试从缓存获取(60秒缓存)
|
||||
cache_key = f"watchlist_price:{market}:{symbol}"
|
||||
cached_data = cache.get(cache_key)
|
||||
# 使用 get_realtime_price 获取实时价格(内部已有30秒缓存)
|
||||
# 相比原先的 '1D' K线逻辑,这能更及时地反映 Crypto 等 24h 市场的变化
|
||||
price_data = kline_service.get_realtime_price(market, symbol)
|
||||
|
||||
if cached_data:
|
||||
logger.debug(f"Cache hit: {market}:{symbol}")
|
||||
return {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': cached_data.get('price', 0),
|
||||
'change': cached_data.get('change', 0),
|
||||
'changePercent': cached_data.get('changePercent', 0)
|
||||
}
|
||||
|
||||
# 获取最新的一根K线
|
||||
klines = kline_service.get_kline(market, symbol, '1D', 2)
|
||||
|
||||
if klines and len(klines) > 0:
|
||||
latest = klines[-1]
|
||||
prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0)
|
||||
current_price = latest.get('close', 0)
|
||||
|
||||
change = round(current_price - prev_close, 4) if prev_close else 0
|
||||
change_percent = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0
|
||||
|
||||
result = {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': current_price,
|
||||
'change': change,
|
||||
'changePercent': change_percent
|
||||
}
|
||||
|
||||
# 缓存60秒
|
||||
cache.set(cache_key, result, 60)
|
||||
|
||||
return result
|
||||
else:
|
||||
return {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': 0,
|
||||
'change': 0,
|
||||
'changePercent': 0
|
||||
}
|
||||
return {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': price_data.get('price', 0),
|
||||
'change': price_data.get('change', 0),
|
||||
'changePercent': price_data.get('changePercent', 0)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}")
|
||||
return {
|
||||
@@ -346,45 +315,26 @@ def get_single_price(market: str, symbol: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@market_bp.route('/watchlist/prices', methods=['POST'])
|
||||
@market_bp.route('/watchlist/prices', methods=['GET'])
|
||||
def get_watchlist_prices():
|
||||
"""
|
||||
批量获取自选股价格
|
||||
|
||||
请求体:
|
||||
{
|
||||
"watchlist": [
|
||||
{"market": "USStock", "symbol": "AAPL"},
|
||||
{"market": "Crypto", "symbol": "BTC"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
响应:
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{"market": "USStock", "symbol": "AAPL", "price": 150.0, "change": 1.5, "changePercent": 1.0},
|
||||
{"market": "Crypto", "symbol": "BTC", "price": 95000.0, "change": 1000, "changePercent": 1.05}
|
||||
]
|
||||
}
|
||||
Params (Query String):
|
||||
watchlist: JSON string of list of {market, symbol} objects
|
||||
e.g. ?watchlist=[{"market":"USStock","symbol":"AAPL"}]
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Request body is required',
|
||||
'data': []
|
||||
}), 400
|
||||
|
||||
watchlist = data.get('watchlist', [])
|
||||
watchlist_str = request.args.get('watchlist', '[]')
|
||||
try:
|
||||
watchlist = json.loads(watchlist_str)
|
||||
except Exception:
|
||||
watchlist = []
|
||||
|
||||
if not watchlist or not isinstance(watchlist, list):
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Invalid watchlist format',
|
||||
'msg': 'Invalid watchlist format (expected JSON list in query param)',
|
||||
'data': []
|
||||
}), 400
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Portfolio API routes (local-only).
|
||||
Manages manual positions (user's existing holdings) and AI monitoring tasks.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
import json
|
||||
import traceback
|
||||
import time
|
||||
@@ -13,6 +13,7 @@ from app.services.kline import KlineService
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.cache import CacheManager
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.auth import login_required
|
||||
from app.services.symbol_name import resolve_symbol_name
|
||||
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
|
||||
|
||||
@@ -23,18 +24,16 @@ kline_service = KlineService()
|
||||
cache = CacheManager()
|
||||
|
||||
# Thread pool for parallel price fetching
|
||||
# 降低并发数避免触发API限制(尤其是外汇/美股等有速率限制的API)
|
||||
# Lower concurrency to avoid triggering API limits (especially for forex/US stocks)
|
||||
executor = ThreadPoolExecutor(max_workers=3)
|
||||
|
||||
# 请求间隔(秒),避免请求过快
|
||||
# Request interval (seconds) to avoid too frequent requests
|
||||
REQUEST_INTERVAL = 0.3
|
||||
|
||||
# 速率限制相关
|
||||
# Rate limiting related
|
||||
_request_lock = threading.Lock()
|
||||
_last_request_time = {} # {market: timestamp}
|
||||
|
||||
DEFAULT_USER_ID = 1
|
||||
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
@@ -109,10 +108,12 @@ def _get_single_price(market: str, symbol: str, force_refresh: bool = False) ->
|
||||
# ==================== Position CRUD ====================
|
||||
|
||||
@portfolio_bp.route('/positions', methods=['GET'])
|
||||
@login_required
|
||||
def get_positions():
|
||||
"""Get all manual positions with current prices."""
|
||||
"""Get all manual positions with current prices for the current user."""
|
||||
try:
|
||||
# 检查是否强制刷新(跳过缓存)
|
||||
user_id = g.user_id
|
||||
# Check if force refresh (skip cache)
|
||||
force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes')
|
||||
|
||||
with get_db_connection() as db:
|
||||
@@ -124,7 +125,7 @@ def get_positions():
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -212,9 +213,11 @@ def get_positions():
|
||||
|
||||
|
||||
@portfolio_bp.route('/positions', methods=['POST'])
|
||||
@login_required
|
||||
def add_position():
|
||||
"""Add a new manual position."""
|
||||
"""Add a new manual position for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
market = (data.get('market') or '').strip()
|
||||
symbol = _normalize_symbol(data.get('symbol'))
|
||||
@@ -244,7 +247,6 @@ def add_position():
|
||||
name = name_in or resolved or symbol
|
||||
|
||||
tags_json = json.dumps(tags if isinstance(tags, list) else [], ensure_ascii=False)
|
||||
now = _now_ts()
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -252,7 +254,7 @@ def add_position():
|
||||
"""
|
||||
INSERT INTO qd_manual_positions
|
||||
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
ON CONFLICT(user_id, market, symbol, side) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
quantity = excluded.quantity,
|
||||
@@ -261,9 +263,9 @@ def add_position():
|
||||
notes = excluded.notes,
|
||||
tags = excluded.tags,
|
||||
group_name = excluded.group_name,
|
||||
updated_at = excluded.updated_at
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(DEFAULT_USER_ID, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name, now, now)
|
||||
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name)
|
||||
)
|
||||
position_id = cur.lastrowid
|
||||
db.commit()
|
||||
@@ -277,9 +279,11 @@ def add_position():
|
||||
|
||||
|
||||
@portfolio_bp.route('/positions/<int:position_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_position(position_id):
|
||||
"""Update an existing position."""
|
||||
"""Update an existing position for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
|
||||
updates = []
|
||||
@@ -323,10 +327,9 @@ def update_position(position_id):
|
||||
if not updates:
|
||||
return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400
|
||||
|
||||
updates.append('updated_at = ?')
|
||||
params.append(_now_ts())
|
||||
updates.append('updated_at = NOW()')
|
||||
params.append(position_id)
|
||||
params.append(DEFAULT_USER_ID)
|
||||
params.append(user_id)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -345,14 +348,16 @@ def update_position(position_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/positions/<int:position_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_position(position_id):
|
||||
"""Delete a position."""
|
||||
"""Delete a position for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?",
|
||||
(position_id, DEFAULT_USER_ID)
|
||||
(position_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -365,10 +370,12 @@ def delete_position(position_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/summary', methods=['GET'])
|
||||
@login_required
|
||||
def get_portfolio_summary():
|
||||
"""Get portfolio summary with total value, PnL, and market distribution."""
|
||||
"""Get portfolio summary with total value, PnL, and market distribution for the current user."""
|
||||
try:
|
||||
# 检查是否强制刷新
|
||||
user_id = g.user_id
|
||||
# Check if force refresh
|
||||
force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes')
|
||||
|
||||
with get_db_connection() as db:
|
||||
@@ -379,7 +386,7 @@ def get_portfolio_summary():
|
||||
FROM qd_manual_positions
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -485,9 +492,11 @@ def get_portfolio_summary():
|
||||
# ==================== Monitor CRUD ====================
|
||||
|
||||
@portfolio_bp.route('/monitors', methods=['GET'])
|
||||
@login_required
|
||||
def get_monitors():
|
||||
"""Get all position monitors."""
|
||||
"""Get all position monitors for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -498,7 +507,7 @@ def get_monitors():
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -529,9 +538,11 @@ def get_monitors():
|
||||
|
||||
|
||||
@portfolio_bp.route('/monitors', methods=['POST'])
|
||||
@login_required
|
||||
def add_monitor():
|
||||
"""Add a new position monitor."""
|
||||
"""Add a new position monitor for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
position_ids = data.get('position_ids') or []
|
||||
@@ -547,9 +558,7 @@ def add_monitor():
|
||||
monitor_type = 'ai'
|
||||
|
||||
# Calculate next_run_at based on interval
|
||||
now = _now_ts()
|
||||
interval_minutes = int(config.get('interval_minutes') or 60)
|
||||
next_run_at = now + (interval_minutes * 60)
|
||||
|
||||
position_ids_json = json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False)
|
||||
config_json = json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False)
|
||||
@@ -561,10 +570,10 @@ def add_monitor():
|
||||
"""
|
||||
INSERT INTO qd_position_monitors
|
||||
(user_id, name, position_ids, monitor_type, config, notification_config, is_active, next_run_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW() + INTERVAL '%s minutes', NOW(), NOW())
|
||||
""",
|
||||
(DEFAULT_USER_ID, name, position_ids_json, monitor_type, config_json, notification_config_json,
|
||||
1 if is_active else 0, next_run_at, now, now)
|
||||
(user_id, name, position_ids_json, monitor_type, config_json, notification_config_json,
|
||||
1 if is_active else 0, interval_minutes)
|
||||
)
|
||||
monitor_id = cur.lastrowid
|
||||
db.commit()
|
||||
@@ -578,9 +587,11 @@ def add_monitor():
|
||||
|
||||
|
||||
@portfolio_bp.route('/monitors/<int:monitor_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_monitor(monitor_id):
|
||||
"""Update an existing monitor."""
|
||||
"""Update an existing monitor for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
|
||||
updates = []
|
||||
@@ -599,15 +610,14 @@ def update_monitor(monitor_id):
|
||||
updates.append('monitor_type = ?')
|
||||
params.append((data.get('monitor_type') or 'ai').strip())
|
||||
|
||||
next_run_interval = None # Will store interval for special handling
|
||||
if 'config' in data:
|
||||
config = data.get('config') or {}
|
||||
updates.append('config = ?')
|
||||
params.append(json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False))
|
||||
|
||||
# Recalculate next_run_at if interval changed
|
||||
interval_minutes = int(config.get('interval_minutes') or 60)
|
||||
updates.append('next_run_at = ?')
|
||||
params.append(_now_ts() + (interval_minutes * 60))
|
||||
# Recalculate next_run_at if interval changed (handled separately for PostgreSQL)
|
||||
next_run_interval = int(config.get('interval_minutes') or 60)
|
||||
|
||||
if 'notification_config' in data:
|
||||
notification_config = data.get('notification_config') or {}
|
||||
@@ -621,10 +631,13 @@ def update_monitor(monitor_id):
|
||||
if not updates:
|
||||
return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400
|
||||
|
||||
updates.append('updated_at = ?')
|
||||
params.append(_now_ts())
|
||||
# Add next_run_at update if interval was changed
|
||||
if next_run_interval is not None:
|
||||
updates.append(f"next_run_at = NOW() + INTERVAL '{next_run_interval} minutes'")
|
||||
|
||||
updates.append('updated_at = NOW()')
|
||||
params.append(monitor_id)
|
||||
params.append(DEFAULT_USER_ID)
|
||||
params.append(user_id)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -643,14 +656,16 @@ def update_monitor(monitor_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/monitors/<int:monitor_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_monitor(monitor_id):
|
||||
"""Delete a monitor."""
|
||||
"""Delete a monitor for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?",
|
||||
(monitor_id, DEFAULT_USER_ID)
|
||||
(monitor_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -663,6 +678,7 @@ def delete_monitor(monitor_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/monitors/<int:monitor_id>/run', methods=['POST'])
|
||||
@login_required
|
||||
def run_monitor_now(monitor_id):
|
||||
"""Manually trigger a monitor to run immediately."""
|
||||
try:
|
||||
@@ -692,9 +708,11 @@ def run_monitor_now(monitor_id):
|
||||
# ==================== Alerts CRUD ====================
|
||||
|
||||
@portfolio_bp.route('/alerts', methods=['GET'])
|
||||
@login_required
|
||||
def get_alerts():
|
||||
"""Get all position alerts."""
|
||||
"""Get all position alerts for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -708,7 +726,7 @@ def get_alerts():
|
||||
WHERE a.user_id = ?
|
||||
ORDER BY a.id DESC
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -743,9 +761,11 @@ def get_alerts():
|
||||
|
||||
|
||||
@portfolio_bp.route('/alerts', methods=['POST'])
|
||||
@login_required
|
||||
def add_alert():
|
||||
"""Add a new position alert."""
|
||||
"""Add a new position alert for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
position_id = data.get('position_id') # Can be None for symbol-level alerts
|
||||
market = (data.get('market') or '').strip()
|
||||
@@ -768,7 +788,7 @@ def add_alert():
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT market, symbol FROM qd_manual_positions WHERE id = ? AND user_id = ?",
|
||||
(position_id, DEFAULT_USER_ID)
|
||||
(position_id, user_id)
|
||||
)
|
||||
pos = cur.fetchone()
|
||||
cur.close()
|
||||
@@ -782,7 +802,6 @@ def add_alert():
|
||||
if threshold <= 0 and alert_type.startswith('price_'):
|
||||
return jsonify({'code': 0, 'msg': 'Threshold must be positive for price alerts', 'data': None}), 400
|
||||
|
||||
now = _now_ts()
|
||||
notification_config_json = json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False)
|
||||
|
||||
with get_db_connection() as db:
|
||||
@@ -793,7 +812,7 @@ def add_alert():
|
||||
if position_id:
|
||||
cur.execute(
|
||||
"SELECT id FROM qd_position_alerts WHERE position_id = ? AND user_id = ?",
|
||||
(position_id, DEFAULT_USER_ID)
|
||||
(position_id, user_id)
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
@@ -805,11 +824,11 @@ def add_alert():
|
||||
"""
|
||||
UPDATE qd_position_alerts
|
||||
SET alert_type = ?, threshold = ?, notification_config = ?,
|
||||
is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = ?
|
||||
is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(alert_type, threshold, notification_config_json,
|
||||
1 if is_active else 0, repeat_interval, notes, now, existing_alert_id)
|
||||
1 if is_active else 0, repeat_interval, notes, existing_alert_id)
|
||||
)
|
||||
alert_id = existing_alert_id
|
||||
else:
|
||||
@@ -819,10 +838,10 @@ def add_alert():
|
||||
INSERT INTO qd_position_alerts
|
||||
(user_id, position_id, market, symbol, alert_type, threshold, notification_config,
|
||||
is_active, repeat_interval, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(DEFAULT_USER_ID, position_id, market, symbol, alert_type, threshold, notification_config_json,
|
||||
1 if is_active else 0, repeat_interval, notes, now, now)
|
||||
(user_id, position_id, market, symbol, alert_type, threshold, notification_config_json,
|
||||
1 if is_active else 0, repeat_interval, notes)
|
||||
)
|
||||
alert_id = cur.lastrowid
|
||||
|
||||
@@ -837,9 +856,11 @@ def add_alert():
|
||||
|
||||
|
||||
@portfolio_bp.route('/alerts/<int:alert_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_alert(alert_id):
|
||||
"""Update an existing alert."""
|
||||
"""Update an existing alert for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
|
||||
updates = []
|
||||
@@ -877,10 +898,9 @@ def update_alert(alert_id):
|
||||
if not updates:
|
||||
return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400
|
||||
|
||||
updates.append('updated_at = ?')
|
||||
params.append(_now_ts())
|
||||
updates.append('updated_at = NOW()')
|
||||
params.append(alert_id)
|
||||
params.append(DEFAULT_USER_ID)
|
||||
params.append(user_id)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -899,14 +919,16 @@ def update_alert(alert_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/alerts/<int:alert_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_alert(alert_id):
|
||||
"""Delete an alert."""
|
||||
"""Delete an alert for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?",
|
||||
(alert_id, DEFAULT_USER_ID)
|
||||
(alert_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -921,9 +943,11 @@ def delete_alert(alert_id):
|
||||
# ==================== Groups ====================
|
||||
|
||||
@portfolio_bp.route('/groups', methods=['GET'])
|
||||
@login_required
|
||||
def get_groups():
|
||||
"""Get list of all groups with position counts."""
|
||||
"""Get list of all groups with position counts for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -934,14 +958,14 @@ def get_groups():
|
||||
GROUP BY group_name
|
||||
ORDER BY group_name
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
|
||||
# Also get count of ungrouped
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as count FROM qd_manual_positions WHERE user_id = ? AND (group_name IS NULL OR group_name = '')",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
ungrouped = cur.fetchone()
|
||||
cur.close()
|
||||
@@ -971,9 +995,11 @@ def get_groups():
|
||||
|
||||
|
||||
@portfolio_bp.route('/groups/rename', methods=['POST'])
|
||||
@login_required
|
||||
def rename_group():
|
||||
"""Rename a group."""
|
||||
"""Rename a group for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
old_name = (data.get('old_name') or '').strip()
|
||||
new_name = (data.get('new_name') or '').strip()
|
||||
@@ -981,12 +1007,11 @@ def rename_group():
|
||||
if not old_name:
|
||||
return jsonify({'code': 0, 'msg': 'old_name is required', 'data': None}), 400
|
||||
|
||||
now = _now_ts()
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"UPDATE qd_manual_positions SET group_name = ?, updated_at = ? WHERE user_id = ? AND group_name = ?",
|
||||
(new_name, now, DEFAULT_USER_ID, old_name)
|
||||
"UPDATE qd_manual_positions SET group_name = ?, updated_at = NOW() WHERE user_id = ? AND group_name = ?",
|
||||
(new_name, user_id, old_name)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
Settings API - 读取和保存 .env 配置
|
||||
|
||||
Admin-only endpoints for system configuration management.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.config_loader import clear_config_cache
|
||||
from app.utils.auth import login_required, admin_required
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -791,8 +794,10 @@ def write_env_file(env_values):
|
||||
|
||||
|
||||
@settings_bp.route('/schema', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_settings_schema():
|
||||
"""获取配置项定义"""
|
||||
"""获取配置项定义 (admin only)"""
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
@@ -801,8 +806,10 @@ def get_settings_schema():
|
||||
|
||||
|
||||
@settings_bp.route('/values', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_settings_values():
|
||||
"""获取当前配置值 - 包括敏感信息(真实值)"""
|
||||
"""获取当前配置值 - 包括敏感信息(真实值)(admin only)"""
|
||||
env_values = read_env_file()
|
||||
|
||||
# 构建返回数据,返回真实值
|
||||
@@ -825,8 +832,10 @@ def get_settings_values():
|
||||
|
||||
|
||||
@settings_bp.route('/save', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def save_settings():
|
||||
"""保存配置"""
|
||||
"""保存配置 (admin only)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
@@ -878,8 +887,10 @@ def save_settings():
|
||||
|
||||
|
||||
@settings_bp.route('/openrouter-balance', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_openrouter_balance():
|
||||
"""查询 OpenRouter 账户余额"""
|
||||
"""查询 OpenRouter 账户余额 (admin only)"""
|
||||
try:
|
||||
import requests
|
||||
from app.config.api_keys import APIKeys
|
||||
@@ -954,8 +965,10 @@ def get_openrouter_balance():
|
||||
|
||||
|
||||
@settings_bp.route('/test-connection', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def test_connection():
|
||||
"""测试API连接"""
|
||||
"""测试API连接 (admin only)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
service = data.get('service')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
交易策略 API 路由
|
||||
Trading Strategy API Routes
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
import traceback
|
||||
import time
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.services.backtest import BacktestService
|
||||
from app import get_trading_executor
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.auth import login_required
|
||||
from app.data_sources import DataSourceFactory
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -29,12 +30,13 @@ def get_strategy_service() -> StrategyService:
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies', methods=['GET'])
|
||||
@login_required
|
||||
def list_strategies():
|
||||
"""
|
||||
策略列表(本地版:单用户)
|
||||
List strategies for the current user.
|
||||
"""
|
||||
try:
|
||||
user_id = request.args.get('user_id', type=int) or 1
|
||||
user_id = g.user_id
|
||||
items = get_strategy_service().list_strategies(user_id=user_id)
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'strategies': items}})
|
||||
except Exception as e:
|
||||
@@ -44,12 +46,14 @@ def list_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/detail', methods=['GET'])
|
||||
@login_required
|
||||
def get_strategy_detail():
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400
|
||||
st = get_strategy_service().get_strategy(strategy_id)
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': st})
|
||||
@@ -60,11 +64,13 @@ def get_strategy_detail():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_strategy():
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
# Local mode default user
|
||||
payload['user_id'] = int(payload.get('user_id') or 1)
|
||||
# Use current user's ID
|
||||
payload['user_id'] = user_id
|
||||
payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy'
|
||||
new_id = get_strategy_service().create_strategy(payload)
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}})
|
||||
@@ -75,18 +81,20 @@ def create_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/batch-create', methods=['POST'])
|
||||
@login_required
|
||||
def batch_create_strategies():
|
||||
"""
|
||||
批量创建策略(多币种)
|
||||
Batch create strategies (multiple symbols)
|
||||
|
||||
请求体:
|
||||
strategy_name: 策略基础名称
|
||||
symbols: 币种数组,如 ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
|
||||
... 其他策略配置
|
||||
Request body:
|
||||
strategy_name: Base strategy name
|
||||
symbols: Array of symbols, e.g. ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
|
||||
... other strategy config
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
payload['user_id'] = int(payload.get('user_id') or 1)
|
||||
payload['user_id'] = user_id
|
||||
payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy'
|
||||
|
||||
result = get_strategy_service().batch_create_strategies(payload)
|
||||
@@ -94,13 +102,13 @@ def batch_create_strategies():
|
||||
if result['success']:
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': f"成功创建 {result['total_created']} 个策略",
|
||||
'msg': f"Successfully created {result['total_created']} strategies",
|
||||
'data': result
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': '批量创建失败',
|
||||
'msg': 'Batch creation failed',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -110,31 +118,33 @@ def batch_create_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/batch-start', methods=['POST'])
|
||||
@login_required
|
||||
def batch_start_strategies():
|
||||
"""
|
||||
批量启动策略
|
||||
Batch start strategies
|
||||
|
||||
请求体:
|
||||
strategy_ids: 策略ID数组
|
||||
或
|
||||
strategy_group_id: 策略组ID
|
||||
Request body:
|
||||
strategy_ids: Array of strategy IDs
|
||||
or
|
||||
strategy_group_id: Strategy group ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
strategy_ids = payload.get('strategy_ids') or []
|
||||
strategy_group_id = payload.get('strategy_group_id')
|
||||
|
||||
# 如果提供了策略组ID,获取组内所有策略
|
||||
# If strategy_group_id provided, get all strategies in the group
|
||||
if strategy_group_id and not strategy_ids:
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id)
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id)
|
||||
|
||||
if not strategy_ids:
|
||||
return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400
|
||||
return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400
|
||||
|
||||
# 先更新数据库状态
|
||||
result = get_strategy_service().batch_start_strategies(strategy_ids)
|
||||
# Update database status first
|
||||
result = get_strategy_service().batch_start_strategies(strategy_ids, user_id=user_id)
|
||||
|
||||
# 然后启动执行器
|
||||
# Then start executor
|
||||
executor = get_trading_executor()
|
||||
for sid in result.get('success_ids', []):
|
||||
try:
|
||||
@@ -144,7 +154,7 @@ def batch_start_strategies():
|
||||
|
||||
return jsonify({
|
||||
'code': 1 if result['success'] else 0,
|
||||
'msg': f"成功启动 {len(result.get('success_ids', []))} 个策略",
|
||||
'msg': f"Successfully started {len(result.get('success_ids', []))} strategies",
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -154,27 +164,29 @@ def batch_start_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/batch-stop', methods=['POST'])
|
||||
@login_required
|
||||
def batch_stop_strategies():
|
||||
"""
|
||||
批量停止策略
|
||||
Batch stop strategies
|
||||
|
||||
请求体:
|
||||
strategy_ids: 策略ID数组
|
||||
或
|
||||
strategy_group_id: 策略组ID
|
||||
Request body:
|
||||
strategy_ids: Array of strategy IDs
|
||||
or
|
||||
strategy_group_id: Strategy group ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
strategy_ids = payload.get('strategy_ids') or []
|
||||
strategy_group_id = payload.get('strategy_group_id')
|
||||
|
||||
if strategy_group_id and not strategy_ids:
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id)
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id)
|
||||
|
||||
if not strategy_ids:
|
||||
return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400
|
||||
return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400
|
||||
|
||||
# 先停止执行器
|
||||
# Stop executor first
|
||||
executor = get_trading_executor()
|
||||
for sid in strategy_ids:
|
||||
try:
|
||||
@@ -182,12 +194,12 @@ def batch_stop_strategies():
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop executor for strategy {sid}: {e}")
|
||||
|
||||
# 然后更新数据库状态
|
||||
result = get_strategy_service().batch_stop_strategies(strategy_ids)
|
||||
# Then update database status
|
||||
result = get_strategy_service().batch_stop_strategies(strategy_ids, user_id=user_id)
|
||||
|
||||
return jsonify({
|
||||
'code': 1 if result['success'] else 0,
|
||||
'msg': f"成功停止 {len(result.get('success_ids', []))} 个策略",
|
||||
'msg': f"Successfully stopped {len(result.get('success_ids', []))} strategies",
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -197,40 +209,42 @@ def batch_stop_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/batch-delete', methods=['DELETE'])
|
||||
@login_required
|
||||
def batch_delete_strategies():
|
||||
"""
|
||||
批量删除策略
|
||||
Batch delete strategies
|
||||
|
||||
请求体:
|
||||
strategy_ids: 策略ID数组
|
||||
或
|
||||
strategy_group_id: 策略组ID
|
||||
Request body:
|
||||
strategy_ids: Array of strategy IDs
|
||||
or
|
||||
strategy_group_id: Strategy group ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
strategy_ids = payload.get('strategy_ids') or []
|
||||
strategy_group_id = payload.get('strategy_group_id')
|
||||
|
||||
if strategy_group_id and not strategy_ids:
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id)
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id)
|
||||
|
||||
if not strategy_ids:
|
||||
return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400
|
||||
return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400
|
||||
|
||||
# 先停止执行器
|
||||
# Stop executor first
|
||||
executor = get_trading_executor()
|
||||
for sid in strategy_ids:
|
||||
try:
|
||||
executor.stop_strategy(sid)
|
||||
except Exception as e:
|
||||
pass # 忽略停止错误
|
||||
pass # Ignore stop errors
|
||||
|
||||
# 然后删除
|
||||
result = get_strategy_service().batch_delete_strategies(strategy_ids)
|
||||
# Then delete
|
||||
result = get_strategy_service().batch_delete_strategies(strategy_ids, user_id=user_id)
|
||||
|
||||
return jsonify({
|
||||
'code': 1 if result['success'] else 0,
|
||||
'msg': f"成功删除 {len(result.get('success_ids', []))} 个策略",
|
||||
'msg': f"Successfully deleted {len(result.get('success_ids', []))} strategies",
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -240,13 +254,15 @@ def batch_delete_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/update', methods=['PUT'])
|
||||
@login_required
|
||||
def update_strategy():
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400
|
||||
payload = request.get_json() or {}
|
||||
ok = get_strategy_service().update_strategy(strategy_id, payload)
|
||||
ok = get_strategy_service().update_strategy(strategy_id, payload, user_id=user_id)
|
||||
if not ok:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': None})
|
||||
@@ -257,12 +273,14 @@ def update_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_strategy():
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400
|
||||
ok = get_strategy_service().delete_strategy(strategy_id)
|
||||
ok = get_strategy_service().delete_strategy(strategy_id, user_id=user_id)
|
||||
return jsonify({'code': 1 if ok else 0, 'msg': 'success' if ok else 'failed', 'data': None})
|
||||
except Exception as e:
|
||||
logger.error(f"delete_strategy failed: {str(e)}")
|
||||
@@ -271,12 +289,20 @@ def delete_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/trades', methods=['GET'])
|
||||
@login_required
|
||||
def get_trades():
|
||||
"""交易记录(从本地 SQLite 读取)"""
|
||||
"""Get trade records for the current user's strategy."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'trades': [], 'items': []}}), 400
|
||||
|
||||
# Verify strategy belongs to user
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': {'trades': [], 'items': []}}), 404
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -299,12 +325,20 @@ def get_trades():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/positions', methods=['GET'])
|
||||
@login_required
|
||||
def get_positions():
|
||||
"""持仓记录(从本地 SQLite 读取)"""
|
||||
"""Get position records for the current user's strategy."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'positions': [], 'items': []}}), 400
|
||||
|
||||
# Verify strategy belongs to user
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': {'positions': [], 'items': []}}), 404
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -382,10 +416,10 @@ def get_positions():
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_strategy_positions
|
||||
SET current_price = ?, unrealized_pnl = ?, pnl_percent = ?, updated_at = ?
|
||||
SET current_price = ?, unrealized_pnl = ?, pnl_percent = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(float(cp or 0.0), float(pnl), float(pct), int(now), int(rr.get("id"))),
|
||||
(float(cp or 0.0), float(pnl), float(pct), int(rr.get("id"))),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -400,14 +434,18 @@ def get_positions():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/equityCurve', methods=['GET'])
|
||||
@login_required
|
||||
def get_equity_curve():
|
||||
"""净值曲线(本地简单计算:initial_capital + 累计 profit)"""
|
||||
"""Get equity curve for the current user's strategy."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': []}), 400
|
||||
|
||||
st = get_strategy_service().get_strategy(strategy_id) or {}
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) or {}
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': []}), 404
|
||||
initial = float(st.get('initial_capital') or (st.get('trading_config') or {}).get('initial_capital') or 0)
|
||||
if initial <= 0:
|
||||
initial = 1000.0
|
||||
@@ -447,14 +485,16 @@ def get_equity_curve():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/stop', methods=['POST'])
|
||||
@login_required
|
||||
def stop_strategy():
|
||||
"""
|
||||
停止策略
|
||||
Stop a strategy for the current user.
|
||||
|
||||
参数:
|
||||
id: 策略ID
|
||||
Params:
|
||||
id: Strategy ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
|
||||
if not strategy_id:
|
||||
@@ -464,18 +504,23 @@ def stop_strategy():
|
||||
'data': None
|
||||
}), 400
|
||||
|
||||
# 获取策略类型
|
||||
# Verify strategy belongs to user
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
|
||||
|
||||
# Get strategy type
|
||||
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
|
||||
|
||||
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
|
||||
if strategy_type == 'PromptBasedStrategy':
|
||||
return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting/stopping AI strategies', 'data': None}), 400
|
||||
|
||||
# 指标策略
|
||||
# Indicator strategy
|
||||
get_trading_executor().stop_strategy(strategy_id)
|
||||
|
||||
# 更新策略状态
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'stopped')
|
||||
# Update strategy status
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'stopped', user_id=user_id)
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
@@ -494,14 +539,16 @@ def stop_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/start', methods=['POST'])
|
||||
@login_required
|
||||
def start_strategy():
|
||||
"""
|
||||
启动策略
|
||||
Start a strategy for the current user.
|
||||
|
||||
参数:
|
||||
id: 策略ID
|
||||
Params:
|
||||
id: Strategy ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
|
||||
if not strategy_id:
|
||||
@@ -511,22 +558,27 @@ def start_strategy():
|
||||
'data': None
|
||||
}), 400
|
||||
|
||||
# 获取策略类型
|
||||
# Verify strategy belongs to user
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
|
||||
|
||||
# Get strategy type
|
||||
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
|
||||
|
||||
# 更新策略状态
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'running')
|
||||
# Update strategy status
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'running', user_id=user_id)
|
||||
|
||||
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
|
||||
if strategy_type == 'PromptBasedStrategy':
|
||||
return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting AI strategies', 'data': None}), 400
|
||||
|
||||
# 指标策略
|
||||
# Indicator strategy
|
||||
success = get_trading_executor().start_strategy(strategy_id)
|
||||
|
||||
if not success:
|
||||
# 如果启动失败,恢复状态
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'stopped')
|
||||
# If start failed, restore status
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'stopped', user_id=user_id)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Failed to start strategy executor',
|
||||
@@ -550,12 +602,13 @@ def start_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/test-connection', methods=['POST'])
|
||||
@login_required
|
||||
def test_connection():
|
||||
"""
|
||||
测试交易所连接
|
||||
Test exchange connection.
|
||||
|
||||
请求体:
|
||||
exchange_config: 交易所配置
|
||||
Request body:
|
||||
exchange_config: Exchange configuration
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
@@ -619,12 +672,13 @@ def test_connection():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/get-symbols', methods=['POST'])
|
||||
@login_required
|
||||
def get_symbols():
|
||||
"""
|
||||
获取交易所交易对列表
|
||||
Get exchange trading pairs list.
|
||||
|
||||
请求体:
|
||||
exchange_config: 交易所配置
|
||||
Request body:
|
||||
exchange_config: Exchange configuration
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
@@ -662,9 +716,10 @@ def get_symbols():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/preview-compile', methods=['POST'])
|
||||
@login_required
|
||||
def preview_compile():
|
||||
"""
|
||||
预览编译后的策略结果
|
||||
Preview compiled strategy result.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
@@ -708,9 +763,10 @@ def preview_compile():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/notifications', methods=['GET'])
|
||||
@login_required
|
||||
def get_strategy_notifications():
|
||||
"""
|
||||
Strategy signal notifications (browser channel persistence).
|
||||
Strategy signal notifications for the current user.
|
||||
|
||||
Query:
|
||||
- id: strategy id (optional)
|
||||
@@ -718,16 +774,39 @@ def get_strategy_notifications():
|
||||
- since_id: return rows with id > since_id (optional)
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
limit = request.args.get('limit', type=int) or 50
|
||||
limit = max(1, min(200, int(limit)))
|
||||
since_id = request.args.get('since_id', type=int) or 0
|
||||
|
||||
# Get user's strategy IDs for filtering notifications
|
||||
user_strategy_ids = []
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT id FROM qd_strategies_trading WHERE user_id = ?", (user_id,))
|
||||
rows = cur.fetchall() or []
|
||||
user_strategy_ids = [r.get('id') for r in rows if r.get('id')]
|
||||
cur.close()
|
||||
|
||||
if not user_strategy_ids:
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}})
|
||||
|
||||
where = []
|
||||
args = []
|
||||
|
||||
# Filter by user's strategies
|
||||
if strategy_id:
|
||||
where.append("strategy_id = ?")
|
||||
args.append(int(strategy_id))
|
||||
if strategy_id in user_strategy_ids:
|
||||
where.append("strategy_id = ?")
|
||||
args.append(int(strategy_id))
|
||||
else:
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}})
|
||||
else:
|
||||
placeholders = ",".join(["?"] * len(user_strategy_ids))
|
||||
where.append(f"strategy_id IN ({placeholders})")
|
||||
args.extend(user_strategy_ids)
|
||||
|
||||
if since_id:
|
||||
where.append("id > ?")
|
||||
args.append(int(since_id))
|
||||
@@ -756,19 +835,25 @@ def get_strategy_notifications():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/notifications/read', methods=['POST'])
|
||||
@login_required
|
||||
def mark_notification_read():
|
||||
"""Mark a single notification as read."""
|
||||
"""Mark a single notification as read for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
notification_id = data.get('id')
|
||||
if not notification_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing id'}), 400
|
||||
|
||||
# Only update notifications for user's strategies
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"UPDATE qd_strategy_notifications SET is_read = 1 WHERE id = ?",
|
||||
(int(notification_id),)
|
||||
"""
|
||||
UPDATE qd_strategy_notifications SET is_read = 1
|
||||
WHERE id = ? AND strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
""",
|
||||
(int(notification_id), user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -780,12 +865,20 @@ def mark_notification_read():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/notifications/read-all', methods=['POST'])
|
||||
@login_required
|
||||
def mark_all_notifications_read():
|
||||
"""Mark all notifications as read."""
|
||||
"""Mark all notifications as read for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("UPDATE qd_strategy_notifications SET is_read = 1")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_strategy_notifications SET is_read = 1
|
||||
WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
@@ -796,12 +889,20 @@ def mark_all_notifications_read():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/notifications/clear', methods=['DELETE'])
|
||||
@login_required
|
||||
def clear_notifications():
|
||||
"""Clear all notifications (delete from database)."""
|
||||
"""Clear all notifications for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("DELETE FROM qd_strategy_notifications")
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM qd_strategy_notifications
|
||||
WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
@@ -41,11 +41,10 @@ def _extract_meta_from_code(code: str) -> Dict[str, str]:
|
||||
return {"name": name, "description": description}
|
||||
|
||||
|
||||
@strategy_code_bp.route("/strategy/getStrategies", methods=["POST"])
|
||||
@strategy_code_bp.route("/strategy/getStrategies", methods=["GET"])
|
||||
def get_strategies():
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get("userid") or 1)
|
||||
user_id = int(request.args.get("userid") or 1)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
User Management API Routes
|
||||
|
||||
Provides endpoints for user CRUD operations, role management, etc.
|
||||
Only accessible by admin users.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app.services.user_service import get_user_service
|
||||
from app.utils.auth import login_required, admin_required
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
user_bp = Blueprint('user_manage', __name__)
|
||||
|
||||
|
||||
@user_bp.route('/list', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def list_users():
|
||||
"""
|
||||
List all users (admin only).
|
||||
|
||||
Query params:
|
||||
page: int (default 1)
|
||||
page_size: int (default 20, max 100)
|
||||
"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
page_size = request.args.get('page_size', 20, type=int)
|
||||
page_size = min(100, max(1, page_size))
|
||||
|
||||
result = get_user_service().list_users(page=page, page_size=page_size)
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"list_users failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/detail', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_user_detail():
|
||||
"""Get user detail by ID (admin only)"""
|
||||
try:
|
||||
user_id = request.args.get('id', type=int)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400
|
||||
|
||||
user = get_user_service().get_user_by_id(user_id)
|
||||
if not user:
|
||||
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': user
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_detail failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
"""
|
||||
Create a new user (admin only).
|
||||
|
||||
Request body:
|
||||
username: str (required)
|
||||
password: str (required)
|
||||
email: str (optional)
|
||||
nickname: str (optional)
|
||||
role: str (optional, default 'user')
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
user_id = get_user_service().create_user(data)
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'User created successfully',
|
||||
'data': {'id': user_id}
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"create_user failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/update', methods=['PUT'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def update_user():
|
||||
"""
|
||||
Update user information (admin only).
|
||||
|
||||
Query params:
|
||||
id: int (required)
|
||||
|
||||
Request body:
|
||||
email: str (optional)
|
||||
nickname: str (optional)
|
||||
role: str (optional)
|
||||
status: str (optional)
|
||||
"""
|
||||
try:
|
||||
user_id = request.args.get('id', type=int)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
success = get_user_service().update_user(user_id, data)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'User updated successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"update_user failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user():
|
||||
"""Delete a user (admin only)"""
|
||||
try:
|
||||
user_id = request.args.get('id', type=int)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400
|
||||
|
||||
# Prevent deleting self
|
||||
if hasattr(g, 'user_id') and g.user_id == user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Cannot delete yourself', 'data': None}), 400
|
||||
|
||||
success = get_user_service().delete_user(user_id)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'User deleted successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Delete failed', 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"delete_user failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/reset-password', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def reset_user_password():
|
||||
"""
|
||||
Reset a user's password (admin only).
|
||||
|
||||
Request body:
|
||||
user_id: int (required)
|
||||
new_password: str (required)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = data.get('user_id')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400
|
||||
|
||||
if len(new_password) < 6:
|
||||
return jsonify({'code': 0, 'msg': 'Password must be at least 6 characters', 'data': None}), 400
|
||||
|
||||
success = get_user_service().reset_password(user_id, new_password)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'Password reset successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Reset failed', 'data': None}), 400
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"reset_user_password failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/roles', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_roles():
|
||||
"""Get available roles and their permissions"""
|
||||
service = get_user_service()
|
||||
|
||||
roles = []
|
||||
for role in service.ROLES:
|
||||
roles.append({
|
||||
'id': role,
|
||||
'name': role.capitalize(),
|
||||
'permissions': service.get_user_permissions(role)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': {'roles': roles}
|
||||
})
|
||||
|
||||
|
||||
# Self-service endpoints (accessible by any logged-in user)
|
||||
|
||||
@user_bp.route('/profile', methods=['GET'])
|
||||
@login_required
|
||||
def get_profile():
|
||||
"""Get current user's profile"""
|
||||
try:
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
|
||||
|
||||
user = get_user_service().get_user_by_id(user_id)
|
||||
if not user:
|
||||
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
|
||||
|
||||
# Add permissions
|
||||
user['permissions'] = get_user_service().get_user_permissions(user.get('role', 'user'))
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': user
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"get_profile failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/profile/update', methods=['PUT'])
|
||||
@login_required
|
||||
def update_profile():
|
||||
"""
|
||||
Update current user's profile (limited fields).
|
||||
|
||||
Request body:
|
||||
nickname: str (optional)
|
||||
email: str (optional)
|
||||
avatar: str (optional)
|
||||
"""
|
||||
try:
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Only allow updating certain fields for self-service
|
||||
allowed = {}
|
||||
for field in ['nickname', 'email', 'avatar']:
|
||||
if field in data:
|
||||
allowed[field] = data[field]
|
||||
|
||||
if not allowed:
|
||||
return jsonify({'code': 0, 'msg': 'No valid fields to update', 'data': None}), 400
|
||||
|
||||
success = get_user_service().update_user(user_id, allowed)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'Profile updated successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"update_profile failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/change-password', methods=['POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""
|
||||
Change current user's password.
|
||||
|
||||
Request body:
|
||||
old_password: str (required)
|
||||
new_password: str (required)
|
||||
"""
|
||||
try:
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
old_password = data.get('old_password', '')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not old_password or not new_password:
|
||||
return jsonify({'code': 0, 'msg': 'Both old and new password required', 'data': None}), 400
|
||||
|
||||
if len(new_password) < 6:
|
||||
return jsonify({'code': 0, 'msg': 'New password must be at least 6 characters', 'data': None}), 400
|
||||
|
||||
success = get_user_service().change_password(user_id, old_password, new_password)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Old password incorrect', 'data': None}), 400
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"change_password failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
Reference in New Issue
Block a user