@@ -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()
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
"""记录交易到数据库"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user