Refactor code for improved readability and consistency

- Cleaned up whitespace and formatting in various files including http.py, language.py, logger.py, safe_exec.py, and SQL migration scripts.
- Consolidated import statements and removed unnecessary blank lines.
- Updated logging configuration for better clarity.
- Enhanced the safe execution code with improved error handling and logging.
- Removed commented-out code and unnecessary variables in backfill_zero_trades.py and other scripts.
- Added a pyproject.toml for Ruff and Vulture configuration.
- Introduced requirements-dev.txt for development dependencies.
- Removed commented-out stock entries in init.sql for cleaner migration scripts.
This commit is contained in:
dienakdz
2026-04-09 14:30:51 +07:00
parent 103055b3df
commit 87f2845483
157 changed files with 19026 additions and 17773 deletions
+40 -39
View File
@@ -1,51 +1,52 @@
"""
API Routes Module
"""
from flask import Flask
def register_routes(app: Flask):
"""Register all API route blueprints"""
from app.routes.kline import kline_bp
from app.routes.backtest import backtest_bp
from app.routes.health import health_bp
from app.routes.market import market_bp
from app.routes.strategy import strategy_bp
from app.routes.credentials import credentials_bp
from app.routes.auth import auth_bp
from app.routes.ai_chat import ai_chat_bp
from app.routes.indicator import indicator_bp
from app.routes.dashboard import dashboard_bp
from app.routes.settings import settings_bp
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.global_market import global_market_bp
from app.routes.community import community_bp
from app.routes.fast_analysis import fast_analysis_bp
from app.routes.auth import auth_bp
from app.routes.backtest import backtest_bp
from app.routes.billing import billing_bp
from app.routes.quick_trade import quick_trade_bp
from app.routes.community import community_bp
from app.routes.credentials import credentials_bp
from app.routes.dashboard import dashboard_bp
from app.routes.fast_analysis import fast_analysis_bp
from app.routes.global_market import global_market_bp
from app.routes.health import health_bp
from app.routes.ibkr import ibkr_bp
from app.routes.indicator import indicator_bp
from app.routes.kline import kline_bp
from app.routes.market import market_bp
from app.routes.mt5 import mt5_bp
from app.routes.polymarket import polymarket_bp
from app.routes.portfolio import portfolio_bp
from app.routes.quick_trade import quick_trade_bp
from app.routes.settings import settings_bp
from app.routes.strategy import strategy_bp
from app.routes.user import user_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
app.register_blueprint(user_bp, url_prefix='/api/users') # User management
app.register_blueprint(kline_bp, url_prefix='/api/indicator')
app.register_blueprint(backtest_bp, url_prefix='/api/indicator')
app.register_blueprint(market_bp, url_prefix='/api/market')
app.register_blueprint(ai_chat_bp, url_prefix='/api/ai')
app.register_blueprint(indicator_bp, url_prefix='/api/indicator')
app.register_blueprint(strategy_bp, url_prefix='/api')
app.register_blueprint(credentials_bp, url_prefix='/api/credentials')
app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard')
app.register_blueprint(settings_bp, url_prefix='/api/settings')
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')
app.register_blueprint(community_bp, url_prefix='/api/community')
app.register_blueprint(fast_analysis_bp, url_prefix='/api/fast-analysis')
app.register_blueprint(billing_bp, url_prefix='/api/billing')
app.register_blueprint(quick_trade_bp, url_prefix='/api/quick-trade')
app.register_blueprint(polymarket_bp, url_prefix='/api/polymarket')
app.register_blueprint(auth_bp, url_prefix="/api/auth") # Auth routes
app.register_blueprint(user_bp, url_prefix="/api/users") # User management
app.register_blueprint(kline_bp, url_prefix="/api/indicator")
app.register_blueprint(backtest_bp, url_prefix="/api/indicator")
app.register_blueprint(market_bp, url_prefix="/api/market")
app.register_blueprint(ai_chat_bp, url_prefix="/api/ai")
app.register_blueprint(indicator_bp, url_prefix="/api/indicator")
app.register_blueprint(strategy_bp, url_prefix="/api")
app.register_blueprint(credentials_bp, url_prefix="/api/credentials")
app.register_blueprint(dashboard_bp, url_prefix="/api/dashboard")
app.register_blueprint(settings_bp, url_prefix="/api/settings")
app.register_blueprint(portfolio_bp, url_prefix="/api/portfolio")
app.register_blueprint(ibkr_bp, url_prefix="/api/ibkr")
app.register_blueprint(mt5_bp, url_prefix="/api/mt5")
app.register_blueprint(global_market_bp, url_prefix="/api/global-market")
app.register_blueprint(community_bp, url_prefix="/api/community")
app.register_blueprint(fast_analysis_bp, url_prefix="/api/fast-analysis")
app.register_blueprint(billing_bp, url_prefix="/api/billing")
app.register_blueprint(quick_trade_bp, url_prefix="/api/quick-trade")
app.register_blueprint(polymarket_bp, url_prefix="/api/polymarket")
+15 -18
View File
@@ -3,44 +3,41 @@ AI chat API routes (optional).
Currently kept as a minimal compatibility layer for legacy frontend calls.
"""
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify, request
from app.utils.logger import get_logger
logger = get_logger(__name__)
ai_chat_bp = Blueprint('ai_chat', __name__)
ai_chat_bp = Blueprint("ai_chat", __name__)
@ai_chat_bp.route('/chat/message', methods=['POST'])
@ai_chat_bp.route("/chat/message", methods=["POST"])
def chat_message():
"""
Minimal placeholder for legacy chat.
Return a friendly message instead of 404, so the UI can evolve gradually.
"""
data = request.get_json() or {}
msg = (data.get('message') or '').strip()
msg = (data.get("message") or "").strip()
if not msg:
return jsonify({'code': 0, 'msg': 'Missing message', 'data': None}), 400
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'reply': 'Chat API is not implemented yet in local-only mode.',
'echo': msg
return jsonify({"code": 0, "msg": "Missing message", "data": None}), 400
return jsonify(
{
"code": 1,
"msg": "success",
"data": {"reply": "Chat API is not implemented yet in local-only mode.", "echo": msg},
}
})
)
@ai_chat_bp.route('/chat/history', methods=['GET'])
@ai_chat_bp.route("/chat/history", methods=["GET"])
def get_chat_history():
"""Return empty history (compatibility stub)."""
return jsonify({'code': 1, 'msg': 'success', 'data': []})
return jsonify({"code": 1, "msg": "success", "data": []})
@ai_chat_bp.route('/chat/history/save', methods=['POST'])
@ai_chat_bp.route("/chat/history/save", methods=["POST"])
def save_chat_history():
"""No-op save (compatibility stub)."""
return jsonify({'code': 1, 'msg': 'success', 'data': None})
return jsonify({"code": 1, "msg": "success", "data": None})
File diff suppressed because it is too large Load Diff
+299 -218
View File
@@ -1,27 +1,29 @@
"""
Backtest API routes
"""
from flask import Blueprint, request, jsonify, g
from datetime import datetime
import traceback
import json
import time
import os
import traceback
from datetime import datetime
import requests
from flask import Blueprint, g, jsonify, request
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
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
backtest_bp = Blueprint('backtest', __name__)
backtest_bp = Blueprint("backtest", __name__)
backtest_service = BacktestService()
def _openrouter_base_and_key() -> tuple[str, str]:
from app.config import APIKeys
# Use APIKeys to get the key (handles env var + config cache properly)
key = APIKeys.OPENROUTER_API_KEY or ""
base = os.getenv("OPENROUTER_BASE_URL", "").strip()
@@ -55,9 +57,9 @@ def _normalize_lang(lang: str | None) -> str:
"fr-FR",
"ja-JP",
}
l = (lang or "").strip()
if not l:
return "zh-CN"
normalized_lang = (lang or "").strip()
if not normalized_lang:
return "en-US"
alias = {
"zh": "zh-CN",
"zh-cn": "zh-CN",
@@ -81,53 +83,49 @@ def _normalize_lang(lang: str | None) -> str:
"ar": "ar-SA",
"ar-sa": "ar-SA",
}
l2 = alias.get(l.lower(), l)
l2 = alias.get(normalized_lang.lower(), normalized_lang)
return l2 if l2 in supported else "zh-CN"
@backtest_bp.route('/backtest/precision-info', methods=['GET'])
@backtest_bp.route("/backtest/precision-info", methods=["GET"])
def get_precision_info():
"""
Get backtest accuracy information (for front-end prompts)
Params (Query String):
market: market type
startDate: start date (YYYY-MM-DD)
endDate: end date (YYYY-MM-DD)
Returns:
Accuracy information, including recommended execution time frame and estimated number of K-lines
"""
try:
# 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', '')
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
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
end_date = datetime.strptime(end_date_str, '%Y-%m-%d')
return jsonify({"code": 0, "msg": "startDate and endDate are required"}), 400
start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
end_date = datetime.strptime(end_date_str, "%Y-%m-%d")
exec_tf, precision_info = backtest_service.get_execution_timeframe(start_date, end_date, market)
return jsonify({
'code': 1,
'msg': 'success',
'data': precision_info
})
return jsonify({"code": 1, "msg": "success", "data": precision_info})
except Exception as e:
logger.error(f"Get precision info failed: {e}")
return jsonify({'code': 0, 'msg': str(e)}), 400
return jsonify({"code": 0, "msg": str(e)}), 400
@backtest_bp.route('/backtest', methods=['POST'])
@backtest_bp.route("/backtest", methods=["POST"])
@login_required
def run_backtest():
"""
Run indicator backtest for the current user.
Params:
indicatorId: Indicator ID (optional)
indicatorCode: Indicator Python code
@@ -143,34 +141,30 @@ def run_backtest():
try:
data = request.get_json()
if not data:
return jsonify({
'code': 0,
'msg': 'Request body is required',
'data': None
}), 400
return jsonify({"code": 0, "msg": "Request body is required", "data": None}), 400
# 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', '')
market = data.get('market', '')
timeframe = data.get('timeframe', '1D')
start_date_str = data.get('startDate', '')
end_date_str = data.get('endDate', '')
initial_capital = float(data.get('initialCapital', 10000))
commission = float(data.get('commission', 0.001))
slippage = float(data.get('slippage', 0.0))
leverage = int(data.get('leverage', 1))
trade_direction = data.get('tradeDirection', 'long') # long, short, both
strategy_config = data.get('strategyConfig') or {}
indicator_code = data.get("indicatorCode", "")
indicator_id = data.get("indicatorId")
symbol = data.get("symbol", "")
market = data.get("market", "")
timeframe = data.get("timeframe", "1D")
start_date_str = data.get("startDate", "")
end_date_str = data.get("endDate", "")
initial_capital = float(data.get("initialCapital", 10000))
commission = float(data.get("commission", 0.001))
slippage = float(data.get("slippage", 0.0))
leverage = int(data.get("leverage", 1))
trade_direction = data.get("tradeDirection", "long") # long, short, both
strategy_config = data.get("strategyConfig") or {}
# Multi-timeframe backtesting switch (enabled by default, only valid for cryptocurrency markets)
enable_mtf = data.get('enableMtf', True)
enable_mtf = data.get("enableMtf", True)
if isinstance(enable_mtf, str):
enable_mtf = enable_mtf.lower() in ['true', '1', 'yes']
enable_mtf = enable_mtf.lower() in ["true", "1", "yes"]
# (Debug) log received params if needed
# If frontend only provides indicatorId, load code from local DB.
if (not indicator_code or not str(indicator_code).strip()) and indicator_id:
try:
@@ -180,53 +174,50 @@ def run_backtest():
cur.execute("SELECT code FROM qd_indicator_codes WHERE id = ?", (iid,))
row = cur.fetchone()
cur.close()
if row and row.get('code'):
indicator_code = row.get('code')
if row and row.get("code"):
indicator_code = row.get("code")
except Exception:
pass
# Parameter validation
if not all([indicator_code, symbol, market, timeframe, start_date_str, end_date_str]):
return jsonify({
'code': 0,
'msg': 'Missing required parameters',
'data': None
}), 400
return jsonify({"code": 0, "msg": "Missing required parameters", "data": None}), 400
# conversion date
# Start date: 00:00:00 today
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
# End date: 23:59:59 of the current day, ensuring that the entire day's data is included
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
end_date = datetime.strptime(end_date_str, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
# Validation time range limit
days_diff = (end_date - start_date).days
# Set different time limits based on cycles
if timeframe == '1m':
if timeframe == "1m":
max_days = 30 # 1 minute K-line up to 1 month
max_range_text = '1 month'
elif timeframe == '5m':
max_range_text = "1 month"
elif timeframe == "5m":
max_days = 180 # 5 minute K-line up to 6 months
max_range_text = '6 months'
elif timeframe in ['15m', '30m']:
max_range_text = "6 months"
elif timeframe in ["15m", "30m"]:
max_days = 365 # 15-minute and 30-minute K-line up to 1 year
max_range_text = '1 year'
max_range_text = "1 year"
else: # 1H, 4H, 1D, 1W
max_days = 1095 # 1 hour and above up to 3 years
max_range_text = '3 years'
max_range_text = "3 years"
if days_diff > max_days:
return jsonify({
'code': 0,
'msg': f'Backtest range exceeds limit: timeframe {timeframe} supports up to {max_range_text} ({max_days} days), but you selected {days_diff} days',
'data': None
}), 400
return jsonify(
{
"code": 0,
"msg": f"Backtest range exceeds limit: timeframe {timeframe} supports up to {max_range_text} ({max_days} days), but you selected {days_diff} days",
"data": None,
}
), 400
# Execute backtesting (supports multi-time frame high-precision backtesting)
# Cryptocurrency markets and using multi-timeframe backtesting when MTF is enabled
if enable_mtf and market.lower() in ['crypto', 'cryptocurrency']:
if enable_mtf and market.lower() in ["crypto", "cryptocurrency"]:
result = backtest_service.run_multi_timeframe(
indicator_code=indicator_code,
market=market,
@@ -240,7 +231,7 @@ def run_backtest():
leverage=leverage,
trade_direction=trade_direction,
strategy_config=strategy_config,
enable_mtf=True
enable_mtf=True,
)
else:
result = backtest_service.run(
@@ -255,20 +246,20 @@ def run_backtest():
slippage=slippage,
leverage=leverage,
trade_direction=trade_direction,
strategy_config=strategy_config
strategy_config=strategy_config,
)
# Add accuracy information for standard backtests
result['precision_info'] = {
'enabled': False,
'timeframe': timeframe,
'precision': 'standard',
'message': '使用标准K线回测'
result["precision_info"] = {
"enabled": False,
"timeframe": timeframe,
"precision": "standard",
"message": "使用标准K线回测",
}
run_id = backtest_service.persist_run(
user_id=user_id,
indicator_id=int(indicator_id) if indicator_id is not None else None,
run_type='indicator',
run_type="indicator",
market=market,
symbol=symbol,
timeframe=timeframe,
@@ -280,67 +271,52 @@ def run_backtest():
leverage=leverage,
trade_direction=trade_direction,
strategy_config=strategy_config,
config_snapshot={'indicatorId': int(indicator_id) if indicator_id is not None else None},
status='success',
error_message='',
config_snapshot={"indicatorId": int(indicator_id) if indicator_id is not None else None},
status="success",
error_message="",
result=result,
code=indicator_code,
)
return jsonify({
'code': 1,
'msg': 'Backtest succeeded',
'data': {
'runId': run_id,
'result': result
}
})
return jsonify({"code": 1, "msg": "Backtest succeeded", "data": {"runId": run_id, "result": result}})
except ValueError as e:
logger.warning(f"Invalid backtest parameters: {str(e)}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 400
return jsonify({"code": 0, "msg": str(e), "data": None}), 400
except Exception as e:
logger.error(f"Backtest failed: {str(e)}")
logger.error(traceback.format_exc())
try:
data = data if isinstance(data, dict) else {}
user_id = g.user_id
indicator_id = data.get('indicatorId')
indicator_id = data.get("indicatorId")
backtest_service.persist_run(
user_id=user_id,
indicator_id=int(indicator_id) if indicator_id is not None else None,
run_type='indicator',
market=str(data.get('market', '') or ''),
symbol=str(data.get('symbol', '') or ''),
timeframe=str(data.get('timeframe', '') or ''),
start_date_str=str(data.get('startDate', '') or ''),
end_date_str=str(data.get('endDate', '') or ''),
initial_capital=float(data.get('initialCapital', 0) or 0),
commission=float(data.get('commission', 0) or 0),
slippage=float(data.get('slippage', 0) or 0),
leverage=int(data.get('leverage', 1) or 1),
trade_direction=str(data.get('tradeDirection', 'long') or 'long'),
strategy_config=data.get('strategyConfig') or {},
config_snapshot={'indicatorId': int(indicator_id) if indicator_id is not None else None},
status='failed',
run_type="indicator",
market=str(data.get("market", "") or ""),
symbol=str(data.get("symbol", "") or ""),
timeframe=str(data.get("timeframe", "") or ""),
start_date_str=str(data.get("startDate", "") or ""),
end_date_str=str(data.get("endDate", "") or ""),
initial_capital=float(data.get("initialCapital", 0) or 0),
commission=float(data.get("commission", 0) or 0),
slippage=float(data.get("slippage", 0) or 0),
leverage=int(data.get("leverage", 1) or 1),
trade_direction=str(data.get("tradeDirection", "long") or "long"),
strategy_config=data.get("strategyConfig") or {},
config_snapshot={"indicatorId": int(indicator_id) if indicator_id is not None else None},
status="failed",
error_message=str(e),
result=None,
code=str(data.get('indicatorCode', '') or ''),
code=str(data.get("indicatorCode", "") or ""),
)
except Exception:
pass
return jsonify({
'code': 0,
'msg': f'Backtest failed: {str(e)}',
'data': None
}), 500
return jsonify({"code": 0, "msg": f"Backtest failed: {str(e)}", "data": None}), 500
@backtest_bp.route('/backtest/history', methods=['GET'])
@backtest_bp.route("/backtest/history", methods=["GET"])
@login_required
def get_backtest_history():
"""
@@ -357,17 +333,17 @@ def get_backtest_history():
try:
# 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 = 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 = request.args.get('indicatorId')
strategy_id = request.args.get('strategyId')
run_type = (request.args.get('runType') or '').strip()
symbol = (request.args.get('symbol') or '').strip()
market = (request.args.get('market') or '').strip()
timeframe = (request.args.get('timeframe') or '').strip()
indicator_id = request.args.get("indicatorId")
strategy_id = request.args.get("strategyId")
run_type = (request.args.get("runType") or "").strip()
symbol = (request.args.get("symbol") or "").strip()
market = (request.args.get("market") or "").strip()
timeframe = (request.args.get("timeframe") or "").strip()
rows = backtest_service.list_runs(
user_id=user_id,
limit=limit,
@@ -380,14 +356,14 @@ def get_backtest_history():
timeframe=timeframe,
)
return jsonify({'code': 1, 'msg': 'OK', 'data': rows})
return jsonify({"code": 1, "msg": "OK", "data": rows})
except Exception as e:
logger.error(f"get_backtest_history failed: {e}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@backtest_bp.route('/backtest/get', methods=['GET'])
@backtest_bp.route("/backtest/get", methods=["GET"])
@login_required
def get_backtest_run():
"""
@@ -398,19 +374,19 @@ def get_backtest_run():
"""
try:
user_id = g.user_id
run_id = int(request.args.get('runId') or 0)
run_id = int(request.args.get("runId") or 0)
if not run_id:
return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400
return jsonify({"code": 0, "msg": "runId is required", "data": None}), 400
row = backtest_service.get_run(user_id=user_id, run_id=run_id)
if not row:
return jsonify({'code': 0, 'msg': 'run not found', 'data': None}), 404
return jsonify({"code": 0, "msg": "run not found", "data": None}), 404
return jsonify({'code': 1, 'msg': 'OK', 'data': row})
return jsonify({"code": 1, "msg": "OK", "data": row})
except Exception as e:
logger.error(f"get_backtest_run failed: {e}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
@@ -463,15 +439,43 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
# Minimal localized headings to keep heuristic readable across locales.
headings = {
"zh-CN": {"overall": "【总体建议】", "params": "【参数建议(可直接改回测配置测试)】", "next": "【下一步建议的回测方法】"},
"zh-TW": {"overall": "【總體建議】", "params": "【參數建議(可直接改回測配置測試)】", "next": "下一步回測方法建議"},
"en-US": {"overall": "Overall", "params": "Parameter suggestions (edit backtest config and re-run)", "next": "Next steps"},
"zh-CN": {
"overall": "总体建议",
"params": "【参数建议(可直接改回测配置测试)】",
"next": "【下一步建议的回测方法】",
},
"zh-TW": {
"overall": "【總體建議】",
"params": "【參數建議(可直接改回測配置測試)】",
"next": "【下一步回測方法建議】",
},
"en-US": {
"overall": "Overall",
"params": "Parameter suggestions (edit backtest config and re-run)",
"next": "Next steps",
},
"ko-KR": {"overall": "요약", "params": "파라미터 제안(백테스트 설정 변경)", "next": "다음 단계"},
"th-TH": {"overall": "สรุป", "params": "ข้อเสนอแนะพารามิเตอร์ (ปรับค่าที่ตั้งแบ็กเทสต์)", "next": "ขั้นตอนถัดไป"},
"vi-VN": {"overall": "Tổng quan", "params": "Gợi ý tham số (sửa cấu hình backtest và chạy lại)", "next": "Bước tiếp theo"},
"ar-SA": {"overall": "ملخص", "params": "اقتراحات المعلمات (عدّل إعدادات الاختبار وأعد التشغيل)", "next": "الخطوات التالية"},
"de-DE": {"overall": "Überblick", "params": "Parameter-Vorschläge (Backtest-Konfiguration anpassen)", "next": "Nächste Schritte"},
"fr-FR": {"overall": "Vue densemble", "params": "Suggestions de paramètres (modifier la config et relancer)", "next": "Étapes suivantes"},
"vi-VN": {
"overall": "Tổng quan",
"params": "Gợi ý tham số (sửa cấu hình backtest và chạy lại)",
"next": "Bước tiếp theo",
},
"ar-SA": {
"overall": "ملخص",
"params": "اقتراحات المعلمات (عدّل إعدادات الاختبار وأعد التشغيل)",
"next": "الخطوات التالية",
},
"de-DE": {
"overall": "Überblick",
"params": "Parameter-Vorschläge (Backtest-Konfiguration anpassen)",
"next": "Nächste Schritte",
},
"fr-FR": {
"overall": "Vue densemble",
"params": "Suggestions de paramètres (modifier la config et relancer)",
"next": "Étapes suivantes",
},
"ja-JP": {"overall": "概要", "params": "パラメータ提案(設定変更→再バックテスト)", "next": "次のステップ"},
}
h = headings.get(lang, headings["en-US"])
@@ -479,62 +483,96 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
lines = []
if lang == "en-US":
if len(runs) > 1:
lines.append(f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id','')}; validate with A/B tests across runs.")
lines.append(
f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id', '')}; validate with A/B tests across runs."
)
lines.append(h["overall"])
elif lang == "zh-TW":
if len(runs) > 1:
lines.append(f"已收到 {len(runs)} 條回測記錄。以下以記錄 #{r0.get('id','')} 為主給出參數調整建議,並建議你用多組記錄做 A/B 驗證。")
lines.append(
f"已收到 {len(runs)} 條回測記錄。以下以記錄 #{r0.get('id', '')} 為主給出參數調整建議,並建議你用多組記錄做 A/B 驗證。"
)
lines.append(h["overall"])
else:
if len(runs) > 1:
if lang == "ko-KR":
lines.append(f"{len(runs)}개의 백테스트 기록을 받았습니다. 아래는 #{r0.get('id','')} 기준으로 제안하며, 여러 기록으로 A/B 검증을 권장합니다.")
lines.append(
f"{len(runs)}개의 백테스트 기록을 받았습니다. 아래는 #{r0.get('id', '')} 기준으로 제안하며, 여러 기록으로 A/B 검증을 권장합니다."
)
elif lang == "th-TH":
lines.append(f"ได้รับประวัติแบ็กเทสต์ {len(runs)} รายการ ข้อเสนอแนะด้านล่างอิงจาก #{r0.get('id','')} และแนะนำให้ทำ A/B test เทียบหลายชุด")
lines.append(
f"ได้รับประวัติแบ็กเทสต์ {len(runs)} รายการ ข้อเสนอแนะด้านล่างอิงจาก #{r0.get('id', '')} และแนะนำให้ทำ A/B test เทียบหลายชุด"
)
elif lang == "vi-VN":
lines.append(f"Đã nhận {len(runs)} bản ghi backtest. Gợi ý bên dưới tập trung vào #{r0.get('id','')} và khuyến nghị A/B test với nhiều bản ghi.")
lines.append(
f"Đã nhận {len(runs)} bản ghi backtest. Gợi ý bên dưới tập trung vào #{r0.get('id', '')} và khuyến nghị A/B test với nhiều bản ghi."
)
elif lang == "ar-SA":
lines.append(f"تم استلام {len(runs)} من سجلات الاختبار الخلفي. تركّز الاقتراحات أدناه على التشغيل #{r0.get('id','')} مع توصية باختبارات A/B.")
lines.append(
f"تم استلام {len(runs)} من سجلات الاختبار الخلفي. تركّز الاقتراحات أدناه على التشغيل #{r0.get('id', '')} مع توصية باختبارات A/B."
)
elif lang == "de-DE":
lines.append(f"{len(runs)} Backtest-Läufe empfangen. Vorschläge unten fokussieren auf Lauf #{r0.get('id','')}; A/B-Tests über mehrere Läufe empfohlen.")
lines.append(
f"{len(runs)} Backtest-Läufe empfangen. Vorschläge unten fokussieren auf Lauf #{r0.get('id', '')}; A/B-Tests über mehrere Läufe empfohlen."
)
elif lang == "fr-FR":
lines.append(f"{len(runs)} exécutions de backtest reçues. Suggestions ci-dessous centrées sur #{r0.get('id','')}; A/B tests recommandés.")
lines.append(
f"{len(runs)} exécutions de backtest reçues. Suggestions ci-dessous centrées sur #{r0.get('id', '')}; A/B tests recommandés."
)
elif lang == "ja-JP":
lines.append(f"{len(runs)} 件のバックテスト記録を受け取りました。以下は #{r0.get('id','')} を中心に提案し、複数記録でA/B検証を推奨します。")
lines.append(
f"{len(runs)} 件のバックテスト記録を受け取りました。以下は #{r0.get('id', '')} を中心に提案し、複数記録でA/B検証を推奨します。"
)
else:
lines.append(f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id','')}; validate with A/B tests across runs.")
lines.append(
f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id', '')}; validate with A/B tests across runs."
)
lines.append(h["overall"])
if sharpe < 0 or total_return < 0:
if lang == "en-US":
lines.append("- Strategy is losing/unstable: reduce risk first (lower entryPct, fewer/smaller scale-ins), then refine signal filters.")
lines.append(
"- Strategy is losing/unstable: reduce risk first (lower entryPct, fewer/smaller scale-ins), then refine signal filters."
)
elif lang == "zh-TW":
lines.append("- 目前策略偏虧損/不穩定:先降低風險暴露(降低開倉資金占比 entryPct、減少加倉次數/比例),再調整信號過濾。")
lines.append(
"- 目前策略偏虧損/不穩定:先降低風險暴露(降低開倉資金占比 entryPct、減少加倉次數/比例),再調整信號過濾。"
)
else:
lines.append("- 当前策略整体偏亏损/不稳定:优先降低风险暴露(降低开仓资金占比 entryPct、减少加仓次数/比例),再调信号过滤。")
lines.append(
"- 当前策略整体偏亏损/不稳定:优先降低风险暴露(降低开仓资金占比 entryPct、减少加仓次数/比例),再调信号过滤。"
)
if max_dd > 30:
if lang == "en-US":
lines.append("- Max drawdown is high: tighten stop-loss or reduce leverage/entry size; consider enabling trailing to protect profits.")
lines.append(
"- Max drawdown is high: tighten stop-loss or reduce leverage/entry size; consider enabling trailing to protect profits."
)
elif lang == "zh-TW":
lines.append("- 最大回撤偏大:建議優先收緊止損或降低槓桿/開倉倉位;同時考慮啟用移動止盈以保護盈利回撤。")
else:
lines.append("- 最大回撤较大:建议优先收紧止损或降低杠杆/开仓仓位;同时考虑启用移动止盈保护盈利回撤。")
if trades < 10:
if lang == "en-US":
lines.append("- Too few trades: rules may be too strict; relax thresholds or remove one filter to get enough samples.")
lines.append(
"- Too few trades: rules may be too strict; relax thresholds or remove one filter to get enough samples."
)
elif lang == "zh-TW":
lines.append("- 交易次數偏少:可能條件過嚴,建議適度放寬信號門檻或減少過濾條件,確保有足夠樣本驗證。")
else:
lines.append("- 交易次数偏少:可能条件过严,建议适当放宽信号阈值或减少过滤条件,确保有足够样本验证。")
if win_rate < 35 and profit_factor >= 1.2:
if lang == "en-US":
lines.append("- Low win rate but decent PF: consider slightly wider stop-loss and use trailing to lock profits.")
lines.append(
"- Low win rate but decent PF: consider slightly wider stop-loss and use trailing to lock profits."
)
elif lang == "zh-TW":
lines.append("- 勝率偏低但盈虧比不差:可考慮略放寬止損(讓盈利單跑起來),並用移動止盈鎖住利潤。")
else:
lines.append("- 胜率偏低但盈亏比不差:可以考虑放宽止损(让盈利单跑起来)并用移动止盈锁利润。")
if win_rate >= 55 and profit_factor < 1.1:
if lang == "en-US":
lines.append("- Win rate is OK but PF is low: raise take-profit or enable trailing to improve winners; avoid taking profits too early.")
lines.append(
"- Win rate is OK but PF is low: raise take-profit or enable trailing to improve winners; avoid taking profits too early."
)
elif lang == "zh-TW":
lines.append("- 勝率不低但盈虧比偏小:考慮提高止盈或啟用移動止盈,讓單筆盈利更充分;避免過早止盈。")
else:
@@ -543,32 +581,56 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
lines.append("\n" + h["params"])
if stop_loss <= 0:
if lang == "en-US":
lines.append("- Stop-loss: set stopLossPct (margin PnL basis). For crypto leverage, start with 2%~6% (then consider leverage conversion) and grid test.")
lines.append(
"- Stop-loss: set stopLossPct (margin PnL basis). For crypto leverage, start with 2%~6% (then consider leverage conversion) and grid test."
)
elif lang == "zh-TW":
lines.append("- 止損:建議設定 stopLossPct(按保證金口徑)。在加密+槓桿下,先從 2%~6%(再結合槓桿換算)做網格測試。")
lines.append(
"- 止損:建議設定 stopLossPct(按保證金口徑)。在加密+槓桿下,先從 2%~6%(再結合槓桿換算)做網格測試。"
)
else:
lines.append("- 止损:建议设置 stopLossPct(按保证金口径)。在加密+杠杆下,先从 2%~6%(再结合杠杆换算)做网格测试。")
lines.append(
"- 止损:建议设置 stopLossPct(按保证金口径)。在加密+杠杆下,先从 2%~6%(再结合杠杆换算)做网格测试。"
)
else:
if lang == "en-US":
lines.append(f"- Stop-loss: current stopLossPct={stop_loss:.4f} (margin basis). Test ±30% around it and monitor drawdown/liquidations.")
lines.append(
f"- Stop-loss: current stopLossPct={stop_loss:.4f} (margin basis). Test ±30% around it and monitor drawdown/liquidations."
)
elif lang == "zh-TW":
lines.append(f"- 止損:目前 stopLossPct={stop_loss:.4f}(保證金口徑)。建議圍繞它做 ±30% 區間測試,並觀察回撤/爆倉次數變化。")
lines.append(
f"- 止損:目前 stopLossPct={stop_loss:.4f}(保證金口徑)。建議圍繞它做 ±30% 區間測試,並觀察回撤/爆倉次數變化。"
)
else:
lines.append(f"- 止损:当前 stopLossPct={stop_loss:.4f}(保证金口径)。建议围绕它做 ±30% 的区间测试,并观察回撤/爆仓次数变化。")
lines.append(
f"- 止损:当前 stopLossPct={stop_loss:.4f}(保证金口径)。建议围绕它做 ±30% 的区间测试,并观察回撤/爆仓次数变化。"
)
if take_profit > 0 and (not trailing_enabled):
if lang == "en-US":
lines.append(f"- Take-profit: current takeProfitPct={take_profit:.4f}. Also test enabling trailing to reduce profit giveback.")
lines.append(
f"- Take-profit: current takeProfitPct={take_profit:.4f}. Also test enabling trailing to reduce profit giveback."
)
elif lang == "zh-TW":
lines.append(f"- 止盈:目前 takeProfitPct={take_profit:.4f}。建議同時測試啟用移動止盈(trailing)以降低盈利回撤。")
lines.append(
f"- 止盈:目前 takeProfitPct={take_profit:.4f}。建議同時測試啟用移動止盈(trailing)以降低盈利回撤。"
)
else:
lines.append(f"- 止盈:当前 takeProfitPct={take_profit:.4f}。建议同时测试开启移动止盈(trailing)以降低盈利回撤。")
lines.append(
f"- 止盈:当前 takeProfitPct={take_profit:.4f}。建议同时测试开启移动止盈(trailing)以降低盈利回撤。"
)
if trailing_enabled:
if lang == "en-US":
lines.append(f"- Trailing: enabled, pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}. Set activation near typical winner PnL and test pct at 0.5x~1.5x.")
lines.append(
f"- Trailing: enabled, pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}. Set activation near typical winner PnL and test pct at 0.5x~1.5x."
)
elif lang == "zh-TW":
lines.append(f"- 移動止盈:已啟用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建議將 activationPct 設為略低於常見單筆盈利水平,並把 pct 做 0.5x~1.5x 測試。")
lines.append(
f"- 移動止盈:已啟用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建議將 activationPct 設為略低於常見單筆盈利水平,並把 pct 做 0.5x~1.5x 測試。"
)
else:
lines.append(f"- 移动止盈:已启用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建议把 activationPct 设为略低于常见单笔盈利水平,并把 pct 做 0.5x~1.5x 测试。")
lines.append(
f"- 移动止盈:已启用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建议把 activationPct 设为略低于常见单笔盈利水平,并把 pct 做 0.5x~1.5x 测试。"
)
else:
if lang == "en-US":
lines.append("- Trailing: consider trailing.enabled=true; start with pct=1%~3% (margin basis) and test.")
@@ -577,23 +639,37 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
else:
lines.append("- 移动止盈:建议开启 trailing.enabled=true,并从 pct=1%~3%(保证金口径换算后)开始测试。")
if lang == "en-US":
lines.append(f"- Entry sizing: entryPct={entry_pct:.4f}. Test 0.2/0.3/0.5/0.8 to find a better return/drawdown sweet spot.")
lines.append(
f"- Entry sizing: entryPct={entry_pct:.4f}. Test 0.2/0.3/0.5/0.8 to find a better return/drawdown sweet spot."
)
elif lang == "zh-TW":
lines.append(f"- 開倉倉位:目前 entryPct={entry_pct:.4f}。建議先用 0.2/0.3/0.5/0.8 分層回測,找收益/回撤更優的甜區。")
lines.append(
f"- 開倉倉位:目前 entryPct={entry_pct:.4f}。建議先用 0.2/0.3/0.5/0.8 分層回測,找收益/回撤更優的甜區。"
)
else:
lines.append(f"- 开仓仓位:当前 entryPct={entry_pct:.4f}。建议先用 0.2/0.3/0.5/0.8 做分层回测,找收益/回撤更优的甜区。")
lines.append(
f"- 开仓仓位:当前 entryPct={entry_pct:.4f}。建议先用 0.2/0.3/0.5/0.8 做分层回测,找收益/回撤更优的甜区。"
)
# Scaling (very light guidance)
if isinstance(trend_add, dict) and trend_add.get("enabled"):
if lang == "en-US":
lines.append("- Trend scale-in: reduce sizePct or maxTimes to avoid drawdown expansion; verify same-bar conflict rules match expectations.")
lines.append(
"- Trend scale-in: reduce sizePct or maxTimes to avoid drawdown expansion; verify same-bar conflict rules match expectations."
)
elif lang == "zh-TW":
lines.append("- 順勢加倉:建議優先降低 sizePct 或 maxTimes,避免回撤擴大;並確認同K線主信號禁用加減倉規則符合預期。")
lines.append(
"- 順勢加倉:建議優先降低 sizePct 或 maxTimes,避免回撤擴大;並確認同K線主信號禁用加減倉規則符合預期。"
)
else:
lines.append("- 顺势加仓:建议优先降低 sizePct 或 maxTimes,避免回撤扩大;并确保同K线主信号禁用加减仓的规则与你预期一致。")
lines.append(
"- 顺势加仓:建议优先降低 sizePct 或 maxTimes,避免回撤扩大;并确保同K线主信号禁用加减仓的规则与你预期一致。"
)
if isinstance(dca_add, dict) and dca_add.get("enabled"):
if lang == "en-US":
lines.append("- DCA scale-in: very risky under leverage; keep maxTimes small, sizePct low, and use stricter stop-loss.")
lines.append(
"- DCA scale-in: very risky under leverage; keep maxTimes small, sizePct low, and use stricter stop-loss."
)
elif lang == "zh-TW":
lines.append("- 逆勢加倉:加密槓桿下風險極高,建議 maxTimes 更小、sizePct 更低,並採用更嚴格止損。")
else:
@@ -607,7 +683,9 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
lines.append("- 顺势减仓:适合降低波动,但可能降低收益;建议和移动止盈一起对比测试。")
if isinstance(adverse_reduce, dict) and adverse_reduce.get("enabled"):
if lang == "en-US":
lines.append("- Adverse reduce: can control drawdowns but increases fees/slippage; consider enabling under higher leverage.")
lines.append(
"- Adverse reduce: can control drawdowns but increases fees/slippage; consider enabling under higher leverage."
)
elif lang == "zh-TW":
lines.append("- 逆勢減倉:可用於控回撤,但可能增加手續費/滑點成本;建議優先在高槓桿時開啟。")
else:
@@ -622,12 +700,14 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
lines.append("- 重點同時看:總收益、最大回撤、夏普、交易次數、爆倉/止損觸發次數。")
else:
# Keep English for other locales to ensure readability in fallback mode.
lines.append("- Keep signal logic fixed; run parameter grid tests (coarse → fine). Change only 1-2 params per run.")
lines.append(
"- Keep signal logic fixed; run parameter grid tests (coarse → fine). Change only 1-2 params per run."
)
lines.append("- Track: total return, max drawdown, Sharpe, trade count, liquidation/stop-loss triggers.")
return "\n".join(lines)
@backtest_bp.route('/backtest/aiAnalyze', methods=['POST'])
@backtest_bp.route("/backtest/aiAnalyze", methods=["POST"])
@login_required
def ai_analyze_backtest_runs():
"""
@@ -641,16 +721,16 @@ def ai_analyze_backtest_runs():
data = request.get_json() or {}
user_id = g.user_id
backtest_service.ensure_storage_schema()
lang = _normalize_lang(data.get('lang'))
run_ids = data.get('runIds') or []
lang = _normalize_lang(data.get("lang"))
run_ids = data.get("runIds") or []
if not isinstance(run_ids, list) or not run_ids:
return jsonify({'code': 0, 'msg': 'runIds is required', 'data': None}), 400
return jsonify({"code": 0, "msg": "runIds is required", "data": None}), 400
# Limit to avoid huge prompts / payload.
run_ids = [int(x) for x in run_ids if str(x).strip().isdigit()]
run_ids = run_ids[:10]
if not run_ids:
return jsonify({'code': 0, 'msg': 'runIds is required', 'data': None}), 400
return jsonify({"code": 0, "msg": "runIds is required", "data": None}), 400
placeholders = ",".join(["?"] * len(run_ids))
with get_db_connection() as db:
@@ -673,28 +753,28 @@ def ai_analyze_backtest_runs():
runs: list[dict] = []
for r in rows:
try:
r['strategy_config'] = json.loads(r.get('strategy_config') or '{}')
r["strategy_config"] = json.loads(r.get("strategy_config") or "{}")
except Exception:
r['strategy_config'] = {}
r["strategy_config"] = {}
try:
r['config_snapshot'] = json.loads(r.get('config_snapshot') or '{}')
r["config_snapshot"] = json.loads(r.get("config_snapshot") or "{}")
except Exception:
r['config_snapshot'] = {}
r["config_snapshot"] = {}
try:
r['result'] = json.loads(r.get('result_json') or '{}')
r["result"] = json.loads(r.get("result_json") or "{}")
except Exception:
r['result'] = {}
r.pop('result_json', None)
r["result"] = {}
r.pop("result_json", None)
runs.append(r)
if not runs:
return jsonify({'code': 0, 'msg': 'runs not found', 'data': None}), 404
return jsonify({"code": 0, "msg": "runs not found", "data": None}), 404
# OpenRouter (optional)
base_url, api_key = _openrouter_base_and_key()
if not api_key:
analysis = _heuristic_ai_advice(runs, lang)
return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'heuristic', 'lang': lang}})
return jsonify({"code": 1, "msg": "OK", "data": {"analysis": analysis, "mode": "heuristic", "lang": lang}})
model = (os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini") or "").strip() or "openai/gpt-4o-mini"
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.4") or 0.4)
@@ -767,21 +847,23 @@ def ai_analyze_backtest_runs():
analysis = content.strip()
if not analysis:
analysis = _heuristic_ai_advice(runs, lang)
return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'heuristic_fallback', 'lang': lang}})
return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'llm', 'lang': lang}})
return jsonify(
{"code": 1, "msg": "OK", "data": {"analysis": analysis, "mode": "heuristic_fallback", "lang": lang}}
)
return jsonify({"code": 1, "msg": "OK", "data": {"analysis": analysis, "mode": "llm", "lang": lang}})
except requests.exceptions.RequestException as e:
# Do not fail the whole endpoint if LLM provider is misconfigured or rate-limited.
logger.error(f"OpenRouter request failed, falling back to heuristic: {e}")
analysis = _heuristic_ai_advice(runs, lang)
return jsonify(
{
'code': 1,
'msg': 'OK',
'data': {
'analysis': analysis,
'mode': 'heuristic_fallback',
'lang': lang,
'llmError': str(e),
"code": 1,
"msg": "OK",
"data": {
"analysis": analysis,
"mode": "heuristic_fallback",
"lang": lang,
"llmError": str(e),
},
}
)
@@ -789,5 +871,4 @@ def ai_analyze_backtest_runs():
except Exception as e:
logger.error(f"ai_analyze_backtest_runs failed: {e}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
+3 -4
View File
@@ -6,12 +6,12 @@ The current version first implements the minimum availability of "fast commercia
- Users activate/issue points immediately after purchasing on the front end (can be replaced with a real payment gateway later)
"""
from flask import Blueprint, jsonify, request, g
from flask import Blueprint, g, jsonify, request
from app.utils.auth import login_required
from app.utils.logger import get_logger
from app.services.billing_service import get_billing_service
from app.services.usdt_payment_service import get_usdt_payment_service
from app.utils.auth import login_required
from app.utils.logger import get_logger
logger = get_logger(__name__)
@@ -102,4 +102,3 @@ def usdt_get_order(order_id: int):
except Exception as e:
logger.error(f"usdt_get_order failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
+133 -163
View File
@@ -4,11 +4,11 @@ Community APIs - indicator community interface
REST API that provides indicator markets, buying, commenting, and more.
"""
from flask import Blueprint, jsonify, request, g
from flask import Blueprint, g, jsonify, request
from app.services.community_service import get_community_service
from app.utils.auth import login_required
from app.utils.logger import get_logger
from app.services.community_service import get_community_service
logger = get_logger(__name__)
@@ -19,12 +19,13 @@ community_bp = Blueprint("community", __name__)
# indicator market
# ==========================================
@community_bp.route("/indicators", methods=["GET"])
@login_required
def get_market_indicators():
"""
Get a list of market indicators
Query params:
page: page number (default 1)
page_size: Number of pages per page (default 12)
@@ -33,15 +34,15 @@ def get_market_indicators():
sort_by: 'newest' / 'hot' / 'price_asc' / 'price_desc' / 'rating'
"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 12))
keyword = request.args.get('keyword', '').strip()
pricing_type = request.args.get('pricing_type', '').strip() or None
sort_by = request.args.get('sort_by', 'newest').strip()
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 12))
keyword = request.args.get("keyword", "").strip()
pricing_type = request.args.get("pricing_type", "").strip() or None
sort_by = request.args.get("sort_by", "newest").strip()
# Limit the number of pages per page
page_size = min(max(page_size, 1), 50)
service = get_community_service()
result = service.get_market_indicators(
page=page,
@@ -49,14 +50,14 @@ def get_market_indicators():
keyword=keyword if keyword else None,
pricing_type=pricing_type,
sort_by=sort_by,
user_id=g.user_id
user_id=g.user_id,
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
return jsonify({"code": 1, "msg": "success", "data": result})
except Exception as e:
logger.error(f"get_market_indicators failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@community_bp.route("/indicators/<int:indicator_id>", methods=["GET"])
@@ -66,27 +67,28 @@ def get_indicator_detail(indicator_id: int):
try:
service = get_community_service()
result = service.get_indicator_detail(indicator_id, user_id=g.user_id)
if not result:
return jsonify({'code': 0, 'msg': 'indicator_not_found', 'data': None}), 404
return jsonify({'code': 1, 'msg': 'success', 'data': result})
return jsonify({"code": 0, "msg": "indicator_not_found", "data": None}), 404
return jsonify({"code": 1, "msg": "success", "data": result})
except Exception as e:
logger.error(f"get_indicator_detail failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
# ==========================================
# Purchase function
# ==========================================
@community_bp.route("/indicators/<int:indicator_id>/purchase", methods=["POST"])
@login_required
def purchase_indicator(indicator_id: int):
"""
buy indicator
will automatically:
1. Check whether the points are sufficient
2. Deduct buyer points and increase seller points
@@ -95,19 +97,16 @@ def purchase_indicator(indicator_id: int):
"""
try:
service = get_community_service()
success, message, data = service.purchase_indicator(
buyer_id=g.user_id,
indicator_id=indicator_id
)
success, message, data = service.purchase_indicator(buyer_id=g.user_id, indicator_id=indicator_id)
if success:
return jsonify({'code': 1, 'msg': message, 'data': data})
return jsonify({"code": 1, "msg": message, "data": data})
else:
return jsonify({'code': 0, 'msg': message, 'data': data}), 400
return jsonify({"code": 0, "msg": message, "data": data}), 400
except Exception as e:
logger.error(f"purchase_indicator failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@community_bp.route("/my-purchases", methods=["GET"])
@@ -115,49 +114,42 @@ def purchase_indicator(indicator_id: int):
def get_my_purchases():
"""Get a list of indicators I purchased"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 20))
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 20))
page_size = min(max(page_size, 1), 50)
service = get_community_service()
result = service.get_my_purchases(
user_id=g.user_id,
page=page,
page_size=page_size
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
result = service.get_my_purchases(user_id=g.user_id, page=page, page_size=page_size)
return jsonify({"code": 1, "msg": "success", "data": result})
except Exception as e:
logger.error(f"get_my_purchases failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
# ==========================================
# Comment function
# ==========================================
@community_bp.route("/indicators/<int:indicator_id>/comments", methods=["GET"])
@login_required
def get_comments(indicator_id: int):
"""Get a list of indicator comments"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 20))
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 20))
page_size = min(max(page_size, 1), 50)
service = get_community_service()
result = service.get_comments(
indicator_id=indicator_id,
page=page,
page_size=page_size
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
result = service.get_comments(indicator_id=indicator_id, page=page, page_size=page_size)
return jsonify({"code": 1, "msg": "success", "data": result})
except Exception as e:
logger.error(f"get_comments failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@community_bp.route("/indicators/<int:indicator_id>/comments", methods=["POST"])
@@ -165,34 +157,31 @@ def get_comments(indicator_id: int):
def add_comment(indicator_id: int):
"""
Add comment
Request body:
rating: 1-5 star rating
content: Comment content (optional, up to 500 words)
Note: Only users who have purchased can comment, and they can only comment once
"""
try:
data = request.get_json() or {}
rating = int(data.get('rating', 5))
content = (data.get('content') or '').strip()
rating = int(data.get("rating", 5))
content = (data.get("content") or "").strip()
service = get_community_service()
success, message, result = service.add_comment(
user_id=g.user_id,
indicator_id=indicator_id,
rating=rating,
content=content
user_id=g.user_id, indicator_id=indicator_id, rating=rating, content=content
)
if success:
return jsonify({'code': 1, 'msg': message, 'data': result})
return jsonify({"code": 1, "msg": message, "data": result})
else:
return jsonify({'code': 0, 'msg': message, 'data': result}), 400
return jsonify({"code": 0, "msg": message, "data": result}), 400
except Exception as e:
logger.error(f"add_comment failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@community_bp.route("/indicators/<int:indicator_id>/comments/<int:comment_id>", methods=["PUT"])
@@ -200,33 +189,29 @@ def add_comment(indicator_id: int):
def update_comment(indicator_id: int, comment_id: int):
"""
Update comments (you can only modify your own comments)
Request body:
rating: 1-5 star rating
content: Comment content (up to 500 words)
"""
try:
data = request.get_json() or {}
rating = int(data.get('rating', 5))
content = (data.get('content') or '').strip()
rating = int(data.get("rating", 5))
content = (data.get("content") or "").strip()
service = get_community_service()
success, message, result = service.update_comment(
user_id=g.user_id,
comment_id=comment_id,
indicator_id=indicator_id,
rating=rating,
content=content
user_id=g.user_id, comment_id=comment_id, indicator_id=indicator_id, rating=rating, content=content
)
if success:
return jsonify({'code': 1, 'msg': message, 'data': result})
return jsonify({"code": 1, "msg": message, "data": result})
else:
return jsonify({'code': 0, 'msg': message, 'data': result}), 400
return jsonify({"code": 0, "msg": message, "data": result}), 400
except Exception as e:
logger.error(f"update_comment failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@community_bp.route("/indicators/<int:indicator_id>/my-comment", methods=["GET"])
@@ -235,22 +220,20 @@ def get_my_comment(indicator_id: int):
"""Get the current user's comments on the specified indicator (for editing)"""
try:
service = get_community_service()
result = service.get_user_comment(
user_id=g.user_id,
indicator_id=indicator_id
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
result = service.get_user_comment(user_id=g.user_id, indicator_id=indicator_id)
return jsonify({"code": 1, "msg": "success", "data": result})
except Exception as e:
logger.error(f"get_my_comment failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
# ==========================================
# Real offer performance
# ==========================================
@community_bp.route("/indicators/<int:indicator_id>/performance", methods=["GET"])
@login_required
def get_indicator_performance(indicator_id: int):
@@ -258,22 +241,23 @@ def get_indicator_performance(indicator_id: int):
try:
service = get_community_service()
result = service.get_indicator_performance(indicator_id)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
return jsonify({"code": 1, "msg": "success", "data": result})
except Exception as e:
logger.error(f"get_indicator_performance failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
# ==========================================
# Administrator review function
# ==========================================
def _is_admin():
"""Check if the current user is an administrator"""
role = getattr(g, 'user_role', None)
return role == 'admin'
role = getattr(g, "user_role", None)
return role == "admin"
@community_bp.route("/admin/pending-indicators", methods=["GET"])
@@ -281,7 +265,7 @@ def _is_admin():
def get_pending_indicators():
"""
Get the list of indicators to be reviewed (for administrators only)
Query params:
page: page number (default 1)
page_size: Number of pages per page (default 20)
@@ -289,25 +273,21 @@ def get_pending_indicators():
"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 20))
review_status = request.args.get('review_status', 'pending').strip() or 'pending'
return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 20))
review_status = request.args.get("review_status", "pending").strip() or "pending"
page_size = min(max(page_size, 1), 100)
service = get_community_service()
result = service.get_pending_indicators(
page=page,
page_size=page_size,
review_status=review_status
)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
result = service.get_pending_indicators(page=page, page_size=page_size, review_status=review_status)
return jsonify({"code": 1, "msg": "success", "data": result})
except Exception as e:
logger.error(f"get_pending_indicators failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@community_bp.route("/admin/review-stats", methods=["GET"])
@@ -316,16 +296,16 @@ def get_review_stats():
"""Get audit statistics (for administrators only)"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403
service = get_community_service()
result = service.get_review_stats()
return jsonify({'code': 1, 'msg': 'success', 'data': result})
return jsonify({"code": 1, "msg": "success", "data": result})
except Exception as e:
logger.error(f"get_review_stats failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@community_bp.route("/admin/indicators/<int:indicator_id>/review", methods=["POST"])
@@ -333,38 +313,35 @@ def get_review_stats():
def review_indicator(indicator_id: int):
"""
Audit indicators (for administrators only)
Request body:
action: 'approve' / 'reject'
note: review notes (optional)
"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403
data = request.get_json() or {}
action = data.get('action', '').strip()
note = data.get('note', '').strip()
if action not in ('approve', 'reject'):
return jsonify({'code': 0, 'msg': 'invalid_action', 'data': None}), 400
action = data.get("action", "").strip()
note = data.get("note", "").strip()
if action not in ("approve", "reject"):
return jsonify({"code": 0, "msg": "invalid_action", "data": None}), 400
service = get_community_service()
success, message = service.review_indicator(
admin_id=g.user_id,
indicator_id=indicator_id,
action=action,
note=note
admin_id=g.user_id, indicator_id=indicator_id, action=action, note=note
)
if success:
return jsonify({'code': 1, 'msg': message, 'data': None})
return jsonify({"code": 1, "msg": message, "data": None})
else:
return jsonify({'code': 0, 'msg': message, 'data': None}), 400
return jsonify({"code": 0, "msg": message, "data": None}), 400
except Exception as e:
logger.error(f"review_indicator failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@community_bp.route("/admin/indicators/<int:indicator_id>/unpublish", methods=["POST"])
@@ -372,32 +349,28 @@ def review_indicator(indicator_id: int):
def unpublish_indicator(indicator_id: int):
"""
Delisting indicator (for administrators only)
Request body:
note: Reason for delisting (optional)
"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403
data = request.get_json() or {}
note = data.get('note', '').strip()
note = data.get("note", "").strip()
service = get_community_service()
success, message = service.unpublish_indicator(
admin_id=g.user_id,
indicator_id=indicator_id,
note=note
)
success, message = service.unpublish_indicator(admin_id=g.user_id, indicator_id=indicator_id, note=note)
if success:
return jsonify({'code': 1, 'msg': message, 'data': None})
return jsonify({"code": 1, "msg": message, "data": None})
else:
return jsonify({'code': 0, 'msg': message, 'data': None}), 400
return jsonify({"code": 0, "msg": message, "data": None}), 400
except Exception as e:
logger.error(f"unpublish_indicator failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@community_bp.route("/admin/indicators/<int:indicator_id>", methods=["DELETE"])
@@ -406,19 +379,16 @@ def admin_delete_indicator(indicator_id: int):
"""Delete indicator (only for administrators)"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403
service = get_community_service()
success, message = service.admin_delete_indicator(
admin_id=g.user_id,
indicator_id=indicator_id
)
success, message = service.admin_delete_indicator(admin_id=g.user_id, indicator_id=indicator_id)
if success:
return jsonify({'code': 1, 'msg': message, 'data': None})
return jsonify({"code": 1, "msg": message, "data": None})
else:
return jsonify({'code': 0, 'msg': message, 'data': None}), 400
return jsonify({"code": 0, "msg": message, "data": None}), 400
except Exception as e:
logger.error(f"admin_delete_indicator failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
+98 -86
View File
@@ -4,32 +4,32 @@ Exchange credentials vault.
encrypted_config stores Fernet ciphertext derived from SECRET_KEY (see app.utils.credential_crypto).
"""
import traceback
import json
from flask import Blueprint, request, jsonify, g
import traceback
import requests as rq
from flask import Blueprint, g, jsonify, request
from app.utils.auth import login_required
from app.utils.credential_crypto import decrypt_credential_blob, encrypt_credential_blob
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.utils.auth import login_required
from app.utils.credential_crypto import encrypt_credential_blob, decrypt_credential_blob
logger = get_logger(__name__)
credentials_bp = Blueprint('credentials', __name__)
credentials_bp = Blueprint("credentials", __name__)
def _api_key_hint(api_key: str) -> str:
if not api_key:
return ''
return ""
s = str(api_key)
if len(s) <= 8:
return s[:2] + '***'
return s[:2] + "***"
return f"{s[:4]}...{s[-4:]}"
@credentials_bp.route('/list', methods=['GET'])
@credentials_bp.route("/list", methods=["GET"])
@login_required
def list_credentials():
"""List all credentials for the current user."""
@@ -45,7 +45,7 @@ def list_credentials():
WHERE user_id = %s
ORDER BY id DESC
""",
(user_id,)
(user_id,),
)
rows = cur.fetchall() or []
cur.close()
@@ -53,26 +53,35 @@ def list_credentials():
items = []
for row in rows:
item = dict(row or {})
item['enable_demo_trading'] = False
item["enable_demo_trading"] = False
try:
plain = decrypt_credential_blob(item.get('encrypted_config'))
plain = decrypt_credential_blob(item.get("encrypted_config"))
cfg = json.loads(plain) if plain else {}
item['enable_demo_trading'] = bool(cfg.get('enable_demo_trading') or cfg.get('enableDemoTrading'))
item["enable_demo_trading"] = bool(cfg.get("enable_demo_trading") or cfg.get("enableDemoTrading"))
except Exception:
item['enable_demo_trading'] = False
item.pop('encrypted_config', None)
item["enable_demo_trading"] = False
item.pop("encrypted_config", None)
items.append(item)
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': items}})
return jsonify({"code": 1, "msg": "success", "data": {"items": items}})
except Exception as e:
logger.error(f"list_credentials failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500
return jsonify({"code": 0, "msg": str(e), "data": {"items": []}}), 500
CRYPTO_EXCHANGES = [
'binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange',
'kraken', 'kucoin', 'gate', 'bitfinex', 'deepcoin', 'htx'
"binance",
"okx",
"bitget",
"bybit",
"coinbaseexchange",
"kraken",
"kucoin",
"gate",
"bitfinex",
"deepcoin",
"htx",
]
@@ -89,7 +98,7 @@ def _egress_ipify(url: str) -> str:
return ""
@credentials_bp.route('/egress-ip', methods=['GET'])
@credentials_bp.route("/egress-ip", methods=["GET"])
@login_required
def get_egress_ip():
"""
@@ -112,7 +121,7 @@ def get_egress_ip():
)
@credentials_bp.route('/create', methods=['POST'])
@credentials_bp.route("/create", methods=["POST"])
@login_required
def create_credential():
"""Create a new credential for the current user.
@@ -122,53 +131,59 @@ def create_credential():
try:
user_id = g.user_id
data = request.get_json() or {}
name = (data.get('name') or '').strip()
exchange_id = (data.get('exchange_id') or '').strip().lower()
name = (data.get("name") or "").strip()
exchange_id = (data.get("exchange_id") or "").strip().lower()
if not exchange_id:
return jsonify({'code': 0, 'msg': 'Missing exchange_id', 'data': None}), 400
return jsonify({"code": 0, "msg": "Missing exchange_id", "data": None}), 400
config = {'exchange_id': exchange_id}
hint = ''
config = {"exchange_id": exchange_id}
hint = ""
if exchange_id == 'ibkr':
if exchange_id == "ibkr":
# Interactive Brokers (US stocks)
config.update({
'ibkr_host': (data.get('ibkr_host') or '127.0.0.1').strip(),
'ibkr_port': int(data.get('ibkr_port') or 7497),
'ibkr_client_id': int(data.get('ibkr_client_id') or 1),
'ibkr_account': (data.get('ibkr_account') or '').strip()
})
config.update(
{
"ibkr_host": (data.get("ibkr_host") or "127.0.0.1").strip(),
"ibkr_port": int(data.get("ibkr_port") or 7497),
"ibkr_client_id": int(data.get("ibkr_client_id") or 1),
"ibkr_account": (data.get("ibkr_account") or "").strip(),
}
)
hint = f"{config['ibkr_host']}:{config['ibkr_port']}"
elif exchange_id == 'mt5':
elif exchange_id == "mt5":
# MetaTrader 5 (Forex)
mt5_server = (data.get('mt5_server') or '').strip()
mt5_login = str(data.get('mt5_login') or '').strip()
mt5_password = (data.get('mt5_password') or '').strip()
mt5_server = (data.get("mt5_server") or "").strip()
mt5_login = str(data.get("mt5_login") or "").strip()
mt5_password = (data.get("mt5_password") or "").strip()
if not mt5_server or not mt5_login or not mt5_password:
return jsonify({'code': 0, 'msg': 'Missing mt5_server/mt5_login/mt5_password', 'data': None}), 400
config.update({
'mt5_server': mt5_server,
'mt5_login': mt5_login,
'mt5_password': mt5_password,
'mt5_terminal_path': (data.get('mt5_terminal_path') or '').strip()
})
return jsonify({"code": 0, "msg": "Missing mt5_server/mt5_login/mt5_password", "data": None}), 400
config.update(
{
"mt5_server": mt5_server,
"mt5_login": mt5_login,
"mt5_password": mt5_password,
"mt5_terminal_path": (data.get("mt5_terminal_path") or "").strip(),
}
)
hint = f"{mt5_server}/{mt5_login}"
elif exchange_id in CRYPTO_EXCHANGES:
# Crypto exchanges
api_key = (data.get('api_key') or '').strip()
secret_key = (data.get('secret_key') or '').strip()
api_key = (data.get("api_key") or "").strip()
secret_key = (data.get("secret_key") or "").strip()
if not api_key or not secret_key:
return jsonify({'code': 0, 'msg': 'Missing api_key/secret_key', 'data': None}), 400
config.update({
'api_key': api_key,
'secret_key': secret_key,
'passphrase': (data.get('passphrase') or '').strip(),
'enable_demo_trading': bool(data.get('enable_demo_trading', False))
})
return jsonify({"code": 0, "msg": "Missing api_key/secret_key", "data": None}), 400
config.update(
{
"api_key": api_key,
"secret_key": secret_key,
"passphrase": (data.get("passphrase") or "").strip(),
"enable_demo_trading": bool(data.get("enable_demo_trading", False)),
}
)
hint = _api_key_hint(api_key)
else:
return jsonify({'code': 0, 'msg': f'Unsupported exchange: {exchange_id}', 'data': None}), 400
return jsonify({"code": 0, "msg": f"Unsupported exchange: {exchange_id}", "data": None}), 400
plaintext_config = json.dumps(config, ensure_ascii=False)
stored_blob = encrypt_credential_blob(plaintext_config)
@@ -181,47 +196,44 @@ def create_credential():
VALUES (%s, %s, %s, %s, %s, NOW(), NOW())
RETURNING id
""",
(user_id, name, exchange_id, hint, stored_blob)
(user_id, name, exchange_id, hint, stored_blob),
)
row = cur.fetchone()
new_id = (row or {}).get('id')
new_id = (row or {}).get("id")
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}})
return jsonify({"code": 1, "msg": "success", "data": {"id": new_id}})
except Exception as e:
logger.error(f"create_credential failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@credentials_bp.route('/delete', methods=['DELETE'])
@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)
cred_id = request.args.get("id", type=int)
if not cred_id:
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
return jsonify({"code": 0, "msg": "Missing id", "data": None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"DELETE FROM qd_exchange_credentials WHERE id = %s AND user_id = %s",
(cred_id, user_id)
)
cur.execute("DELETE FROM qd_exchange_credentials WHERE id = %s AND user_id = %s", (cred_id, user_id))
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
return jsonify({"code": 1, "msg": "success", "data": None})
except Exception as e:
logger.error(f"delete_credential failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@credentials_bp.route('/get', methods=['GET'])
@credentials_bp.route("/get", methods=["GET"])
@login_required
def get_credential():
"""
@@ -229,9 +241,9 @@ def get_credential():
"""
try:
user_id = g.user_id
cred_id = request.args.get('id', type=int)
cred_id = request.args.get("id", type=int)
if not cred_id:
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
return jsonify({"code": 0, "msg": "Missing id", "data": None}), 400
with get_db_connection() as db:
cur = db.cursor()
@@ -241,34 +253,34 @@ def get_credential():
FROM qd_exchange_credentials
WHERE id = %s AND user_id = %s
""",
(cred_id, user_id)
(cred_id, user_id),
)
row = cur.fetchone()
cur.close()
if not row:
return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404
return jsonify({"code": 0, "msg": "Not found", "data": None}), 404
raw = row.get('encrypted_config')
raw = row.get("encrypted_config")
plain = decrypt_credential_blob(raw)
decrypted = json.loads(plain) if plain else {}
# Ensure exchange_id is present
decrypted['exchange_id'] = row.get('exchange_id') or decrypted.get('exchange_id')
decrypted["exchange_id"] = row.get("exchange_id") or decrypted.get("exchange_id")
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'id': row.get('id'),
'name': row.get('name'),
'exchange_id': row.get('exchange_id'),
'api_key_hint': row.get('api_key_hint'),
'config': decrypted
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
"id": row.get("id"),
"name": row.get("name"),
"exchange_id": row.get("exchange_id"),
"api_key_hint": row.get("api_key_hint"),
"config": decrypted,
},
}
})
)
except Exception as e:
logger.error(f"get_credential failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
+50 -33
View File
@@ -13,13 +13,13 @@ from __future__ import annotations
import json
import time
from typing import Any, Dict, List, Tuple
from typing import Any, Dict, List
from flask import Blueprint, jsonify, request, g
from flask import Blueprint, g, jsonify, request
from app.utils.auth import login_required
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__)
@@ -44,7 +44,7 @@ def _format_datetime(dt: Any) -> Any:
"""Convert datetime object to ISO format string for JSON serialization."""
if dt is None:
return None
if hasattr(dt, 'isoformat'):
if hasattr(dt, "isoformat"):
return dt.isoformat()
return dt
@@ -96,7 +96,9 @@ def _calc_unrealized_pnl(side: str, entry_price: float, current_price: float, si
return 0.0
def _calc_pnl_percent(entry_price: float, size: float, pnl: float, leverage: float = 1.0, market_type: str = "spot") -> float:
def _calc_pnl_percent(
entry_price: float, size: float, pnl: float, leverage: float = 1.0, market_type: str = "spot"
) -> float:
try:
denom = float(entry_price or 0.0) * float(size or 0.0)
if denom <= 0:
@@ -189,7 +191,7 @@ def _compute_performance_stats(trades: List[Dict[str, Any]], initial_capital: fl
# Calculate drawdown percentage: drawdown / peak_equity * 100
# If peak_equity is 0 or very small, use a fallback calculation
if peak_equity > 0:
max_drawdown_pct = (max_drawdown / peak_equity * 100)
max_drawdown_pct = max_drawdown / peak_equity * 100
elif initial_capital > 0:
# Fallback: use initial capital as baseline
max_drawdown_pct = (max_drawdown / initial_capital * 100) if initial_capital > 0 else 0.0
@@ -202,10 +204,10 @@ def _compute_performance_stats(trades: List[Dict[str, Any]], initial_capital: fl
cumulative.append(acc)
peak_profit = max(cumulative) if cumulative else 0.0
if peak_profit > 0:
max_drawdown_pct = (max_drawdown / peak_profit * 100)
max_drawdown_pct = max_drawdown / peak_profit * 100
else:
max_drawdown_pct = 0.0
# Cap drawdown percentage at reasonable maximum (e.g., 10000%) to avoid display issues
if max_drawdown_pct > 10000:
max_drawdown_pct = 10000.0
@@ -277,16 +279,18 @@ def _compute_strategy_stats(trades: List[Dict[str, Any]], strategies: List[Dict[
total_pnl = sum(_safe_float(t.get("profit"), 0.0) for t in strades)
roi = (total_pnl / capital * 100) if capital > 0 else 0.0
result.append({
"strategy_id": sid,
"strategy_name": sid_to_name.get(sid, f"Strategy_{sid}"),
"total_trades": stats["total_trades"],
"win_rate": stats["win_rate"],
"profit_factor": stats["profit_factor"],
"total_pnl": round(total_pnl, 2),
"roi": round(roi, 2),
"max_drawdown": stats["max_drawdown"],
})
result.append(
{
"strategy_id": sid,
"strategy_name": sid_to_name.get(sid, f"Strategy_{sid}"),
"total_trades": stats["total_trades"],
"win_rate": stats["win_rate"],
"profit_factor": stats["profit_factor"],
"total_pnl": round(total_pnl, 2),
"roi": round(roi, 2),
"max_drawdown": stats["max_drawdown"],
}
)
# Sort by total PnL descending
result.sort(key=lambda x: x.get("total_pnl", 0), reverse=True)
@@ -301,7 +305,7 @@ def summary():
"""
try:
user_id = g.user_id
# Strategy counts (filtered by user_id)
with get_db_connection() as db:
cur = db.cursor()
@@ -311,7 +315,7 @@ def summary():
FROM qd_strategies_trading
WHERE user_id = ?
""",
(user_id,)
(user_id,),
)
strategies = cur.fetchall() or []
cur.close()
@@ -347,7 +351,7 @@ def summary():
WHERE p.user_id = ?
ORDER BY p.updated_at DESC
""",
(user_id,)
(user_id,),
)
rows = cur.fetchall() or []
cur.close()
@@ -397,17 +401,17 @@ def summary():
ORDER BY t.created_at DESC
LIMIT 500
""",
(user_id,)
(user_id,),
)
recent_trades_raw = cur.fetchall() or []
cur.close()
# Convert datetime to timestamp for frontend compatibility
recent_trades = []
for t in recent_trades_raw:
trade = dict(t)
if trade.get('created_at') and hasattr(trade['created_at'], 'timestamp'):
trade['created_at'] = int(trade['created_at'].timestamp())
if trade.get("created_at") and hasattr(trade["created_at"], "timestamp"):
trade["created_at"] = int(trade["created_at"].timestamp())
recent_trades.append(trade)
# Total equity/pnl (best-effort) - calculate before performance stats for drawdown calculation
@@ -487,7 +491,7 @@ def summary():
# Calendar data: organized by month for monthly calendar view
# Format: { "2024-01": { "days": { "01": 123.45, "02": -50.0, ... }, "total": 500.0 }, ... }
import calendar as cal_module
from datetime import datetime, timedelta
from datetime import datetime
calendar_data: Dict[str, Dict[str, Any]] = {}
for d, p in day_to_profit.items():
@@ -524,10 +528,7 @@ def summary():
calendar_months = []
for month_key in sorted(calendar_data.keys(), reverse=True):
data = calendar_data[month_key]
calendar_months.append({
"month_key": month_key,
**data
})
calendar_months.append({"month_key": month_key, **data})
return jsonify(
{
@@ -612,16 +613,32 @@ def pending_orders():
# Frontend expects these keys:
# - filled_amount, filled_price, error_message
filled_amount = float(r.get("filled") or 0.0)
filled_price = float(r.get("avg_price") or 0.0) if float(r.get("avg_price") or 0.0) > 0 else float(r.get("price") or 0.0)
filled_price = (
float(r.get("avg_price") or 0.0)
if float(r.get("avg_price") or 0.0) > 0
else float(r.get("price") or 0.0)
)
# Derive exchange_id + notify channels without leaking secrets to frontend.
ex_cfg = _safe_json_loads(r.get("strategy_exchange_config"), {}) or {}
notify_cfg = _safe_json_loads(r.get("strategy_notification_config"), {}) or {}
exchange_id = (r.get("exchange_id") or ex_cfg.get("exchange_id") or ex_cfg.get("exchangeId") or "").strip().lower()
exchange_id = (
(r.get("exchange_id") or ex_cfg.get("exchange_id") or ex_cfg.get("exchangeId") or "").strip().lower()
)
notify_channels = _as_list((notify_cfg or {}).get("channels"))
if not notify_channels:
notify_channels = ["browser"]
market_type = (r.get("market_type") or r.get("strategy_market_type") or ex_cfg.get("market_type") or ex_cfg.get("marketType") or "").strip().lower()
market_type = (
(
r.get("market_type")
or r.get("strategy_market_type")
or ex_cfg.get("market_type")
or ex_cfg.get("marketType")
or ""
)
.strip()
.lower()
)
market_category = str(r.get("strategy_market_category") or "").strip().lower()
execution_mode = str(r.get("strategy_execution_mode") or r.get("execution_mode") or "").strip().lower()
+283 -340
View File
@@ -3,19 +3,21 @@ Fast Analysis API Routes
New high-performance analysis endpoints that replace the slow multi-agent system.
"""
from flask import Blueprint, request, jsonify, g
import threading
import time
from app.utils.auth import login_required
from app.utils.logger import get_logger
from app.services.fast_analysis import get_fast_analysis_service
from flask import Blueprint, g, jsonify, request
from app.services.analysis_memory import get_analysis_memory
from app.services.billing_service import get_billing_service
from app.services.fast_analysis import get_fast_analysis_service
from app.utils.auth import login_required
from app.utils.logger import get_logger
logger = get_logger(__name__)
fast_analysis_bp = Blueprint('fast_analysis', __name__)
fast_analysis_bp = Blueprint("fast_analysis", __name__)
# In-memory in-flight guard to avoid duplicate analysis charges caused by rapid repeated clicks.
_analysis_inflight_lock = threading.Lock()
@@ -28,19 +30,22 @@ def _try_refund_credits(user_id: int, amount: int, remark: str):
if int(amount or 0) <= 0:
return
billing = get_billing_service()
billing.add_credits(
user_id=int(user_id),
amount=int(amount),
action='refund',
remark=remark
)
billing.add_credits(user_id=int(user_id), amount=int(amount), action="refund", remark=remark)
except Exception as e:
logger.error(f"Async auto refund failed: {e}", exc_info=True)
def _run_async_analysis_task(task_memory_id: int, market: str, symbol: str, language: str,
model: str, timeframe: str, user_id: int, inflight_key: str,
credits_charged: int = 0):
def _run_async_analysis_task(
task_memory_id: int,
market: str,
symbol: str,
language: str,
model: str,
timeframe: str,
user_id: int,
inflight_key: str,
credits_charged: int = 0,
):
"""
Background worker: execute analysis and update pending history record.
"""
@@ -48,19 +53,14 @@ def _run_async_analysis_task(task_memory_id: int, market: str, symbol: str, lang
service = get_fast_analysis_service()
memory = get_analysis_memory()
result = service.analyze(
market=market,
symbol=symbol,
language=language,
model=model,
timeframe=timeframe,
user_id=user_id
market=market, symbol=symbol, language=language, model=model, timeframe=timeframe, user_id=user_id
)
memory.finalize_pending_task(task_memory_id, result)
if result.get("error"):
_try_refund_credits(
user_id=int(user_id),
amount=int(credits_charged or 0),
remark=f'Auto refund: async fast-analysis failed ({market}:{symbol}:{timeframe})'
remark=f"Auto refund: async fast-analysis failed ({market}:{symbol}:{timeframe})",
)
# analyze() already stores a separate memory row; remove it to avoid duplicates.
@@ -75,7 +75,7 @@ def _run_async_analysis_task(task_memory_id: int, market: str, symbol: str, lang
_try_refund_credits(
user_id=int(user_id),
amount=int(credits_charged or 0),
remark=f'Auto refund: async fast-analysis exception ({market}:{symbol}:{timeframe})'
remark=f"Auto refund: async fast-analysis exception ({market}:{symbol}:{timeframe})",
)
try:
get_analysis_memory().fail_pending_task(task_memory_id, str(e))
@@ -110,12 +110,12 @@ def _release_inflight(key: str):
_analysis_inflight.pop(key, None)
@fast_analysis_bp.route('/analyze', methods=['POST'])
@fast_analysis_bp.route("/analyze", methods=["POST"])
@login_required
def analyze():
"""
Fast AI analysis for any symbol.
POST /api/fast-analysis/analyze
Body: {
"market": "Crypto" | "USStock" | "Forex" | ...,
@@ -124,39 +124,37 @@ def analyze():
"model": "openai/gpt-4o" (optional),
"timeframe": "1D" (optional)
}
Returns:
Fast analysis result with actionable recommendations.
"""
try:
data = request.get_json() or {}
market = (data.get('market') or '').strip()
symbol = (data.get('symbol') or '').strip()
language = data.get('language', 'en-US')
model = data.get('model')
timeframe = data.get('timeframe', '1D')
async_submit = bool(data.get('async_submit', False))
market = (data.get("market") or "").strip()
symbol = (data.get("symbol") or "").strip()
language = data.get("language", "en-US")
model = data.get("model")
timeframe = data.get("timeframe", "1D")
async_submit = bool(data.get("async_submit", False))
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'market and symbol are required',
'data': None
}), 400
return jsonify({"code": 0, "msg": "market and symbol are required", "data": None}), 400
# Get current user's ID to associate analysis with user
user_id = getattr(g, 'user_id', None)
user_id = getattr(g, "user_id", None)
if not user_id:
return jsonify({'code': 0, 'msg': 'Unauthorized', 'data': None}), 401
return jsonify({"code": 0, "msg": "Unauthorized", "data": None}), 401
inflight_key = _build_inflight_key(user_id, market, symbol, timeframe)
if not _acquire_inflight(inflight_key, ttl_sec=90):
return jsonify({
'code': 0,
'msg': 'Analysis already in progress for this symbol/timeframe. Please wait.',
'data': {'in_progress': True}
}), 429
return jsonify(
{
"code": 0,
"msg": "Analysis already in progress for this symbol/timeframe. Please wait.",
"data": {"in_progress": True},
}
), 429
# Billing / credits (best-effort, consistent with polymarket deep analysis)
credits_charged = 0
@@ -166,30 +164,32 @@ def analyze():
try:
billing = get_billing_service()
if billing.is_billing_enabled():
credits_charged = int(billing.get_feature_cost('ai_analysis') or 0)
credits_charged = int(billing.get_feature_cost("ai_analysis") or 0)
if credits_charged > 0:
ok, msg = billing.check_and_consume(
user_id=int(user_id),
feature='ai_analysis',
reference_id=f"fast_analysis_{market}:{symbol}:{timeframe}"
feature="ai_analysis",
reference_id=f"fast_analysis_{market}:{symbol}:{timeframe}",
)
if not ok:
# Standardize insufficient credits message
if str(msg or "").startswith('insufficient_credits'):
if str(msg or "").startswith("insufficient_credits"):
# Format: insufficient_credits:<current>:<cost>
parts = str(msg).split(':')
parts = str(msg).split(":")
cur = float(parts[1]) if len(parts) >= 2 else 0.0
req = float(parts[2]) if len(parts) >= 3 else float(credits_charged)
return jsonify({
'code': 0,
'msg': 'Insufficient credits',
'data': {
'required': req,
'current': cur,
'shortage': max(0.0, req - cur),
return jsonify(
{
"code": 0,
"msg": "Insufficient credits",
"data": {
"required": req,
"current": cur,
"shortage": max(0.0, req - cur),
},
}
}), 400
return jsonify({'code': 0, 'msg': f'Failed to deduct credits: {msg}', 'data': None}), 500
), 400
return jsonify({"code": 0, "msg": f"Failed to deduct credits: {msg}", "data": None}), 500
billing_consumed = True
# Query remaining credits after successful consumption
try:
@@ -199,155 +199,157 @@ def analyze():
except Exception as e:
# Billing failure should not crash analysis by default, but should be visible in logs.
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
service = get_fast_analysis_service()
# Async submit mode: record "processing" immediately and return task id.
if async_submit:
memory = get_analysis_memory()
pending_id = memory.create_pending_task(
market=market,
symbol=symbol,
language=language,
model=model or "",
timeframe=timeframe,
user_id=user_id
market=market, symbol=symbol, language=language, model=model or "", timeframe=timeframe, user_id=user_id
)
if not pending_id:
return jsonify({'code': 0, 'msg': 'Failed to create analysis task', 'data': None}), 500
return jsonify({"code": 0, "msg": "Failed to create analysis task", "data": None}), 500
t = threading.Thread(
target=_run_async_analysis_task,
args=(int(pending_id), market, symbol, language, model, timeframe, int(user_id), inflight_key, int(credits_charged or 0)),
daemon=True
args=(
int(pending_id),
market,
symbol,
language,
model,
timeframe,
int(user_id),
inflight_key,
int(credits_charged or 0),
),
daemon=True,
)
t.start()
# worker owns inflight release
inflight_key = None
return jsonify({
'code': 1,
'msg': 'submitted',
'data': {
'task_id': int(pending_id),
'memory_id': int(pending_id),
'status': 'processing',
'market': market,
'symbol': symbol,
'timeframe': timeframe,
'credits_charged': credits_charged,
'remaining_credits': remaining_credits,
return jsonify(
{
"code": 1,
"msg": "submitted",
"data": {
"task_id": int(pending_id),
"memory_id": int(pending_id),
"status": "processing",
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"credits_charged": credits_charged,
"remaining_credits": remaining_credits,
},
}
})
)
result = service.analyze(
market=market,
symbol=symbol,
language=language,
model=model,
timeframe=timeframe,
user_id=user_id
market=market, symbol=symbol, language=language, model=model, timeframe=timeframe, user_id=user_id
)
if result.get('error'):
if result.get("error"):
# Best-effort refund if we already charged but analysis failed.
if billing_consumed and billing and credits_charged > 0:
try:
billing.add_credits(
user_id=int(user_id),
amount=int(credits_charged),
action='refund',
remark=f'Auto refund: fast-analysis failed ({market}:{symbol}:{timeframe})'
action="refund",
remark=f"Auto refund: fast-analysis failed ({market}:{symbol}:{timeframe})",
)
remaining_credits = float(billing.get_user_credits(int(user_id)))
except Exception as re:
logger.error(f"Auto refund failed: {re}", exc_info=True)
return jsonify({
'code': 0,
'msg': result['error'],
'data': result
}), 500
return jsonify({"code": 0, "msg": result["error"], "data": result}), 500
# memory_id is already set in service.analyze() -> _store_analysis_memory()
# No need to store again here (would create duplicates)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
**(result or {}),
'credits_charged': credits_charged,
'remaining_credits': remaining_credits,
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
**(result or {}),
"credits_charged": credits_charged,
"remaining_credits": remaining_credits,
},
}
})
)
except Exception as e:
# Best-effort refund on unexpected error after charge.
try:
if 'billing_consumed' in locals() and billing_consumed and 'billing' in locals() and billing and credits_charged > 0 and 'user_id' in locals() and user_id:
if (
"billing_consumed" in locals()
and billing_consumed
and "billing" in locals()
and billing
and credits_charged > 0
and "user_id" in locals()
and user_id
):
billing.add_credits(
user_id=int(user_id),
amount=int(credits_charged),
action='refund',
remark=f'Auto refund: fast-analysis exception ({market}:{symbol}:{timeframe})'
action="refund",
remark=f"Auto refund: fast-analysis exception ({market}:{symbol}:{timeframe})",
)
except Exception:
pass
logger.error(f"Fast analysis API failed: {e}", exc_info=True)
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
finally:
try:
if 'inflight_key' in locals() and inflight_key:
if "inflight_key" in locals() and inflight_key:
_release_inflight(inflight_key)
except Exception:
pass
@fast_analysis_bp.route('/analyze-legacy', methods=['POST'])
@fast_analysis_bp.route("/analyze-legacy", methods=["POST"])
@login_required
def analyze_legacy():
"""
Fast analysis with legacy format output.
For backward compatibility with existing frontend.
POST /api/fast-analysis/analyze-legacy
Body: Same as /analyze
Returns:
Result in multi-agent format for frontend compatibility.
"""
try:
data = request.get_json() or {}
market = (data.get('market') or '').strip()
symbol = (data.get('symbol') or '').strip()
language = data.get('language', 'en-US')
model = data.get('model')
timeframe = data.get('timeframe', '1D')
market = (data.get("market") or "").strip()
symbol = (data.get("symbol") or "").strip()
language = data.get("language", "en-US")
model = data.get("model")
timeframe = data.get("timeframe", "1D")
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'market and symbol are required',
'data': None
}), 400
return jsonify({"code": 0, "msg": "market and symbol are required", "data": None}), 400
# Billing / credits (same behavior as /analyze)
user_id = getattr(g, 'user_id', None)
user_id = getattr(g, "user_id", None)
if not user_id:
return jsonify({'code': 0, 'msg': 'Unauthorized', 'data': None}), 401
return jsonify({"code": 0, "msg": "Unauthorized", "data": None}), 401
inflight_key = _build_inflight_key(user_id, market, symbol, timeframe)
if not _acquire_inflight(inflight_key, ttl_sec=90):
return jsonify({
'code': 0,
'msg': 'Analysis already in progress for this symbol/timeframe. Please wait.',
'data': {'in_progress': True}
}), 429
return jsonify(
{
"code": 0,
"msg": "Analysis already in progress for this symbol/timeframe. Please wait.",
"data": {"in_progress": True},
}
), 429
credits_charged = 0
remaining_credits = None
@@ -356,28 +358,30 @@ def analyze_legacy():
try:
billing = get_billing_service()
if billing.is_billing_enabled():
credits_charged = int(billing.get_feature_cost('ai_analysis') or 0)
credits_charged = int(billing.get_feature_cost("ai_analysis") or 0)
if credits_charged > 0:
ok, msg = billing.check_and_consume(
user_id=int(user_id),
feature='ai_analysis',
reference_id=f"fast_analysis_legacy_{market}:{symbol}:{timeframe}"
feature="ai_analysis",
reference_id=f"fast_analysis_legacy_{market}:{symbol}:{timeframe}",
)
if not ok:
if str(msg or "").startswith('insufficient_credits'):
parts = str(msg).split(':')
if str(msg or "").startswith("insufficient_credits"):
parts = str(msg).split(":")
cur = float(parts[1]) if len(parts) >= 2 else 0.0
req = float(parts[2]) if len(parts) >= 3 else float(credits_charged)
return jsonify({
'code': 0,
'msg': 'Insufficient credits',
'data': {
'required': req,
'current': cur,
'shortage': max(0.0, req - cur),
return jsonify(
{
"code": 0,
"msg": "Insufficient credits",
"data": {
"required": req,
"current": cur,
"shortage": max(0.0, req - cur),
},
}
}), 400
return jsonify({'code': 0, 'msg': f'Failed to deduct credits: {msg}', 'data': None}), 500
), 400
return jsonify({"code": 0, "msg": f"Failed to deduct credits: {msg}", "data": None}), 500
billing_consumed = True
try:
remaining_credits = float(billing.get_user_credits(int(user_id)))
@@ -385,192 +389,161 @@ def analyze_legacy():
remaining_credits = None
except Exception as e:
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
service = get_fast_analysis_service()
result = service.analyze_legacy_format(
market=market,
symbol=symbol,
language=language,
model=model,
timeframe=timeframe
market=market, symbol=symbol, language=language, model=model, timeframe=timeframe
)
if result.get('error'):
if result.get("error"):
if billing_consumed and billing and credits_charged > 0:
try:
billing.add_credits(
user_id=int(user_id),
amount=int(credits_charged),
action='refund',
remark=f'Auto refund: fast-analysis-legacy failed ({market}:{symbol}:{timeframe})'
action="refund",
remark=f"Auto refund: fast-analysis-legacy failed ({market}:{symbol}:{timeframe})",
)
remaining_credits = float(billing.get_user_credits(int(user_id)))
except Exception as re:
logger.error(f"Auto refund failed (legacy): {re}", exc_info=True)
return jsonify({
'code': 0,
'msg': result['error'],
'data': result
}), 500
return jsonify({
'code': 1,
'msg': 'success',
'data': {
**(result or {}),
'credits_charged': credits_charged,
'remaining_credits': remaining_credits,
return jsonify({"code": 0, "msg": result["error"], "data": result}), 500
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
**(result or {}),
"credits_charged": credits_charged,
"remaining_credits": remaining_credits,
},
}
})
)
except Exception as e:
try:
if 'billing_consumed' in locals() and billing_consumed and 'billing' in locals() and billing and credits_charged > 0 and 'user_id' in locals() and user_id:
if (
"billing_consumed" in locals()
and billing_consumed
and "billing" in locals()
and billing
and credits_charged > 0
and "user_id" in locals()
and user_id
):
billing.add_credits(
user_id=int(user_id),
amount=int(credits_charged),
action='refund',
remark=f'Auto refund: fast-analysis-legacy exception ({market}:{symbol}:{timeframe})'
action="refund",
remark=f"Auto refund: fast-analysis-legacy exception ({market}:{symbol}:{timeframe})",
)
except Exception:
pass
logger.error(f"Fast analysis legacy API failed: {e}", exc_info=True)
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
finally:
try:
if 'inflight_key' in locals() and inflight_key:
if "inflight_key" in locals() and inflight_key:
_release_inflight(inflight_key)
except Exception:
pass
@fast_analysis_bp.route('/history', methods=['GET'])
@fast_analysis_bp.route("/history", methods=["GET"])
@login_required
def get_history():
"""
Get analysis history for a symbol.
GET /api/fast-analysis/history?market=Crypto&symbol=BTC/USDT&days=7&limit=10
"""
try:
market = request.args.get('market', '').strip()
symbol = request.args.get('symbol', '').strip()
days = int(request.args.get('days', 7))
limit = min(int(request.args.get('limit', 10)), 50)
market = request.args.get("market", "").strip()
symbol = request.args.get("symbol", "").strip()
days = int(request.args.get("days", 7))
limit = min(int(request.args.get("limit", 10)), 50)
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'market and symbol are required',
'data': None
}), 400
return jsonify({"code": 0, "msg": "market and symbol are required", "data": None}), 400
memory = get_analysis_memory()
history = memory.get_recent(market, symbol, days, limit)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'items': history,
'total': len(history)
}
})
return jsonify({"code": 1, "msg": "success", "data": {"items": history, "total": len(history)}})
except Exception as e:
logger.error(f"Get history failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@fast_analysis_bp.route('/history/all', methods=['GET'])
@fast_analysis_bp.route("/history/all", methods=["GET"])
@login_required
def get_all_history():
"""
Get all analysis history with pagination.
GET /api/fast-analysis/history/all?page=1&pagesize=20
"""
try:
page = int(request.args.get('page', 1))
pagesize = min(int(request.args.get('pagesize', 20)), 50)
page = int(request.args.get("page", 1))
pagesize = min(int(request.args.get("pagesize", 20)), 50)
# Get current user's ID to filter history
user_id = getattr(g, 'user_id', None)
user_id = getattr(g, "user_id", None)
memory = get_analysis_memory()
result = memory.get_all_history(user_id=user_id, page=page, page_size=pagesize)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'list': result['items'],
'total': result['total'],
'page': result['page'],
'pagesize': result['page_size']
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
"list": result["items"],
"total": result["total"],
"page": result["page"],
"pagesize": result["page_size"],
},
}
})
)
except Exception as e:
logger.error(f"Get all history failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@fast_analysis_bp.route('/history/<int:memory_id>', methods=['DELETE'])
@fast_analysis_bp.route("/history/<int:memory_id>", methods=["DELETE"])
@login_required
def delete_history(memory_id: int):
"""
Delete a history record.
DELETE /api/fast-analysis/history/123
"""
try:
# Get current user's ID to ensure they can only delete their own records
user_id = getattr(g, 'user_id', None)
user_id = getattr(g, "user_id", None)
memory = get_analysis_memory()
success = memory.delete_history(memory_id, user_id=user_id)
if success:
return jsonify({
'code': 1,
'msg': 'Deleted successfully',
'data': None
})
return jsonify({"code": 1, "msg": "Deleted successfully", "data": None})
else:
return jsonify({
'code': 0,
'msg': 'Record not found or no permission',
'data': None
}), 404
return jsonify({"code": 0, "msg": "Record not found or no permission", "data": None}), 404
except Exception as e:
logger.error(f"Delete history failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@fast_analysis_bp.route('/feedback', methods=['POST'])
@fast_analysis_bp.route("/feedback", methods=["POST"])
@login_required
def submit_feedback():
"""
Submit user feedback on an analysis.
POST /api/fast-analysis/feedback
Body: {
"memory_id": 123,
@@ -579,119 +552,89 @@ def submit_feedback():
"""
try:
data = request.get_json() or {}
memory_id = int(data.get('memory_id', 0))
feedback = (data.get('feedback') or '').strip()
memory_id = int(data.get("memory_id", 0))
feedback = (data.get("feedback") or "").strip()
if not memory_id or not feedback:
return jsonify({
'code': 0,
'msg': 'memory_id and feedback are required',
'data': None
}), 400
valid_feedback = ['helpful', 'not_helpful', 'accurate', 'inaccurate']
return jsonify({"code": 0, "msg": "memory_id and feedback are required", "data": None}), 400
valid_feedback = ["helpful", "not_helpful", "accurate", "inaccurate"]
if feedback not in valid_feedback:
return jsonify({
'code': 0,
'msg': f'feedback must be one of: {valid_feedback}',
'data': None
}), 400
return jsonify({"code": 0, "msg": f"feedback must be one of: {valid_feedback}", "data": None}), 400
memory = get_analysis_memory()
success = memory.record_feedback(memory_id, feedback)
return jsonify({
'code': 1 if success else 0,
'msg': 'success' if success else 'failed',
'data': None
})
return jsonify({"code": 1 if success else 0, "msg": "success" if success else "failed", "data": None})
except Exception as e:
logger.error(f"Submit feedback failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@fast_analysis_bp.route('/performance', methods=['GET'])
@fast_analysis_bp.route("/performance", methods=["GET"])
@login_required
def get_performance():
"""
Get AI analysis performance statistics.
GET /api/fast-analysis/performance?market=Crypto&symbol=BTC/USDT&days=30
"""
try:
market = request.args.get('market', '').strip() or None
symbol = request.args.get('symbol', '').strip() or None
days = int(request.args.get('days', 30))
market = request.args.get("market", "").strip() or None
symbol = request.args.get("symbol", "").strip() or None
days = int(request.args.get("days", 30))
memory = get_analysis_memory()
stats = memory.get_performance_stats(market, symbol, days)
return jsonify({
'code': 1,
'msg': 'success',
'data': stats
})
return jsonify({"code": 1, "msg": "success", "data": stats})
except Exception as e:
logger.error(f"Get performance failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@fast_analysis_bp.route('/similar-patterns', methods=['GET'])
@fast_analysis_bp.route("/similar-patterns", methods=["GET"])
@login_required
def get_similar_patterns():
"""
Get similar historical patterns for current market conditions.
GET /api/fast-analysis/similar-patterns?market=Crypto&symbol=BTC/USDT
"""
try:
market = request.args.get('market', '').strip()
symbol = request.args.get('symbol', '').strip()
market = request.args.get("market", "").strip()
symbol = request.args.get("symbol", "").strip()
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'market and symbol are required',
'data': None
}), 400
return jsonify({"code": 0, "msg": "market and symbol are required", "data": None}), 400
# Get current indicators
service = get_fast_analysis_service()
data = service._collect_market_data(market, symbol)
indicators = data.get('indicators', {})
indicators = data.get("indicators", {})
# Find similar patterns
memory = get_analysis_memory()
patterns = memory.get_similar_patterns(market, symbol, indicators)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'patterns': patterns,
'current_indicators': {
'rsi': indicators.get('rsi', {}).get('value'),
'macd_signal': indicators.get('macd', {}).get('signal'),
'trend': indicators.get('moving_averages', {}).get('trend'),
}
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
"patterns": patterns,
"current_indicators": {
"rsi": indicators.get("rsi", {}).get("value"),
"macd_signal": indicators.get("macd", {}).get("signal"),
"trend": indicators.get("moving_averages", {}).get("trend"),
},
},
}
})
)
except Exception as e:
logger.error(f"Get similar patterns failed: {e}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -1,33 +1,34 @@
"""
Health check routing
"""
from flask import Blueprint, jsonify
from datetime import datetime
health_bp = Blueprint('health', __name__)
from flask import Blueprint, jsonify
health_bp = Blueprint("health", __name__)
@health_bp.route('/', methods=['GET'])
@health_bp.route("/", methods=["GET"])
def index():
"""API Home Page"""
return jsonify({
'name': 'QuantDinger Python API',
'version': '2.0.0',
'status': 'running',
'timestamp': datetime.now().isoformat()
})
return jsonify(
{
"name": "QuantDinger Python API",
"version": "2.0.0",
"status": "running",
"timestamp": datetime.now().isoformat(),
}
)
@health_bp.route('/health', methods=['GET'])
@health_bp.route("/health", methods=["GET"])
def health_check():
"""health check"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat()
})
return jsonify({"status": "healthy", "timestamp": datetime.now().isoformat()})
@health_bp.route('/api/health', methods=['GET'])
@health_bp.route("/api/health", methods=["GET"])
def api_health_check():
"""Compatible path: used for container health check/anti-generation probe and other scenarios."""
return health_check()
+112 -188
View File
@@ -4,15 +4,15 @@ Interactive Brokers API Routes
Standalone API endpoints for US stock trading.
"""
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify, request
from app.utils.logger import get_logger
from app.services.ibkr_trading import IBKRClient, IBKRConfig
from app.services.ibkr_trading.client import get_ibkr_client, reset_ibkr_client
from app.utils.logger import get_logger
logger = get_logger(__name__)
ibkr_bp = Blueprint('ibkr', __name__)
ibkr_bp = Blueprint("ibkr", __name__)
# Global client instance
_client: IBKRClient = None
@@ -28,32 +28,27 @@ def _get_client() -> IBKRClient:
# ==================== Connection Management ====================
@ibkr_bp.route('/status', methods=['GET'])
@ibkr_bp.route("/status", methods=["GET"])
def get_status():
"""
Get connection status.
GET /api/ibkr/status
"""
try:
client = _get_client()
return jsonify({
"success": True,
"data": client.get_connection_status()
})
return jsonify({"success": True, "data": client.get_connection_status()})
except Exception as e:
logger.error(f"Get status failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
@ibkr_bp.route('/connect', methods=['POST'])
@ibkr_bp.route("/connect", methods=["POST"])
def connect():
"""
Connect to TWS / IB Gateway.
POST /api/ibkr/connect
Body: {
"host": "127.0.0.1", // Optional, default 127.0.0.1
@@ -64,172 +59,132 @@ def connect():
}
"""
global _client
try:
data = request.get_json() or {}
# Build config
config = IBKRConfig(
host=data.get('host', '127.0.0.1'),
port=int(data.get('port', 7497)),
client_id=int(data.get('clientId', 1)),
account=data.get('account', ''),
readonly=data.get('readonly', False),
host=data.get("host", "127.0.0.1"),
port=int(data.get("port", 7497)),
client_id=int(data.get("clientId", 1)),
account=data.get("account", ""),
readonly=data.get("readonly", False),
)
# Disconnect existing connection
if _client is not None and _client.connected:
_client.disconnect()
# Create new client and connect
_client = IBKRClient(config)
success = _client.connect()
if success:
return jsonify({
"success": True,
"message": "Connected successfully",
"data": _client.get_connection_status()
})
return jsonify(
{"success": True, "message": "Connected successfully", "data": _client.get_connection_status()}
)
else:
return jsonify({
"success": False,
"error": "Connection failed. Please check if TWS/Gateway is running."
}), 400
except ImportError as e:
return jsonify({
"success": False,
"error": "ib_insync not installed. Run: pip install ib_insync"
}), 500
return jsonify(
{"success": False, "error": "Connection failed. Please check if TWS/Gateway is running."}
), 400
except ImportError:
return jsonify({"success": False, "error": "ib_insync not installed. Run: pip install ib_insync"}), 500
except Exception as e:
logger.error(f"Connection failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
@ibkr_bp.route('/disconnect', methods=['POST'])
@ibkr_bp.route("/disconnect", methods=["POST"])
def disconnect():
"""
Disconnect from IBKR.
POST /api/ibkr/disconnect
"""
global _client
try:
if _client is not None:
_client.disconnect()
_client = None
reset_ibkr_client()
return jsonify({
"success": True,
"message": "Disconnected"
})
return jsonify({"success": True, "message": "Disconnected"})
except Exception as e:
logger.error(f"Disconnect failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
# ==================== Account Queries ====================
@ibkr_bp.route('/account', methods=['GET'])
@ibkr_bp.route("/account", methods=["GET"])
def get_account():
"""
Get account information.
GET /api/ibkr/account
"""
try:
client = _get_client()
if not client.connected:
return jsonify({
"success": False,
"error": "Not connected to IBKR"
}), 400
return jsonify({
"success": True,
"data": client.get_account_summary()
})
return jsonify({"success": False, "error": "Not connected to IBKR"}), 400
return jsonify({"success": True, "data": client.get_account_summary()})
except Exception as e:
logger.error(f"Get account info failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
@ibkr_bp.route('/positions', methods=['GET'])
@ibkr_bp.route("/positions", methods=["GET"])
def get_positions():
"""
Get positions.
GET /api/ibkr/positions
"""
try:
client = _get_client()
if not client.connected:
return jsonify({
"success": False,
"error": "Not connected to IBKR"
}), 400
return jsonify({"success": False, "error": "Not connected to IBKR"}), 400
positions = client.get_positions()
return jsonify({
"success": True,
"data": positions
})
return jsonify({"success": True, "data": positions})
except Exception as e:
logger.error(f"Get positions failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
@ibkr_bp.route('/orders', methods=['GET'])
@ibkr_bp.route("/orders", methods=["GET"])
def get_orders():
"""
Get open orders.
GET /api/ibkr/orders
"""
try:
client = _get_client()
if not client.connected:
return jsonify({
"success": False,
"error": "Not connected to IBKR"
}), 400
return jsonify({"success": False, "error": "Not connected to IBKR"}), 400
orders = client.get_open_orders()
return jsonify({
"success": True,
"data": orders
})
return jsonify({"success": True, "data": orders})
except Exception as e:
logger.error(f"Get orders failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
# ==================== Trading ====================
@ibkr_bp.route('/order', methods=['POST'])
@ibkr_bp.route("/order", methods=["POST"])
def place_order():
"""
Place an order.
POST /api/ibkr/order
Body: {
"symbol": "AAPL", // Required, symbol code
@@ -243,140 +198,109 @@ def place_order():
try:
client = _get_client()
if not client.connected:
return jsonify({
"success": False,
"error": "Not connected to IBKR"
}), 400
return jsonify({"success": False, "error": "Not connected to IBKR"}), 400
data = request.get_json() or {}
# Validate parameters
symbol = data.get('symbol')
side = data.get('side')
quantity = data.get('quantity')
symbol = data.get("symbol")
side = data.get("side")
quantity = data.get("quantity")
if not symbol:
return jsonify({"success": False, "error": "Missing symbol"}), 400
if not side or side.lower() not in ('buy', 'sell'):
if not side or side.lower() not in ("buy", "sell"):
return jsonify({"success": False, "error": "side must be buy or sell"}), 400
if not quantity or float(quantity) <= 0:
return jsonify({"success": False, "error": "quantity must be > 0"}), 400
market_type = data.get('marketType', 'USStock')
order_type = data.get('orderType', 'market').lower()
market_type = data.get("marketType", "USStock")
order_type = data.get("orderType", "market").lower()
# Place order
if order_type == 'limit':
price = data.get('price')
if order_type == "limit":
price = data.get("price")
if not price or float(price) <= 0:
return jsonify({"success": False, "error": "Limit order requires price"}), 400
result = client.place_limit_order(
symbol=symbol,
side=side,
quantity=float(quantity),
price=float(price),
market_type=market_type
symbol=symbol, side=side, quantity=float(quantity), price=float(price), market_type=market_type
)
else:
result = client.place_market_order(
symbol=symbol,
side=side,
quantity=float(quantity),
market_type=market_type
symbol=symbol, side=side, quantity=float(quantity), market_type=market_type
)
if result.success:
return jsonify({
"success": True,
"message": result.message,
"data": {
"orderId": result.order_id,
"filled": result.filled,
"avgPrice": result.avg_price,
"status": result.status,
"raw": result.raw
return jsonify(
{
"success": True,
"message": result.message,
"data": {
"orderId": result.order_id,
"filled": result.filled,
"avgPrice": result.avg_price,
"status": result.status,
"raw": result.raw,
},
}
})
)
else:
return jsonify({
"success": False,
"error": result.message
}), 400
return jsonify({"success": False, "error": result.message}), 400
except Exception as e:
logger.error(f"Place order failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
@ibkr_bp.route('/order/<int:order_id>', methods=['DELETE'])
@ibkr_bp.route("/order/<int:order_id>", methods=["DELETE"])
def cancel_order(order_id: int):
"""
Cancel an order.
DELETE /api/ibkr/order/<order_id>
"""
try:
client = _get_client()
if not client.connected:
return jsonify({
"success": False,
"error": "Not connected to IBKR"
}), 400
return jsonify({"success": False, "error": "Not connected to IBKR"}), 400
success = client.cancel_order(order_id)
if success:
return jsonify({
"success": True,
"message": f"Order {order_id} cancelled"
})
return jsonify({"success": True, "message": f"Order {order_id} cancelled"})
else:
return jsonify({
"success": False,
"error": f"Order {order_id} not found"
}), 404
return jsonify({"success": False, "error": f"Order {order_id} not found"}), 404
except Exception as e:
logger.error(f"Cancel order failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
# ==================== Market Data ====================
@ibkr_bp.route('/quote', methods=['GET'])
@ibkr_bp.route("/quote", methods=["GET"])
def get_quote():
"""
Get real-time quote.
GET /api/ibkr/quote?symbol=AAPL&marketType=USStock
"""
try:
client = _get_client()
if not client.connected:
return jsonify({
"success": False,
"error": "Not connected to IBKR"
}), 400
symbol = request.args.get('symbol')
market_type = request.args.get('marketType', 'USStock')
return jsonify({"success": False, "error": "Not connected to IBKR"}), 400
symbol = request.args.get("symbol")
market_type = request.args.get("marketType", "USStock")
if not symbol:
return jsonify({"success": False, "error": "Missing symbol"}), 400
quote = client.get_quote(symbol, market_type)
return jsonify(quote)
except Exception as e:
logger.error(f"Get quote failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
+249 -188
View File
@@ -10,24 +10,23 @@ For local mode, we expose Python equivalents under `/api/indicator/*`.
from __future__ import annotations
import builtins
import json
import os
import re
import time
import traceback
from typing import Any, Dict, List
import builtins
from typing import Any, Dict
from flask import Blueprint, Response, jsonify, request, g
import pandas as pd
import numpy as np
import pandas as pd
from flask import Blueprint, Response, g, jsonify, request
from app.services.indicator_params import IndicatorCaller
from app.utils.auth import login_required
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.utils.auth import login_required
from app.services.indicator_params import IndicatorCaller
from app.utils.safe_exec import validate_code_safety, safe_exec_code
import requests
from app.utils.safe_exec import safe_exec_code, validate_code_safety
logger = get_logger(__name__)
@@ -92,32 +91,34 @@ def _row_to_indicator(row: Dict[str, Any], user_id: int) -> Dict[str, Any]:
def _generate_mock_df(length=200):
"""Generate mock K-line data for verification."""
from datetime import datetime, timedelta
dates = [datetime.now() - timedelta(minutes=i) for i in range(length)]
dates.reverse()
# Random walk with trend
returns = np.random.normal(0, 0.002, length)
price_path = 10000 * np.exp(np.cumsum(returns))
close = price_path
high = close * (1 + np.abs(np.random.normal(0, 0.001, length)))
low = close * (1 - np.abs(np.random.normal(0, 0.001, length)))
open_p = close * (1 + np.random.normal(0, 0.001, length)) # Slight deviation from close
open_p = close * (1 + np.random.normal(0, 0.001, length)) # Slight deviation from close
# Ensure High is highest and Low is lowest
high = np.maximum(high, np.maximum(open_p, close))
low = np.minimum(low, np.minimum(open_p, close))
volume = np.abs(np.random.normal(100, 50, length)) * 1000
df = pd.DataFrame({
'time': [int(d.timestamp() * 1000) for d in dates],
'open': open_p,
'high': high,
'low': low,
'close': close,
'volume': volume
})
df = pd.DataFrame(
{
"time": [int(d.timestamp() * 1000) for d in dates],
"open": open_p,
"high": high,
"low": low,
"close": close,
"volume": volume,
}
)
return df
@@ -211,9 +212,9 @@ def save_indicator():
now = _now_ts() # For BIGINT fields (createtime, updatetime)
# Check whether the user is an administrator (indicators published by the administrator automatically pass the review)
user_role = getattr(g, 'user_role', 'user')
is_admin = user_role == 'admin'
user_role = getattr(g, "user_role", "user")
is_admin = user_role == "admin"
with get_db_connection() as db:
cur = db.cursor()
# Best-effort schema upgrade for VIP-free indicators
@@ -226,13 +227,13 @@ def save_indicator():
if publish_to_community:
cur.execute(
"SELECT publish_to_community, review_status FROM qd_indicator_codes WHERE id = ? AND user_id = ?",
(indicator_id, user_id)
(indicator_id, user_id),
)
existing = cur.fetchone()
was_published = existing and existing.get('publish_to_community')
was_published = existing and existing.get("publish_to_community")
# If it has not been published before, publish it now and set the review status
# Posted by the administrator, it passes directly, and ordinary users need to wait for review.
new_review_status = 'approved' if is_admin else 'pending'
new_review_status = "approved" if is_admin else "pending"
if not was_published:
cur.execute(
"""
@@ -244,8 +245,21 @@ def save_indicator():
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, vip_free,
new_review_status, user_id if is_admin else None, now, indicator_id, user_id),
(
name,
code,
description,
publish_to_community,
pricing_type,
price,
preview_image,
vip_free,
new_review_status,
user_id if is_admin else None,
now,
indicator_id,
user_id,
),
)
else:
# Updates that have been released will remain in their original review status.
@@ -258,7 +272,19 @@ def save_indicator():
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, vip_free, now, indicator_id, user_id),
(
name,
code,
description,
publish_to_community,
pricing_type,
price,
preview_image,
vip_free,
now,
indicator_id,
user_id,
),
)
else:
# Unpublish and clear review status
@@ -272,13 +298,24 @@ def save_indicator():
updatetime = ?, updated_at = NOW()
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
""",
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id),
(
name,
code,
description,
publish_to_community,
pricing_type,
price,
preview_image,
now,
indicator_id,
user_id,
),
)
else:
# New indicators - those released by administrators are passed directly, ordinary users need to wait for review
review_status = None
if publish_to_community:
review_status = 'approved' if is_admin else 'pending'
review_status = "approved" if is_admin else "pending"
cur.execute(
"""
INSERT INTO qd_indicator_codes
@@ -287,7 +324,20 @@ def save_indicator():
createtime, updatetime, created_at, updated_at)
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
""",
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, vip_free, review_status, now, now),
(
user_id,
name,
code,
description,
publish_to_community,
pricing_type,
price,
preview_image,
vip_free,
review_status,
now,
now,
),
)
indicator_id = int(cur.lastrowid or 0)
db.commit()
@@ -330,12 +380,12 @@ def delete_indicator():
def get_indicator_params():
"""
Get parameter declaration of indicator
Used by the front end to display a configurable parameter form when creating a policy.
Query params:
indicator_id: indicator ID
Returns:
params: [
{
@@ -349,19 +399,19 @@ def get_indicator_params():
"""
try:
from app.services.indicator_params import get_indicator_params as get_params
indicator_id = request.args.get("indicator_id")
if not indicator_id:
return jsonify({"code": 0, "msg": "indicator_id is required", "data": None}), 400
try:
indicator_id = int(indicator_id)
except ValueError:
return jsonify({"code": 0, "msg": "indicator_id must be an integer", "data": None}), 400
params = get_params(indicator_id)
return jsonify({"code": 1, "msg": "success", "data": params})
except Exception as e:
logger.error(f"get_indicator_params failed: {str(e)}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@@ -380,42 +430,47 @@ def verify_code():
try:
data = request.get_json() or {}
code = data.get("code") or ""
if not code or not str(code).strip():
return jsonify({"code": 0, "msg": "Code is empty", "data": None}), 400
# 1. Generate mock data
df = _generate_mock_df()
# 2. Prepare execution environment (sandboxed)
exec_env = {
'df': df.copy(),
'pd': pd,
'np': np,
'output': None
}
exec_env = {"df": df.copy(), "pd": pd, "np": np, "output": None}
# 2.1 Create restricted __import__ that only allows safe modules
def safe_import(name, *args, **kwargs):
"""Only allow importing a small set of safe modules inside indicator scripts."""
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time']
if name in allowed_modules or name.split('.')[0] in allowed_modules:
allowed_modules = ["numpy", "pandas", "math", "json", "datetime", "time"]
if name in allowed_modules or name.split(".")[0] in allowed_modules:
return builtins.__import__(name, *args, **kwargs)
raise ImportError(f"Import not allowed: {name}")
safe_builtins = {
k: getattr(builtins, k)
for k in dir(builtins)
if not k.startswith('_') and k not in [
'eval', 'exec', 'compile', 'open', 'input',
'help', 'exit', 'quit',
'copyright', 'credits', 'license'
if not k.startswith("_")
and k
not in [
"eval",
"exec",
"compile",
"open",
"input",
"help",
"exit",
"quit",
"copyright",
"credits",
"license",
]
}
safe_builtins['__import__'] = safe_import
safe_builtins["__import__"] = safe_import
exec_env_sandbox = exec_env.copy()
exec_env_sandbox['__builtins__'] = safe_builtins
exec_env_sandbox["__builtins__"] = safe_builtins
# 2.2 Pre-import commonly used modules into the sandbox
pre_import_code = """
@@ -426,95 +481,118 @@ import pandas as pd
exec(pre_import_code, exec_env_sandbox)
except Exception as e:
tb = traceback.format_exc()
return jsonify({
"code": 0,
"msg": f"Runtime Error during pre-import: {str(e)}",
"data": {"type": type(e).__name__, "details": tb}
})
return jsonify(
{
"code": 0,
"msg": f"Runtime Error during pre-import: {str(e)}",
"data": {"type": type(e).__name__, "details": tb},
}
)
# 3. Static safety check
is_safe, error_msg = validate_code_safety(code)
if not is_safe:
logger.error(f"Indicator verifyCode security check failed: {error_msg}")
return jsonify({
"code": 0,
"msg": f"Code contains unsafe operations: {error_msg}",
"data": {"type": "SecurityError", "details": error_msg}
})
return jsonify(
{
"code": 0,
"msg": f"Code contains unsafe operations: {error_msg}",
"data": {"type": "SecurityError", "details": error_msg},
}
)
# 4. Execute code with timeout in sandbox
exec_result = safe_exec_code(
code=code,
exec_globals=exec_env_sandbox,
exec_locals=exec_env_sandbox,
timeout=20 # indicator verification should be quick
timeout=20, # indicator verification should be quick
)
if not exec_result.get('success'):
error_detail = exec_result.get('error') or 'Unknown error'
return jsonify({
"code": 0,
"msg": f"Runtime Error: {error_detail}",
"data": {"type": "RuntimeError", "details": error_detail}
})
# 5. Check output
output = exec_env_sandbox.get('output')
if output is None:
return jsonify({
"code": 0,
"msg": "Missing 'output' variable. Your code must define an 'output' dictionary.",
"data": {"type": "MissingOutput"}
})
if not isinstance(output, dict):
return jsonify({
"code": 0,
"msg": f"'output' must be a dictionary, got {type(output).__name__}",
"data": {"type": "InvalidOutputType"}
})
# Check required fields
if 'plots' not in output and 'signals' not in output:
return jsonify({
"code": 0,
"msg": "'output' dict should contain 'plots' or 'signals' list.",
"data": {"type": "InvalidOutputStructure"}
})
# Basic check for lengths
plots = output.get('plots', [])
signals = output.get('signals', [])
for p in plots:
if 'data' not in p:
return jsonify({"code": 0, "msg": f"Plot '{p.get('name')}' missing 'data' field.", "data": {"type": "InvalidPlot"}})
if len(p['data']) != len(df):
return jsonify({
"code": 0,
"msg": f"Plot '{p.get('name')}' data length ({len(p['data'])}) does not match DataFrame length ({len(df)}).",
"data": {"type": "LengthMismatch"}
})
for s in signals:
if 'data' not in s:
return jsonify({"code": 0, "msg": f"Signal '{s.get('type')}' missing 'data' field.", "data": {"type": "InvalidSignal"}})
if len(s['data']) != len(df):
return jsonify({
"code": 0,
"msg": f"Signal '{s.get('type')}' data length ({len(s['data'])}) does not match DataFrame length ({len(df)}).",
"data": {"type": "LengthMismatch"}
})
if not exec_result.get("success"):
error_detail = exec_result.get("error") or "Unknown error"
return jsonify(
{
"code": 0,
"msg": f"Runtime Error: {error_detail}",
"data": {"type": "RuntimeError", "details": error_detail},
}
)
return jsonify({
"code": 1,
"msg": "Verification passed! Code executed successfully.",
"data": {
"plots_count": len(plots),
"signals_count": len(signals)
# 5. Check output
output = exec_env_sandbox.get("output")
if output is None:
return jsonify(
{
"code": 0,
"msg": "Missing 'output' variable. Your code must define an 'output' dictionary.",
"data": {"type": "MissingOutput"},
}
)
if not isinstance(output, dict):
return jsonify(
{
"code": 0,
"msg": f"'output' must be a dictionary, got {type(output).__name__}",
"data": {"type": "InvalidOutputType"},
}
)
# Check required fields
if "plots" not in output and "signals" not in output:
return jsonify(
{
"code": 0,
"msg": "'output' dict should contain 'plots' or 'signals' list.",
"data": {"type": "InvalidOutputStructure"},
}
)
# Basic check for lengths
plots = output.get("plots", [])
signals = output.get("signals", [])
for p in plots:
if "data" not in p:
return jsonify(
{"code": 0, "msg": f"Plot '{p.get('name')}' missing 'data' field.", "data": {"type": "InvalidPlot"}}
)
if len(p["data"]) != len(df):
return jsonify(
{
"code": 0,
"msg": f"Plot '{p.get('name')}' data length ({len(p['data'])}) does not match DataFrame length ({len(df)}).",
"data": {"type": "LengthMismatch"},
}
)
for s in signals:
if "data" not in s:
return jsonify(
{
"code": 0,
"msg": f"Signal '{s.get('type')}' missing 'data' field.",
"data": {"type": "InvalidSignal"},
}
)
if len(s["data"]) != len(df):
return jsonify(
{
"code": 0,
"msg": f"Signal '{s.get('type')}' data length ({len(s['data'])}) does not match DataFrame length ({len(df)}).",
"data": {"type": "LengthMismatch"},
}
)
return jsonify(
{
"code": 1,
"msg": "Verification passed! Code executed successfully.",
"data": {"plots_count": len(plots), "signals_count": len(signals)},
}
})
)
except Exception as e:
logger.error(f"verify_code failed: {str(e)}", exc_info=True)
@@ -602,8 +680,8 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
def _template_code() -> str:
# Fallback template that follows the project expectations.
header = (
f"my_indicator_name = \"Custom Indicator\"\n"
f"my_indicator_description = \"{prompt.replace('\\n', ' ')[:200]}\"\n\n"
f'my_indicator_name = "Custom Indicator"\n'
f'my_indicator_description = "{prompt.replace("\\n", " ")[:200]}"\n\n'
)
body = (
"df = df.copy()\n\n"
@@ -646,17 +724,19 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
def _generate_code_via_llm() -> str:
"""Use unified LLMService to support all configured providers (OpenRouter, OpenAI, Grok, etc.)."""
from app.services.llm import LLMService
llm = LLMService()
# Get provider and model from env config (no frontend override)
current_provider = llm.provider
current_model = llm.get_code_generation_model()
current_api_key = llm.get_api_key()
base_url = llm.get_base_url()
logger.info(f"AI Code Generation - Provider: {current_provider.value}, Model: {current_model}, Base URL: {base_url}, API Key configured: {bool(current_api_key)}")
logger.info(
f"AI Code Generation - Provider: {current_provider.value}, Model: {current_model}, Base URL: {base_url}, API Key configured: {bool(current_api_key)}"
)
# Check if any LLM provider is configured
if not current_api_key:
logger.warning("No LLM API key configured, using template code")
@@ -674,7 +754,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
)
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7)
# Call LLM using the unified API (auto-selects provider based on LLM_PROVIDER env)
# use_json_mode=False because we want raw Python code output
content = llm.call_llm_api(
@@ -684,9 +764,9 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
],
model=current_model,
temperature=temperature,
use_json_mode=False # Code generation doesn't need JSON mode
use_json_mode=False, # Code generation doesn't need JSON mode
)
# Clean up markdown code blocks if present
content = content.strip()
if content.startswith("```python"):
@@ -695,18 +775,18 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
content = content[3:]
if content.endswith("```"):
content = content[:-3]
return content.strip() or _template_code()
# Capture user_id before generator runs (generator executes outside request context)
user_id = g.user_id
def stream():
from app.services.billing_service import get_billing_service
billing = get_billing_service()
ok, msg = billing.check_and_consume(
user_id=user_id,
feature='ai_code_gen',
reference_id=f"ai_code_gen_{user_id}_{int(time.time())}"
user_id=user_id, feature="ai_code_gen", reference_id=f"ai_code_gen_{user_id}_{int(time.time())}"
)
if not ok:
yield "data: " + json.dumps({"error": f"Insufficient credits: {msg}"}, ensure_ascii=False) + "\n\n"
@@ -741,7 +821,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
def call_indicator():
"""
Call another indicator (for use by the front-end Pyodide environment)
POST /api/indicator/callIndicator
Body: {
"indicatorRef": int | str, # indicator ID or name
@@ -749,7 +829,7 @@ def call_indicator():
"params": Dict, # Parameters passed to the called indicator (optional)
"currentIndicatorId": int # Current indicator ID (used for circular dependency detection, optional)
}
Returns:
{
"code": 1,
@@ -765,62 +845,43 @@ def call_indicator():
kline_data = data.get("klineData", [])
params = data.get("params") or {}
current_indicator_id = data.get("currentIndicatorId")
if not indicator_ref:
return jsonify({
"code": 0,
"msg": "indicatorRef is required",
"data": None
}), 400
return jsonify({"code": 0, "msg": "indicatorRef is required", "data": None}), 400
if not kline_data or not isinstance(kline_data, list):
return jsonify({
"code": 0,
"msg": "klineData must be a non-empty list",
"data": None
}), 400
return jsonify({"code": 0, "msg": "klineData must be a non-empty list", "data": None}), 400
# Get user ID
user_id = g.user_id
# Create IndicatorCaller
indicator_caller = IndicatorCaller(user_id, current_indicator_id)
# Convert the K-line data passed in from the front end into a DataFrame
df = pd.DataFrame(kline_data)
# Make sure necessary columns exist
required_columns = ['open', 'high', 'low', 'close', 'volume']
required_columns = ["open", "high", "low", "close", "volume"]
for col in required_columns:
if col not in df.columns:
df[col] = 0.0
# Convert data type
df['open'] = df['open'].astype('float64')
df['high'] = df['high'].astype('float64')
df['low'] = df['low'].astype('float64')
df['close'] = df['close'].astype('float64')
df['volume'] = df['volume'].astype('float64')
df["open"] = df["open"].astype("float64")
df["high"] = df["high"].astype("float64")
df["low"] = df["low"].astype("float64")
df["close"] = df["close"].astype("float64")
df["volume"] = df["volume"].astype("float64")
# call indicator
result_df = indicator_caller.call_indicator(indicator_ref, df, params)
# Convert the DataFrame to JSON format (a format that the front end can use)
result_dict = result_df.to_dict(orient='records')
return jsonify({
"code": 1,
"msg": "success",
"data": {
"df": result_dict,
"columns": list(result_df.columns)
}
})
result_dict = result_df.to_dict(orient="records")
return jsonify({"code": 1, "msg": "success", "data": {"df": result_dict, "columns": list(result_df.columns)}})
except Exception as e:
logger.error(f"Error calling indicator: {e}", exc_info=True)
return jsonify({
"code": 0,
"msg": str(e),
"data": None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
+47 -77
View File
@@ -1,24 +1,25 @@
"""
K-line data API routing
"""
from flask import Blueprint, request, jsonify
from datetime import datetime
import traceback
from flask import Blueprint, jsonify, request
from app.services.kline import KlineService
from app.utils.logger import get_logger
logger = get_logger(__name__)
kline_bp = Blueprint('kline', __name__)
kline_bp = Blueprint("kline", __name__)
kline_service = KlineService()
@kline_bp.route('/kline', methods=['GET'])
@kline_bp.route("/kline", methods=["GET"])
def get_kline():
"""
Get K-line data
parameter:
market: market type (Crypto, USStock, Forex, Futures)
symbol: trading pair/stock code
@@ -28,96 +29,65 @@ def get_kline():
"""
try:
# To force GET, use 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')
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)
if not symbol:
return jsonify({
'code': 0,
'msg': 'Missing symbol parameter',
'data': None
}), 400
return jsonify({"code": 0, "msg": "Missing symbol parameter", "data": None}), 400
logger.info(f"Requesting K-lines: {market}:{symbol}, timeframe={timeframe}, limit={limit}")
klines = kline_service.get_kline(
market=market,
symbol=symbol,
timeframe=timeframe,
limit=limit,
before_time=before_time
market=market, symbol=symbol, timeframe=timeframe, limit=limit, before_time=before_time
)
if not klines:
# Give more detailed tips for specific situations
msg = 'No data found'
if market == 'Forex' and timeframe == '1m':
msg = 'Forex 1-minute data requires Tiingo paid subscription'
elif market == 'Forex' and timeframe in ('1W', '1M'):
msg = 'No weekly/monthly data available for this period'
return jsonify({
'code': 0,
'msg': msg,
'data': [],
'hint': 'tiingo_subscription' if (market == 'Forex' and timeframe == '1m') else None
})
return jsonify({
'code': 1,
'msg': 'success',
'data': klines
})
msg = "No data found"
if market == "Forex" and timeframe == "1m":
msg = "Forex 1-minute data requires Tiingo paid subscription"
elif market == "Forex" and timeframe in ("1W", "1M"):
msg = "No weekly/monthly data available for this period"
return jsonify(
{
"code": 0,
"msg": msg,
"data": [],
"hint": "tiingo_subscription" if (market == "Forex" and timeframe == "1m") else None,
}
)
return jsonify({"code": 1, "msg": "success", "data": klines})
except Exception as e:
logger.error(f"Failed to fetch K-lines: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'Failed to fetch kline data: {str(e)}',
'data': None
}), 500
return jsonify({"code": 0, "msg": f"Failed to fetch kline data: {str(e)}", "data": None}), 500
@kline_bp.route('/price', methods=['GET'])
@kline_bp.route("/price", methods=["GET"])
def get_price():
"""Get the latest price"""
try:
market = request.args.get('market', 'USStock')
symbol = request.args.get('symbol', '')
market = request.args.get("market", "USStock")
symbol = request.args.get("symbol", "")
if not symbol:
return jsonify({
'code': 0,
'msg': 'Missing symbol parameter',
'data': None
}), 400
return jsonify({"code": 0, "msg": "Missing symbol parameter", "data": None}), 400
price_data = kline_service.get_latest_price(market, symbol)
if not price_data:
return jsonify({
'code': 0,
'msg': 'No price data found',
'data': None
})
return jsonify({
'code': 1,
'msg': 'success',
'data': price_data
})
return jsonify({"code": 0, "msg": "No price data found", "data": None})
return jsonify({"code": 1, "msg": "success", "data": price_data})
except Exception as e:
logger.error(f"Failed to fetch price: {str(e)}")
return jsonify({
'code': 0,
'msg': f'Failed to fetch price: {str(e)}',
'data': None
}), 500
return jsonify({"code": 0, "msg": f"Failed to fetch price: {str(e)}", "data": None}), 500
+204 -251
View File
@@ -2,45 +2,55 @@
Market API routes (local-only).
Provides watchlist, market metadata, symbol search, and pricing helpers for the frontend.
"""
from flask import Blueprint, request, jsonify, g
import traceback
import json
import time
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
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.config_loader import load_addon_config
from app.utils.auth import login_required
from flask import Blueprint, g, jsonify, request
from app.data.market_symbols_seed import (
get_hot_symbols as seed_get_hot_symbols,
search_symbols as seed_search_symbols,
get_symbol_name as seed_get_symbol_name
)
from app.data.market_symbols_seed import (
get_symbol_name as seed_get_symbol_name,
)
from app.data.market_symbols_seed import (
search_symbols as seed_search_symbols,
)
from app.services.kline import KlineService
from app.services.symbol_name import resolve_symbol_name
from app.utils.auth import login_required
from app.utils.cache import CacheManager
from app.utils.config_loader import load_addon_config
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
market_bp = Blueprint('market', __name__)
market_bp = Blueprint("market", __name__)
kline_service = KlineService()
cache = CacheManager()
# Thread pool for parallel price fetching
executor = ThreadPoolExecutor(max_workers=10)
def _now_ts() -> int:
return int(time.time())
def _normalize_symbol(symbol: str) -> str:
return (symbol or '').strip().upper()
return (symbol or "").strip().upper()
def _ensure_watchlist_table():
# Table is created by db schema init; this is only a sanity hook.
return True
@market_bp.route('/config', methods=['GET'])
@market_bp.route("/config", methods=["GET"])
def get_public_config():
"""
Public config for frontend (local mode).
@@ -48,60 +58,57 @@ def get_public_config():
"""
try:
cfg = load_addon_config()
models = (cfg.get('ai', {}) or {}).get('models')
models = (cfg.get("ai", {}) or {}).get("models")
if not isinstance(models, dict) or not models:
# Fallback defaults (offline friendly)
models = {
# Keep some legacy defaults
'openai/gpt-4o': 'GPT-4o',
"openai/gpt-4o": "GPT-4o",
# Unified frontend model list (OpenRouter-style ids)
'x-ai/grok-code-fast-1': 'xAI: Grok Code Fast 1',
'x-ai/grok-4-fast': 'xAI: Grok 4 Fast',
'x-ai/grok-4.1-fast': 'xAI: Grok 4.1 Fast',
'google/gemini-2.5-flash': 'Google: Gemini 2.5 Flash',
'google/gemini-2.0-flash-001': 'Google: Gemini 2.0 Flash',
'google/gemini-3-pro-preview': 'Google: Gemini 3 Pro Preview',
'google/gemini-2.5-flash-lite': 'Google: Gemini 2.5 Flash Lite',
'google/gemini-2.5-pro': 'Google: Gemini 2.5 Pro',
'openai/gpt-4o-mini': 'OpenAI: GPT-4o-mini',
'openai/gpt-5-mini': 'OpenAI: GPT-5 Mini',
'openai/gpt-4.1-mini': 'OpenAI: GPT-4.1 Mini',
'deepseek/deepseek-v3.2': 'DeepSeek: DeepSeek V3.2',
'minimax/minimax-m2': 'MiniMax: MiniMax M2',
'anthropic/claude-sonnet-4': 'Anthropic: Claude Sonnet 4',
'anthropic/claude-sonnet-4.5': 'Anthropic: Claude Sonnet 4.5',
'anthropic/claude-opus-4.5': 'Anthropic: Claude Opus 4.5',
'anthropic/claude-haiku-4.5': 'Anthropic: Claude Haiku 4.5',
'z-ai/glm-4.6': 'Z.AI: GLM 4.6',
"x-ai/grok-code-fast-1": "xAI: Grok Code Fast 1",
"x-ai/grok-4-fast": "xAI: Grok 4 Fast",
"x-ai/grok-4.1-fast": "xAI: Grok 4.1 Fast",
"google/gemini-2.5-flash": "Google: Gemini 2.5 Flash",
"google/gemini-2.0-flash-001": "Google: Gemini 2.0 Flash",
"google/gemini-3-pro-preview": "Google: Gemini 3 Pro Preview",
"google/gemini-2.5-flash-lite": "Google: Gemini 2.5 Flash Lite",
"google/gemini-2.5-pro": "Google: Gemini 2.5 Pro",
"openai/gpt-4o-mini": "OpenAI: GPT-4o-mini",
"openai/gpt-5-mini": "OpenAI: GPT-5 Mini",
"openai/gpt-4.1-mini": "OpenAI: GPT-4.1 Mini",
"deepseek/deepseek-v3.2": "DeepSeek: DeepSeek V3.2",
"minimax/minimax-m2": "MiniMax: MiniMax M2",
"anthropic/claude-sonnet-4": "Anthropic: Claude Sonnet 4",
"anthropic/claude-sonnet-4.5": "Anthropic: Claude Sonnet 4.5",
"anthropic/claude-opus-4.5": "Anthropic: Claude Opus 4.5",
"anthropic/claude-haiku-4.5": "Anthropic: Claude Haiku 4.5",
"z-ai/glm-4.6": "Z.AI: GLM 4.6",
}
return jsonify({'code': 1, 'msg': 'success', 'data': {'models': models, 'qdt_cost': {}}})
return jsonify({"code": 1, "msg": "success", "data": {"models": models, "qdt_cost": {}}})
except Exception as e:
logger.error(f"get_public_config failed: {str(e)}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@market_bp.route('/types', methods=['GET'])
@market_bp.route("/types", methods=["GET"])
def get_market_types():
"""Return supported market types for the add-watchlist modal."""
# Keep a stable UX order; add CN/HK stocks near US stocks.
desired_order = ['USStock', 'CNStock', 'HKStock', 'Crypto', 'Forex', 'Futures']
# Keep a stable UX order for the supported market set.
desired_order = ["USStock", "Crypto", "Forex", "Futures"]
order_rank = {v: i for i, v in enumerate(desired_order)}
def _normalize_item(x):
# Expected: {value: 'USStock', i18nKey: '...'}
if isinstance(x, dict):
v = (x.get('value') or '').strip()
v = (x.get("value") or "").strip()
if not v:
return None
return {
'value': v,
'i18nKey': x.get('i18nKey') or f'dashboard.analysis.market.{v}'
}
return {"value": v, "i18nKey": x.get("i18nKey") or f"dashboard.analysis.market.{v}"}
if isinstance(x, str):
v = x.strip()
if not v:
return None
return {'value': v, 'i18nKey': f'dashboard.analysis.market.{v}'}
return {"value": v, "i18nKey": f"dashboard.analysis.market.{v}"}
return None
def _sort_items(items):
@@ -111,79 +118,79 @@ def get_market_types():
norm = _normalize_item(it)
if norm:
out.append(norm)
out.sort(key=lambda it: (order_rank.get(it['value'], 10_000)))
out.sort(key=lambda it: order_rank.get(it["value"], 10_000))
return out
cfg = load_addon_config()
data = (cfg.get('market', {}) or {}).get('types')
data = (cfg.get("market", {}) or {}).get("types")
# Normalize & force desired order (even if config overrides the list order).
if isinstance(data, list) and data:
data = _sort_items(data)
else:
data = _sort_items(desired_order)
return jsonify({'code': 1, 'msg': 'success', 'data': data})
return jsonify({"code": 1, "msg": "success", "data": data})
@market_bp.route('/menuFooterConfig', methods=['GET'])
@market_bp.route("/menuFooterConfig", methods=["GET"])
def get_menu_footer_config():
"""
Compatibility stub for old PHP `getMenuFooterConfig`.
Frontend can also hardcode this locally; this endpoint remains for completeness.
"""
data = {
'contact': {
'support_url': 'https://github.com/',
'feature_request_url': 'https://github.com/',
'email': 'support@example.com',
'live_chat_url': 'https://github.com/'
"contact": {
"support_url": "https://github.com/",
"feature_request_url": "https://github.com/",
"email": "support@example.com",
"live_chat_url": "https://github.com/",
},
'social_accounts': [
{'name': 'GitHub', 'icon': 'github', 'url': 'https://github.com/'},
{'name': 'X', 'icon': 'x', 'url': 'https://x.com/'}
"social_accounts": [
{"name": "GitHub", "icon": "github", "url": "https://github.com/"},
{"name": "X", "icon": "x", "url": "https://x.com/"},
],
'legal': {
'user_agreement': '',
'privacy_policy': ''
},
'copyright': '© 2025-2026 QuantDinger'
"legal": {"user_agreement": "", "privacy_policy": ""},
"copyright": "© 2025-2026 QuantDinger",
}
return jsonify({'code': 1, 'msg': 'success', 'data': data})
return jsonify({"code": 1, "msg": "success", "data": data})
@market_bp.route('/symbols/search', methods=['GET'])
@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:
market = (request.args.get('market') or '').strip()
keyword = (request.args.get('keyword') or '').strip().upper()
limit = int(request.args.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': []})
return jsonify({"code": 1, "msg": "success", "data": []})
out = seed_search_symbols(market=market, keyword=keyword, limit=limit)
return jsonify({'code': 1, 'msg': 'success', 'data': out})
return jsonify({"code": 1, "msg": "success", "data": out})
except Exception as e:
logger.error(f"search_symbols failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
return jsonify({"code": 0, "msg": str(e), "data": []}), 500
@market_bp.route('/symbols/hot', methods=['GET'])
@market_bp.route("/symbols/hot", methods=["GET"])
def get_hot_symbols():
"""Return a small curated hot list per market (local-only)."""
try:
market = (request.args.get('market') or '').strip()
limit = int(request.args.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})
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
return jsonify({"code": 0, "msg": str(e), "data": []}), 500
@market_bp.route('/watchlist/get', methods=['GET'])
@market_bp.route("/watchlist/get", methods=["GET"])
@login_required
def get_watchlist():
"""Get watchlist for the current user."""
@@ -193,8 +200,7 @@ def get_watchlist():
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",
(user_id,)
"SELECT id, market, symbol, name FROM qd_watchlist WHERE user_id = ? ORDER BY id DESC", (user_id,)
)
rows = cur.fetchall() or []
@@ -202,42 +208,43 @@ def get_watchlist():
# This keeps UI consistent without requiring users to re-add items.
for row in rows:
try:
market = row.get('market')
symbol = row.get('symbol')
current_name = (row.get('name') or '').strip()
market = row.get("market")
symbol = row.get("symbol")
current_name = (row.get("name") or "").strip()
if not market or not symbol:
continue
if current_name and current_name != symbol:
continue
resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol)
if resolved and resolved != current_name:
row['name'] = resolved
row["name"] = resolved
cur.execute(
"UPDATE qd_watchlist SET name = ?, updated_at = NOW() WHERE user_id = ? AND market = ? AND symbol = ?",
(resolved, user_id, market, symbol)
(resolved, user_id, market, symbol),
)
except Exception:
continue
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': rows})
return jsonify({"code": 1, "msg": "success", "data": rows})
except Exception as e:
logger.error(f"get_watchlist failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
return jsonify({"code": 0, "msg": str(e), "data": []}), 500
@market_bp.route('/watchlist/add', methods=['POST'])
@market_bp.route("/watchlist/add", methods=["POST"])
@login_required
def add_watchlist():
"""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'))
name_in = (data.get('name') or '').strip()
market = (data.get("market") or "").strip()
symbol = _normalize_symbol(data.get("symbol"))
name_in = (data.get("name") or "").strip()
if not market or not symbol:
return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400
return jsonify({"code": 0, "msg": "Missing market or symbol", "data": None}), 400
# Prefer frontend-provided name (search results), otherwise resolve via seed/public sources.
resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol)
@@ -248,47 +255,45 @@ def add_watchlist():
# Insert or update (PostgreSQL UPSERT)
cur.execute(
"""
INSERT INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at)
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)
(user_id, market, symbol, name),
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
return jsonify({"code": 1, "msg": "success", "data": None})
except Exception as e:
logger.error(f"add_watchlist failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@market_bp.route('/watchlist/remove', methods=['POST'])
@market_bp.route("/watchlist/remove", methods=["POST"])
@login_required
def remove_watchlist():
"""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'))
symbol = _normalize_symbol(data.get("symbol"))
if not symbol:
return jsonify({'code': 0, 'msg': 'Missing symbol', 'data': None}), 400
return jsonify({"code": 0, "msg": "Missing symbol", "data": None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"DELETE FROM qd_watchlist WHERE user_id = ? AND symbol = ?",
(user_id, symbol)
)
cur.execute("DELETE FROM qd_watchlist WHERE user_id = ? AND symbol = ?", (user_id, symbol))
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
return jsonify({"code": 1, "msg": "success", "data": None})
except Exception as e:
logger.error(f"remove_watchlist failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
def get_single_price(market: str, symbol: str) -> dict:
@@ -297,62 +302,54 @@ def get_single_price(market: str, symbol: str) -> dict:
# Use get_realtime_price to get the real-time price (30 seconds cached internally)
# Compared with the original '1D' K-line logic, this can reflect changes in 24h markets such as Crypto in a more timely manner
price_data = kline_service.get_realtime_price(market, symbol)
return {
'market': market,
'symbol': symbol,
'price': price_data.get('price', 0),
'change': price_data.get('change', 0),
'changePercent': price_data.get('changePercent', 0)
"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 {
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0
}
return {"market": market, "symbol": symbol, "price": 0, "change": 0, "changePercent": 0}
@market_bp.route('/watchlist/prices', methods=['GET'])
@market_bp.route("/watchlist/prices", methods=["GET"])
def get_watchlist_prices():
"""
Get the prices of self-selected stocks in batches
Params (Query String):
watchlist: JSON string of list of {market, symbol} objects
e.g. ?watchlist=[{"market":"USStock","symbol":"AAPL"}]
"""
try:
watchlist_str = request.args.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 (expected JSON list in query param)',
'data': []
}), 400
return jsonify(
{"code": 0, "msg": "Invalid watchlist format (expected JSON list in query param)", "data": []}
), 400
# logger.info(f"Start getting {len(watchlist)} self-selected stock price data")
results = []
# Fetch prices in parallel using thread pool
futures = {}
for item in watchlist:
market = item.get('market', '')
symbol = item.get('symbol', '')
market = item.get("market", "")
symbol = item.get("symbol", "")
if market and symbol:
future = executor.submit(get_single_price, market, symbol)
futures[future] = (market, symbol)
# Collect results (with timeout protection)
completed_futures = set()
try:
@@ -364,94 +361,70 @@ def get_watchlist_prices():
except Exception as e:
market, symbol = futures[future]
logger.warning(f"Price fetch failed: {market}:{symbol} - {str(e)}")
results.append({
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0
})
results.append({"market": market, "symbol": symbol, "price": 0, "change": 0, "changePercent": 0})
except TimeoutError:
# Add default results for unfinished tasks on timeout
for future, (market, symbol) in futures.items():
if future not in completed_futures:
logger.warning(f"Price fetch timed out: {market}:{symbol}")
results.append({
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0,
'error': 'timeout'
})
success_count = sum(1 for r in results if r.get('price', 0) > 0)
results.append(
{
"market": market,
"symbol": symbol,
"price": 0,
"change": 0,
"changePercent": 0,
"error": "timeout",
}
)
success_count = sum(1 for r in results if r.get("price", 0) > 0)
logger.info(f"Watchlist prices: {success_count}/{len(results)} successful")
return jsonify({
'code': 1,
'msg': 'success',
'data': results
})
return jsonify({"code": 1, "msg": "success", "data": results})
except Exception as e:
logger.error(f"Batch watchlist price fetch failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'Failed: {str(e)}',
'data': []
}), 500
return jsonify({"code": 0, "msg": f"Failed: {str(e)}", "data": []}), 500
@market_bp.route('/price', methods=['GET'])
@market_bp.route("/price", methods=["GET"])
def get_price():
"""
Get the price of a single target
parameter:
market: market type
symbol: transaction target
"""
try:
market = request.args.get('market', '')
symbol = request.args.get('symbol', '')
market = request.args.get("market", "")
symbol = request.args.get("symbol", "")
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'Missing market or symbol parameter(s)',
'data': None
}), 400
return jsonify({"code": 0, "msg": "Missing market or symbol parameter(s)", "data": None}), 400
result = get_single_price(market, symbol)
return jsonify({
'code': 1,
'msg': 'success',
'data': result
})
return jsonify({"code": 1, "msg": "success", "data": result})
except Exception as e:
logger.error(f"Failed to fetch price: {str(e)}")
return jsonify({
'code': 0,
'msg': f'Failed: {str(e)}',
'data': None
}), 500
return jsonify({"code": 0, "msg": f"Failed: {str(e)}", "data": None}), 500
@market_bp.route('/stock/name', methods=['POST'])
@market_bp.route("/stock/name", methods=["POST"])
def get_stock_name():
"""
Get stock name
Request body:
{
"market": "USStock",
"symbol": "AAPL"
}
response:
{
"code": 1,
@@ -464,102 +437,82 @@ def get_stock_name():
try:
data = request.get_json()
if not data:
return jsonify({
'code': 0,
'msg': 'Request body is required',
'data': None
}), 400
market = data.get('market', '')
symbol = data.get('symbol', '')
return jsonify({"code": 0, "msg": "Request body is required", "data": None}), 400
market = data.get("market", "")
symbol = data.get("symbol", "")
if not market or not symbol:
return jsonify({
'code': 0,
'msg': 'Missing market or symbol parameter(s)',
'data': None
}), 400
return jsonify({"code": 0, "msg": "Missing market or symbol parameter(s)", "data": None}), 400
# Try to get from cache (1 day cache)
cache_key = f"stock_name:{market}:{symbol}"
cached_name = cache.get(cache_key)
if cached_name:
logger.debug(f"Stock name cache hit: {market}:{symbol}")
return jsonify({
'code': 1,
'msg': 'success',
'data': {'name': cached_name}
})
return jsonify({"code": 1, "msg": "success", "data": {"name": cached_name}})
# Get stock names based on different markets
stock_name = symbol # Default use code
try:
if market == 'USStock':
if market == "USStock":
# For stocks, try to get basic information
import yfinance as yf
yf_symbol = symbol
ticker = yf.Ticker(yf_symbol)
info = ticker.info
# Try to get the name
stock_name = info.get('longName') or info.get('shortName') or symbol
elif market == 'Crypto':
stock_name = info.get("longName") or info.get("shortName") or symbol
elif market == "Crypto":
# Cryptocurrency, using trading pair format
if '/' in symbol:
if "/" in symbol:
stock_name = symbol
else:
stock_name = f"{symbol}/USDT"
elif market == 'Forex':
elif market == "Forex":
# Forex
forex_names = {
'XAUUSD': '黄金',
'XAGUSD': '白银',
'EURUSD': '欧元/美元',
'GBPUSD': '英镑/美元',
'USDJPY': '美元/日元',
'AUDUSD': '澳元/美元',
'USDCAD': '美元/加元',
'USDCHF': '美元/瑞郎',
"XAUUSD": "黄金",
"XAGUSD": "白银",
"EURUSD": "欧元/美元",
"GBPUSD": "英镑/美元",
"USDJPY": "美元/日元",
"AUDUSD": "澳元/美元",
"USDCAD": "美元/加元",
"USDCHF": "美元/瑞郎",
}
stock_name = forex_names.get(symbol, symbol)
elif market == 'Futures':
elif market == "Futures":
# futures
futures_names = {
'GC': '黄金期货',
'SI': '白银期货',
'CL': '原油期货',
'NG': '天然气期货',
'ZC': '玉米期货',
'ZW': '小麦期货',
'BTCUSDT': 'BTC永续合约',
'ETHUSDT': 'ETH永续合约',
"GC": "黄金期货",
"SI": "白银期货",
"CL": "原油期货",
"NG": "天然气期货",
"ZC": "玉米期货",
"ZW": "小麦期货",
"BTCUSDT": "BTC永续合约",
"ETHUSDT": "ETH永续合约",
}
stock_name = futures_names.get(symbol, symbol)
except Exception as e:
logger.warning(f"Failed to fetch stock name; falling back to symbol: {market}:{symbol} - {str(e)}")
stock_name = symbol
# Cache for 1 day
cache.set(cache_key, stock_name, 86400)
return jsonify({
'code': 1,
'msg': 'success',
'data': {'name': stock_name}
})
return jsonify({"code": 1, "msg": "success", "data": {"name": stock_name}})
except Exception as e:
logger.error(f"Failed to fetch stock name: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'Failed: {str(e)}',
'data': None
}), 500
return jsonify({"code": 0, "msg": f"Failed: {str(e)}", "data": None}), 500
+81 -99
View File
@@ -4,7 +4,7 @@ MetaTrader 5 Trading API Routes
Provides REST API for MT5 trading operations.
"""
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify, request
from app.utils.logger import get_logger
@@ -23,7 +23,9 @@ def _ensure_mt5_imports():
global MT5Client, MT5Config
if MT5Client is None or MT5Config is None:
try:
from app.services.mt5_trading import MT5Client as _MT5Client, MT5Config as _MT5Config
from app.services.mt5_trading import MT5Client as _MT5Client
from app.services.mt5_trading import MT5Config as _MT5Config
MT5Client = _MT5Client
MT5Config = _MT5Config
except ImportError as e:
@@ -45,6 +47,7 @@ def _get_client():
# ==================== Connection Management ====================
@mt5_bp.route("/status", methods=["GET"])
def get_status():
"""Get MT5 connection status."""
@@ -54,11 +57,9 @@ def get_status():
status = client.get_connection_status()
return jsonify(status)
except ImportError as e:
return jsonify({
"connected": False,
"error": str(e),
"hint": "MetaTrader5 library is not installed or not on Windows"
})
return jsonify(
{"connected": False, "error": str(e), "hint": "MetaTrader5 library is not installed or not on Windows"}
)
except Exception as e:
logger.error(f"Get MT5 status failed: {e}")
return jsonify({"connected": False, "error": str(e)})
@@ -68,7 +69,7 @@ def get_status():
def connect():
"""
Connect to MT5 terminal.
Request body:
{
"login": 12345678, // MT5 account number
@@ -78,64 +79,53 @@ def connect():
}
"""
global _client
try:
_ensure_mt5_imports()
data = request.get_json() or {}
login = data.get("login") or data.get("mt5_login")
password = data.get("password") or data.get("mt5_password")
server = data.get("server") or data.get("mt5_server")
terminal_path = data.get("terminal_path") or data.get("mt5_terminal_path") or ""
if not login or not password or not server:
return jsonify({
"success": False,
"error": "Missing required fields: login, password, server"
}), 400
return jsonify({"success": False, "error": "Missing required fields: login, password, server"}), 400
config = MT5Config(
login=int(login),
password=str(password),
server=str(server),
terminal_path=str(terminal_path),
)
# Create new client with config
_client = MT5Client(config)
if _client.connect():
account_info = _client.get_account_info()
return jsonify({
"success": True,
"message": "Connected to MT5",
"account": account_info
})
return jsonify({"success": True, "message": "Connected to MT5", "account": account_info})
else:
return jsonify({
"success": False,
"error": "Failed to connect to MT5. Check credentials and ensure terminal is running."
}), 400
return jsonify(
{
"success": False,
"error": "Failed to connect to MT5. Check credentials and ensure terminal is running.",
}
), 400
except ImportError as e:
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
except Exception as e:
logger.error(f"MT5 connect failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
return jsonify({"success": False, "error": str(e)}), 500
@mt5_bp.route("/disconnect", methods=["POST"])
def disconnect():
"""Disconnect from MT5 terminal."""
global _client
try:
if _client is not None:
_client.disconnect()
@@ -148,6 +138,7 @@ def disconnect():
# ==================== Account Queries ====================
@mt5_bp.route("/account", methods=["GET"])
def get_account():
"""Get account information."""
@@ -155,7 +146,7 @@ def get_account():
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
info = client.get_account_info()
return jsonify(info)
except Exception as e:
@@ -170,7 +161,7 @@ def get_positions():
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
symbol = request.args.get("symbol")
positions = client.get_positions(symbol=symbol)
return jsonify({"success": True, "positions": positions})
@@ -186,7 +177,7 @@ def get_orders():
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
symbol = request.args.get("symbol")
orders = client.get_orders(symbol=symbol)
return jsonify({"success": True, "orders": orders})
@@ -202,7 +193,7 @@ def get_symbols():
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
group = request.args.get("group", "*")
symbols = client.get_symbols(group=group)
return jsonify({"success": True, "symbols": symbols})
@@ -213,11 +204,12 @@ def get_symbols():
# ==================== Trading ====================
@mt5_bp.route("/order", methods=["POST"])
def place_order():
"""
Place an order.
Request body:
{
"symbol": "EURUSD",
@@ -231,28 +223,22 @@ def place_order():
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
data = request.get_json() or {}
symbol = data.get("symbol")
side = data.get("side")
volume = data.get("volume") or data.get("quantity")
order_type = data.get("orderType", "market").lower()
price = data.get("price")
comment = data.get("comment", "QuantDinger")
if not symbol or not side or not volume:
return jsonify({
"success": False,
"error": "Missing required fields: symbol, side, volume"
}), 400
return jsonify({"success": False, "error": "Missing required fields: symbol, side, volume"}), 400
if order_type == "limit":
if not price:
return jsonify({
"success": False,
"error": "Limit order requires price"
}), 400
return jsonify({"success": False, "error": "Limit order requires price"}), 400
result = client.place_limit_order(
symbol=symbol,
side=side,
@@ -267,23 +253,22 @@ def place_order():
volume=float(volume),
comment=comment,
)
if result.success:
return jsonify({
"success": True,
"order_id": result.order_id,
"deal_id": result.deal_id,
"filled": result.filled,
"price": result.price,
"status": result.status,
"message": result.message,
})
return jsonify(
{
"success": True,
"order_id": result.order_id,
"deal_id": result.deal_id,
"filled": result.filled,
"price": result.price,
"status": result.status,
"message": result.message,
}
)
else:
return jsonify({
"success": False,
"error": result.message
}), 400
return jsonify({"success": False, "error": result.message}), 400
except Exception as e:
logger.error(f"MT5 place order failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@@ -293,7 +278,7 @@ def place_order():
def close_position():
"""
Close a position.
Request body:
{
"ticket": 123456789, // Position ticket
@@ -304,38 +289,34 @@ def close_position():
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
data = request.get_json() or {}
ticket = data.get("ticket")
volume = data.get("volume")
if not ticket:
return jsonify({
"success": False,
"error": "Missing required field: ticket"
}), 400
return jsonify({"success": False, "error": "Missing required field: ticket"}), 400
result = client.close_position(
ticket=int(ticket),
volume=float(volume) if volume else None,
)
if result.success:
return jsonify({
"success": True,
"order_id": result.order_id,
"deal_id": result.deal_id,
"filled": result.filled,
"price": result.price,
"message": result.message,
})
return jsonify(
{
"success": True,
"order_id": result.order_id,
"deal_id": result.deal_id,
"filled": result.filled,
"price": result.price,
"message": result.message,
}
)
else:
return jsonify({
"success": False,
"error": result.message
}), 400
return jsonify({"success": False, "error": result.message}), 400
except Exception as e:
logger.error(f"MT5 close position failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@@ -348,12 +329,12 @@ def cancel_order(ticket: int):
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
if client.cancel_order(ticket):
return jsonify({"success": True, "message": f"Order {ticket} cancelled"})
else:
return jsonify({"success": False, "error": "Failed to cancel order"}), 400
except Exception as e:
logger.error(f"MT5 cancel order failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@@ -361,11 +342,12 @@ def cancel_order(ticket: int):
# ==================== Market Data ====================
@mt5_bp.route("/quote", methods=["GET"])
def get_quote():
"""
Get real-time quote.
Query params:
- symbol: Trading symbol (e.g., EURUSD)
"""
@@ -373,14 +355,14 @@ def get_quote():
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
symbol = request.args.get("symbol")
if not symbol:
return jsonify({"success": False, "error": "Missing symbol parameter"}), 400
quote = client.get_quote(symbol)
return jsonify(quote)
except Exception as e:
logger.error(f"MT5 get quote failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
+163 -177
View File
@@ -2,18 +2,20 @@
Polymarket prediction market API routing
Provide on-demand analysis interface (read-only, no transactions involved)
"""
from flask import Blueprint, jsonify, request, g
from app.utils.auth import login_required
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from app.data_sources.polymarket import PolymarketDataSource
import re
import json
import re
from flask import Blueprint, g, jsonify, request
from app.data_sources.polymarket import PolymarketDataSource
from app.utils.auth import login_required
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
polymarket_bp = Blueprint('polymarket', __name__)
polymarket_bp = Blueprint("polymarket", __name__)
# Initialize service
polymarket_source = PolymarketDataSource()
@@ -24,13 +26,13 @@ polymarket_source = PolymarketDataSource()
def analyze_polymarket():
"""
Analyze Polymarket prediction market (user enters link or title)
POST /api/polymarket/analyze
Body: {
"input": "https://polymarket.com/event/xxx" or "market title",
"language": "zh-CN" (optional)
}
process:
1. Parse market_id or slug from input
2. Get market data from API
@@ -39,40 +41,33 @@ def analyze_polymarket():
5. Return analysis results
"""
try:
from decimal import Decimal
from app.services.billing_service import BillingService
from app.services.polymarket_analyzer import PolymarketAnalyzer
from decimal import Decimal
user_id = getattr(g, 'user_id', None)
user_id = getattr(g, "user_id", None)
if not user_id:
return jsonify({
"code": 0,
"msg": "User not authenticated",
"data": None
}), 401
return jsonify({"code": 0, "msg": "User not authenticated", "data": None}), 401
data = request.get_json() or {}
input_text = (data.get('input') or '').strip()
language = data.get('language', 'zh-CN')
input_text = (data.get("input") or "").strip()
language = data.get("language", "zh-CN")
if not input_text:
return jsonify({
"code": 0,
"msg": "Input is required (Polymarket URL or market title)",
"data": None
}), 400
return jsonify({"code": 0, "msg": "Input is required (Polymarket URL or market title)", "data": None}), 400
# 1. Parse market_id or slug
market_id = None
slug = None
# Try to extract from URL
url_patterns = [
r'polymarket\.com/event/([^/?]+)',
r'polymarket\.com/markets/(\d+)',
r'polymarket\.com/market/(\d+)',
r"polymarket\.com/event/([^/?]+)",
r"polymarket\.com/markets/(\d+)",
r"polymarket\.com/market/(\d+)",
]
for pattern in url_patterns:
match = re.search(pattern, input_text)
if match:
@@ -83,7 +78,7 @@ def analyze_polymarket():
else:
slug = extracted
break
# If not extracted from the URL, try searching the market
if not market_id and not slug:
# Try searching by title
@@ -91,16 +86,18 @@ def analyze_polymarket():
search_results = polymarket_source.search_markets(input_text, limit=5)
if search_results:
# Use first search result
market_id = search_results[0].get('market_id')
market_id = search_results[0].get("market_id")
logger.info(f"Found market via search: {market_id}")
if not market_id and not slug:
return jsonify({
"code": 0,
"msg": "Could not parse market ID or slug from input. Please provide a valid Polymarket URL or market title.",
"data": None
}), 400
return jsonify(
{
"code": 0,
"msg": "Could not parse market ID or slug from input. Please provide a valid Polymarket URL or market title.",
"data": None,
}
), 400
# 2. Obtain market data
if market_id:
market = polymarket_source.get_market_details(market_id)
@@ -109,123 +106,103 @@ def analyze_polymarket():
search_results = polymarket_source.search_markets(slug, limit=10)
market = None
for result in search_results:
if result.get('slug') == slug or slug in (result.get('question') or ''):
if result.get("slug") == slug or slug in (result.get("question") or ""):
market = result
market_id = result.get('market_id')
market_id = result.get("market_id")
break
if not market and search_results:
# Use first search result
market = search_results[0]
market_id = market.get('market_id')
market_id = market.get("market_id")
if not market:
return jsonify({
"code": 0,
"msg": "Market not found. Please check the URL or title.",
"data": None
}), 404
return jsonify({"code": 0, "msg": "Market not found. Please check the URL or title.", "data": None}), 404
if not market_id:
market_id = market.get('market_id')
market_id = market.get("market_id")
if not market_id:
return jsonify({
"code": 0,
"msg": "Invalid market data",
"data": None
}), 400
return jsonify({"code": 0, "msg": "Invalid market data", "data": None}), 400
# 3. Check billing
billing = BillingService()
cost = 0
if billing.is_billing_enabled():
cost = billing.get_feature_cost('polymarket_deep_analysis')
cost = billing.get_feature_cost("polymarket_deep_analysis")
if cost > 0:
user_credits = billing.get_user_credits(user_id)
if user_credits < Decimal(str(cost)):
return jsonify({
"code": 0,
"msg": "Insufficient credits",
"data": {
"required": cost,
"current": float(user_credits),
"shortage": float(Decimal(str(cost)) - user_credits)
return jsonify(
{
"code": 0,
"msg": "Insufficient credits",
"data": {
"required": cost,
"current": float(user_credits),
"shortage": float(Decimal(str(cost)) - user_credits),
},
}
}), 400
), 400
# Deduct points (use check_and_consume method, it will automatically get the cost from the configuration)
success, error_msg = billing.check_and_consume(
user_id=user_id,
feature='polymarket_deep_analysis',
reference_id=f"polymarket_{market_id}"
user_id=user_id, feature="polymarket_deep_analysis", reference_id=f"polymarket_{market_id}"
)
if not success:
# Check whether it is an error due to insufficient points
if error_msg.startswith('insufficient_credits'):
parts = error_msg.split(':')
if error_msg.startswith("insufficient_credits"):
parts = error_msg.split(":")
if len(parts) >= 3:
current_credits = parts[1]
required_credits = parts[2]
return jsonify({
"code": 0,
"msg": "Insufficient credits",
"data": {
"required": float(required_credits),
"current": float(current_credits),
"shortage": float(Decimal(required_credits) - Decimal(current_credits))
return jsonify(
{
"code": 0,
"msg": "Insufficient credits",
"data": {
"required": float(required_credits),
"current": float(current_credits),
"shortage": float(Decimal(required_credits) - Decimal(current_credits)),
},
}
}), 400
return jsonify({
"code": 0,
"msg": f"Failed to deduct credits: {error_msg}",
"data": None
}), 500
), 400
return jsonify({"code": 0, "msg": f"Failed to deduct credits: {error_msg}", "data": None}), 500
# 4. Perform AI analysis (pass language and model parameters)
analyzer = PolymarketAnalyzer()
model = request.get_json().get('model') # Optional: Get model parameters from request
model = request.get_json().get("model") # Optional: Get model parameters from request
analysis_result = analyzer.analyze_market(
market_id,
user_id=user_id,
use_cache=False,
language=language,
model=model
market_id, user_id=user_id, use_cache=False, language=language, model=model
)
if analysis_result.get('error'):
return jsonify({
"code": 0,
"msg": analysis_result.get('error', 'Analysis failed'),
"data": None
}), 500
if analysis_result.get("error"):
return jsonify({"code": 0, "msg": analysis_result.get("error", "Analysis failed"), "data": None}), 500
# 5. Get remaining points
remaining_credits = 0
if billing.is_billing_enabled():
remaining_credits = float(billing.get_user_credits(user_id))
return jsonify({
"code": 1,
"msg": "success",
"data": {
"market": market,
"analysis": analysis_result,
"credits_charged": cost,
"remaining_credits": remaining_credits
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
"market": market,
"analysis": analysis_result,
"credits_charged": cost,
"remaining_credits": remaining_credits,
},
}
})
)
except Exception as e:
logger.error(f"Polymarket analyze API failed: {e}", exc_info=True)
return jsonify({
"code": 0,
"msg": str(e),
"data": None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@polymarket_bp.route("/history", methods=["GET"])
@@ -233,30 +210,34 @@ def analyze_polymarket():
def get_polymarket_history():
"""
Get user's Polymarket analysis history.
GET /api/polymarket/history?page=1&page_size=20
"""
try:
user_id = g.user_id
page = request.args.get('page', 1, type=int)
page_size = min(request.args.get('page_size', 20, type=int), 100)
page = request.args.get("page", 1, type=int)
page_size = min(request.args.get("page_size", 20, type=int), 100)
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# Get total
cur.execute("""
cur.execute(
"""
SELECT COUNT(*) AS total
FROM qd_analysis_tasks
WHERE user_id = %s AND market = 'Polymarket'
""", (user_id,))
""",
(user_id,),
)
total_row = cur.fetchone()
total = total_row['total'] if total_row else 0
total = total_row["total"] if total_row else 0
# Get history
cur.execute("""
SELECT
cur.execute(
"""
SELECT
t.id,
t.symbol AS market_id,
t.model,
@@ -269,60 +250,65 @@ def get_polymarket_history():
WHERE t.user_id = %s AND t.market = 'Polymarket'
ORDER BY t.created_at DESC
LIMIT %s OFFSET %s
""", (user_id, page_size, offset))
""",
(user_id, page_size, offset),
)
rows = cur.fetchall() or []
cur.close()
# Parse results
items = []
for row in rows:
result_json = row.get('result_json', '{}')
result_json = row.get("result_json", "{}")
try:
result_data = json.loads(result_json) if result_json else {}
except:
except Exception as e:
logger.debug(f"Failed to parse result JSON for task {row.get('id')}: {e}")
result_data = {}
market_data = result_data.get('market', {})
analysis_data = result_data.get('analysis', {})
created_at = row.get('created_at')
completed_at = row.get('completed_at')
if created_at and hasattr(created_at, 'isoformat'):
market_data = result_data.get("market", {})
analysis_data = result_data.get("analysis", {})
created_at = row.get("created_at")
completed_at = row.get("completed_at")
if created_at and hasattr(created_at, "isoformat"):
created_at = created_at.isoformat()
if completed_at and hasattr(completed_at, 'isoformat'):
if completed_at and hasattr(completed_at, "isoformat"):
completed_at = completed_at.isoformat()
items.append({
'id': row.get('id'),
'market_id': row.get('market_id'),
'market_title': market_data.get('question') or market_data.get('title') or f"Market {row.get('market_id')}",
'market_url': market_data.get('polymarket_url'),
'ai_predicted_probability': analysis_data.get('ai_predicted_probability'),
'market_probability': analysis_data.get('market_probability'),
'recommendation': analysis_data.get('recommendation'),
'opportunity_score': analysis_data.get('opportunity_score'),
'confidence_score': analysis_data.get('confidence_score'),
'status': row.get('status'),
'created_at': created_at,
'completed_at': completed_at
})
return jsonify({
"code": 1,
"msg": "success",
"data": {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size
items.append(
{
"id": row.get("id"),
"market_id": row.get("market_id"),
"market_title": market_data.get("question")
or market_data.get("title")
or f"Market {row.get('market_id')}",
"market_url": market_data.get("polymarket_url"),
"ai_predicted_probability": analysis_data.get("ai_predicted_probability"),
"market_probability": analysis_data.get("market_probability"),
"recommendation": analysis_data.get("recommendation"),
"opportunity_score": analysis_data.get("opportunity_score"),
"confidence_score": analysis_data.get("confidence_score"),
"status": row.get("status"),
"created_at": created_at,
"completed_at": completed_at,
}
)
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size,
},
}
})
)
except Exception as e:
logger.error(f"Get Polymarket history failed: {e}", exc_info=True)
return jsonify({
"code": 0,
"msg": str(e),
"data": None
}), 500
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
File diff suppressed because it is too large Load Diff
+279 -185
View File
@@ -15,6 +15,7 @@ Endpoints:
from __future__ import annotations
import json
import re as _re
import time
import traceback
import uuid
@@ -22,40 +23,64 @@ from typing import Any, Dict
from flask import Blueprint, g, jsonify, request
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.utils.auth import login_required
from app.utils.credential_crypto import decrypt_credential_blob
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
import re as _re
_FRIENDLY_ERROR_PATTERNS = [
# Insufficient balance / margin
(_re.compile(r"INSUFFICIENT[_ ]?AVAILABLE|insufficient.{0,20}(balance|margin|fund)|margin.{0,30}while available|not enough|资金不足", _re.IGNORECASE),
"quickTrade.errorHints.insufficientBalance"),
(
_re.compile(
r"INSUFFICIENT[_ ]?AVAILABLE|insufficient.{0,20}(balance|margin|fund)|margin.{0,30}while available|not enough|资金不足",
_re.IGNORECASE,
),
"quickTrade.errorHints.insufficientBalance",
),
# Invalid size / quantity
(_re.compile(r"invalid.{0,10}size|invalid.{0,10}(qty|quantity|amount|volume)|Order size.{0,20}(too small|below|minimum)|MIN_NOTIONAL", _re.IGNORECASE),
"quickTrade.errorHints.invalidSize"),
(
_re.compile(
r"invalid.{0,10}size|invalid.{0,10}(qty|quantity|amount|volume)|Order size.{0,20}(too small|below|minimum)|MIN_NOTIONAL",
_re.IGNORECASE,
),
"quickTrade.errorHints.invalidSize",
),
# Invalid price
(_re.compile(r"invalid.{0,10}price|price.{0,20}(deviate|deviation|exceed|out of range)", _re.IGNORECASE),
"quickTrade.errorHints.invalidPrice"),
(
_re.compile(r"invalid.{0,10}price|price.{0,20}(deviate|deviation|exceed|out of range)", _re.IGNORECASE),
"quickTrade.errorHints.invalidPrice",
),
# Rate limit
(_re.compile(r"rate.?limit|too many request|429|REQUEST_FREQUENCY", _re.IGNORECASE),
"quickTrade.errorHints.rateLimit"),
(
_re.compile(r"rate.?limit|too many request|429|REQUEST_FREQUENCY", _re.IGNORECASE),
"quickTrade.errorHints.rateLimit",
),
# API key / permission
(_re.compile(r"(invalid|wrong|expired).{0,10}(api.?key|key|signature|sign)|NOT_LOGIN|UNAUTHORIZED|permission.{0,10}denied|IP.{0,20}(not|whitelist|restrict)", _re.IGNORECASE),
"quickTrade.errorHints.authError"),
(
_re.compile(
r"(invalid|wrong|expired).{0,10}(api.?key|key|signature|sign)|NOT_LOGIN|UNAUTHORIZED|permission.{0,10}denied|IP.{0,20}(not|whitelist|restrict)",
_re.IGNORECASE,
),
"quickTrade.errorHints.authError",
),
# Position / reduce-only conflict
(_re.compile(r"reduce.?only|position.{0,20}(not exist|not found|side)|POSITION_NOT_EXIST", _re.IGNORECASE),
"quickTrade.errorHints.positionConflict"),
(
_re.compile(r"reduce.?only|position.{0,20}(not exist|not found|side)|POSITION_NOT_EXIST", _re.IGNORECASE),
"quickTrade.errorHints.positionConflict",
),
# Network / timeout
(_re.compile(r"timeout|timed? ?out|connect|ECONNREFUSED|SSL|ConnectionError|RemoteDisconnected", _re.IGNORECASE),
"quickTrade.errorHints.networkError"),
(
_re.compile(r"timeout|timed? ?out|connect|ECONNREFUSED|SSL|ConnectionError|RemoteDisconnected", _re.IGNORECASE),
"quickTrade.errorHints.networkError",
),
# Exchange maintenance
(_re.compile(r"maintenance|unavailable|system.{0,10}(busy|error|upgrade)|suspend|暂停", _re.IGNORECASE),
"quickTrade.errorHints.exchangeMaintenance"),
(
_re.compile(r"maintenance|unavailable|system.{0,10}(busy|error|upgrade)|suspend|暂停", _re.IGNORECASE),
"quickTrade.errorHints.exchangeMaintenance",
),
]
@@ -67,11 +92,13 @@ def _parse_trade_error_hint(error_str: str) -> str:
return hint_key
return ""
quick_trade_bp = Blueprint('quick_trade', __name__)
quick_trade_bp = Blueprint("quick_trade", __name__)
# ────────── helpers ──────────
def _symbols_match_quick_trade(user_symbol: str, position_symbol: str) -> bool:
"""Match UI symbol (e.g. ETH/USDT) with exchange-native ids (e.g. ETH_USDT, ETH-USDT-SWAP)."""
@@ -92,31 +119,33 @@ def _symbols_match_quick_trade(user_symbol: str, position_symbol: str) -> bool:
return (len(a) >= 6 and a in b) or (len(b) >= 6 and b in a)
def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_type: str, limit_price: float = 0.0) -> float:
def _convert_usdt_to_base_qty(
client, symbol: str, usdt_amount: float, market_type: str, limit_price: float = 0.0
) -> float:
"""
Convert USDT amount to base asset quantity for all exchanges.
This is a unified function that works for all exchanges.
For spot: converts USDT -> base qty (e.g., 100 USDT -> 0.033 ETH)
For swap: converts USDT -> base qty (e.g., 100 USDT -> 0.033 ETH), which will then be converted to contracts
Args:
client: Exchange client instance
symbol: Trading pair (e.g., "ETH/USDT")
usdt_amount: USDT amount to convert
market_type: "spot" or "swap"
limit_price: For limit orders, use this price if provided (optional)
Returns:
Base asset quantity
"""
if usdt_amount <= 0:
return usdt_amount
try:
# Try to get current price from exchange
current_price = 0.0
# For limit orders, use the provided price
if limit_price > 0:
current_price = limit_price
@@ -127,17 +156,27 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
try:
ticker = client.get_ticker(symbol=symbol)
if isinstance(ticker, dict):
current_price = float(ticker.get("last") or ticker.get("lastPx") or ticker.get("close") or ticker.get("price") or 0)
current_price = float(
ticker.get("last")
or ticker.get("lastPx")
or ticker.get("close")
or ticker.get("price")
or 0
)
except Exception:
current_price = 0.0
# OKX
from app.services.live_trading.okx import OkxClient
if current_price <= 0 and isinstance(client, OkxClient):
try:
from app.services.live_trading.symbols import to_okx_spot_inst_id, to_okx_swap_inst_id
inst_id = to_okx_spot_inst_id(symbol) if market_type == "spot" else to_okx_swap_inst_id(symbol)
logger.debug(f"OKX: Getting ticker for inst_id={inst_id}, symbol={symbol}, market_type={market_type}")
logger.debug(
f"OKX: Getting ticker for inst_id={inst_id}, symbol={symbol}, market_type={market_type}"
)
ticker = client.get_ticker(inst_id=inst_id)
if ticker:
current_price = float(ticker.get("last") or ticker.get("lastPx") or 0)
@@ -150,21 +189,24 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
except Exception as e:
logger.error(f"OKX: Failed to get ticker: {e}")
raise
# Binance - try to get price from public API
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.binance_spot import BinanceSpotClient
if current_price <= 0 and isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
try:
# Binance public ticker endpoint
base_url = getattr(client, "base_url", "")
if "binance" in base_url.lower():
import requests
if isinstance(client, BinanceFuturesClient):
ticker_url = f"{base_url}/fapi/v1/ticker/price"
else:
ticker_url = f"{base_url}/api/v3/ticker/price"
from app.services.live_trading.symbols import to_binance_futures_symbol
# Binance spot and futures use the same symbol format
sym = to_binance_futures_symbol(symbol)
resp = requests.get(ticker_url, params={"symbol": sym}, timeout=5)
@@ -177,9 +219,11 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
# Bybit v5 — same host as trading API; tickers/orderbook are public
from app.services.live_trading.bybit import BybitClient
if current_price <= 0 and isinstance(client, BybitClient):
try:
import requests
from app.services.live_trading.symbols import to_bybit_symbol
bu = (getattr(client, "base_url", "") or "").rstrip("/")
@@ -198,10 +242,7 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
t0 = lst[0]
current_price = float(
str(
t0.get("lastPrice")
or t0.get("markPrice")
or t0.get("indexPrice")
or 0
t0.get("lastPrice") or t0.get("markPrice") or t0.get("indexPrice") or 0
).replace(",", "")
or 0
)
@@ -224,22 +265,26 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
current_price = bp or ap
except Exception:
pass
# Other exchanges - can be added as needed
# For exchanges without price API, we'll use a fallback
if current_price > 0:
base_qty = usdt_amount / current_price
logger.info(f"Converted USDT amount {usdt_amount} to base qty {base_qty:.8f} using price {current_price} for {symbol}")
logger.info(
f"Converted USDT amount {usdt_amount} to base qty {base_qty:.8f} using price {current_price} for {symbol}"
)
return base_qty
else:
# Can't get price - this is critical for quick trade
# Quick trade always expects USDT input, so we must convert
logger.error(f"CRITICAL: Could not get price for {symbol} on {type(client).__name__} to convert USDT amount {usdt_amount}")
logger.error(f"This will cause order to fail. Please check exchange API connectivity or symbol format.")
logger.error(
f"CRITICAL: Could not get price for {symbol} on {type(client).__name__} to convert USDT amount {usdt_amount}"
)
logger.error("This will cause order to fail. Please check exchange API connectivity or symbol format.")
# Still return original amount as fallback, but log error
return usdt_amount
except Exception as e:
logger.warning(f"Failed to convert USDT amount to base qty: {e}, using original amount")
return usdt_amount
@@ -289,6 +334,7 @@ def _build_exchange_config(credential_id: int, user_id: int, overrides: Dict[str
def _create_client(exchange_config: Dict[str, Any], market_type: str = "swap"):
"""Create exchange client from config."""
from app.services.live_trading.factory import create_client
return create_client(exchange_config, market_type=market_type)
@@ -328,10 +374,25 @@ def _record_quick_trade(
RETURNING id
""",
(
user_id, credential_id, exchange_id, symbol, side, order_type,
amount, price, leverage, market_type, tp_price, sl_price,
status, exchange_order_id, filled, avg_price,
error_msg, source, json.dumps(raw_result or {}),
user_id,
credential_id,
exchange_id,
symbol,
side,
order_type,
amount,
price,
leverage,
market_type,
tp_price,
sl_price,
status,
exchange_order_id,
filled,
avg_price,
error_msg,
source,
json.dumps(raw_result or {}),
),
)
row = cur.fetchone()
@@ -345,7 +406,8 @@ def _record_quick_trade(
# ────────── endpoints ──────────
@quick_trade_bp.route('/place-order', methods=['POST'])
@quick_trade_bp.route("/place-order", methods=["POST"])
@login_required
def place_order():
"""
@@ -425,6 +487,7 @@ def place_order():
if market_type != "spot" and margin_mode in ("cross", "isolated"):
try:
from app.services.live_trading.binance import BinanceFuturesClient
if isinstance(client, BinanceFuturesClient):
client.set_margin_type(symbol=symbol, margin_mode=margin_mode)
except Exception as me:
@@ -435,28 +498,30 @@ def place_order():
# For limit orders, use the provided price; for market orders, fetch current price
limit_price_for_conversion = price if order_type == "limit" and price > 0 else 0.0
base_qty = _convert_usdt_to_base_qty(client, symbol, usdt_amount, market_type, limit_price_for_conversion)
# Validate conversion: if base_qty equals usdt_amount, conversion likely failed
# For swap markets, base_qty should be much smaller than usdt_amount (e.g., 100 USDT -> 0.033 ETH)
if market_type != "spot" and base_qty == usdt_amount and usdt_amount >= 1:
logger.error(f"USDT conversion may have failed: base_qty ({base_qty}) equals usdt_amount ({usdt_amount})")
logger.error(f"This suggests the price fetch failed. Order may fail due to insufficient margin.")
logger.error("This suggests the price fetch failed. Order may fail due to insufficient margin.")
# ---- set leverage (futures only) ----
if market_type != "spot" and leverage > 1:
try:
if hasattr(client, "set_leverage"):
from app.services.live_trading.okx import OkxClient
from app.services.live_trading.gate import GateUsdtFuturesClient
from app.services.live_trading.okx import OkxClient
# OKX requires inst_id instead of symbol
if isinstance(client, OkxClient):
from app.services.live_trading.symbols import to_okx_swap_inst_id
inst_id = to_okx_swap_inst_id(symbol)
client.set_leverage(inst_id=inst_id, lever=leverage)
# Gate requires contract (currency_pair) instead of symbol
elif isinstance(client, GateUsdtFuturesClient):
from app.services.live_trading.symbols import to_gate_currency_pair
contract = to_gate_currency_pair(symbol)
if not client.set_leverage(contract=contract, leverage=leverage):
logger.warning(
@@ -488,14 +553,14 @@ def place_order():
# Use execution.py's place_order_from_signal for market orders to ensure consistency
# Convert side to signal_type: buy -> open_long, sell -> open_short (for swap) or close_long (for spot)
from app.services.live_trading.execution import place_order_from_signal
if market_type == "spot":
# Spot: buy = open_long, sell = close_long (assuming we're closing a position)
signal_type = "open_long" if side == "buy" else "close_long"
else:
# Swap: buy = open_long, sell = open_short
signal_type = "open_long" if side == "buy" else "open_short"
result = place_order_from_signal(
client=client,
signal_type=signal_type,
@@ -543,17 +608,19 @@ def place_order():
raw_result=raw,
)
return jsonify({
"code": 1,
"msg": "Order placed successfully",
"data": {
"trade_id": trade_id,
"exchange_order_id": exchange_order_id,
"filled": filled,
"avg_price": avg_fill,
"status": "filled" if filled > 0 else "submitted",
},
})
return jsonify(
{
"code": 1,
"msg": "Order placed successfully",
"data": {
"trade_id": trade_id,
"exchange_order_id": exchange_order_id,
"filled": filled,
"avg_price": avg_fill,
"status": "filled" if filled > 0 else "submitted",
},
}
)
except Exception as e:
logger.error(f"quick trade failed: {e}")
@@ -597,9 +664,9 @@ def _market_order_kwargs(client, symbol, amount, side, market_type, client_order
"""Build kwargs compatible with any exchange client's place_market_order."""
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.binance_spot import BinanceSpotClient
from app.services.live_trading.okx import OkxClient
from app.services.live_trading.bitget import BitgetMixClient
from app.services.live_trading.bybit import BybitClient
from app.services.live_trading.okx import OkxClient
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
return {"quantity": amount, "client_order_id": client_order_id}
@@ -624,9 +691,9 @@ def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client
"""Build kwargs compatible with any exchange client's place_limit_order."""
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.binance_spot import BinanceSpotClient
from app.services.live_trading.okx import OkxClient
from app.services.live_trading.bybit import BybitClient
from app.services.live_trading.deepcoin import DeepcoinClient
from app.services.live_trading.okx import OkxClient
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
return {"quantity": amount, "price": price, "client_order_id": client_order_id}
@@ -645,7 +712,7 @@ def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client
return {"size": amount, "price": price, "client_order_id": client_order_id}
@quick_trade_bp.route('/balance', methods=['GET'])
@quick_trade_bp.route("/balance", methods=["GET"])
@login_required
def get_balance():
"""
@@ -678,7 +745,9 @@ def get_balance():
from app.services.live_trading.bitget import BitgetMixClient
if isinstance(client, BitgetMixClient):
pt = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
pt = str(
exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES"
)
raw = client.get_accounts(product_type=pt)
else:
raw = client.get_accounts()
@@ -822,13 +891,19 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An
coins = acc.get("coin", []) if isinstance(acc, dict) else []
for c in coins:
if str(c.get("coin") or "").upper() == "USDT":
result["available"] = float(c.get("availableToWithdraw") or c.get("walletBalance") or 0)
result["available"] = float(
c.get("availableToWithdraw") or c.get("walletBalance") or 0
)
result["total"] = float(c.get("walletBalance") or 0)
return result
# HTX spot
if isinstance(data, dict) and isinstance(data.get("list"), list):
for item in data.get("list") or []:
if str(item.get("currency") or "").upper() == "USDT" and str(item.get("type") or "").lower() in ("trade", "available", ""):
if str(item.get("currency") or "").upper() == "USDT" and str(item.get("type") or "").lower() in (
"trade",
"available",
"",
):
avail = float(item.get("balance") or 0)
result["available"] = avail
total = 0.0
@@ -924,9 +999,12 @@ def _fetch_exchange_positions_raw(
raw = client.get_positions()
items = raw if isinstance(raw, list) else []
c = to_gate_currency_pair(symbol)
logger.info("Gate positions: total=%d, target=%s, contracts=%s",
len(items), c,
[(str(p.get("contract")), p.get("size")) for p in items if isinstance(p, dict) and p.get("size")][:10])
logger.info(
"Gate positions: total=%d, target=%s, contracts=%s",
len(items),
c,
[(str(p.get("contract")), p.get("size")) for p in items if isinstance(p, dict) and p.get("size")][:10],
)
filtered = [p for p in items if isinstance(p, dict) and str(p.get("contract") or "").strip() == c]
out = []
for p in filtered:
@@ -940,8 +1018,12 @@ def _fetch_exchange_positions_raw(
if base_amt > 0:
q["positionAmt"] = base_amt
out.append(q)
logger.info("Gate filtered positions for %s: %d items, sizes=%s", c, len(out),
[(p.get("size"), p.get("positionAmt")) for p in out])
logger.info(
"Gate filtered positions for %s: %d items, sizes=%s",
c,
len(out),
[(p.get("size"), p.get("positionAmt")) for p in out],
)
return out
if isinstance(client, KucoinFuturesClient):
@@ -986,8 +1068,12 @@ def _fetch_exchange_positions_raw(
except Exception:
pass
out_items.append(q)
logger.info("HTX positions for %s: %d items, sizes=%s", symbol, len(out_items),
[(p.get("contract_code"), p.get("volume"), p.get("positionAmt")) for p in out_items])
logger.info(
"HTX positions for %s: %d items, sizes=%s",
symbol,
len(out_items),
[(p.get("contract_code"), p.get("volume"), p.get("positionAmt")) for p in out_items],
)
return {"data": out_items}
if isinstance(client, DeepcoinClient):
@@ -1005,7 +1091,7 @@ def _fetch_exchange_positions_raw(
return None
@quick_trade_bp.route('/position', methods=['GET'])
@quick_trade_bp.route("/position", methods=["GET"])
@login_required
def get_position():
"""
@@ -1027,9 +1113,7 @@ def get_position():
positions = []
try:
raw = _fetch_exchange_positions_raw(
client, exchange_config, symbol=symbol, market_type=market_type
)
raw = _fetch_exchange_positions_raw(client, exchange_config, symbol=symbol, market_type=market_type)
positions = _parse_positions(raw)
except Exception as pe:
logger.warning(f"Position fetch failed: {pe}")
@@ -1067,11 +1151,7 @@ def _parse_positions(raw: Any) -> list:
if not isinstance(item, dict):
continue
sym_raw = str(
item.get("symbol")
or item.get("instId")
or item.get("contract")
or item.get("contract_code")
or ""
item.get("symbol") or item.get("instId") or item.get("contract") or item.get("contract_code") or ""
).strip()
display_symbol = sym_raw
if sym_raw and "/" not in sym_raw:
@@ -1102,7 +1182,7 @@ def _parse_positions(raw: Any) -> list:
)
if abs(size) < 1e-10:
continue
# Binance hedge: positionSide LONG/SHORT with positive positionAmt; one-way: BOTH + signed amt
side = "long"
psu = str(item.get("positionSide", "")).strip().upper()
@@ -1130,45 +1210,53 @@ def _parse_positions(raw: Any) -> list:
side = "long"
elif dir_side in ("sell", "short"):
side = "short"
result.append({
"symbol": display_symbol,
"side": side,
"size": abs(size),
"entry_price": float(
item.get("entryPrice")
or item.get("entry_price")
or item.get("openPriceAvg")
or item.get("avgEntryPrice")
or item.get("avgPrice")
or item.get("avgCost")
or item.get("avgPx")
or item.get("cost_open")
or item.get("trade_avg_price")
or 0
),
"unrealized_pnl": float(
item.get("unRealizedProfit")
or item.get("unrealizedProfit")
or item.get("unrealizedPnl")
or item.get("unrealised_pnl")
or item.get("upl")
or item.get("unrealisedPnl")
or item.get("profit_unreal")
or item.get("pnl")
or 0
),
"leverage": float(item.get("leverage") or item.get("lever") or item.get("lever_rate") or item.get("cross_leverage_limit") or 1),
"mark_price": float(
item.get("markPrice")
or item.get("mark_price")
or item.get("markPx")
or item.get("last_price")
or item.get("last")
or item.get("indexPrice")
or 0
),
})
result.append(
{
"symbol": display_symbol,
"side": side,
"size": abs(size),
"entry_price": float(
item.get("entryPrice")
or item.get("entry_price")
or item.get("openPriceAvg")
or item.get("avgEntryPrice")
or item.get("avgPrice")
or item.get("avgCost")
or item.get("avgPx")
or item.get("cost_open")
or item.get("trade_avg_price")
or 0
),
"unrealized_pnl": float(
item.get("unRealizedProfit")
or item.get("unrealizedProfit")
or item.get("unrealizedPnl")
or item.get("unrealised_pnl")
or item.get("upl")
or item.get("unrealisedPnl")
or item.get("profit_unreal")
or item.get("pnl")
or 0
),
"leverage": float(
item.get("leverage")
or item.get("lever")
or item.get("lever_rate")
or item.get("cross_leverage_limit")
or 1
),
"mark_price": float(
item.get("markPrice")
or item.get("mark_price")
or item.get("markPx")
or item.get("last_price")
or item.get("last")
or item.get("indexPrice")
or 0
),
}
)
except Exception as e:
logger.warning(f"_parse_positions error: {e}")
return result
@@ -1216,12 +1304,12 @@ def _quick_trade_net_base_qty(
return max(0.0, float(net))
@quick_trade_bp.route('/close-position', methods=['POST'])
@quick_trade_bp.route("/close-position", methods=["POST"])
@login_required
def close_position():
"""
Close an existing position.
Body JSON:
credential_id (int) saved exchange credential ID
symbol (str) e.g. "BTC/USDT"
@@ -1234,7 +1322,7 @@ def close_position():
try:
user_id = g.user_id
body = request.get_json(force=True, silent=True) or {}
credential_id = int(body.get("credential_id") or 0)
symbol = str(body.get("symbol") or "").strip()
market_type = str(body.get("market_type") or "swap").strip().lower()
@@ -1245,36 +1333,38 @@ def close_position():
close_scope = "system_tracked"
else:
close_scope = "full"
# ---- validation ----
if not credential_id:
return jsonify({"code": 0, "msg": "Missing credential_id"}), 400
if not symbol:
return jsonify({"code": 0, "msg": "Missing symbol"}), 400
if market_type in ("futures", "future", "perp", "perpetual"):
market_type = "swap"
# ---- build exchange client ----
exchange_config = _build_exchange_config(credential_id, user_id, {
"market_type": market_type,
})
exchange_config = _build_exchange_config(
credential_id,
user_id,
{
"market_type": market_type,
},
)
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
if not exchange_id:
return jsonify({"code": 0, "msg": "Invalid credential: missing exchange_id"}), 400
client = _create_client(exchange_config, market_type=market_type)
# ---- get current position ----
positions = []
try:
raw = _fetch_exchange_positions_raw(
client, exchange_config, symbol=symbol, market_type=market_type
)
raw = _fetch_exchange_positions_raw(client, exchange_config, symbol=symbol, market_type=market_type)
positions = _parse_positions(raw)
except Exception as pe:
logger.warning(f"Position fetch failed: {pe}")
if not positions:
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
@@ -1312,10 +1402,10 @@ def close_position():
position_side = str(position.get("side") or "").strip().lower()
position_size = float(position.get("size") or 0)
if position_size <= 0:
return jsonify({"code": 0, "msg": "Position size is zero or invalid"}), 400
if close_scope == "system_tracked" and market_type != "swap":
return jsonify({"code": 0, "msg": "system_tracked close_scope is only supported for swap/perp"}), 400
@@ -1351,7 +1441,7 @@ def close_position():
actual_close_size = position_size
if actual_close_size <= 0:
return jsonify({"code": 0, "msg": "Close size is zero"}), 400
# ---- determine signal type based on position side ----
if market_type == "spot":
# Spot only supports long positions
@@ -1366,15 +1456,15 @@ def close_position():
signal_type = "close_short"
else:
return jsonify({"code": 0, "msg": f"Unknown position side: {position_side}"}), 400
# ---- place close order ----
from app.services.live_trading.execution import place_order_from_signal
# Generate client_order_id
timestamp_suffix = str(int(time.time()))[-6:]
uuid_suffix = uuid.uuid4().hex[:8]
client_order_id = f"qtc{timestamp_suffix}{uuid_suffix}" # 'c' for close
result = place_order_from_signal(
client=client,
signal_type=signal_type,
@@ -1384,13 +1474,13 @@ def close_position():
exchange_config=exchange_config,
client_order_id=client_order_id,
)
# ---- extract result ----
exchange_order_id = str(getattr(result, "exchange_order_id", "") or "")
filled = float(getattr(result, "filled", 0) or 0)
avg_fill = float(getattr(result, "avg_price", 0) or 0)
raw = getattr(result, "raw", {}) or {}
# ---- calculate USDT amount for recording ----
# Convert base asset quantity to USDT amount for consistent recording
# amount (USDT) = base_qty * price
@@ -1402,7 +1492,7 @@ def close_position():
fallback_price = mark_price if mark_price > 0 else entry_price
if fallback_price > 0:
usdt_amount = actual_close_size * fallback_price
# ---- record trade ----
trade_id = _record_quick_trade(
user_id=user_id,
@@ -1425,23 +1515,25 @@ def close_position():
source=source,
raw_result=raw,
)
return jsonify({
"code": 1,
"msg": "Position closed successfully",
"data": {
"trade_id": trade_id,
"exchange_order_id": exchange_order_id,
"filled": filled,
"avg_price": avg_fill,
"closed_size": actual_close_size,
"position_side": position_side,
"close_scope": close_scope,
"tracked_net_base": tracked_net if close_scope == "system_tracked" else None,
"status": "filled" if filled > 0 else "submitted",
},
})
return jsonify(
{
"code": 1,
"msg": "Position closed successfully",
"data": {
"trade_id": trade_id,
"exchange_order_id": exchange_order_id,
"filled": filled,
"avg_price": avg_fill,
"closed_size": actual_close_size,
"position_side": position_side,
"close_scope": close_scope,
"tracked_net_base": tracked_net if close_scope == "system_tracked" else None,
"status": "filled" if filled > 0 else "submitted",
},
}
)
except Exception as e:
logger.error(f"close_position failed: {e}")
logger.error(traceback.format_exc())
@@ -1453,7 +1545,7 @@ def close_position():
return jsonify(resp), 500
@quick_trade_bp.route('/history', methods=['GET'])
@quick_trade_bp.route("/history", methods=["GET"])
@login_required
def get_history():
"""
@@ -1486,26 +1578,28 @@ def get_history():
trades = []
for r in rows:
trades.append({
"id": r.get("id"),
"exchange_id": r.get("exchange_id") or "",
"symbol": r.get("symbol") or "",
"side": r.get("side") or "",
"order_type": r.get("order_type") or "market",
"amount": float(r.get("amount") or 0),
"price": float(r.get("price") or 0),
"leverage": int(r.get("leverage") or 1),
"market_type": r.get("market_type") or "swap",
"tp_price": float(r.get("tp_price") or 0),
"sl_price": float(r.get("sl_price") or 0),
"status": r.get("status") or "",
"exchange_order_id": r.get("exchange_order_id") or "",
"filled_amount": float(r.get("filled_amount") or 0),
"avg_fill_price": float(r.get("avg_fill_price") or 0),
"error_msg": r.get("error_msg") or "",
"source": r.get("source") or "",
"created_at": str(r.get("created_at") or ""),
})
trades.append(
{
"id": r.get("id"),
"exchange_id": r.get("exchange_id") or "",
"symbol": r.get("symbol") or "",
"side": r.get("side") or "",
"order_type": r.get("order_type") or "market",
"amount": float(r.get("amount") or 0),
"price": float(r.get("price") or 0),
"leverage": int(r.get("leverage") or 1),
"market_type": r.get("market_type") or "swap",
"tp_price": float(r.get("tp_price") or 0),
"sl_price": float(r.get("sl_price") or 0),
"status": r.get("status") or "",
"exchange_order_id": r.get("exchange_order_id") or "",
"filled_amount": float(r.get("filled_amount") or 0),
"avg_fill_price": float(r.get("avg_fill_price") or 0),
"error_msg": r.get("error_msg") or "",
"source": r.get("source") or "",
"created_at": str(r.get("created_at") or ""),
}
)
return jsonify({"code": 1, "msg": "success", "data": {"trades": trades}})
except Exception as e:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff