v2.2.1: frontend closed-source + Docker one-click deploy
- Remove frontend source code (now in private repo) - Add pre-built frontend/dist/ with Nginx serving - Simplify docker-compose.yml (no Node.js build needed) - Update README with docs index and Docker deploy guide - Add admin order list and AI analysis stats tabs - Add quick trade API routes - Clean up redundant files (package-lock.json, yarn.lock, .iml) - Add GitHub Actions workflow for frontend update automation
This commit is contained in:
@@ -74,6 +74,31 @@ def start_pending_order_worker():
|
||||
logger.error(f"Failed to start pending order worker: {e}")
|
||||
|
||||
|
||||
def start_usdt_order_worker():
|
||||
"""Start the USDT order background worker.
|
||||
|
||||
Periodically scans pending/paid USDT orders and checks on-chain status.
|
||||
Ensures orders are confirmed even if the user closes the browser after payment.
|
||||
Only starts if USDT_PAY_ENABLED=true.
|
||||
"""
|
||||
import os
|
||||
if str(os.getenv("USDT_PAY_ENABLED", "False")).lower() not in ("1", "true", "yes"):
|
||||
logger.info("USDT order worker not started (USDT_PAY_ENABLED is not true).")
|
||||
return
|
||||
|
||||
# Avoid running twice with Flask reloader
|
||||
debug = os.getenv("PYTHON_API_DEBUG", "false").lower() == "true"
|
||||
if debug:
|
||||
if os.environ.get("WERKZEUG_RUN_MAIN") != "true":
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.usdt_payment_service import get_usdt_order_worker
|
||||
get_usdt_order_worker().start()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start USDT order worker: {e}")
|
||||
|
||||
|
||||
def restore_running_strategies():
|
||||
"""
|
||||
Restore running strategies on startup.
|
||||
@@ -231,6 +256,7 @@ def create_app(config_name='default'):
|
||||
with app.app_context():
|
||||
start_pending_order_worker()
|
||||
start_portfolio_monitor()
|
||||
start_usdt_order_worker()
|
||||
restore_running_strategies()
|
||||
|
||||
return app
|
||||
|
||||
@@ -25,6 +25,7 @@ def register_routes(app: Flask):
|
||||
from app.routes.community import community_bp
|
||||
from app.routes.fast_analysis import fast_analysis_bp
|
||||
from app.routes.billing import billing_bp
|
||||
from app.routes.quick_trade import quick_trade_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
|
||||
@@ -44,4 +45,5 @@ def register_routes(app: Flask):
|
||||
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(billing_bp, url_prefix='/api/billing')
|
||||
app.register_blueprint(quick_trade_bp, url_prefix='/api/quick-trade')
|
||||
@@ -6,7 +6,6 @@ Local deployment notes:
|
||||
- Credentials are stored as plaintext JSON in DB (encrypted_config column kept for compatibility).
|
||||
"""
|
||||
|
||||
import time
|
||||
import traceback
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
@@ -42,7 +41,7 @@ def list_credentials():
|
||||
"""
|
||||
SELECT id, user_id, name, exchange_id, api_key_hint, created_at, updated_at
|
||||
FROM qd_exchange_credentials
|
||||
WHERE user_id = ?
|
||||
WHERE user_id = %s
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
(user_id,)
|
||||
@@ -57,41 +56,84 @@ def list_credentials():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500
|
||||
|
||||
|
||||
CRYPTO_EXCHANGES = [
|
||||
'binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange',
|
||||
'kraken', 'kucoin', 'gate', 'bitfinex', 'deepcoin'
|
||||
]
|
||||
|
||||
|
||||
@credentials_bp.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_credential():
|
||||
"""Create a new credential for the current user."""
|
||||
"""Create a new credential for the current user.
|
||||
|
||||
Supports crypto exchanges, IBKR (US stocks) and MT5 (Forex).
|
||||
"""
|
||||
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()
|
||||
api_key = (data.get('api_key') or '').strip()
|
||||
secret_key = (data.get('secret_key') or '').strip()
|
||||
passphrase = (data.get('passphrase') 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
|
||||
if not api_key or not secret_key:
|
||||
return jsonify({'code': 0, 'msg': 'Missing api_key/secret_key', 'data': None}), 400
|
||||
|
||||
plaintext_config = json.dumps({
|
||||
'exchange_id': exchange_id,
|
||||
'api_key': api_key,
|
||||
'secret_key': secret_key,
|
||||
'passphrase': passphrase
|
||||
}, ensure_ascii=False)
|
||||
config = {'exchange_id': exchange_id}
|
||||
hint = ''
|
||||
|
||||
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()
|
||||
})
|
||||
hint = f"{config['ibkr_host']}:{config['ibkr_port']}"
|
||||
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()
|
||||
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()
|
||||
})
|
||||
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()
|
||||
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))
|
||||
})
|
||||
hint = _api_key_hint(api_key)
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': f'Unsupported exchange: {exchange_id}', 'data': None}), 400
|
||||
|
||||
plaintext_config = json.dumps(config, ensure_ascii=False)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_exchange_credentials (user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
|
||||
VALUES (%s, %s, %s, %s, %s, NOW(), NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config)
|
||||
(user_id, name, exchange_id, hint, plaintext_config)
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
row = cur.fetchone()
|
||||
new_id = (row or {}).get('id')
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
@@ -115,7 +157,7 @@ def delete_credential():
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_exchange_credentials WHERE id = ? AND user_id = ?",
|
||||
"DELETE FROM qd_exchange_credentials WHERE id = %s AND user_id = %s",
|
||||
(cred_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
@@ -146,7 +188,7 @@ def get_credential():
|
||||
"""
|
||||
SELECT id, user_id, name, exchange_id, encrypted_config, api_key_hint, created_at, updated_at
|
||||
FROM qd_exchange_credentials
|
||||
WHERE id = ? AND user_id = ?
|
||||
WHERE id = %s AND user_id = %s
|
||||
""",
|
||||
(cred_id, user_id)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
"""
|
||||
Quick Trade API — manual / discretionary order placement.
|
||||
|
||||
Allows users to place market or limit orders directly from AI analysis
|
||||
or indicator analysis pages, without creating a strategy first.
|
||||
|
||||
Endpoints:
|
||||
POST /api/quick-trade/place-order — Place a quick order
|
||||
GET /api/quick-trade/balance — Get available balance
|
||||
GET /api/quick-trade/position — Get current position for symbol
|
||||
GET /api/quick-trade/history — Get quick trade history
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
quick_trade_bp = Blueprint('quick_trade', __name__)
|
||||
|
||||
|
||||
# ────────── helpers ──────────
|
||||
|
||||
def _safe_json(v, default=None):
|
||||
if v is None:
|
||||
return default
|
||||
if isinstance(v, (dict, list)):
|
||||
return v
|
||||
try:
|
||||
return json.loads(v) if isinstance(v, str) else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _load_credential(credential_id: int, user_id: int) -> Dict[str, Any]:
|
||||
"""Load exchange credential JSON for the given user."""
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT encrypted_config FROM qd_exchange_credentials WHERE id = %s AND user_id = %s",
|
||||
(int(credential_id), int(user_id)),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
return _safe_json(row.get("encrypted_config"), {})
|
||||
|
||||
|
||||
def _build_exchange_config(credential_id: int, user_id: int, overrides: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""Build exchange config from saved credential + overrides."""
|
||||
base = _load_credential(credential_id, user_id)
|
||||
if not base:
|
||||
raise ValueError("Credential not found or access denied")
|
||||
if overrides:
|
||||
for k, v in overrides.items():
|
||||
if v is not None and (not isinstance(v, str) or v.strip()):
|
||||
base[k] = v
|
||||
return base
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _record_quick_trade(
|
||||
user_id: int,
|
||||
credential_id: int,
|
||||
exchange_id: str,
|
||||
symbol: str,
|
||||
side: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
price: float,
|
||||
leverage: int,
|
||||
market_type: str,
|
||||
tp_price: float,
|
||||
sl_price: float,
|
||||
status: str,
|
||||
exchange_order_id: str,
|
||||
filled: float,
|
||||
avg_price: float,
|
||||
error_msg: str,
|
||||
source: str,
|
||||
raw_result: Dict[str, Any],
|
||||
):
|
||||
"""Insert a quick trade record into the database."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_quick_trades
|
||||
(user_id, credential_id, exchange_id, symbol, side, order_type,
|
||||
amount, price, leverage, market_type, tp_price, sl_price,
|
||||
status, exchange_order_id, filled_amount, avg_fill_price,
|
||||
error_msg, source, raw_result, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
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 {}),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
db.commit()
|
||||
cur.close()
|
||||
return (row or {}).get("id")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record quick trade: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ────────── endpoints ──────────
|
||||
|
||||
@quick_trade_bp.route('/place-order', methods=['POST'])
|
||||
@login_required
|
||||
def place_order():
|
||||
"""
|
||||
Place a quick market or limit order.
|
||||
|
||||
Body JSON:
|
||||
credential_id (int) — saved exchange credential ID
|
||||
symbol (str) — e.g. "BTC/USDT"
|
||||
side (str) — "buy" or "sell"
|
||||
order_type (str) — "market" or "limit" (default: market)
|
||||
amount (float) — order size (USDT quote amount for market buy, or base qty)
|
||||
price (float) — limit price (required for limit orders)
|
||||
leverage (int) — leverage multiplier (default: 1)
|
||||
market_type (str) — "swap" / "spot" (default: swap)
|
||||
tp_price (float) — take-profit price (optional, for record only)
|
||||
sl_price (float) — stop-loss price (optional, for record only)
|
||||
source (str) — "ai_radar" / "ai_analysis" / "indicator" / "manual"
|
||||
"""
|
||||
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()
|
||||
side = str(body.get("side") or "").strip().lower()
|
||||
order_type = str(body.get("order_type") or "market").strip().lower()
|
||||
amount = float(body.get("amount") or 0)
|
||||
price = float(body.get("price") or 0)
|
||||
leverage = int(body.get("leverage") or 1)
|
||||
market_type = str(body.get("market_type") or "swap").strip().lower()
|
||||
tp_price = float(body.get("tp_price") or 0)
|
||||
sl_price = float(body.get("sl_price") or 0)
|
||||
source = str(body.get("source") or "manual").strip()
|
||||
|
||||
# ---- 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 side not in ("buy", "sell"):
|
||||
return jsonify({"code": 0, "msg": "side must be 'buy' or 'sell'"}), 400
|
||||
if amount <= 0:
|
||||
return jsonify({"code": 0, "msg": "amount must be > 0"}), 400
|
||||
if order_type == "limit" and price <= 0:
|
||||
return jsonify({"code": 0, "msg": "price required for limit orders"}), 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_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)
|
||||
|
||||
# ---- set leverage (futures only) ----
|
||||
if market_type != "spot" and leverage > 1:
|
||||
try:
|
||||
if hasattr(client, "set_leverage"):
|
||||
client.set_leverage(symbol=symbol, leverage=leverage)
|
||||
elif hasattr(client, "set_leverage") and callable(getattr(client, "set_leverage", None)):
|
||||
client.set_leverage(symbol=symbol, lever=leverage)
|
||||
except Exception as le:
|
||||
logger.warning(f"set_leverage failed (non-fatal): {le}")
|
||||
|
||||
# ---- place order ----
|
||||
client_order_id = f"qt_{int(time.time())}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
result = None
|
||||
if order_type == "market":
|
||||
result = client.place_market_order(
|
||||
symbol=symbol,
|
||||
side=side.upper() if "binance" in exchange_id else side,
|
||||
**_market_order_kwargs(client, symbol, amount, side, market_type, client_order_id),
|
||||
)
|
||||
else:
|
||||
result = client.place_limit_order(
|
||||
symbol=symbol,
|
||||
side=side.upper() if "binance" in exchange_id else side,
|
||||
**_limit_order_kwargs(client, symbol, amount, price, side, market_type, 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 {}
|
||||
|
||||
# ---- record trade ----
|
||||
trade_id = _record_quick_trade(
|
||||
user_id=user_id,
|
||||
credential_id=credential_id,
|
||||
exchange_id=exchange_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
amount=amount,
|
||||
price=price if order_type == "limit" else avg_fill,
|
||||
leverage=leverage,
|
||||
market_type=market_type,
|
||||
tp_price=tp_price,
|
||||
sl_price=sl_price,
|
||||
status="filled" if filled > 0 else "submitted",
|
||||
exchange_order_id=exchange_order_id,
|
||||
filled=filled,
|
||||
avg_price=avg_fill,
|
||||
error_msg="",
|
||||
source=source,
|
||||
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",
|
||||
},
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"quick trade failed: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
# Try to record the failure
|
||||
try:
|
||||
_record_quick_trade(
|
||||
user_id=g.user_id,
|
||||
credential_id=int(body.get("credential_id") or 0),
|
||||
exchange_id="",
|
||||
symbol=str(body.get("symbol") or ""),
|
||||
side=str(body.get("side") or ""),
|
||||
order_type=str(body.get("order_type") or "market"),
|
||||
amount=float(body.get("amount") or 0),
|
||||
price=0,
|
||||
leverage=int(body.get("leverage") or 1),
|
||||
market_type=str(body.get("market_type") or "swap"),
|
||||
tp_price=0,
|
||||
sl_price=0,
|
||||
status="failed",
|
||||
exchange_order_id="",
|
||||
filled=0,
|
||||
avg_price=0,
|
||||
error_msg=str(e)[:500],
|
||||
source=str(body.get("source") or "manual"),
|
||||
raw_result={},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"code": 0, "msg": str(e)}), 500
|
||||
|
||||
|
||||
def _market_order_kwargs(client, symbol, amount, side, market_type, client_order_id):
|
||||
"""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
|
||||
|
||||
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
|
||||
return {"quantity": amount, "client_order_id": client_order_id}
|
||||
if isinstance(client, OkxClient):
|
||||
return {"size": amount, "client_order_id": client_order_id}
|
||||
if isinstance(client, BitgetMixClient):
|
||||
return {"size": amount, "client_order_id": client_order_id}
|
||||
if isinstance(client, BybitClient):
|
||||
return {"qty": amount, "client_order_id": client_order_id}
|
||||
# Generic fallback
|
||||
return {"size": amount, "client_order_id": client_order_id}
|
||||
|
||||
|
||||
def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client_order_id):
|
||||
"""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
|
||||
|
||||
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
|
||||
return {"quantity": amount, "price": price, "client_order_id": client_order_id}
|
||||
# Generic fallback
|
||||
return {"size": amount, "price": price, "client_order_id": client_order_id}
|
||||
|
||||
|
||||
@quick_trade_bp.route('/balance', methods=['GET'])
|
||||
@login_required
|
||||
def get_balance():
|
||||
"""
|
||||
Get available balance from exchange.
|
||||
|
||||
Query: credential_id (int), market_type (str, default "swap")
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
credential_id = request.args.get("credential_id", type=int)
|
||||
market_type = request.args.get("market_type", "swap").strip().lower()
|
||||
|
||||
if not credential_id:
|
||||
return jsonify({"code": 0, "msg": "Missing credential_id"}), 400
|
||||
|
||||
exchange_config = _build_exchange_config(credential_id, user_id, {"market_type": market_type})
|
||||
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
|
||||
client = _create_client(exchange_config, market_type=market_type)
|
||||
|
||||
balance_data = {"available": 0, "total": 0, "currency": "USDT"}
|
||||
|
||||
try:
|
||||
if hasattr(client, "get_balance"):
|
||||
raw = client.get_balance()
|
||||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||||
elif hasattr(client, "get_account"):
|
||||
raw = client.get_account()
|
||||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||||
elif hasattr(client, "get_accounts"):
|
||||
raw = client.get_accounts()
|
||||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||||
except Exception as be:
|
||||
logger.warning(f"Balance fetch failed: {be}")
|
||||
balance_data["error"] = str(be)
|
||||
|
||||
return jsonify({"code": 1, "msg": "success", "data": balance_data})
|
||||
except Exception as e:
|
||||
logger.error(f"get_balance failed: {e}")
|
||||
return jsonify({"code": 0, "msg": str(e)}), 500
|
||||
|
||||
|
||||
def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, Any]:
|
||||
"""Best-effort parse balance from various exchange responses."""
|
||||
result = {"available": 0, "total": 0, "currency": "USDT"}
|
||||
if not raw:
|
||||
return result
|
||||
try:
|
||||
if isinstance(raw, dict):
|
||||
# Binance futures
|
||||
if "availableBalance" in raw:
|
||||
result["available"] = float(raw.get("availableBalance") or 0)
|
||||
result["total"] = float(raw.get("totalWalletBalance") or raw.get("totalMarginBalance") or 0)
|
||||
return result
|
||||
# Binance spot
|
||||
if "balances" in raw:
|
||||
for b in raw.get("balances", []):
|
||||
if str(b.get("asset") or "").upper() == "USDT":
|
||||
result["available"] = float(b.get("free") or 0)
|
||||
result["total"] = float(b.get("free") or 0) + float(b.get("locked") or 0)
|
||||
return result
|
||||
return result
|
||||
# OKX
|
||||
data = raw.get("data")
|
||||
if isinstance(data, list) and data:
|
||||
first = data[0] if isinstance(data[0], dict) else {}
|
||||
# Account balance
|
||||
details = first.get("details", [])
|
||||
if isinstance(details, list):
|
||||
for d in details:
|
||||
if str(d.get("ccy") or "").upper() == "USDT":
|
||||
result["available"] = float(d.get("availBal") or d.get("availEq") or 0)
|
||||
result["total"] = float(d.get("eq") or d.get("cashBal") or 0)
|
||||
return result
|
||||
# Fallback
|
||||
result["available"] = float(first.get("availBal") or first.get("totalEq") or 0)
|
||||
result["total"] = float(first.get("totalEq") or 0)
|
||||
return result
|
||||
# Bybit
|
||||
if "result" in raw:
|
||||
res = raw["result"]
|
||||
if isinstance(res, dict):
|
||||
coin_list = res.get("list", [])
|
||||
if isinstance(coin_list, list):
|
||||
for acc in coin_list:
|
||||
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["total"] = float(c.get("walletBalance") or 0)
|
||||
return result
|
||||
# Fallback: try to find any USDT-like values
|
||||
if isinstance(raw, dict):
|
||||
for k, v in raw.items():
|
||||
if "avail" in str(k).lower() and isinstance(v, (int, float)):
|
||||
result["available"] = float(v)
|
||||
if "total" in str(k).lower() and isinstance(v, (int, float)):
|
||||
result["total"] = float(v)
|
||||
except Exception as e:
|
||||
logger.warning(f"_parse_balance error: {e}")
|
||||
return result
|
||||
|
||||
|
||||
@quick_trade_bp.route('/position', methods=['GET'])
|
||||
@login_required
|
||||
def get_position():
|
||||
"""
|
||||
Get current position for a symbol from exchange.
|
||||
|
||||
Query: credential_id (int), symbol (str), market_type (str)
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
credential_id = request.args.get("credential_id", type=int)
|
||||
symbol = request.args.get("symbol", "").strip()
|
||||
market_type = request.args.get("market_type", "swap").strip().lower()
|
||||
|
||||
if not credential_id or not symbol:
|
||||
return jsonify({"code": 0, "msg": "Missing credential_id or symbol"}), 400
|
||||
|
||||
exchange_config = _build_exchange_config(credential_id, user_id, {"market_type": market_type})
|
||||
client = _create_client(exchange_config, market_type=market_type)
|
||||
|
||||
positions = []
|
||||
try:
|
||||
if hasattr(client, "get_positions"):
|
||||
raw = client.get_positions(symbol=symbol)
|
||||
positions = _parse_positions(raw)
|
||||
elif hasattr(client, "get_position"):
|
||||
raw = client.get_position(symbol=symbol)
|
||||
positions = _parse_positions(raw)
|
||||
except Exception as pe:
|
||||
logger.warning(f"Position fetch failed: {pe}")
|
||||
|
||||
return jsonify({"code": 1, "msg": "success", "data": {"positions": positions}})
|
||||
except Exception as e:
|
||||
logger.error(f"get_position failed: {e}")
|
||||
return jsonify({"code": 0, "msg": str(e)}), 500
|
||||
|
||||
|
||||
def _parse_positions(raw: Any) -> list:
|
||||
"""Best-effort parse positions from exchange response."""
|
||||
result = []
|
||||
if not raw:
|
||||
return result
|
||||
try:
|
||||
items = []
|
||||
if isinstance(raw, list):
|
||||
items = raw
|
||||
elif isinstance(raw, dict):
|
||||
data = raw.get("data") or raw.get("result") or raw.get("positions") or []
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
items = data.get("list", []) if "list" in data else [data]
|
||||
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
size = float(item.get("posAmt") or item.get("pos") or item.get("size") or item.get("contracts") or 0)
|
||||
if abs(size) < 1e-10:
|
||||
continue
|
||||
result.append({
|
||||
"symbol": item.get("symbol") or item.get("instId") or "",
|
||||
"side": "long" if size > 0 else "short",
|
||||
"size": abs(size),
|
||||
"entry_price": float(item.get("entryPrice") or item.get("avgCost") or item.get("avgPx") or 0),
|
||||
"unrealized_pnl": float(item.get("unRealizedProfit") or item.get("upl") or item.get("unrealisedPnl") or 0),
|
||||
"leverage": float(item.get("leverage") or 1),
|
||||
"mark_price": float(item.get("markPrice") or item.get("markPx") or 0),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"_parse_positions error: {e}")
|
||||
return result
|
||||
|
||||
|
||||
@quick_trade_bp.route('/history', methods=['GET'])
|
||||
@login_required
|
||||
def get_history():
|
||||
"""
|
||||
Get quick trade history for the current user.
|
||||
|
||||
Query: limit (int, default 50), offset (int, default 0)
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
limit = min(int(request.args.get("limit") or 50), 200)
|
||||
offset = int(request.args.get("offset") or 0)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, exchange_id, symbol, side, order_type, amount, price,
|
||||
leverage, market_type, tp_price, sl_price, status,
|
||||
exchange_order_id, filled_amount, avg_fill_price,
|
||||
error_msg, source, created_at
|
||||
FROM qd_quick_trades
|
||||
WHERE user_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(user_id, limit, offset),
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
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 ""),
|
||||
})
|
||||
|
||||
return jsonify({"code": 1, "msg": "success", "data": {"trades": trades}})
|
||||
except Exception as e:
|
||||
logger.error(f"get_history failed: {e}")
|
||||
return jsonify({"code": 0, "msg": str(e)}), 500
|
||||
@@ -640,7 +640,7 @@ def test_connection():
|
||||
Test exchange connection.
|
||||
|
||||
Request body:
|
||||
exchange_config: Exchange configuration
|
||||
exchange_config: Exchange configuration (may contain credential_id or inline keys)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
@@ -664,29 +664,38 @@ def test_connection():
|
||||
logger.error(f"Invalid exchange_config type: {type(exchange_config)}, data: {str(exchange_config)[:200]}")
|
||||
# Frontend expects HTTP 200 with {code:0} for business failures.
|
||||
return jsonify({'code': 0, 'msg': 'Invalid exchange config format; please check your payload', 'data': None})
|
||||
|
||||
# 验证必要字段
|
||||
if not exchange_config.get('exchange_id'):
|
||||
|
||||
# Resolve credential_id → full config (merges credential keys with any overrides).
|
||||
# This allows the frontend to send just {credential_id: 5} without raw api_key/secret_key.
|
||||
from app.services.exchange_execution import resolve_exchange_config
|
||||
user_id = g.user_id if hasattr(g, 'user_id') else 1
|
||||
resolved = resolve_exchange_config(exchange_config, user_id=user_id)
|
||||
|
||||
# 验证必要字段 (check resolved config after credential merge)
|
||||
if not resolved.get('exchange_id'):
|
||||
return jsonify({'code': 0, 'msg': 'Please select an exchange', 'data': None})
|
||||
|
||||
api_key = exchange_config.get('api_key', '')
|
||||
secret_key = exchange_config.get('secret_key', '')
|
||||
api_key = resolved.get('api_key', '')
|
||||
secret_key = resolved.get('secret_key', '')
|
||||
|
||||
# 详细日志排查
|
||||
logger.info(f"Testing connection: exchange_id={exchange_config.get('exchange_id')}")
|
||||
logger.info(f"API Key: {api_key[:5]}... (len={len(api_key)})")
|
||||
logger.info(f"Secret Key: {secret_key[:5]}... (len={len(secret_key)})")
|
||||
logger.info(f"Testing connection: exchange_id={resolved.get('exchange_id')}")
|
||||
if api_key:
|
||||
logger.info(f"API Key: {api_key[:5]}... (len={len(api_key)})")
|
||||
if secret_key:
|
||||
logger.info(f"Secret Key: {secret_key[:5]}... (len={len(secret_key)})")
|
||||
|
||||
# 检查是否有特殊字符
|
||||
if api_key.strip() != api_key:
|
||||
if api_key and api_key.strip() != api_key:
|
||||
logger.warning("API key contains leading/trailing whitespace")
|
||||
if secret_key.strip() != secret_key:
|
||||
if secret_key and secret_key.strip() != secret_key:
|
||||
logger.warning("Secret key contains leading/trailing whitespace")
|
||||
|
||||
if not api_key or not secret_key:
|
||||
return jsonify({'code': 0, 'msg': 'Please provide API key and secret key', 'data': None})
|
||||
|
||||
result = get_strategy_service().test_exchange_connection(exchange_config)
|
||||
# Pass the resolved config (with actual keys) to the service
|
||||
result = get_strategy_service().test_exchange_connection(resolved)
|
||||
|
||||
if result['success']:
|
||||
return jsonify({'code': 1, 'msg': result.get('message') or 'Connection successful', 'data': result.get('data')})
|
||||
|
||||
@@ -1071,3 +1071,450 @@ def get_system_strategies():
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
# ==================== Admin Orders ====================
|
||||
|
||||
@user_bp.route('/admin-orders', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_admin_orders():
|
||||
"""
|
||||
Get all orders across the system (admin only).
|
||||
Merges qd_membership_orders and qd_usdt_orders into a unified list.
|
||||
|
||||
Query params:
|
||||
page: int (default 1)
|
||||
page_size: int (default 20, max 100)
|
||||
status: str (optional, filter by status: paid/pending/confirmed/expired/all)
|
||||
search: str (optional, search by username/email)
|
||||
"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
page_size = request.args.get('page_size', 20, type=int)
|
||||
status_filter = request.args.get('status', '', type=str).strip().lower()
|
||||
search = request.args.get('search', '', type=str).strip()
|
||||
page_size = min(100, max(1, page_size))
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# --- USDT Orders (primary) ---
|
||||
usdt_conditions = []
|
||||
usdt_params = []
|
||||
|
||||
if status_filter and status_filter != 'all':
|
||||
usdt_conditions.append("o.status = ?")
|
||||
usdt_params.append(status_filter)
|
||||
|
||||
if search:
|
||||
usdt_conditions.append("(u.username ILIKE ? OR u.email ILIKE ? OR u.nickname ILIKE ?)")
|
||||
like_val = f"%{search}%"
|
||||
usdt_params.extend([like_val, like_val, like_val])
|
||||
|
||||
usdt_where = ""
|
||||
if usdt_conditions:
|
||||
usdt_where = "WHERE " + " AND ".join(usdt_conditions)
|
||||
|
||||
# Count
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM qd_usdt_orders o LEFT JOIN qd_users u ON u.id = o.user_id {usdt_where}",
|
||||
tuple(usdt_params)
|
||||
)
|
||||
usdt_total = cur.fetchone()['cnt']
|
||||
|
||||
# --- Membership Orders (mock) ---
|
||||
mock_conditions = []
|
||||
mock_params = []
|
||||
|
||||
if status_filter and status_filter != 'all':
|
||||
mock_conditions.append("m.status = ?")
|
||||
mock_params.append(status_filter)
|
||||
|
||||
if search:
|
||||
mock_conditions.append("(u.username ILIKE ? OR u.email ILIKE ? OR u.nickname ILIKE ?)")
|
||||
like_val = f"%{search}%"
|
||||
mock_params.extend([like_val, like_val, like_val])
|
||||
|
||||
mock_where = ""
|
||||
if mock_conditions:
|
||||
mock_where = "WHERE " + " AND ".join(mock_conditions)
|
||||
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM qd_membership_orders m LEFT JOIN qd_users u ON u.id = m.user_id {mock_where}",
|
||||
tuple(mock_params)
|
||||
)
|
||||
mock_total = cur.fetchone()['cnt']
|
||||
|
||||
total = usdt_total + mock_total
|
||||
|
||||
# Use UNION ALL to merge both tables into one sorted list
|
||||
# We select a unified schema
|
||||
union_sql = f"""
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
o.id,
|
||||
'usdt' AS order_type,
|
||||
o.user_id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
u.email AS user_email,
|
||||
o.plan,
|
||||
o.amount_usdt AS amount,
|
||||
'USDT' AS currency,
|
||||
o.chain,
|
||||
o.address,
|
||||
o.tx_hash,
|
||||
o.status,
|
||||
o.created_at,
|
||||
o.paid_at,
|
||||
o.confirmed_at,
|
||||
o.expires_at
|
||||
FROM qd_usdt_orders o
|
||||
LEFT JOIN qd_users u ON u.id = o.user_id
|
||||
{usdt_where}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
m.id,
|
||||
'mock' AS order_type,
|
||||
m.user_id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
u.email AS user_email,
|
||||
m.plan,
|
||||
m.price_usd AS amount,
|
||||
'USD' AS currency,
|
||||
'' AS chain,
|
||||
'' AS address,
|
||||
'' AS tx_hash,
|
||||
m.status,
|
||||
m.created_at,
|
||||
m.paid_at,
|
||||
NULL AS confirmed_at,
|
||||
NULL AS expires_at
|
||||
FROM qd_membership_orders m
|
||||
LEFT JOIN qd_users u ON u.id = m.user_id
|
||||
{mock_where}
|
||||
) AS combined
|
||||
ORDER BY combined.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
all_params = list(usdt_params) + list(mock_params) + [page_size, offset]
|
||||
cur.execute(union_sql, tuple(all_params))
|
||||
rows = cur.fetchall() or []
|
||||
|
||||
# Summary stats
|
||||
cur.execute(
|
||||
f"""SELECT
|
||||
COUNT(*) AS total_orders,
|
||||
COALESCE(SUM(CASE WHEN status IN ('paid','confirmed') THEN 1 ELSE 0 END), 0) AS paid_orders,
|
||||
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_orders,
|
||||
COALESCE(SUM(CASE WHEN status IN ('expired','cancelled','failed') THEN 1 ELSE 0 END), 0) AS failed_orders,
|
||||
COALESCE(SUM(CASE WHEN status IN ('paid','confirmed') THEN amount_usdt ELSE 0 END), 0) AS total_revenue
|
||||
FROM qd_usdt_orders"""
|
||||
)
|
||||
summary_row = cur.fetchone() or {}
|
||||
|
||||
cur.close()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
created_at = row.get('created_at')
|
||||
paid_at = row.get('paid_at')
|
||||
confirmed_at = row.get('confirmed_at')
|
||||
expires_at = row.get('expires_at')
|
||||
if hasattr(created_at, 'isoformat'):
|
||||
created_at = created_at.isoformat()
|
||||
if hasattr(paid_at, 'isoformat'):
|
||||
paid_at = paid_at.isoformat()
|
||||
if hasattr(confirmed_at, 'isoformat'):
|
||||
confirmed_at = confirmed_at.isoformat()
|
||||
if hasattr(expires_at, 'isoformat'):
|
||||
expires_at = expires_at.isoformat()
|
||||
|
||||
items.append({
|
||||
'id': row['id'],
|
||||
'order_type': row.get('order_type') or '',
|
||||
'user_id': row.get('user_id'),
|
||||
'username': row.get('username') or '',
|
||||
'nickname': row.get('nickname') or '',
|
||||
'user_email': row.get('user_email') or '',
|
||||
'plan': row.get('plan') or '',
|
||||
'amount': float(row.get('amount') or 0),
|
||||
'currency': row.get('currency') or '',
|
||||
'chain': row.get('chain') or '',
|
||||
'address': row.get('address') or '',
|
||||
'tx_hash': row.get('tx_hash') or '',
|
||||
'status': row.get('status') or '',
|
||||
'created_at': created_at,
|
||||
'paid_at': paid_at,
|
||||
'confirmed_at': confirmed_at,
|
||||
'expires_at': expires_at
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': {
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'summary': {
|
||||
'total_orders': int(summary_row.get('total_orders') or 0),
|
||||
'paid_orders': int(summary_row.get('paid_orders') or 0),
|
||||
'pending_orders': int(summary_row.get('pending_orders') or 0),
|
||||
'failed_orders': int(summary_row.get('failed_orders') or 0),
|
||||
'total_revenue': round(float(summary_row.get('total_revenue') or 0), 2)
|
||||
}
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"get_admin_orders failed: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
# ==================== Admin AI Analysis Stats ====================
|
||||
|
||||
@user_bp.route('/admin-ai-stats', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_admin_ai_stats():
|
||||
"""
|
||||
Get AI analysis usage statistics across the system (admin only).
|
||||
Does NOT expose analysis results, only aggregated counts/stats.
|
||||
|
||||
Query params:
|
||||
page: int (default 1)
|
||||
page_size: int (default 20, max 100)
|
||||
search: str (optional, search by username)
|
||||
"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
page_size = request.args.get('page_size', 20, type=int)
|
||||
search = request.args.get('search', '', type=str).strip()
|
||||
page_size = min(100, max(1, page_size))
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# --- Overall summary (from qd_analysis_tasks + qd_analysis_memory) ---
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total_tasks,
|
||||
COUNT(DISTINCT user_id) AS unique_users,
|
||||
COUNT(DISTINCT symbol) AS unique_symbols,
|
||||
COUNT(DISTINCT market) AS unique_markets
|
||||
FROM qd_analysis_tasks
|
||||
""")
|
||||
task_summary = cur.fetchone() or {}
|
||||
|
||||
memory_summary = {}
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total_memory,
|
||||
COALESCE(SUM(CASE WHEN was_correct = true THEN 1 ELSE 0 END), 0) AS correct_count,
|
||||
COALESCE(SUM(CASE WHEN was_correct = false THEN 1 ELSE 0 END), 0) AS incorrect_count,
|
||||
COALESCE(SUM(CASE WHEN user_feedback = 'helpful' THEN 1 ELSE 0 END), 0) AS helpful_count,
|
||||
COALESCE(SUM(CASE WHEN user_feedback = 'not_helpful' THEN 1 ELSE 0 END), 0) AS not_helpful_count
|
||||
FROM qd_analysis_memory
|
||||
""")
|
||||
memory_summary = cur.fetchone() or {}
|
||||
except Exception as mem_err:
|
||||
logger.warning(f"qd_analysis_memory query failed (table/column may not exist): {mem_err}")
|
||||
db.rollback()
|
||||
cur = db.cursor() # re-create cursor after rollback
|
||||
memory_summary = {}
|
||||
|
||||
# --- Per-user stats ---
|
||||
user_conditions = []
|
||||
user_params = []
|
||||
if search:
|
||||
user_conditions.append("(u.username ILIKE ? OR u.nickname ILIKE ? OR u.email ILIKE ?)")
|
||||
like_val = f"%{search}%"
|
||||
user_params.extend([like_val, like_val, like_val])
|
||||
|
||||
user_where = ""
|
||||
if user_conditions:
|
||||
user_where = "WHERE " + " AND ".join(user_conditions)
|
||||
|
||||
# Count distinct users who have analysis records
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(DISTINCT t.user_id) AS cnt
|
||||
FROM qd_analysis_tasks t
|
||||
LEFT JOIN qd_users u ON u.id = t.user_id
|
||||
{user_where}
|
||||
""",
|
||||
tuple(user_params)
|
||||
)
|
||||
user_total = cur.fetchone()['cnt']
|
||||
|
||||
# Get per-user aggregated stats
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
t.user_id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
u.email,
|
||||
COUNT(*) AS analysis_count,
|
||||
COUNT(DISTINCT t.symbol) AS symbol_count,
|
||||
COUNT(DISTINCT t.market) AS market_count,
|
||||
MAX(t.created_at) AS last_analysis_at,
|
||||
MIN(t.created_at) AS first_analysis_at
|
||||
FROM qd_analysis_tasks t
|
||||
LEFT JOIN qd_users u ON u.id = t.user_id
|
||||
{user_where}
|
||||
GROUP BY t.user_id, u.username, u.nickname, u.email
|
||||
ORDER BY analysis_count DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
tuple(user_params) + (page_size, offset)
|
||||
)
|
||||
user_rows = cur.fetchall() or []
|
||||
|
||||
# Get per-user analysis_memory stats (correct/helpful counts)
|
||||
user_ids = [r['user_id'] for r in user_rows if r.get('user_id')]
|
||||
memory_stats_map = {}
|
||||
if user_ids:
|
||||
try:
|
||||
placeholders = ','.join(['?'] * len(user_ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
user_id,
|
||||
COUNT(*) AS memory_count,
|
||||
COALESCE(SUM(CASE WHEN was_correct = true THEN 1 ELSE 0 END), 0) AS correct,
|
||||
COALESCE(SUM(CASE WHEN was_correct = false THEN 1 ELSE 0 END), 0) AS incorrect,
|
||||
COALESCE(SUM(CASE WHEN user_feedback = 'helpful' THEN 1 ELSE 0 END), 0) AS helpful,
|
||||
COALESCE(SUM(CASE WHEN user_feedback = 'not_helpful' THEN 1 ELSE 0 END), 0) AS not_helpful
|
||||
FROM qd_analysis_memory
|
||||
WHERE user_id IN ({placeholders})
|
||||
GROUP BY user_id
|
||||
""",
|
||||
tuple(user_ids)
|
||||
)
|
||||
for row in (cur.fetchall() or []):
|
||||
memory_stats_map[row['user_id']] = {
|
||||
'memory_count': row['memory_count'],
|
||||
'correct': row['correct'],
|
||||
'incorrect': row['incorrect'],
|
||||
'helpful': row['helpful'],
|
||||
'not_helpful': row['not_helpful']
|
||||
}
|
||||
except Exception as mem_err:
|
||||
logger.warning(f"qd_analysis_memory per-user query failed: {mem_err}")
|
||||
db.rollback()
|
||||
cur = db.cursor() # re-create cursor after rollback
|
||||
memory_stats_map = {}
|
||||
|
||||
# Get recent analysis records (last 50)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.user_id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
t.market,
|
||||
t.symbol,
|
||||
t.model,
|
||||
t.status,
|
||||
t.created_at,
|
||||
t.completed_at
|
||||
FROM qd_analysis_tasks t
|
||||
LEFT JOIN qd_users u ON u.id = t.user_id
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 50
|
||||
"""
|
||||
)
|
||||
recent_rows = cur.fetchall() or []
|
||||
|
||||
cur.close()
|
||||
|
||||
# Build per-user items
|
||||
user_items = []
|
||||
for row in user_rows:
|
||||
uid = row.get('user_id')
|
||||
ms = memory_stats_map.get(uid, {})
|
||||
last_at = row.get('last_analysis_at')
|
||||
first_at = row.get('first_analysis_at')
|
||||
if hasattr(last_at, 'isoformat'):
|
||||
last_at = last_at.isoformat()
|
||||
if hasattr(first_at, 'isoformat'):
|
||||
first_at = first_at.isoformat()
|
||||
|
||||
user_items.append({
|
||||
'user_id': uid,
|
||||
'username': row.get('username') or '',
|
||||
'nickname': row.get('nickname') or '',
|
||||
'email': row.get('email') or '',
|
||||
'analysis_count': row.get('analysis_count') or 0,
|
||||
'symbol_count': row.get('symbol_count') or 0,
|
||||
'market_count': row.get('market_count') or 0,
|
||||
'correct': ms.get('correct', 0),
|
||||
'incorrect': ms.get('incorrect', 0),
|
||||
'helpful': ms.get('helpful', 0),
|
||||
'not_helpful': ms.get('not_helpful', 0),
|
||||
'last_analysis_at': last_at,
|
||||
'first_analysis_at': first_at
|
||||
})
|
||||
|
||||
# Build recent records
|
||||
recent_items = []
|
||||
for row in recent_rows:
|
||||
created_at = row.get('created_at')
|
||||
completed_at = row.get('completed_at')
|
||||
if hasattr(created_at, 'isoformat'):
|
||||
created_at = created_at.isoformat()
|
||||
if hasattr(completed_at, 'isoformat'):
|
||||
completed_at = completed_at.isoformat()
|
||||
|
||||
recent_items.append({
|
||||
'id': row['id'],
|
||||
'user_id': row.get('user_id'),
|
||||
'username': row.get('username') or '',
|
||||
'nickname': row.get('nickname') or '',
|
||||
'market': row.get('market') or '',
|
||||
'symbol': row.get('symbol') or '',
|
||||
'model': row.get('model') or '',
|
||||
'status': row.get('status') or '',
|
||||
'created_at': created_at,
|
||||
'completed_at': completed_at
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': {
|
||||
'user_stats': user_items,
|
||||
'user_total': user_total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'recent': recent_items,
|
||||
'summary': {
|
||||
'total_analyses': int(task_summary.get('total_tasks') or 0),
|
||||
'unique_users': int(task_summary.get('unique_users') or 0),
|
||||
'unique_symbols': int(task_summary.get('unique_symbols') or 0),
|
||||
'unique_markets': int(task_summary.get('unique_markets') or 0),
|
||||
'total_memory': int(memory_summary.get('total_memory') or 0),
|
||||
'correct_count': int(memory_summary.get('correct_count') or 0),
|
||||
'incorrect_count': int(memory_summary.get('incorrect_count') or 0),
|
||||
'helpful_count': int(memory_summary.get('helpful_count') or 0),
|
||||
'not_helpful_count': int(memory_summary.get('not_helpful_count') or 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"get_admin_ai_stats failed: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
@@ -61,7 +61,7 @@ def load_strategy_configs(strategy_id: int) -> Dict[str, Any]:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, exchange_config, trading_config, market_type, leverage, execution_mode, market_category
|
||||
SELECT id, user_id, exchange_config, trading_config, market_type, leverage, execution_mode, market_category
|
||||
FROM qd_strategies_trading
|
||||
WHERE id = %s
|
||||
""",
|
||||
@@ -77,9 +77,11 @@ def load_strategy_configs(strategy_id: int) -> Dict[str, Any]:
|
||||
leverage = float(row.get("leverage") or trading_config.get("leverage") or exchange_config.get("leverage") or 1.0)
|
||||
execution_mode = (row.get("execution_mode") or "signal").strip().lower()
|
||||
market_category = (row.get("market_category") or "Crypto").strip()
|
||||
user_id = int(row.get("user_id") or 1)
|
||||
|
||||
return {
|
||||
"strategy_id": int(strategy_id),
|
||||
"user_id": user_id,
|
||||
"exchange_config": exchange_config if isinstance(exchange_config, dict) else {},
|
||||
"trading_config": trading_config if isinstance(trading_config, dict) else {},
|
||||
"market_type": market_type,
|
||||
|
||||
@@ -207,7 +207,8 @@ class PendingOrderWorker:
|
||||
if exec_mode != "live":
|
||||
logger.debug(f"[PositionSync] Strategy {sid} skipped: execution_mode='{exec_mode}' (needs 'live')")
|
||||
continue
|
||||
exchange_config = resolve_exchange_config(sc.get("exchange_config") or {})
|
||||
sync_user_id = int(sc.get("user_id") or 1)
|
||||
exchange_config = resolve_exchange_config(sc.get("exchange_config") or {}, user_id=sync_user_id)
|
||||
safe_cfg = safe_exchange_config_for_log(exchange_config)
|
||||
market_type = (sc.get("market_type") or exchange_config.get("market_type") or "swap")
|
||||
market_type = str(market_type or "swap").strip().lower()
|
||||
@@ -832,7 +833,8 @@ class PendingOrderWorker:
|
||||
return
|
||||
|
||||
cfg = load_strategy_configs(strategy_id)
|
||||
exchange_config = resolve_exchange_config(cfg.get("exchange_config") or {})
|
||||
strategy_user_id = int(cfg.get("user_id") or 1)
|
||||
exchange_config = resolve_exchange_config(cfg.get("exchange_config") or {}, user_id=strategy_user_id)
|
||||
safe_cfg = safe_exchange_config_for_log(exchange_config)
|
||||
exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower()
|
||||
market_category = str(cfg.get("market_category") or "Crypto").strip()
|
||||
|
||||
@@ -549,6 +549,12 @@ class StrategyService:
|
||||
trading_config = payload.get('trading_config') or {}
|
||||
exchange_config = payload.get('exchange_config') or {}
|
||||
|
||||
# When credential_id is present, strip raw API keys to avoid
|
||||
# storing secrets in the strategy record — they live in qd_exchange_credentials.
|
||||
if isinstance(exchange_config, dict) and exchange_config.get('credential_id'):
|
||||
for _secret_key in ('api_key', 'secret_key', 'passphrase', 'apiKey', 'secret', 'password'):
|
||||
exchange_config.pop(_secret_key, None)
|
||||
|
||||
# Strategy group fields
|
||||
strategy_group_id = payload.get('strategy_group_id') or ''
|
||||
group_base_name = payload.get('group_base_name') or ''
|
||||
@@ -779,7 +785,13 @@ class StrategyService:
|
||||
trading_config = payload.get('trading_config') if payload.get('trading_config') is not None else (existing.get('trading_config') or {})
|
||||
exchange_config = payload.get('exchange_config') if payload.get('exchange_config') is not None else (existing.get('exchange_config') or {})
|
||||
ai_model_config = payload.get('ai_model_config') if payload.get('ai_model_config') is not None else (existing.get('ai_model_config') or {})
|
||||
|
||||
|
||||
# When credential_id is present, strip raw API keys to avoid
|
||||
# storing secrets in the strategy record — they live in qd_exchange_credentials.
|
||||
if isinstance(exchange_config, dict) and exchange_config.get('credential_id'):
|
||||
for _secret_key in ('api_key', 'secret_key', 'passphrase', 'apiKey', 'secret', 'password'):
|
||||
exchange_config.pop(_secret_key, None)
|
||||
|
||||
# Handle cross-sectional strategy config updates
|
||||
if payload.get('cs_strategy_type') is not None:
|
||||
trading_config['cs_strategy_type'] = payload.get('cs_strategy_type')
|
||||
|
||||
@@ -4,14 +4,15 @@ USDT Payment Service (方案B:每单独立地址 + 自动对账)
|
||||
MVP:
|
||||
- 只支持 USDT-TRC20
|
||||
- 使用 XPUB 派生地址(服务端只保存 xpub,不保存私钥)
|
||||
- 通过 TronGrid API 轮询到账(前端轮询订单状态时触发刷新)
|
||||
- 后台 Worker 线程自动轮询链上到账 + 前端轮询双保险
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
@@ -233,10 +234,13 @@ class UsdtPaymentService:
|
||||
# -------------------- Chain check --------------------
|
||||
|
||||
def _refresh_order_in_tx(self, cur, row: Dict[str, Any]) -> None:
|
||||
"""Check chain status for a single order and update in the current transaction."""
|
||||
cfg = self._get_cfg()
|
||||
status = (row.get("status") or "").lower()
|
||||
chain = (row.get("chain") or "").upper()
|
||||
order_id = row.get("id")
|
||||
|
||||
# --- Expiry check (only for pending; paid orders should still be confirmed) ---
|
||||
expires_at = row.get("expires_at")
|
||||
now = datetime.now(timezone.utc)
|
||||
if expires_at and isinstance(expires_at, datetime):
|
||||
@@ -244,7 +248,7 @@ class UsdtPaymentService:
|
||||
if exp.tzinfo is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
if status == "pending" and exp <= now:
|
||||
cur.execute("UPDATE qd_usdt_orders SET status = 'expired', updated_at = NOW() WHERE id = ?", (row["id"],))
|
||||
cur.execute("UPDATE qd_usdt_orders SET status = 'expired', updated_at = NOW() WHERE id = ?", (order_id,))
|
||||
return
|
||||
|
||||
if chain != "TRC20":
|
||||
@@ -257,6 +261,12 @@ class UsdtPaymentService:
|
||||
if not address or amount <= 0:
|
||||
return
|
||||
|
||||
# --- For 'paid' status, skip chain query and just check confirm delay ---
|
||||
if status == "paid":
|
||||
self._try_confirm_paid_order(cur, row, cfg, now)
|
||||
return
|
||||
|
||||
# --- For 'pending' status, query chain for incoming transfer ---
|
||||
tx = self._find_trc20_usdt_incoming(address, amount, row.get("created_at"))
|
||||
if not tx:
|
||||
return
|
||||
@@ -265,37 +275,60 @@ class UsdtPaymentService:
|
||||
paid_at = datetime.now(timezone.utc)
|
||||
cur.execute(
|
||||
"UPDATE qd_usdt_orders SET status = 'paid', tx_hash = ?, paid_at = ?, updated_at = NOW() WHERE id = ? AND status = 'pending'",
|
||||
(tx_hash, paid_at, row["id"]),
|
||||
(tx_hash, paid_at, order_id),
|
||||
)
|
||||
|
||||
# Confirm after a short delay to reduce reorg/uncle risk (TRON usually stable)
|
||||
# If already old enough, confirm now.
|
||||
# Try to confirm immediately if delay is satisfied
|
||||
confirm_sec = int(cfg.get("confirm_seconds") or 30)
|
||||
try:
|
||||
if confirm_sec <= 0:
|
||||
confirm_sec = 0
|
||||
# If transaction timestamp is available, use it
|
||||
tx_ts = tx.get("block_timestamp")
|
||||
if tx_ts:
|
||||
tx_time = datetime.fromtimestamp(int(tx_ts) / 1000.0, tz=timezone.utc)
|
||||
if (now - tx_time).total_seconds() >= confirm_sec:
|
||||
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), tx_hash)
|
||||
else:
|
||||
# no timestamp -> confirm immediately
|
||||
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), tx_hash)
|
||||
self._confirm_and_activate_in_tx(cur, order_id, row.get("user_id"), row.get("plan"), tx_hash)
|
||||
elif confirm_sec <= 0:
|
||||
self._confirm_and_activate_in_tx(cur, order_id, row.get("user_id"), row.get("plan"), tx_hash)
|
||||
except Exception:
|
||||
# do not block
|
||||
pass
|
||||
|
||||
def _try_confirm_paid_order(self, cur, row: Dict[str, Any], cfg: Dict[str, Any], now: datetime) -> None:
|
||||
"""For orders already in 'paid' status, check if confirm delay is met and activate."""
|
||||
confirm_sec = int(cfg.get("confirm_seconds") or 30)
|
||||
paid_at = row.get("paid_at")
|
||||
if paid_at:
|
||||
if isinstance(paid_at, str):
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(paid_at.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
paid_at = None
|
||||
if paid_at and paid_at.tzinfo is None:
|
||||
paid_at = paid_at.replace(tzinfo=timezone.utc)
|
||||
if paid_at and (now - paid_at).total_seconds() >= confirm_sec:
|
||||
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), row.get("tx_hash") or "")
|
||||
return
|
||||
# Fallback: if paid_at missing but confirm_sec <= 0, confirm now
|
||||
if confirm_sec <= 0:
|
||||
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), row.get("tx_hash") or "")
|
||||
|
||||
def _confirm_and_activate_in_tx(self, cur, order_id: int, user_id: int, plan: str, tx_hash: str) -> None:
|
||||
# Mark confirmed if not already
|
||||
"""Mark order as confirmed and activate membership. Idempotent: skips if already confirmed."""
|
||||
# --- Idempotency check: re-read current status ---
|
||||
try:
|
||||
cur.execute("SELECT status FROM qd_usdt_orders WHERE id = ?", (order_id,))
|
||||
current = cur.fetchone()
|
||||
if current and (current.get("status") or "").lower() == "confirmed":
|
||||
logger.debug(f"USDT order {order_id} already confirmed, skipping activation.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Mark confirmed
|
||||
cur.execute(
|
||||
"UPDATE qd_usdt_orders SET status='confirmed', confirmed_at = NOW(), updated_at = NOW() WHERE id = ? AND status IN ('paid','pending')",
|
||||
(order_id,),
|
||||
)
|
||||
# Activate membership (idempotent-ish: billing_service stacks vip)
|
||||
# Activate membership
|
||||
try:
|
||||
# We use existing membership activation (writes qd_membership_orders + credits logs).
|
||||
ok, msg, data = self.billing.purchase_membership(int(user_id), str(plan))
|
||||
logger.info(f"USDT activate membership: order={order_id} user={user_id} plan={plan} ok={ok} msg={msg}")
|
||||
except Exception as e:
|
||||
@@ -340,13 +373,9 @@ class UsdtPaymentService:
|
||||
if min_ts and int(it.get("block_timestamp") or 0) < min_ts:
|
||||
continue
|
||||
val = int(it.get("value") or 0)
|
||||
if val != target:
|
||||
# Accept payments >= order amount (tolerance for overpayment)
|
||||
if val < target:
|
||||
continue
|
||||
# basic checks
|
||||
token = it.get("token_info") or {}
|
||||
if str(token.get("symbol") or "").upper() != "USDT":
|
||||
# some APIs omit symbol; contract filter should already ensure
|
||||
pass
|
||||
return it
|
||||
except Exception:
|
||||
continue
|
||||
@@ -354,8 +383,114 @@ class UsdtPaymentService:
|
||||
return None
|
||||
return None
|
||||
|
||||
# -------------------- Batch refresh (for worker) --------------------
|
||||
|
||||
def refresh_all_active_orders(self) -> int:
|
||||
"""
|
||||
Scan all pending/paid USDT orders and refresh their chain status.
|
||||
Called by the background UsdtOrderWorker.
|
||||
|
||||
Returns the number of orders that were updated to 'confirmed' or 'expired'.
|
||||
"""
|
||||
updated = 0
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
self._ensure_schema_best_effort(cur)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, plan, chain, amount_usdt, address_index, address, status, tx_hash,
|
||||
paid_at, confirmed_at, expires_at, created_at, updated_at
|
||||
FROM qd_usdt_orders
|
||||
WHERE status IN ('pending', 'paid')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
|
||||
for row in rows:
|
||||
old_status = (row.get("status") or "").lower()
|
||||
try:
|
||||
self._refresh_order_in_tx(cur, row)
|
||||
except Exception as e:
|
||||
logger.debug(f"refresh_all: order {row.get('id')} error: {e}")
|
||||
continue
|
||||
|
||||
# Check if status changed
|
||||
try:
|
||||
cur.execute("SELECT status FROM qd_usdt_orders WHERE id = ?", (row["id"],))
|
||||
new_row = cur.fetchone()
|
||||
new_status = (new_row.get("status") or "").lower() if new_row else old_status
|
||||
if new_status != old_status:
|
||||
updated += 1
|
||||
logger.info(f"USDT order {row['id']}: {old_status} -> {new_status}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.error(f"refresh_all_active_orders error: {e}", exc_info=True)
|
||||
return updated
|
||||
|
||||
|
||||
# ==================== Background Worker ====================
|
||||
|
||||
class UsdtOrderWorker:
|
||||
"""
|
||||
Background thread that periodically scans pending/paid USDT orders
|
||||
and checks on-chain status via TronGrid API.
|
||||
|
||||
This ensures that even if the user closes the browser after payment,
|
||||
the order will still be confirmed and membership activated.
|
||||
"""
|
||||
|
||||
def __init__(self, poll_interval_sec: float = 30.0):
|
||||
self.poll_interval_sec = float(poll_interval_sec)
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def start(self) -> bool:
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return True
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._run_loop, name="UsdtOrderWorker", daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("UsdtOrderWorker started (interval=%ss)", self.poll_interval_sec)
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
logger.info("UsdtOrderWorker stopped")
|
||||
|
||||
def _run_loop(self):
|
||||
# Wait a bit on startup to let the app fully initialize
|
||||
self._stop_event.wait(timeout=10)
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
svc = get_usdt_payment_service()
|
||||
cfg = svc._get_cfg()
|
||||
if cfg["enabled"]:
|
||||
updated = svc.refresh_all_active_orders()
|
||||
if updated > 0:
|
||||
logger.info(f"UsdtOrderWorker: refreshed {updated} orders")
|
||||
except Exception as e:
|
||||
logger.error(f"UsdtOrderWorker loop error: {e}", exc_info=True)
|
||||
|
||||
self._stop_event.wait(timeout=self.poll_interval_sec)
|
||||
|
||||
|
||||
# ==================== Singletons ====================
|
||||
|
||||
_svc = None
|
||||
_worker = None
|
||||
|
||||
|
||||
def get_usdt_payment_service() -> UsdtPaymentService:
|
||||
@@ -364,3 +499,10 @@ def get_usdt_payment_service() -> UsdtPaymentService:
|
||||
_svc = UsdtPaymentService()
|
||||
return _svc
|
||||
|
||||
|
||||
def get_usdt_order_worker() -> UsdtOrderWorker:
|
||||
global _worker
|
||||
if _worker is None:
|
||||
interval = float(os.getenv("USDT_WORKER_POLL_INTERVAL", "30"))
|
||||
_worker = UsdtOrderWorker(poll_interval_sec=interval)
|
||||
return _worker
|
||||
|
||||
@@ -347,6 +347,8 @@ TRONGRID_API_KEY=
|
||||
# Order confirmation delay and expiration
|
||||
USDT_PAY_CONFIRM_SECONDS=30
|
||||
USDT_PAY_EXPIRE_MINUTES=30
|
||||
# Background worker poll interval (seconds) for checking pending USDT orders
|
||||
USDT_WORKER_POLL_INTERVAL=30
|
||||
|
||||
# New user registration bonus credits (新用户注册赠送积分)
|
||||
CREDITS_REGISTER_BONUS=100
|
||||
|
||||
@@ -802,6 +802,36 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Quick Trades (manual / discretionary orders from Quick Trade Panel)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS qd_quick_trades (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
credential_id INTEGER DEFAULT 0,
|
||||
exchange_id VARCHAR(40) NOT NULL DEFAULT '',
|
||||
symbol VARCHAR(60) NOT NULL DEFAULT '',
|
||||
side VARCHAR(10) NOT NULL DEFAULT '', -- buy / sell
|
||||
order_type VARCHAR(20) NOT NULL DEFAULT 'market', -- market / limit
|
||||
amount DECIMAL(24, 8) DEFAULT 0,
|
||||
price DECIMAL(24, 8) DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 1,
|
||||
market_type VARCHAR(20) DEFAULT 'swap', -- swap / spot
|
||||
tp_price DECIMAL(24, 8) DEFAULT 0,
|
||||
sl_price DECIMAL(24, 8) DEFAULT 0,
|
||||
status VARCHAR(20) DEFAULT 'submitted', -- submitted / filled / failed / cancelled
|
||||
exchange_order_id VARCHAR(120) DEFAULT '',
|
||||
filled_amount DECIMAL(24, 8) DEFAULT 0,
|
||||
avg_fill_price DECIMAL(24, 8) DEFAULT 0,
|
||||
error_msg TEXT DEFAULT '',
|
||||
source VARCHAR(40) DEFAULT 'manual', -- ai_radar / ai_analysis / indicator / manual
|
||||
raw_result JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quick_trades_user ON qd_quick_trades(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_quick_trades_created ON qd_quick_trades(created_at DESC);
|
||||
|
||||
-- =============================================================================
|
||||
-- Completion Notice
|
||||
-- =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user