Signed-off-by: Dinger <quantdinger@gmail.com>
This commit is contained in:
Dinger
2026-04-06 01:39:25 +08:00
parent 15c901364b
commit 3ca291a346
214 changed files with 2771 additions and 8535 deletions
+68 -143
View File
@@ -265,44 +265,27 @@ def run_backtest():
'message': '使用标准K线回测'
}
# Persist backtest run for AI optimization / history
run_id = None
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_backtest_runs
(user_id, indicator_id, market, symbol, timeframe, start_date, end_date,
initial_capital, commission, slippage, leverage, trade_direction,
strategy_config, status, error_message, result_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
""",
(
user_id,
int(indicator_id) if indicator_id is not None else None,
market,
symbol,
timeframe,
start_date_str,
end_date_str,
initial_capital,
commission,
slippage,
leverage,
trade_direction,
json.dumps(strategy_config or {}, ensure_ascii=False),
'success',
'',
json.dumps(result or {}, ensure_ascii=False)
)
)
run_id = cur.lastrowid
db.commit()
cur.close()
except Exception:
# Do not break the main backtest response if persistence fails.
logger.warning("Failed to persist backtest run", exc_info=True)
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',
market=market,
symbol=symbol,
timeframe=timeframe,
start_date_str=start_date_str,
end_date_str=end_date_str,
initial_capital=initial_capital,
commission=commission,
slippage=slippage,
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='',
result=result,
code=indicator_code,
)
return jsonify({
'code': 1,
@@ -323,42 +306,31 @@ def run_backtest():
except Exception as e:
logger.error(f"Backtest failed: {str(e)}")
logger.error(traceback.format_exc())
# Best-effort persist failed run (if we have enough context)
try:
data = data if isinstance(data, dict) else {}
user_id = g.user_id
indicator_id = data.get('indicatorId')
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_backtest_runs
(user_id, indicator_id, market, symbol, timeframe, start_date, end_date,
initial_capital, commission, slippage, leverage, trade_direction,
strategy_config, status, error_message, result_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
""",
(
user_id,
int(indicator_id) if indicator_id is not None else None,
str(data.get('market', '') or ''),
str(data.get('symbol', '') or ''),
str(data.get('timeframe', '') or ''),
str(data.get('startDate', '') or ''),
str(data.get('endDate', '') or ''),
float(data.get('initialCapital', 0) or 0),
float(data.get('commission', 0) or 0),
float(data.get('slippage', 0) or 0),
int(data.get('leverage', 1) or 1),
str(data.get('tradeDirection', 'long') or 'long'),
json.dumps(data.get('strategyConfig') or {}, ensure_ascii=False),
'failed',
str(e),
''
)
)
db.commit()
cur.close()
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',
error_message=str(e),
result=None,
code=str(data.get('indicatorCode', '') or ''),
)
except Exception:
pass
return jsonify({
@@ -391,53 +363,22 @@ def get_backtest_history():
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()
where = ["user_id = ?"]
params = [user_id]
if indicator_id is not None and str(indicator_id).strip() != "":
try:
where.append("indicator_id = ?")
params.append(int(indicator_id))
except Exception:
pass
if symbol:
where.append("symbol = ?")
params.append(symbol)
if market:
where.append("market = ?")
params.append(market)
if timeframe:
where.append("timeframe = ?")
params.append(timeframe)
where_sql = " AND ".join(where)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"""
SELECT id, user_id, indicator_id, market, symbol, timeframe,
start_date, end_date, initial_capital, commission, slippage,
leverage, trade_direction, strategy_config, status, error_message,
created_at
FROM qd_backtest_runs
WHERE {where_sql}
ORDER BY id DESC
LIMIT ? OFFSET ?
""",
(*params, limit, offset)
)
rows = cur.fetchall() or []
cur.close()
# Parse strategy_config JSON best-effort
for r in rows:
try:
r['strategy_config'] = json.loads(r.get('strategy_config') or '{}')
except Exception:
pass
rows = backtest_service.list_runs(
user_id=user_id,
limit=limit,
offset=offset,
indicator_id=int(indicator_id) if indicator_id is not None and str(indicator_id).strip() != "" else None,
strategy_id=int(strategy_id) if strategy_id is not None and str(strategy_id).strip() != "" else None,
run_type=run_type or None,
symbol=symbol,
market=market,
timeframe=timeframe,
)
return jsonify({'code': 1, 'msg': 'OK', 'data': rows})
except Exception as e:
@@ -461,35 +402,10 @@ def get_backtest_run():
if not run_id:
return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, user_id, indicator_id, market, symbol, timeframe,
start_date, end_date, initial_capital, commission, slippage,
leverage, trade_direction, strategy_config, status, error_message,
result_json, created_at
FROM qd_backtest_runs
WHERE id = ? AND user_id = ?
""",
(run_id, user_id),
)
row = cur.fetchone()
cur.close()
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
try:
row['strategy_config'] = json.loads(row.get('strategy_config') or '{}')
except Exception:
pass
try:
row['result'] = json.loads(row.get('result_json') or '{}')
except Exception:
row['result'] = {}
row.pop('result_json', None)
return jsonify({'code': 1, 'msg': 'OK', 'data': row})
except Exception as e:
logger.error(f"get_backtest_run failed: {e}")
@@ -724,6 +640,7 @@ def ai_analyze_backtest_runs():
try:
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 []
if not isinstance(run_ids, list) or not run_ids:
@@ -740,9 +657,9 @@ def ai_analyze_backtest_runs():
cur = db.cursor()
cur.execute(
f"""
SELECT id, user_id, indicator_id, market, symbol, timeframe,
SELECT id, user_id, indicator_id, strategy_id, strategy_name, run_type, market, symbol, timeframe,
start_date, end_date, initial_capital, commission, slippage,
leverage, trade_direction, strategy_config, status, error_message,
leverage, trade_direction, strategy_config, config_snapshot, status, error_message,
result_json, created_at
FROM qd_backtest_runs
WHERE user_id = ? AND id IN ({placeholders})
@@ -759,6 +676,10 @@ def ai_analyze_backtest_runs():
r['strategy_config'] = json.loads(r.get('strategy_config') or '{}')
except Exception:
r['strategy_config'] = {}
try:
r['config_snapshot'] = json.loads(r.get('config_snapshot') or '{}')
except Exception:
r['config_snapshot'] = {}
try:
r['result'] = json.loads(r.get('result_json') or '{}')
except Exception:
@@ -806,6 +727,9 @@ def ai_analyze_backtest_runs():
"selectedRuns": [
{
"id": r.get("id"),
"strategy_id": r.get("strategy_id"),
"strategy_name": r.get("strategy_name"),
"run_type": r.get("run_type"),
"market": r.get("market"),
"symbol": r.get("symbol"),
"timeframe": r.get("timeframe"),
@@ -814,6 +738,7 @@ def ai_analyze_backtest_runs():
"leverage": r.get("leverage"),
"trade_direction": r.get("trade_direction"),
"strategy_config": r.get("strategy_config") or {},
"config_snapshot": r.get("config_snapshot") or {},
"result": r.get("result") or {},
"status": r.get("status"),
}
@@ -833,7 +758,7 @@ def ai_analyze_backtest_runs():
{"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
],
},
timeout=120,
timeout=30,
)
try:
resp.raise_for_status()
+16 -3
View File
@@ -40,7 +40,7 @@ def list_credentials():
cur = db.cursor()
cur.execute(
"""
SELECT id, user_id, name, exchange_id, api_key_hint, created_at, updated_at
SELECT id, user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at
FROM qd_exchange_credentials
WHERE user_id = %s
ORDER BY id DESC
@@ -50,7 +50,20 @@ def list_credentials():
rows = cur.fetchall() or []
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': rows}})
items = []
for row in rows:
item = dict(row or {})
item['enable_demo_trading'] = False
try:
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'))
except Exception:
item['enable_demo_trading'] = False
item.pop('encrypted_config', None)
items.append(item)
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())
@@ -59,7 +72,7 @@ def list_credentials():
CRYPTO_EXCHANGES = [
'binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange',
'kraken', 'kucoin', 'gate', 'bitfinex', 'deepcoin'
'kraken', 'kucoin', 'gate', 'bitfinex', 'deepcoin', 'htx'
]
+46 -8
View File
@@ -65,9 +65,17 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
logger.info(f"Using limit price {limit_price} for USDT conversion")
else:
# Try to get current market price from exchange
if hasattr(client, "get_ticker"):
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)
except Exception:
current_price = 0.0
# OKX
from app.services.live_trading.okx import OkxClient
if isinstance(client, 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)
@@ -88,7 +96,7 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
# 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 isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
if current_price <= 0 and isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
try:
# Binance public ticker endpoint
base_url = getattr(client, "base_url", "")
@@ -284,10 +292,10 @@ def place_order():
market_type = "spot" if leverage == 1 else "swap"
if market_type in ("futures", "future", "perp", "perpetual"):
market_type = "swap"
# Override: if leverage > 1, force swap; if leverage = 1, force spot
# Override only when user did not explicitly choose market_type.
if leverage > 1:
market_type = "swap"
elif leverage == 1:
elif leverage == 1 and not str(body.get("market_type") or "").strip():
market_type = "spot"
# ---- build exchange client ----
@@ -485,6 +493,8 @@ def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client
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
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
return {"quantity": amount, "price": price, "client_order_id": client_order_id}
@@ -497,6 +507,8 @@ def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client
pos_side = "long" if side.lower() == "buy" else "short"
kwargs["pos_side"] = pos_side
return kwargs
if isinstance(client, (BybitClient, DeepcoinClient)):
return {"qty": amount, "price": price, "client_order_id": client_order_id}
# Generic fallback
return {"size": amount, "price": price, "client_order_id": client_order_id}
@@ -592,6 +604,26 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An
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", ""):
avail = float(item.get("balance") or 0)
result["available"] = avail
total = 0.0
for item in data.get("list") or []:
if str(item.get("currency") or "").upper() == "USDT":
total += float(item.get("balance") or 0)
if total > 0 or result["available"] > 0:
result["total"] = total or result["available"]
return result
# HTX swap
if isinstance(data, list) and data and isinstance(data[0], dict):
first = data[0]
if "margin_available" in first or "margin_balance" in first or "withdraw_available" in first:
result["available"] = float(first.get("margin_available") or first.get("withdraw_available") or 0)
result["total"] = float(first.get("margin_balance") or first.get("margin_static") or 0)
return result
# Fallback: try to find any USDT-like values
if isinstance(raw, dict):
for k, v in raw.items():
@@ -679,7 +711,7 @@ def _parse_positions(raw: Any) -> list:
# SWAP: posAmt, pos
# SPOT: bal (balance), availBal (available balance)
size = float(item.get("posAmt") or item.get("pos") or item.get("size") or item.get("contracts") or
item.get("bal") or item.get("availBal") or 0)
item.get("bal") or item.get("availBal") or item.get("volume") or 0)
if abs(size) < 1e-10:
continue
@@ -693,15 +725,21 @@ def _parse_positions(raw: Any) -> list:
pos_side = str(item.get("posSide", "")).strip().lower()
if pos_side in ("long", "short"):
side = pos_side
elif item.get("direction"):
dir_side = str(item.get("direction") or "").strip().lower()
if dir_side in ("buy", "long"):
side = "long"
elif dir_side in ("sell", "short"):
side = "short"
result.append({
"symbol": item.get("symbol") or item.get("instId") or "",
"side": side,
"size": abs(size),
"entry_price": float(item.get("entryPrice") or item.get("avgCost") or item.get("avgPx") or item.get("avgPx") or 0),
"unrealized_pnl": float(item.get("unRealizedProfit") or item.get("upl") or item.get("unrealisedPnl") or item.get("pnl") or 0),
"entry_price": float(item.get("entryPrice") or item.get("avgCost") or item.get("avgPx") or item.get("cost_open") or 0),
"unrealized_pnl": float(item.get("unRealizedProfit") 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 1),
"mark_price": float(item.get("markPrice") or item.get("markPx") or item.get("last") or 0),
"mark_price": float(item.get("markPrice") or item.get("markPx") or item.get("last_price") or item.get("last") or 0),
})
except Exception as e:
logger.warning(f"_parse_positions error: {e}")
+179 -3
View File
@@ -2,12 +2,15 @@
Trading Strategy API Routes
"""
from flask import Blueprint, request, jsonify, g
from datetime import datetime
import json
import traceback
import time
from app.services.strategy import StrategyService
from app.services.strategy_compiler import StrategyCompiler
from app.services.backtest import BacktestService
from app.services.strategy_snapshot import StrategySnapshotResolver
from app import get_trading_executor
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
@@ -21,6 +24,7 @@ strategy_bp = Blueprint('strategy', __name__)
# Local mode: avoid heavy initialization during module import.
# Instantiate services lazily on first use to keep startup clean.
_strategy_service = None
_backtest_service = None
def get_strategy_service() -> StrategyService:
global _strategy_service
@@ -29,6 +33,13 @@ def get_strategy_service() -> StrategyService:
return _strategy_service
def get_backtest_service() -> BacktestService:
global _backtest_service
if _backtest_service is None:
_backtest_service = BacktestService()
return _backtest_service
@strategy_bp.route('/strategies', methods=['GET'])
@login_required
def list_strategies():
@@ -63,6 +74,168 @@ def get_strategy_detail():
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/backtest', methods=['POST'])
@login_required
def run_strategy_backtest():
try:
payload = request.get_json() or {}
user_id = g.user_id
strategy_id = int(payload.get('strategyId') or 0)
if not strategy_id:
return jsonify({'code': 0, 'msg': 'strategyId is required', 'data': None}), 400
start_date_str = str(payload.get('startDate') or '').strip()
end_date_str = str(payload.get('endDate') or '').strip()
if not start_date_str or not end_date_str:
return jsonify({'code': 0, 'msg': 'startDate and endDate are required', 'data': None}), 400
strategy = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
if not strategy:
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
resolver = StrategySnapshotResolver(user_id=user_id)
snapshot = resolver.resolve(strategy, payload.get('overrideConfig') or {})
snapshot['user_id'] = user_id
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
days_diff = (end_date - start_date).days
timeframe = snapshot.get('timeframe') or '1D'
if timeframe == '1m':
max_days = 30
max_range_text = '1 month'
elif timeframe == '5m':
max_days = 180
max_range_text = '6 months'
elif timeframe in ['15m', '30m']:
max_days = 365
max_range_text = '1 year'
else:
max_days = 1095
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
svc = get_backtest_service()
result = svc.run_strategy_snapshot(snapshot, start_date=start_date, end_date=end_date)
run_id = svc.persist_run(
user_id=user_id,
indicator_id=snapshot.get('indicator_id'),
strategy_id=snapshot.get('strategy_id'),
strategy_name=snapshot.get('strategy_name') or '',
run_type=snapshot.get('run_type') or 'strategy_indicator',
market=snapshot.get('market') or '',
symbol=snapshot.get('symbol') or '',
timeframe=snapshot.get('timeframe') or '',
start_date_str=start_date_str,
end_date_str=end_date_str,
initial_capital=float(snapshot.get('initial_capital') or 0),
commission=float(snapshot.get('commission') or 0),
slippage=float(snapshot.get('slippage') or 0),
leverage=int(snapshot.get('leverage') or 1),
trade_direction=str(snapshot.get('trade_direction') or 'long'),
strategy_config=snapshot.get('strategy_config') or {},
config_snapshot=snapshot.get('config_snapshot') or {},
status='success',
error_message='',
result=result,
code=snapshot.get('code') or '',
)
return jsonify({'code': 1, 'msg': 'success', 'data': {'runId': run_id, 'result': result}})
except ValueError as e:
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
except Exception as e:
logger.error(f"run_strategy_backtest failed: {str(e)}")
logger.error(traceback.format_exc())
try:
payload = payload if isinstance(payload, dict) else {}
strategy_id = int(payload.get('strategyId') or 0)
strategy = get_strategy_service().get_strategy(strategy_id, user_id=g.user_id) if strategy_id else None
if strategy:
resolver = StrategySnapshotResolver(user_id=g.user_id)
snapshot = resolver.resolve(strategy, payload.get('overrideConfig') or {})
snapshot['user_id'] = g.user_id
get_backtest_service().persist_run(
user_id=g.user_id,
indicator_id=snapshot.get('indicator_id'),
strategy_id=snapshot.get('strategy_id'),
strategy_name=snapshot.get('strategy_name') or '',
run_type=snapshot.get('run_type') or 'strategy_indicator',
market=snapshot.get('market') or '',
symbol=snapshot.get('symbol') or '',
timeframe=snapshot.get('timeframe') or '',
start_date_str=str(payload.get('startDate') or ''),
end_date_str=str(payload.get('endDate') or ''),
initial_capital=float(snapshot.get('initial_capital') or 0),
commission=float(snapshot.get('commission') or 0),
slippage=float(snapshot.get('slippage') or 0),
leverage=int(snapshot.get('leverage') or 1),
trade_direction=str(snapshot.get('trade_direction') or 'long'),
strategy_config=snapshot.get('strategy_config') or {},
config_snapshot=snapshot.get('config_snapshot') or {},
status='failed',
error_message=str(e),
result=None,
code=snapshot.get('code') or '',
)
except Exception:
pass
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/backtest/history', methods=['GET'])
@login_required
def get_strategy_backtest_history():
try:
user_id = g.user_id
strategy_id = int(request.args.get('strategyId') or request.args.get('id') or 0)
if not strategy_id:
return jsonify({'code': 0, 'msg': 'strategyId is required', 'data': None}), 400
limit = max(1, min(int(request.args.get('limit') or 50), 200))
offset = max(0, int(request.args.get('offset') or 0))
symbol = (request.args.get('symbol') or '').strip()
market = (request.args.get('market') or '').strip()
timeframe = (request.args.get('timeframe') or '').strip()
rows = get_backtest_service().list_runs(
user_id=user_id,
strategy_id=strategy_id,
limit=limit,
offset=offset,
symbol=symbol,
market=market,
timeframe=timeframe,
)
rows = [r for r in rows if str(r.get('run_type') or '').startswith('strategy_')]
return jsonify({'code': 1, 'msg': 'success', 'data': rows})
except Exception as e:
logger.error(f"get_strategy_backtest_history failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/backtest/get', methods=['GET'])
@login_required
def get_strategy_backtest_run():
try:
user_id = g.user_id
run_id = int(request.args.get('runId') or 0)
if not run_id:
return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400
row = get_backtest_service().get_run(user_id=user_id, run_id=run_id)
if not row or not str(row.get('run_type') or '').startswith('strategy_'):
return jsonify({'code': 0, 'msg': 'run not found', 'data': None}), 404
return jsonify({'code': 1, 'msg': 'success', 'data': row})
except Exception as e:
logger.error(f"get_strategy_backtest_run failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/create', methods=['POST'])
@login_required
def create_strategy():
@@ -695,7 +868,7 @@ def test_connection():
return jsonify({'code': 0, 'msg': 'Please provide API key and secret key', 'data': None})
# Pass the resolved config (with actual keys) to the service
result = get_strategy_service().test_exchange_connection(resolved)
result = get_strategy_service().test_exchange_connection(resolved, user_id=user_id)
if result['success']:
return jsonify({'code': 1, 'msg': result.get('message') or 'Connection successful', 'data': result.get('data')})
@@ -1079,9 +1252,12 @@ def ai_generate_strategy():
Generate Python strategy code that follows this framework:
- def on_init(ctx): Initialize strategy parameters using ctx.param(name, default)
- def on_bar(ctx, bar): Core logic called on each K-line bar
- bar has: open, high, low, close, volume, timestamp
- bar supports both bar.close and bar['close'] access, and has: open, high, low, close, volume, timestamp
- ctx.buy(price, amount), ctx.sell(price, amount), ctx.close_position()
- ctx.position (current position), ctx.balance, ctx.equity
- ctx.position supports both numeric checks and dict-style fields:
- if not ctx.position / if ctx.position > 0 / if ctx.position < 0
- ctx.position['side'], ctx.position['size'], ctx.position['entry_price']
- ctx.balance, ctx.equity
- ctx.bars(n) to get last N bars, ctx.log(message) to log
- def on_order_filled(ctx, order): Optional callback when order fills
- def on_stop(ctx): Optional cleanup when strategy stops
+44 -1
View File
@@ -873,16 +873,48 @@ def get_system_strategies():
page: int (default 1)
page_size: int (default 20, max 100)
status: str (optional, filter by status: running/stopped/all)
execution_mode: str (optional, live/signal — omit or all for any)
search: str (optional, search by strategy name/symbol/username)
sort_by: str (optional, whitelist; default status+updated_at)
sort_order: str (optional, asc or desc; default desc when sort_by set)
"""
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()
execution_filter = request.args.get('execution_mode', '', type=str).strip().lower()
search = request.args.get('search', '', type=str).strip()
sort_by = request.args.get('sort_by', '', type=str).strip().lower()
sort_order = request.args.get('sort_order', 'desc', type=str).strip().lower()
if sort_order not in ('asc', 'desc'):
sort_order = 'desc'
page_size = min(100, max(1, page_size))
offset = (page - 1) * page_size
sort_sql_map = {
'id': 's.id',
'updated_at': 's.updated_at',
'created_at': 's.created_at',
'initial_capital': 's.initial_capital',
'strategy_name': 's.strategy_name',
'symbol': 's.symbol',
'status': 's.status',
'execution_mode': 's.execution_mode',
'leverage': 's.leverage',
}
sort_expr_map = {
'total_pnl': (
"(COALESCE((SELECT SUM(unrealized_pnl) FROM qd_strategy_positions p WHERE p.strategy_id = s.id), 0)"
" + COALESCE((SELECT SUM(profit) FROM qd_strategy_trades t WHERE t.strategy_id = s.id), 0))"
),
'trade_count': '(SELECT COUNT(*) FROM qd_strategy_trades t WHERE t.strategy_id = s.id)',
'position_count': '(SELECT COUNT(*) FROM qd_strategy_positions p WHERE p.strategy_id = s.id)',
'total_equity': (
'COALESCE((SELECT SUM(equity) FROM qd_strategy_positions p WHERE p.strategy_id = s.id), 0)'
),
}
direction = 'ASC' if sort_order == 'asc' else 'DESC'
with get_db_connection() as db:
cur = db.cursor()
@@ -894,6 +926,10 @@ def get_system_strategies():
conditions.append("s.status = ?")
params.append(status_filter)
if execution_filter in ('live', 'signal'):
conditions.append("s.execution_mode = ?")
params.append(execution_filter)
if search:
conditions.append(
"(s.strategy_name ILIKE ? OR s.symbol ILIKE ? OR u.username ILIKE ? OR u.nickname ILIKE ?)"
@@ -905,6 +941,13 @@ def get_system_strategies():
if conditions:
where_clause = "WHERE " + " AND ".join(conditions)
if sort_by in sort_sql_map:
order_clause = f"ORDER BY {sort_sql_map[sort_by]} {direction}, s.id DESC"
elif sort_by in sort_expr_map:
order_clause = f"ORDER BY {sort_expr_map[sort_by]} {direction}, s.id DESC"
else:
order_clause = "ORDER BY s.status DESC, s.updated_at DESC, s.id DESC"
# Get total count
count_sql = f"""
SELECT COUNT(*) as cnt
@@ -941,7 +984,7 @@ def get_system_strategies():
FROM qd_strategies_trading s
LEFT JOIN qd_users u ON u.id = s.user_id
{where_clause}
ORDER BY s.status DESC, s.updated_at DESC
{order_clause}
LIMIT ? OFFSET ?
"""
cur.execute(query_sql, tuple(params) + (page_size, offset))
File diff suppressed because it is too large Load Diff
@@ -19,13 +19,14 @@ from app.services.live_trading.symbols import to_binance_futures_symbol
class BinanceFuturesClient(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0, broker_id: str = ""):
if not base_url:
base_url = "https://demo-fapi.binance.com" if enable_demo_trading else "https://fapi.binance.com"
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.broker_id = (broker_id or "").strip()
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Binance api_key/secret_key")
@@ -162,6 +163,21 @@ class BinanceFuturesClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
if not raw:
return ""
if not broker_id:
return raw[:36]
prefix = f"x-{broker_id}"
if raw.startswith(prefix):
return raw[:36]
suffix_budget = max(0, 36 - len(prefix))
if suffix_budget <= 0:
return prefix[:36]
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
# Use server-accepted timestamp in ms.
@@ -649,8 +665,9 @@ class BinanceFuturesClient(BaseRestClient):
}
if reduce_only:
params["reduceOnly"] = "true"
if client_order_id:
params["newClientOrderId"] = str(client_order_id)
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
# Hedge mode requires explicit positionSide (LONG/SHORT). One-way mode should not use LONG/SHORT.
dual_side = self.get_dual_side_position()
@@ -787,8 +804,9 @@ class BinanceFuturesClient(BaseRestClient):
}
if reduce_only:
params["reduceOnly"] = "true"
if client_order_id:
params["newClientOrderId"] = str(client_order_id)
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
dual_side = self.get_dual_side_position()
pos_norm = self._normalize_position_side(position_side)
@@ -16,13 +16,14 @@ from app.services.live_trading.symbols import to_binance_futures_symbol
class BinanceSpotClient(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0, broker_id: str = ""):
if not base_url:
base_url = "https://demo-api.binance.com" if enable_demo_trading else "https://api.binance.com"
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.broker_id = (broker_id or "").strip()
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Binance api_key/secret_key")
@@ -157,6 +158,21 @@ class BinanceSpotClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
if not raw:
return ""
if not broker_id:
return raw[:36]
prefix = f"x-{broker_id}"
if raw.startswith(prefix):
return raw[:36]
suffix_budget = max(0, 36 - len(prefix))
if suffix_budget <= 0:
return prefix[:36]
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000)
@@ -382,8 +398,9 @@ class BinanceSpotClient(BaseRestClient):
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
"price": self._dec_str(px_dec),
}
if client_order_id:
params["newClientOrderId"] = str(client_order_id)
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
try:
raw = self._signed_request("POST", "/api/v3/order", params=params)
except LiveTradingError as e:
@@ -423,8 +440,9 @@ class BinanceSpotClient(BaseRestClient):
"type": "MARKET",
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
}
if client_order_id:
params["newClientOrderId"] = str(client_order_id)
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
try:
raw = self._signed_request("POST", "/api/v3/order", params=params)
except LiveTradingError as e:
@@ -40,12 +40,14 @@ class BitgetMixClient(BaseRestClient):
base_url: str = "https://api.bitget.com",
timeout_sec: float = 15.0,
channel_api_code: str = "qvz9x",
simulated_trading: bool = False,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.passphrase = (passphrase or "").strip()
self.channel_api_code = (channel_api_code or "").strip()
self.simulated_trading = bool(simulated_trading)
if not self.api_key or not self.secret_key or not self.passphrase:
raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase")
@@ -193,6 +195,8 @@ class BitgetMixClient(BaseRestClient):
"ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json",
}
if self.simulated_trading:
headers["PAPTRADING"] = "1"
clean_path = str(request_path or "").split("?", 1)[0]
if self.channel_api_code and clean_path in self._CHANNEL_API_CODE_ORDER_PATHS:
headers["X-CHANNEL-API-CODE"] = self.channel_api_code
@@ -41,12 +41,14 @@ class BitgetSpotClient(BaseRestClient):
base_url: str = "https://api.bitget.com",
timeout_sec: float = 15.0,
channel_api_code: str = "qvz9x",
simulated_trading: bool = False,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.passphrase = (passphrase or "").strip()
self.channel_api_code = (channel_api_code or "").strip()
self.simulated_trading = bool(simulated_trading)
if not self.api_key or not self.secret_key or not self.passphrase:
raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase")
@@ -167,6 +169,8 @@ class BitgetSpotClient(BaseRestClient):
"ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json",
}
if self.simulated_trading:
h["PAPTRADING"] = "1"
clean_path = str(request_path or "").split("?", 1)[0]
if self.channel_api_code and clean_path in self._CHANNEL_API_CODE_ORDER_PATHS:
h["X-CHANNEL-API-CODE"] = self.channel_api_code
@@ -474,4 +478,14 @@ class BitgetSpotClient(BaseRestClient):
"""
return self._signed_request("GET", "/api/v2/spot/account/assets")
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
sym = to_bitget_um_symbol(symbol)
raw = self._public_request("GET", "/api/v2/spot/market/tickers", params={"symbol": sym})
data = raw.get("data") if isinstance(raw, dict) else None
if isinstance(data, list) and data and isinstance(data[0], dict):
return data[0]
if isinstance(data, dict):
return data
return {}
@@ -33,12 +33,15 @@ class BybitClient(BaseRestClient):
timeout_sec: float = 15.0,
category: str = "linear", # "linear" (USDT perpetual) or "spot"
recv_window_ms: int = 5000,
broker_referer: str = "",
hedge_mode: bool = False,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.category = (category or "linear").strip().lower()
self.broker_referer = self._DEFAULT_BROKER_REFERER
self.broker_referer = (broker_referer or self._DEFAULT_BROKER_REFERER).strip()
self.hedge_mode = bool(hedge_mode)
if self.category not in ("linear", "spot"):
self.category = "linear"
try:
@@ -158,8 +161,9 @@ class BybitClient(BaseRestClient):
def _sign(self, prehash: str) -> str:
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
def _resolve_position_idx(pos_side: str) -> Optional[int]:
def _resolve_position_idx(self, pos_side: str) -> Optional[int]:
if not self.hedge_mode:
return None
ps = str(pos_side or "").strip().lower()
if ps == "long":
return 1
@@ -2,7 +2,7 @@
Translate a strategy signal into a direct-exchange order call.
Supports:
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex, Deepcoin
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex, Deepcoin, HTX
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
- Forex brokers: MetaTrader 5 (MT5)
"""
@@ -29,6 +29,9 @@ from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativ
# Lazy import Deepcoin
DeepcoinClient = None
# Lazy import HTX
HtxClient = None
# Lazy import IBKR
IBKRClient = None
@@ -98,6 +101,26 @@ def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
raise LiveTradingError(f"Unsupported signal_type: {signal_type}")
def _quote_amount_from_base_qty(client: BaseRestClient, *, symbol: str, base_qty: float) -> float:
if float(base_qty or 0.0) <= 0:
return 0.0
if not hasattr(client, "get_ticker"):
return float(base_qty or 0.0)
try:
ticker = client.get_ticker(symbol=symbol)
except Exception:
return float(base_qty or 0.0)
if not isinstance(ticker, dict):
return float(base_qty or 0.0)
try:
price = float(ticker.get("last") or ticker.get("lastPr") or ticker.get("lastPrice") or ticker.get("price") or 0.0)
except Exception:
price = 0.0
if price <= 0:
return float(base_qty or 0.0)
return float(base_qty or 0.0) * price
def place_order_from_signal(
client: BaseRestClient,
*,
@@ -171,11 +194,13 @@ def place_order_from_signal(
client_order_id=client_order_id,
)
if isinstance(client, BitgetSpotClient):
# For spot market BUY, Bitget may expect quote size; we pass base size here and let caller override if needed.
spot_size = qty
if side == "buy":
spot_size = _quote_amount_from_base_qty(client, symbol=symbol, base_qty=qty)
return client.place_market_order(
symbol=symbol,
side=side,
size=qty,
size=spot_size,
client_order_id=client_order_id,
)
if isinstance(client, BybitClient):
@@ -192,12 +217,19 @@ def place_order_from_signal(
if isinstance(client, KrakenClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
if isinstance(client, KucoinSpotClient):
# KuCoin market BUY often requires quote funds; this simplified path does not convert.
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id, quote_size=False)
quote_size = False
kucoin_size = qty
if side == "buy":
kucoin_size = _quote_amount_from_base_qty(client, symbol=symbol, base_qty=qty)
quote_size = kucoin_size > 0 and kucoin_size != qty
return client.place_market_order(symbol=symbol, side=side, size=kucoin_size, client_order_id=client_order_id, quote_size=quote_size)
if isinstance(client, KucoinFuturesClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
if isinstance(client, GateSpotClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
gate_size = qty
if side == "buy":
gate_size = _quote_amount_from_base_qty(client, symbol=symbol, base_qty=qty)
return client.place_market_order(symbol=symbol, side=side, size=gate_size, client_order_id=client_order_id)
if isinstance(client, GateUsdtFuturesClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
if isinstance(client, BitfinexClient):
@@ -226,6 +258,24 @@ def place_order_from_signal(
client_order_id=client_order_id,
)
global HtxClient
if HtxClient is None:
try:
from app.services.live_trading.htx import HtxClient as _HtxClient
HtxClient = _HtxClient
except ImportError:
pass
if HtxClient is not None and isinstance(client, HtxClient):
return client.place_market_order(
symbol=symbol,
side=side,
qty=qty,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=client_order_id,
)
# Check for IBKR client (lazy import to avoid circular dependency)
global IBKRClient
if IBKRClient is None:
@@ -2,7 +2,7 @@
Factory for direct exchange clients.
Supports:
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex, Deepcoin, HTX
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
- Forex brokers: MetaTrader 5 (MT5)
"""
@@ -25,6 +25,7 @@ from app.services.live_trading.kucoin import KucoinSpotClient, KucoinFuturesClie
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
from app.services.live_trading.deepcoin import DeepcoinClient
from app.services.live_trading.htx import HtxClient
# Lazy import IBKR to avoid ImportError if ib_insync not installed
IBKRClient = None
@@ -68,14 +69,16 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
is_demo = _demo_enabled(exchange_config)
if exchange_id == "binance":
spot_broker_id = _get(exchange_config, "spot_broker_id", "spotBrokerId", "broker_id", "brokerId") or "A2NAPZAC"
futures_broker_id = _get(exchange_config, "futures_broker_id", "futuresBrokerId", "broker_id", "brokerId") or "HBpUbQjT"
if mt == "spot":
default_url = "https://demo-api.binance.com" if is_demo else "https://api.binance.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_url
return BinanceSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo)
return BinanceSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo, broker_id=spot_broker_id)
# Default to USDT-M futures
default_url = "https://demo-fapi.binance.com" if is_demo else "https://fapi.binance.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_url
return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo)
return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo, broker_id=futures_broker_id)
if exchange_id == "okx":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://www.okx.com"
broker_code = "56fa80b0ce8cBCDE"
@@ -92,21 +95,48 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com"
if mt == "spot":
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x"
return BitgetSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
return BitgetSpotClient(
api_key=api_key,
secret_key=secret_key,
passphrase=passphrase,
base_url=base_url,
channel_api_code=channel_api_code,
simulated_trading=is_demo,
)
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x"
return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
return BitgetMixClient(
api_key=api_key,
secret_key=secret_key,
passphrase=passphrase,
base_url=base_url,
channel_api_code=channel_api_code,
simulated_trading=is_demo,
)
if exchange_id == "bybit":
default_bybit = "https://api-testnet.bybit.com" if is_demo else "https://api.bybit.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_bybit
category = "spot" if mt == "spot" else "linear"
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
broker_referer = _get(exchange_config, "bybit_referer", "broker_referer", "brokerReferer") or "Ri001020"
hedge_mode_raw = exchange_config.get("hedge_mode")
if hedge_mode_raw is None:
hedge_mode_raw = exchange_config.get("hedgeMode")
if hedge_mode_raw is None:
hedge_mode_raw = exchange_config.get("position_mode") or exchange_config.get("positionMode")
hedge_mode = False
if isinstance(hedge_mode_raw, bool):
hedge_mode = hedge_mode_raw
else:
hedge_mode = str(hedge_mode_raw or "").strip().lower() in ("true", "1", "yes", "hedge", "both_side")
return BybitClient(
api_key=api_key,
secret_key=secret_key,
base_url=base_url,
category=category,
recv_window_ms=recv_window_ms,
broker_referer=broker_referer,
hedge_mode=hedge_mode,
)
if exchange_id in ("coinbaseexchange", "coinbase_exchange"):
@@ -135,13 +165,14 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
return KucoinFuturesClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=fut_url)
if exchange_id == "gate":
gate_channel_id = _get(exchange_config, "gate_channel_id", "gateChannelId") or "dinger"
if mt == "spot":
default_gate = "https://api-testnet.gateio.ws" if is_demo else "https://api.gateio.ws"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_gate
return GateSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
default_fut = "https://fx-api-testnet.gateio.ws" if is_demo else "https://api.gateio.ws"
return GateSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, channel_id=gate_channel_id)
default_fut = "https://fx-api-testnet.gateio.ws" if is_demo else "https://fx-api.gateio.ws"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_fut
return GateUsdtFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
return GateUsdtFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, channel_id=gate_channel_id)
if exchange_id == "bitfinex":
# Same REST host; use keys from Bitfinex paper/sub-account where applicable.
@@ -151,6 +182,8 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
return BitfinexDerivativesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
if exchange_id == "deepcoin":
if is_demo and not (_get(exchange_config, "base_url", "baseUrl")):
raise LiveTradingError("Deepcoin demo/testnet is not configured in this project yet. Please disable demo mode or provide an explicit testnet base_url.")
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.deepcoin.com"
return DeepcoinClient(
api_key=api_key,
@@ -160,6 +193,21 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
market_type=mt,
)
if exchange_id == "htx":
if is_demo and not (_get(exchange_config, "base_url", "baseUrl") or _get(exchange_config, "futures_base_url", "futuresBaseUrl")):
raise LiveTradingError("HTX demo/testnet is not configured in this project yet. Please disable demo mode or provide explicit testnet base_url/futures_base_url.")
spot_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.huobi.pro"
futures_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or "https://api.hbdm.com"
broker_id = _get(exchange_config, "broker_id", "brokerId") or "AA7b890547"
return HtxClient(
api_key=api_key,
secret_key=secret_key,
base_url=spot_url,
futures_base_url=futures_url,
market_type=mt,
broker_id=broker_id,
)
# Traditional brokers (IBKR for US stocks only)
if exchange_id == "ibkr":
# Note: Market category validation should be done at the caller level
@@ -25,10 +25,11 @@ from app.services.live_trading.symbols import to_gate_currency_pair
class _GateBase(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.channel_id = (channel_id or "").strip()
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Gate api_key/secret_key")
@@ -37,7 +38,25 @@ class _GateBase(BaseRestClient):
return hmac.new(self.secret_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha512).hexdigest()
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
return {"KEY": self.api_key, "Timestamp": ts, "SIGN": sign, "Content-Type": "application/json"}
headers = {"KEY": self.api_key, "Timestamp": ts, "SIGN": sign, "Content-Type": "application/json"}
if self.channel_id:
headers["X-Gate-Channel-Id"] = self.channel_id[:19]
return headers
def _format_text(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
if not raw:
return ""
normalized = []
for ch in raw:
if ch.isalnum() or ch in ("-", "_", "."):
normalized.append(ch)
text = "".join(normalized).strip()
if not text:
return ""
if not text.startswith("t-"):
text = f"t-{text}"
return text[:28]
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
m = str(method or "GET").upper()
@@ -87,8 +106,9 @@ class GateSpotClient(_GateBase):
"price": str(px),
"time_in_force": "gtc",
}
if client_order_id:
body["text"] = str(client_order_id)
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/spot/orders", json_body=body)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -106,8 +126,9 @@ class GateSpotClient(_GateBase):
"type": "market",
"amount": str(qty),
}
if client_order_id:
body["text"] = str(client_order_id)
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/spot/orders", json_body=body)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -162,8 +183,8 @@ class GateSpotClient(_GateBase):
class GateUsdtFuturesClient(_GateBase):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0):
super().__init__(api_key=api_key, secret_key=secret_key, base_url=base_url, timeout_sec=timeout_sec)
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
super().__init__(api_key=api_key, secret_key=secret_key, base_url=base_url, timeout_sec=timeout_sec, channel_id=channel_id)
# Best-effort cache for contract metadata to convert base qty -> contracts.
self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._contract_cache_ttl_sec = 300.0
@@ -263,8 +284,9 @@ class GateUsdtFuturesClient(_GateBase):
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": "0", "tif": "ioc"}
if reduce_only:
body["reduce_only"] = True
if client_order_id:
body["text"] = str(client_order_id)
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -293,8 +315,9 @@ class GateUsdtFuturesClient(_GateBase):
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": str(px), "tif": "gtc"}
if reduce_only:
body["reduce_only"] = True
if client_order_id:
body["text"] = str(client_order_id)
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -0,0 +1,529 @@
"""
HTX (Huobi) direct REST client for spot and USDT-margined perpetual swap.
References:
- Spot base URL: https://api.huobi.pro
- USDT swap base URL: https://api.hbdm.com
- Spot auth: query params with HmacSHA256 signature
- Swap auth: query params with HmacSHA256 signature, request body in JSON
"""
from __future__ import annotations
import base64
import hashlib
import hmac
from decimal import Decimal, ROUND_DOWN
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode, urlparse
import datetime
import time
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_htx_contract_code, to_htx_spot_symbol
class HtxClient(BaseRestClient):
def __init__(
self,
*,
api_key: str,
secret_key: str,
base_url: str = "https://api.huobi.pro",
futures_base_url: str = "https://api.hbdm.com",
timeout_sec: float = 15.0,
market_type: str = "swap",
broker_id: str = "",
):
chosen_base = futures_base_url if str(market_type or "").strip().lower() == "swap" else base_url
super().__init__(base_url=chosen_base, timeout_sec=timeout_sec)
self.spot_base_url = (base_url or "https://api.huobi.pro").rstrip("/")
self.futures_base_url = (futures_base_url or "https://api.hbdm.com").rstrip("/")
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.market_type = (market_type or "swap").strip().lower()
self.broker_id = (broker_id or "").strip()
if self.market_type not in ("spot", "swap"):
self.market_type = "swap"
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing HTX api_key/secret_key")
self._spot_account_id: Optional[str] = None
self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._contract_cache_ttl_sec = 300.0
self._lever_cache: Dict[str, int] = {}
def _format_spot_client_order_id(self, client_order_id: Optional[str]) -> str:
prefix = str(self.broker_id or "").strip()
raw = str(client_order_id or "").strip()
if not prefix and not raw:
return ""
if not raw:
raw = str(int(time.time() * 1000))
allowed = []
for ch in raw:
if ch.isalnum() or ch in ("_", "-"):
allowed.append(ch)
suffix = "".join(allowed).strip("-_")
if not suffix:
suffix = str(int(time.time() * 1000))
if prefix:
if suffix.startswith(prefix):
combined = suffix
else:
combined = f"{prefix}-{suffix}"
else:
combined = suffix
return combined[:64]
@staticmethod
def _utc_ts() -> str:
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
return Decimal(str(x))
except Exception:
return Decimal("0")
@staticmethod
def _floor_to_int(value: Decimal) -> int:
try:
return int(value.to_integral_value(rounding=ROUND_DOWN))
except Exception:
return 0
def _sign_params(self, *, method: str, base_url: str, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
signed = dict(params or {})
signed["AccessKeyId"] = self.api_key
signed["SignatureMethod"] = "HmacSHA256"
signed["SignatureVersion"] = "2"
signed["Timestamp"] = self._utc_ts()
encoded = urlencode(sorted((str(k), str(v)) for k, v in signed.items()))
host = urlparse(base_url).netloc
payload = "\n".join([str(method or "GET").upper(), host, path, encoded])
digest = hmac.new(self.secret_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()
signed["Signature"] = base64.b64encode(digest).decode("utf-8")
return signed
def _spot_public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
old_base = self.base_url
self.base_url = self.spot_base_url
try:
code, data, text = self._request(method, path, params=params)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX spot HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX spot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _spot_private_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
signed_params = self._sign_params(method=method, base_url=self.spot_base_url, path=path, params=params or {})
old_base = self.base_url
self.base_url = self.spot_base_url
try:
code, data, text = self._request(method, path, params=signed_params, json_body=json_body)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX spot HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX spot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _swap_private_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
signed_params = self._sign_params(method=method, base_url=self.futures_base_url, path=path, params=params or {})
old_base = self.base_url
self.base_url = self.futures_base_url
try:
code, data, text = self._request(method, path, params=signed_params, json_body=json_body)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX swap HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX swap error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _swap_public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
old_base = self.base_url
self.base_url = self.futures_base_url
try:
code, data, text = self._request(method, path, params=params)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX swap HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX swap error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def ping(self) -> bool:
try:
if self.market_type == "spot":
self._spot_public_request("GET", "/v1/common/timestamp")
else:
self._swap_public_request("GET", "/linear-swap-api/v1/swap_contract_info")
return True
except Exception:
return False
def _get_spot_account_id(self) -> str:
if self._spot_account_id:
return self._spot_account_id
raw = self._spot_private_request("GET", "/v1/account/accounts")
data = raw.get("data") or []
if isinstance(data, list):
for item in data:
if not isinstance(item, dict):
continue
if str(item.get("type") or "").lower() == "spot" and str(item.get("state") or "").lower() in ("working", ""):
self._spot_account_id = str(item.get("id") or "")
if self._spot_account_id:
return self._spot_account_id
for item in data:
if isinstance(item, dict) and item.get("id"):
self._spot_account_id = str(item.get("id"))
return self._spot_account_id
raise LiveTradingError("HTX spot account id not found")
def get_accounts(self) -> Any:
if self.market_type == "spot":
return self._spot_private_request("GET", "/v1/account/accounts")
return self.get_balance()
def get_balance(self) -> Any:
if self.market_type == "spot":
account_id = self._get_spot_account_id()
return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance")
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
data = raw.get("data")
if data:
return raw
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
def get_positions(self, *, symbol: str = "") -> Any:
if self.market_type == "spot":
balance = self.get_balance()
items = (((balance.get("data") or {}).get("list")) if isinstance(balance, dict) else None) or []
base_asset = ""
if symbol:
base_asset = str(symbol).split("/", 1)[0].split(":", 1)[0].strip().upper()
rows = []
for item in items:
if not isinstance(item, dict):
continue
ccy = str(item.get("currency") or "").upper()
if not ccy or (base_asset and ccy != base_asset):
continue
bal = self._to_dec(item.get("balance") or "0")
if bal <= 0:
continue
rows.append({
"symbol": f"{ccy}/USDT",
"bal": float(bal),
"availBal": float(self._to_dec(item.get("balance") or "0")),
"cost_open": 0,
"profit_unreal": 0,
})
return {"data": rows}
body = {"contract_code": to_htx_contract_code(symbol)} if symbol else {}
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
if self.market_type == "spot":
raw = self._spot_public_request("GET", "/market/detail/merged", params={"symbol": to_htx_spot_symbol(symbol)})
else:
raw = self._swap_public_request("GET", "/linear-swap-ex/market/detail/merged", params={"contract_code": to_htx_contract_code(symbol)})
tick = raw.get("tick") if isinstance(raw, dict) else {}
return tick if isinstance(tick, dict) else {}
def get_contract_info(self, *, symbol: str) -> Dict[str, Any]:
key = to_htx_contract_code(symbol)
cached = self._contract_cache.get(key)
now = time.time()
if cached:
ts, obj = cached
if obj and (now - float(ts or 0)) <= float(self._contract_cache_ttl_sec or 300):
return obj
raw = self._swap_public_request("GET", "/linear-swap-api/v1/swap_contract_info", params={"contract_code": key})
data = raw.get("data") or []
obj = data[0] if isinstance(data, list) and data and isinstance(data[0], dict) else {}
if obj:
self._contract_cache[key] = (now, obj)
return obj
def _base_to_contracts(self, *, symbol: str, qty: float) -> int:
req = self._to_dec(qty)
if req <= 0:
return 0
info = self.get_contract_info(symbol=symbol) or {}
contract_size = self._to_dec(info.get("contract_size") or info.get("contractSize") or "1")
if contract_size <= 0:
contract_size = Decimal("1")
contracts = req / contract_size
val = self._floor_to_int(contracts)
return val if val > 0 else 1
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
if self.market_type == "spot":
return False
contract_code = to_htx_contract_code(symbol)
try:
lv = int(float(leverage or 1))
except Exception:
lv = 1
if lv < 1:
lv = 1
try:
self._swap_private_request(
"POST",
"/linear-swap-api/v1/swap_cross_switch_lever_rate",
json_body={"contract_code": contract_code, "lever_rate": lv, "margin_account": "USDT"},
)
self._lever_cache[contract_code] = lv
return True
except Exception:
try:
self._swap_private_request(
"POST",
"/linear-swap-api/v1/swap_switch_lever_rate",
json_body={"contract_code": contract_code, "lever_rate": lv},
)
self._lever_cache[contract_code] = lv
return True
except Exception:
return False
def place_market_order(
self,
*,
symbol: str,
side: str,
qty: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
if self.market_type == "spot":
account_id = self._get_spot_account_id()
sd = str(side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
amount = float(qty or 0)
if amount <= 0:
raise LiveTradingError("Invalid qty")
order_type = f"{sd}-market"
if sd == "buy":
tick = self.get_ticker(symbol=symbol)
last = float(tick.get("close") or tick.get("price") or tick.get("lastPrice") or 0)
if last <= 0:
raise LiveTradingError("HTX spot market buy requires latest price for qty->value conversion")
amount = amount * last
body = {
"account-id": account_id,
"symbol": to_htx_spot_symbol(symbol),
"type": order_type,
"amount": f"{amount:.12f}".rstrip("0").rstrip("."),
"source": "spot-api",
}
formatted_client_order_id = self._format_spot_client_order_id(client_order_id)
if formatted_client_order_id:
body["client-order-id"] = formatted_client_order_id
raw = self._spot_private_request("POST", "/v1/order/orders/place", json_body=body)
data = raw.get("data")
oid = str(data or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
contract_code = to_htx_contract_code(symbol)
volume = self._base_to_contracts(symbol=symbol, qty=qty)
if volume <= 0:
raise LiveTradingError("Invalid HTX swap volume")
sd = str(side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
offset = "close" if reduce_only else "open"
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
"offset": offset,
"lever_rate": lever_rate,
"order_price_type": "opponent",
}
if self.broker_id:
body["channel_code"] = self.broker_id
if client_order_id:
body["client_order_id"] = str(client_order_id)[:64]
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def place_limit_order(
self,
*,
symbol: str,
side: str,
size: float,
price: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
px = float(price or 0)
qty = float(size or 0)
if px <= 0 or qty <= 0:
raise LiveTradingError("Invalid size/price")
sd = str(side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
if self.market_type == "spot":
account_id = self._get_spot_account_id()
body = {
"account-id": account_id,
"symbol": to_htx_spot_symbol(symbol),
"type": f"{sd}-limit",
"amount": f"{qty:.12f}".rstrip("0").rstrip("."),
"price": f"{px:.12f}".rstrip("0").rstrip("."),
"source": "spot-api",
}
formatted_client_order_id = self._format_spot_client_order_id(client_order_id)
if formatted_client_order_id:
body["client-order-id"] = formatted_client_order_id
raw = self._spot_private_request("POST", "/v1/order/orders/place", json_body=body)
data = raw.get("data")
oid = str(data or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
contract_code = to_htx_contract_code(symbol)
volume = self._base_to_contracts(symbol=symbol, qty=qty)
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
"offset": "close" if reduce_only else "open",
"lever_rate": lever_rate,
"price": px,
"order_price_type": "limit",
}
if self.broker_id:
body["channel_code"] = self.broker_id
if client_order_id:
body["client_order_id"] = str(client_order_id)[:64]
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
if self.market_type == "spot":
if order_id:
return self._spot_private_request("POST", f"/v1/order/orders/{str(order_id)}/submitcancel")
if client_order_id:
return self._spot_private_request("POST", "/v1/order/orders/submitCancelClientOrder", json_body={"client-order-id": str(client_order_id)})
raise LiveTradingError("HTX cancel_order requires order_id or client_order_id")
body: Dict[str, Any] = {"contract_code": to_htx_contract_code(symbol)}
if order_id:
body["order_id"] = str(order_id)
elif client_order_id:
body["client_order_id"] = str(client_order_id)
else:
raise LiveTradingError("HTX cancel_order requires order_id or client_order_id")
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_cancel", json_body=body)
def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
if self.market_type == "spot":
if order_id:
raw = self._spot_private_request("GET", f"/v1/order/orders/{str(order_id)}")
data = raw.get("data") if isinstance(raw, dict) else {}
return data if isinstance(data, dict) else {}
if client_order_id:
raw = self._spot_private_request("GET", "/v1/order/orders/getClientOrder", params={"clientOrderId": str(client_order_id)})
data = raw.get("data") if isinstance(raw, dict) else {}
return data if isinstance(data, dict) else {}
raise LiveTradingError("HTX get_order requires order_id or client_order_id")
body: Dict[str, Any] = {"contract_code": to_htx_contract_code(symbol)}
if order_id:
body["order_id"] = str(order_id)
elif client_order_id:
body["client_order_id"] = str(client_order_id)
else:
raise LiveTradingError("HTX get_order requires order_id or client_order_id")
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order_info", json_body=body)
data = raw.get("data") or []
if isinstance(data, list) and data and isinstance(data[0], dict):
return data[0]
return {}
def wait_for_fill(
self,
*,
symbol: str,
order_id: str = "",
client_order_id: str = "",
max_wait_sec: float = 3.0,
poll_interval_sec: float = 0.5,
) -> Dict[str, Any]:
end_ts = time.time() + float(max_wait_sec or 0.0)
last: Dict[str, Any] = {}
while True:
try:
last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) or {}
except Exception:
last = last or {}
filled = 0.0
avg_price = 0.0
fee = 0.0
fee_ccy = "USDT"
status = str(last.get("status") or last.get("state") or "")
try:
filled = float(
last.get("field-amount") or
last.get("filled_amount") or
last.get("trade_volume") or
last.get("trade_volume_avg") or
0.0
)
except Exception:
filled = 0.0
try:
avg_price = float(
last.get("field-cash-amount") or 0.0
)
if filled > 0 and avg_price > 0:
avg_price = avg_price / filled
else:
avg_price = float(last.get("field-avg-price") or last.get("trade_avg_price") or last.get("price") or 0.0)
except Exception:
avg_price = 0.0
try:
fee = abs(float(last.get("fee") or last.get("trade_fee") or 0.0))
except Exception:
fee = 0.0
fee_ccy = str(last.get("fee_asset") or last.get("fee_currency") or fee_ccy or "").strip() or "USDT"
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if str(status).lower() in ("filled", "partial-filled", "submitted", "canceled", "cancelled", "6", "7"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if time.time() >= end_ts:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
time.sleep(float(poll_interval_sec or 0.5))
@@ -86,6 +86,11 @@ class KucoinSpotClient(BaseRestClient):
def get_accounts(self) -> Any:
return self._signed_request("GET", "/api/v1/accounts")
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
raw = self._public_request("GET", "/api/v1/market/orderbook/level1", params={"symbol": to_kucoin_symbol(symbol)})
data = raw.get("data") if isinstance(raw, dict) else None
return data if isinstance(data, dict) else {}
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
@@ -236,3 +236,28 @@ def to_deepcoin_swap_symbol(symbol: str) -> str:
return base_symbol
return f"{base_symbol}-SWAP"
def to_htx_spot_symbol(symbol: str) -> str:
"""
HTX spot symbol format: lowercase concatenated, e.g. btcusdt.
"""
base, quote = _split_base_quote(symbol)
if not base or not quote:
return str(symbol or "").replace("/", "").replace(":", "").lower()
return f"{base}{quote}".lower()
def to_htx_contract_code(symbol: str) -> str:
"""
HTX USDT-margined swap contract code: BASE-QUOTE, e.g. BTC-USDT.
"""
s = str(symbol or "").strip()
if not s:
return s
if "-" in s and "/" not in s:
return s.upper()
base, quote = _split_base_quote(symbol)
if not base or not quote:
return s.replace("/", "-").replace(":", "-").upper()
return f"{base}-{quote}"
@@ -34,6 +34,8 @@ from app.services.live_trading.kucoin import KucoinFuturesClient
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
from app.services.live_trading.bitfinex import BitfinexClient
from app.services.live_trading.bitfinex import BitfinexDerivativesClient
from app.services.live_trading.deepcoin import DeepcoinClient
from app.services.live_trading.htx import HtxClient
from app.services.live_trading.symbols import to_okx_swap_inst_id
from app.services.live_trading.symbols import to_gate_currency_pair
from app.utils.db import get_db_connection
@@ -1478,6 +1480,31 @@ class PendingOrderWorker:
price=limit_price,
client_order_id=limit_client_oid,
)
elif isinstance(client, DeepcoinClient):
res1 = client.place_limit_order(
symbol=str(symbol),
side=side,
qty=remaining,
price=limit_price,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=limit_client_oid,
)
elif isinstance(client, HtxClient):
if market_type == "swap":
try:
client.set_leverage(symbol=str(symbol), leverage=leverage)
except Exception:
pass
res1 = client.place_limit_order(
symbol=str(symbol),
side=side,
size=remaining,
price=limit_price,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=limit_client_oid,
)
else:
raise LiveTradingError(f"Unsupported client type: {type(client)}")
@@ -1563,6 +1590,16 @@ class PendingOrderWorker:
phases["limit_query"] = q
_apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0))
_apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or ""))
elif isinstance(client, DeepcoinClient):
q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec)
phases["limit_query"] = q
_apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0))
_apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or ""))
elif isinstance(client, HtxClient):
q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec)
phases["limit_query"] = q
_apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0))
_apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or ""))
remaining = max(0.0, float(amount or 0.0) - total_base)
@@ -1625,6 +1662,10 @@ class PendingOrderWorker:
phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid)
elif isinstance(client, BitfinexDerivativesClient):
phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid)
elif isinstance(client, DeepcoinClient):
phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid)
elif isinstance(client, HtxClient):
phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid)
except Exception:
pass
except LiveTradingError as e:
@@ -1776,10 +1817,13 @@ class PendingOrderWorker:
client_order_id=market_client_oid,
)
elif isinstance(client, GateSpotClient):
mkt_size = remaining
if side == "buy" and ref_price > 0:
mkt_size = remaining * ref_price
res2 = client.place_market_order(
symbol=str(symbol),
side=side,
size=remaining,
size=mkt_size,
client_order_id=market_client_oid,
)
elif isinstance(client, GateUsdtFuturesClient):
@@ -1803,6 +1847,34 @@ class PendingOrderWorker:
)
elif isinstance(client, BitfinexDerivativesClient):
res2 = client.place_market_order(symbol=str(symbol), side=side, size=remaining, client_order_id=market_client_oid)
elif isinstance(client, DeepcoinClient):
if market_type == "swap":
try:
client.set_leverage(symbol=str(symbol), leverage=leverage)
except Exception:
pass
res2 = client.place_market_order(
symbol=str(symbol),
side=side,
qty=remaining,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=market_client_oid,
)
elif isinstance(client, HtxClient):
if market_type == "swap":
try:
client.set_leverage(symbol=str(symbol), leverage=leverage)
except Exception:
pass
res2 = client.place_market_order(
symbol=str(symbol),
side=side,
qty=remaining,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=market_client_oid,
)
else:
raise LiveTradingError(f"Unsupported client type: {type(client)}")
@@ -1889,6 +1961,16 @@ class PendingOrderWorker:
phases["market_query"] = q2
_apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0))
_apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or ""))
elif isinstance(client, DeepcoinClient):
q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0)
phases["market_query"] = q2
_apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0))
_apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or ""))
elif isinstance(client, HtxClient):
q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0)
phases["market_query"] = q2
_apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0))
_apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or ""))
except LiveTradingError as e:
logger.warning(f"live market phase failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}")
phases["market_error"] = str(e)
+146 -102
View File
@@ -257,7 +257,7 @@ class StrategyService:
logger.error(f"Failed to fetch symbols: {str(e)}")
return {'success': False, 'message': f'Failed to get trading pairs: {str(e)}', 'symbols': []}
def test_exchange_connection(self, exchange_config: Dict[str, Any]) -> Dict[str, Any]:
def test_exchange_connection(self, exchange_config: Dict[str, Any], user_id: int = 1) -> Dict[str, Any]:
"""
Test exchange connection via direct REST clients (no ccxt).
@@ -284,8 +284,9 @@ class StrategyService:
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
from app.services.live_trading.deepcoin import DeepcoinClient
from app.services.live_trading.htx import HtxClient
resolved = resolve_exchange_config(exchange_config or {})
resolved = resolve_exchange_config(exchange_config or {}, user_id=user_id)
safe_cfg = safe_exchange_config_for_log(resolved)
exchange_id = (resolved.get("exchange_id") or "").strip().lower()
@@ -370,13 +371,6 @@ class StrategyService:
'data': {'exchange': safe_cfg}
}
# IMPORTANT:
# Test connection should respect configured market_type (spot vs swap).
# Otherwise Binance will default to futures endpoints (fapi) and spot-only keys will fail with -2015.
market_type = str(resolved.get("market_type") or resolved.get("defaultType") or "swap").strip().lower()
client = create_client(resolved, market_type=market_type)
client_kind = type(client).__name__
# Best-effort detect current egress IP (for Binance IP whitelist debugging).
egress_ip = ""
try:
@@ -385,113 +379,163 @@ class StrategyService:
except Exception:
egress_ip = ""
# 1) Public connectivity
ok_public = False
try:
ok_public = bool(getattr(client, "ping")())
except Exception:
ok_public = False
if not ok_public:
return {
'success': False,
'message': f'Public ping failed: {exchange_id}',
'data': {'exchange': safe_cfg, 'client': client_kind, 'market_type': market_type, 'egress_ip': egress_ip},
}
# 2) Private credential validation (best-effort)
priv_data = None
try:
def _validate_private(client, market_type: str):
if isinstance(client, BinanceFuturesClient):
priv_data = client.get_account()
elif isinstance(client, BinanceSpotClient):
priv_data = client.get_account()
elif isinstance(client, OkxClient):
priv_data = client.get_balance()
elif isinstance(client, BitgetMixClient):
return client.get_account()
if isinstance(client, BinanceSpotClient):
return client.get_account()
if isinstance(client, OkxClient):
return client.get_balance()
if isinstance(client, BitgetMixClient):
product_type = str(resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES")
priv_data = client.get_accounts(product_type=product_type)
elif isinstance(client, BitgetSpotClient):
priv_data = client.get_assets()
elif isinstance(client, BybitClient):
priv_data = client.get_wallet_balance()
elif isinstance(client, CoinbaseExchangeClient):
priv_data = client.get_accounts()
elif isinstance(client, KrakenClient):
priv_data = client.get_balance()
elif isinstance(client, KrakenFuturesClient):
priv_data = client.get_accounts()
elif isinstance(client, KucoinSpotClient):
priv_data = client.get_accounts()
elif isinstance(client, KucoinFuturesClient):
priv_data = client.get_accounts()
elif isinstance(client, GateSpotClient):
priv_data = client.get_accounts()
elif isinstance(client, GateUsdtFuturesClient):
priv_data = client.get_accounts()
elif isinstance(client, BitfinexClient):
priv_data = client.get_wallets()
elif isinstance(client, BitfinexDerivativesClient):
priv_data = client.get_wallets()
elif isinstance(client, DeepcoinClient):
priv_data = client.get_balance()
except Exception as e:
msg = str(e)
# Add actionable hints for the most common Binance auth error.
if exchange_id == "binance" and ("-2015" in msg or "Invalid API-key, IP, or permissions" in msg):
# Auto A/B test: try the other market_type once to pinpoint permission mismatch.
alt_market_type = "spot" if market_type != "spot" else "swap"
alt_client_kind = ""
alt_base_url = ""
alt_ok = False
try:
alt_client = create_client(resolved, market_type=alt_market_type)
alt_client_kind = type(alt_client).__name__
alt_base_url = getattr(alt_client, "base_url", "") or ""
if isinstance(alt_client, BinanceFuturesClient) or isinstance(alt_client, BinanceSpotClient):
_ = alt_client.get_account()
alt_ok = True
except Exception:
alt_ok = False
return client.get_accounts(product_type=product_type)
if isinstance(client, BitgetSpotClient):
return client.get_assets()
if isinstance(client, BybitClient):
return client.get_wallet_balance()
if isinstance(client, CoinbaseExchangeClient):
return client.get_accounts()
if isinstance(client, KrakenClient):
return client.get_balance()
if isinstance(client, KrakenFuturesClient):
return client.get_accounts()
if isinstance(client, KucoinSpotClient):
return client.get_accounts()
if isinstance(client, KucoinFuturesClient):
return client.get_accounts()
if isinstance(client, GateSpotClient):
return client.get_accounts()
if isinstance(client, GateUsdtFuturesClient):
return client.get_accounts()
if isinstance(client, BitfinexClient):
return client.get_wallets()
if isinstance(client, BitfinexDerivativesClient):
return client.get_wallets()
if isinstance(client, DeepcoinClient):
return client.get_balance()
if isinstance(client, HtxClient):
return client.get_balance()
return None
base_url = getattr(client, "base_url", "") or ""
hint = (
f"Binance auth failed (-2015). Verify: "
f"(1) IP whitelist includes this server egress IP={egress_ip or 'unknown'}, "
f"(2) API key permissions match market_type={market_type} "
f"(spot requires Spot permissions; swap requires Futures permissions), "
f"(3) you're using binance.com keys for base_url={base_url or 'unknown'}."
)
if alt_ok:
hint += (
f" Auto-check: your key works for market_type={alt_market_type} "
f"(client={alt_client_kind}, base_url={alt_base_url or 'unknown'}) "
f"but fails for market_type={market_type}. This is almost always a permissions/product mismatch."
def _probe_market_type(market_type: str):
try:
client = create_client(resolved, market_type=market_type)
except Exception as e:
return {
'success': False,
'message': f'Create client failed: {str(e)}',
'data': {
'exchange': safe_cfg,
'market_type': market_type,
'egress_ip': egress_ip,
},
}
client_kind = type(client).__name__
ok_public = False
try:
ok_public = bool(getattr(client, "ping")())
except Exception:
ok_public = False
if not ok_public:
return {
'success': False,
'message': f'Public ping failed: {exchange_id}',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
},
}
try:
priv_data = _validate_private(client, market_type)
except Exception as e:
msg = str(e)
if exchange_id == "binance" and ("-2015" in msg or "Invalid API-key, IP, or permissions" in msg):
alt_market_type = "spot" if market_type != "spot" else "swap"
alt_client_kind = ""
alt_base_url = ""
alt_ok = False
try:
alt_client = create_client(resolved, market_type=alt_market_type)
alt_client_kind = type(alt_client).__name__
alt_base_url = getattr(alt_client, "base_url", "") or ""
if isinstance(alt_client, (BinanceFuturesClient, BinanceSpotClient)):
_ = alt_client.get_account()
alt_ok = True
except Exception:
alt_ok = False
base_url = getattr(client, "base_url", "") or ""
is_demo = str(resolved.get("enable_demo_trading") or resolved.get("enableDemoTrading") or "").strip().lower() in ("true", "1", "yes")
hint = (
f"Binance auth failed (-2015). Verify: "
f"(1) IP whitelist includes this server egress IP={egress_ip or 'unknown'}, "
f"(2) API key permissions match market_type={market_type} "
f"(spot requires Spot permissions; swap requires Futures permissions), "
f"(3) you're using the correct key set for base_url={base_url or 'unknown'}."
)
msg = f"{msg} | {hint}"
if is_demo:
hint += " Demo mode is enabled, so you must use Binance demo/testnet API keys instead of mainnet keys."
else:
hint += " Mainnet mode is enabled, so you must use binance.com mainnet keys."
if alt_ok:
hint += (
f" Auto-check: your key works for market_type={alt_market_type} "
f"(client={alt_client_kind}, base_url={alt_base_url or 'unknown'}) "
f"but fails for market_type={market_type}. This is almost always a permissions/product mismatch."
)
msg = f"{msg} | {hint}"
return {
'success': False,
'message': f'Auth failed: {msg}',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
},
}
return {
'success': False,
'message': f'Auth failed: {msg}',
'success': True,
'message': 'Connection OK',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
'private': priv_data,
},
}
return {
'success': True,
'message': 'Connection OK',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
'private': priv_data,
},
}
raw_market_type = str(resolved.get("market_type") or resolved.get("defaultType") or "").strip().lower()
if raw_market_type in ("futures", "future", "perp", "perpetual"):
raw_market_type = "swap"
explicit_market_type = raw_market_type in ("spot", "swap")
if exchange_id in ("coinbaseexchange", "coinbase_exchange"):
market_candidates = ["spot"]
else:
market_candidates = [raw_market_type] if explicit_market_type else ["spot", "swap"]
last_failure = None
for market_type in market_candidates:
result = _probe_market_type(market_type)
if result.get('success'):
if not explicit_market_type and len(market_candidates) > 1:
result['message'] = f"Connection OK ({market_type})"
return result
last_failure = result
if last_failure and not explicit_market_type and len(market_candidates) > 1:
tried = "/".join(market_candidates)
last_failure['message'] = f"{last_failure.get('message')}. Tried market_type={tried}"
return last_failure or {'success': False, 'message': 'Connection failed', 'data': None}
except Exception as e:
logger.error(f"test_exchange_connection failed: {str(e)}")
return {'success': False, 'message': f'Connection failed: {str(e)}', 'data': None}
@@ -0,0 +1,204 @@
import json
from typing import Any, Dict, Optional
from app.utils.db import get_db_connection
class StrategySnapshotResolver:
"""Resolve stored strategy rows into backtest-ready snapshots."""
def __init__(self, user_id: int = 1):
self.user_id = int(user_id or 1)
def _safe_dict(self, value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return dict(value)
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return {}
def _to_bool(self, value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
def _to_float(self, value: Any, default: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(default or 0.0)
def _to_int(self, value: Any, default: int = 0) -> int:
try:
return int(value)
except Exception:
return int(default or 0)
def _percent_to_ratio(self, value: Any, default: float = 0.0) -> float:
raw = self._to_float(value, default)
if raw <= 0:
return 0.0
if raw > 100:
raw = 100.0
return raw / 100.0
def _build_strategy_config(self, trading_config: Dict[str, Any]) -> Dict[str, Any]:
tc = trading_config or {}
signal_mode = str(tc.get("signal_mode") or "confirmed").strip().lower()
signal_timing = "next_bar_open"
if signal_mode in ("current_bar_close", "close", "same_bar_close"):
signal_timing = "same_bar_close"
return {
"risk": {
"stopLossPct": self._percent_to_ratio(tc.get("stop_loss_pct")),
"takeProfitPct": self._percent_to_ratio(tc.get("take_profit_pct")),
"trailing": {
"enabled": self._to_bool(tc.get("trailing_enabled") or tc.get("trailing_stop")),
"pct": self._percent_to_ratio(tc.get("trailing_stop_pct")),
"activationPct": self._percent_to_ratio(tc.get("trailing_activation_pct")),
},
},
"position": {
"entryPct": self._percent_to_ratio(tc.get("entry_pct") if tc.get("entry_pct") is not None else 100),
},
"scale": {
"trendAdd": {
"enabled": self._to_bool(tc.get("trend_add_enabled")),
"stepPct": self._percent_to_ratio(tc.get("trend_add_step_pct")),
"sizePct": self._percent_to_ratio(tc.get("trend_add_size_pct")),
"maxTimes": self._to_int(tc.get("trend_add_max_times")),
},
"dcaAdd": {
"enabled": self._to_bool(tc.get("dca_add_enabled")),
"stepPct": self._percent_to_ratio(tc.get("dca_add_step_pct")),
"sizePct": self._percent_to_ratio(tc.get("dca_add_size_pct")),
"maxTimes": self._to_int(tc.get("dca_add_max_times")),
},
"trendReduce": {
"enabled": self._to_bool(tc.get("trend_reduce_enabled")),
"stepPct": self._percent_to_ratio(tc.get("trend_reduce_step_pct")),
"sizePct": self._percent_to_ratio(tc.get("trend_reduce_size_pct")),
"maxTimes": self._to_int(tc.get("trend_reduce_max_times")),
},
"adverseReduce": {
"enabled": self._to_bool(tc.get("adverse_reduce_enabled")),
"stepPct": self._percent_to_ratio(tc.get("adverse_reduce_step_pct")),
"sizePct": self._percent_to_ratio(tc.get("adverse_reduce_size_pct")),
"maxTimes": self._to_int(tc.get("adverse_reduce_max_times")),
},
},
"execution": {
"signalTiming": signal_timing,
},
}
def _fetch_indicator_code(self, indicator_id: Optional[int]) -> str:
if not indicator_id:
return ""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT code FROM qd_indicator_codes WHERE id = ?", (int(indicator_id),))
row = cur.fetchone()
cur.close()
return (row or {}).get("code") or ""
except Exception:
return ""
def resolve(self, strategy: Dict[str, Any], override_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not strategy:
raise ValueError("strategy not found")
override = override_config or {}
indicator_config = self._safe_dict(strategy.get("indicator_config"))
trading_config = self._safe_dict(strategy.get("trading_config"))
cs_type = str(trading_config.get("cs_strategy_type") or trading_config.get("strategy_type") or "single").strip().lower()
if cs_type == "cross_sectional":
raise ValueError("Cross-sectional strategies are not supported in strategy backtest yet")
symbol = str(override.get("symbol") or trading_config.get("symbol") or strategy.get("symbol") or "").strip()
market = str(override.get("market") or strategy.get("market_category") or trading_config.get("market_category") or "Crypto").strip() or "Crypto"
if ":" in symbol and "market" not in override:
maybe_market, maybe_symbol = symbol.split(":", 1)
market = maybe_market or market
symbol = maybe_symbol or symbol
timeframe = str(override.get("timeframe") or trading_config.get("timeframe") or strategy.get("timeframe") or "1D").strip() or "1D"
initial_capital = self._to_float(override.get("initialCapital", trading_config.get("initial_capital", strategy.get("initial_capital", 10000))), 10000.0)
leverage = self._to_int(override.get("leverage", trading_config.get("leverage", strategy.get("leverage", 1))), 1)
commission = self._percent_to_ratio(override.get("commission", trading_config.get("commission", 0)))
slippage = self._percent_to_ratio(override.get("slippage", trading_config.get("slippage", 0)))
trade_direction = str(trading_config.get("trade_direction") or "long").strip().lower() or "long"
enable_mtf = self._to_bool(override.get("enableMtf", market.lower() == "crypto"))
strategy_type = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
strategy_mode = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
is_script = strategy_type == "ScriptStrategy" or strategy_mode == "script"
indicator_id = indicator_config.get("indicator_id") or strategy.get("indicator_id")
indicator_name = indicator_config.get("indicator_name") or ""
code = (strategy.get("strategy_code") or "").strip() if is_script else (indicator_config.get("indicator_code") or "").strip()
if not code and indicator_id and not is_script:
code = self._fetch_indicator_code(indicator_id)
if not symbol:
raise ValueError("Strategy symbol is required for backtest")
if not code:
raise ValueError("Strategy code is empty and cannot be backtested")
strategy_config = self._build_strategy_config(trading_config)
snapshot = {
"strategy_id": strategy.get("id"),
"strategy_name": strategy.get("strategy_name") or f"Strategy #{strategy.get('id')}",
"strategy_type": strategy_type,
"strategy_mode": strategy_mode,
"run_type": "strategy_script" if is_script else "strategy_indicator",
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"initial_capital": initial_capital,
"commission": commission,
"slippage": slippage,
"leverage": leverage,
"trade_direction": trade_direction,
"enable_mtf": enable_mtf,
"indicator_id": int(indicator_id) if str(indicator_id or "").isdigit() else None,
"indicator_name": indicator_name,
"indicator_params": trading_config.get("indicator_params") or {},
"code": code,
"strategy_config": strategy_config,
"config_snapshot": {
"strategyMeta": {
"strategyId": strategy.get("id"),
"strategyName": strategy.get("strategy_name"),
"strategyType": strategy_type,
"strategyMode": strategy_mode,
"runType": "strategy_script" if is_script else "strategy_indicator",
},
"marketConfig": {
"market": market,
"symbol": symbol,
"timeframe": timeframe,
},
"signalConfig": {
"indicatorId": int(indicator_id) if str(indicator_id or "").isdigit() else None,
"indicatorName": indicator_name,
"indicatorParams": trading_config.get("indicator_params") or {},
"scriptSource": "strategy_code" if is_script else "indicator_code",
},
"riskConfig": strategy_config.get("risk") or {},
"positionConfig": strategy_config.get("position") or {},
"scaleConfig": strategy_config.get("scale") or {},
"executionConfig": strategy_config.get("execution") or {},
},
}
return snapshot
@@ -2039,7 +2039,13 @@ class TradingExecutor:
return False
# 2. 计算下单数量
available_capital = self._get_available_capital(strategy_id, initial_capital)
available_capital = self._get_available_capital(
strategy_id,
initial_capital,
current_positions=current_positions,
current_price=current_price,
symbol=symbol,
)
amount = 0.0
@@ -2655,12 +2661,83 @@ class TradingExecutor:
def _place_stop_loss_order(self, *args, **kwargs):
pass
def _get_available_capital(self, strategy_id: int, initial_capital: float) -> float:
"""获取可用资金"""
return initial_capital
def _get_available_capital(
self,
strategy_id: int,
initial_capital: float,
current_positions: Optional[List[Dict[str, Any]]] = None,
current_price: Optional[float] = None,
symbol: str = "",
) -> float:
"""获取当前策略可用于仓位计算的净值口径资金。"""
return self._calculate_current_equity(
strategy_id,
initial_capital,
current_positions=current_positions,
current_price=current_price,
symbol=symbol,
)
def _calculate_current_equity(self, strategy_id: int, initial_capital: float) -> float:
return initial_capital
def _calculate_current_equity(
self,
strategy_id: int,
initial_capital: float,
current_positions: Optional[List[Dict[str, Any]]] = None,
current_price: Optional[float] = None,
symbol: str = "",
) -> float:
realized_pnl = 0.0
unrealized_pnl = 0.0
try:
with get_db_connection() as db:
cursor = db.cursor()
cursor.execute(
"""
SELECT COALESCE(SUM(COALESCE(profit, 0) - COALESCE(commission, 0)), 0) AS realized_pnl
FROM qd_strategy_trades
WHERE strategy_id = %s
""",
(strategy_id,)
)
row = cursor.fetchone() or {}
realized_pnl = float(row.get('realized_pnl') or 0.0)
cursor.close()
except Exception as e:
logger.warning(f"Failed to calculate realized pnl for strategy {strategy_id}: {e}")
positions = list(current_positions or [])
if not positions:
try:
positions = self._get_all_positions(strategy_id) or []
except Exception:
positions = []
normalized_symbol = (symbol or "").split(':')[0]
for pos in positions:
try:
side = str(pos.get('side') or '').strip().lower()
size = float(pos.get('size') or 0.0)
entry_price = float(pos.get('entry_price') or 0.0)
if size <= 0 or entry_price <= 0 or side not in ('long', 'short'):
continue
mark_price = pos.get('current_price')
pos_symbol = str(pos.get('symbol') or '')
if current_price and normalized_symbol and pos_symbol.split(':')[0] == normalized_symbol:
mark_price = current_price
mark_price = float(mark_price or 0.0)
if mark_price <= 0:
continue
if side == 'long':
unrealized_pnl += (mark_price - entry_price) * size
else:
unrealized_pnl += (entry_price - mark_price) * size
except Exception:
continue
equity = float(initial_capital or 0.0) + realized_pnl + unrealized_pnl
return max(0.0, equity)
def _record_trade(self, strategy_id: int, symbol: str, type: str, price: float, amount: float, value: float, profit: float = None, commission: float = None):
"""记录交易到数据库"""
+39
View File
@@ -432,6 +432,9 @@ CREATE TABLE IF NOT EXISTS qd_backtest_runs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
indicator_id INTEGER,
strategy_id INTEGER,
strategy_name VARCHAR(255) DEFAULT '',
run_type VARCHAR(50) DEFAULT 'indicator',
market VARCHAR(50) NOT NULL DEFAULT '',
symbol VARCHAR(50) NOT NULL DEFAULT '',
timeframe VARCHAR(10) NOT NULL DEFAULT '',
@@ -443,6 +446,9 @@ CREATE TABLE IF NOT EXISTS qd_backtest_runs (
leverage INTEGER DEFAULT 1,
trade_direction VARCHAR(20) DEFAULT 'long',
strategy_config TEXT DEFAULT '',
config_snapshot TEXT DEFAULT '',
engine_version VARCHAR(50) DEFAULT '',
code_hash VARCHAR(128) DEFAULT '',
status VARCHAR(20) DEFAULT 'success',
error_message TEXT DEFAULT '',
result_json TEXT DEFAULT '',
@@ -451,6 +457,39 @@ CREATE TABLE IF NOT EXISTS qd_backtest_runs (
CREATE INDEX IF NOT EXISTS idx_backtest_runs_user_id ON qd_backtest_runs(user_id);
CREATE INDEX IF NOT EXISTS idx_backtest_runs_indicator_id ON qd_backtest_runs(indicator_id);
CREATE INDEX IF NOT EXISTS idx_backtest_runs_strategy_id ON qd_backtest_runs(strategy_id);
CREATE INDEX IF NOT EXISTS idx_backtest_runs_run_type ON qd_backtest_runs(run_type);
CREATE TABLE IF NOT EXISTS qd_backtest_trades (
id SERIAL PRIMARY KEY,
run_id INTEGER NOT NULL,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER,
trade_index INTEGER DEFAULT 0,
trade_time VARCHAR(64) DEFAULT '',
trade_type VARCHAR(64) DEFAULT '',
side VARCHAR(32) DEFAULT '',
price DOUBLE PRECISION DEFAULT 0,
amount DOUBLE PRECISION DEFAULT 0,
profit DOUBLE PRECISION DEFAULT 0,
balance DOUBLE PRECISION DEFAULT 0,
reason VARCHAR(64) DEFAULT '',
payload_json TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_id ON qd_backtest_trades(run_id);
CREATE TABLE IF NOT EXISTS qd_backtest_equity_points (
id SERIAL PRIMARY KEY,
run_id INTEGER NOT NULL,
point_index INTEGER DEFAULT 0,
point_time VARCHAR(64) DEFAULT '',
point_value DOUBLE PRECISION DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_backtest_equity_points_run_id ON qd_backtest_equity_points(run_id);
-- =============================================================================
-- 13. Exchange Credentials