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:
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 d’ensemble", "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 d’ensemble",
|
||||
"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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user