@@ -273,6 +273,13 @@ def place_order():
|
||||
tp_price = float(body.get("tp_price") or 0)
|
||||
sl_price = float(body.get("sl_price") or 0)
|
||||
source = str(body.get("source") or "manual").strip()
|
||||
margin_mode = str(body.get("margin_mode") or body.get("marginMode") or "").strip().lower()
|
||||
if margin_mode in ("cross", "crossed"):
|
||||
margin_mode = "cross"
|
||||
elif margin_mode in ("iso", "isolated"):
|
||||
margin_mode = "isolated"
|
||||
else:
|
||||
margin_mode = ""
|
||||
|
||||
# ---- validation ----
|
||||
if not credential_id:
|
||||
@@ -286,28 +293,35 @@ def place_order():
|
||||
if order_type == "limit" and price <= 0:
|
||||
return jsonify({"code": 0, "msg": "price required for limit orders"}), 400
|
||||
|
||||
# ---- Auto-determine market_type from leverage ----
|
||||
# leverage = 1 -> spot, leverage > 1 -> swap
|
||||
if not market_type:
|
||||
market_type = "spot" if leverage == 1 else "swap"
|
||||
# ---- market_type: leverage 1 => spot API, else perpetual (swap) ----
|
||||
if market_type in ("futures", "future", "perp", "perpetual"):
|
||||
market_type = "swap"
|
||||
# Override only when user did not explicitly choose market_type.
|
||||
if leverage > 1:
|
||||
market_type = "swap"
|
||||
elif leverage == 1 and not str(body.get("market_type") or "").strip():
|
||||
else:
|
||||
market_type = "spot"
|
||||
|
||||
# ---- build exchange client ----
|
||||
exchange_config = _build_exchange_config(credential_id, user_id, {
|
||||
"market_type": market_type,
|
||||
})
|
||||
cfg_overrides: Dict[str, Any] = {"market_type": market_type}
|
||||
if margin_mode in ("cross", "isolated"):
|
||||
cfg_overrides["margin_mode"] = margin_mode
|
||||
cfg_overrides["td_mode"] = margin_mode
|
||||
exchange_config = _build_exchange_config(credential_id, user_id, cfg_overrides)
|
||||
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
|
||||
if not exchange_id:
|
||||
return jsonify({"code": 0, "msg": "Invalid credential: missing exchange_id"}), 400
|
||||
|
||||
client = _create_client(exchange_config, market_type=market_type)
|
||||
|
||||
# Binance USDT-M: sync isolated/cross margin mode (best-effort; may fail if open orders exist)
|
||||
if market_type != "spot" and margin_mode in ("cross", "isolated"):
|
||||
try:
|
||||
from app.services.live_trading.binance import BinanceFuturesClient
|
||||
if isinstance(client, BinanceFuturesClient):
|
||||
client.set_margin_type(symbol=symbol, margin_mode=margin_mode)
|
||||
except Exception as me:
|
||||
logger.warning(f"Binance set_margin_type failed (non-fatal): {me}")
|
||||
|
||||
# ---- Convert USDT amount to base asset quantity ----
|
||||
# Quick trade always accepts USDT amount, convert to base qty for all exchanges
|
||||
# For limit orders, use the provided price; for market orders, fetch current price
|
||||
@@ -543,7 +557,16 @@ def get_balance():
|
||||
raw = client.get_account()
|
||||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||||
elif hasattr(client, "get_accounts"):
|
||||
raw = client.get_accounts()
|
||||
from app.services.live_trading.bitget import BitgetMixClient
|
||||
|
||||
if isinstance(client, BitgetMixClient):
|
||||
pt = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
|
||||
raw = client.get_accounts(product_type=pt)
|
||||
else:
|
||||
raw = client.get_accounts()
|
||||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||||
elif (exchange_id or "").lower() == "bitget" and market_type == "spot" and hasattr(client, "get_assets"):
|
||||
raw = client.get_assets()
|
||||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||||
except Exception as be:
|
||||
logger.warning(f"Balance fetch failed: {be}")
|
||||
@@ -575,22 +598,62 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An
|
||||
result["total"] = float(b.get("free") or 0) + float(b.get("locked") or 0)
|
||||
return result
|
||||
return result
|
||||
ex = (exchange_id or "").lower()
|
||||
# Bitget mix: { code, data: [ { marginCoin, available, accountEquity, ... } ] }
|
||||
# Must run before OKX — both use data as a list; OKX fallback would zero Bitget.
|
||||
if ex == "bitget" and (market_type or "").lower() != "spot":
|
||||
bg_data = raw.get("data")
|
||||
if isinstance(bg_data, list) and bg_data:
|
||||
row = None
|
||||
for item in bg_data:
|
||||
if isinstance(item, dict) and str(item.get("marginCoin") or "").upper() == "USDT":
|
||||
row = item
|
||||
break
|
||||
if row is None and isinstance(bg_data[0], dict):
|
||||
row = bg_data[0]
|
||||
if isinstance(row, dict):
|
||||
av = (
|
||||
row.get("available")
|
||||
or row.get("availableBalance")
|
||||
or row.get("crossedMaxAvailable")
|
||||
or row.get("isolatedMaxAvailable")
|
||||
or 0
|
||||
)
|
||||
eq = row.get("accountEquity") or row.get("usdtEquity") or row.get("equity") or av
|
||||
result["available"] = float(av or 0)
|
||||
result["total"] = float(eq or 0) if eq is not None else result["available"]
|
||||
return result
|
||||
# Bitget spot: GET /api/v2/spot/account/assets
|
||||
if ex == "bitget" and (market_type or "").lower() == "spot":
|
||||
bg_data = raw.get("data")
|
||||
if isinstance(bg_data, list):
|
||||
for b in bg_data:
|
||||
if isinstance(b, dict) and str(b.get("coin") or "").upper() == "USDT":
|
||||
avail = float(b.get("available") or 0)
|
||||
frozen = float(b.get("frozen") or b.get("locked") or 0)
|
||||
result["available"] = avail
|
||||
result["total"] = avail + frozen
|
||||
return result
|
||||
return result
|
||||
# OKX
|
||||
data = raw.get("data")
|
||||
if isinstance(data, list) and data:
|
||||
first = data[0] if isinstance(data[0], dict) else {}
|
||||
# Account balance
|
||||
details = first.get("details", [])
|
||||
if isinstance(details, list):
|
||||
if isinstance(details, list) and details:
|
||||
for d in details:
|
||||
if str(d.get("ccy") or "").upper() == "USDT":
|
||||
result["available"] = float(d.get("availBal") or d.get("availEq") or 0)
|
||||
result["total"] = float(d.get("eq") or d.get("cashBal") or 0)
|
||||
return result
|
||||
# Fallback
|
||||
result["available"] = float(first.get("availBal") or first.get("totalEq") or 0)
|
||||
result["total"] = float(first.get("totalEq") or 0)
|
||||
return result
|
||||
# OKX-style single-account row (not Bitget — Bitget handled above)
|
||||
if "availBal" in first or "availEq" in first or "totalEq" in first or "adjEq" in first:
|
||||
result["available"] = float(
|
||||
first.get("availBal") or first.get("availEq") or first.get("adjEq") or first.get("totalEq") or 0
|
||||
)
|
||||
result["total"] = float(first.get("totalEq") or first.get("adjEq") or 0)
|
||||
return result
|
||||
# Bybit
|
||||
if "result" in raw:
|
||||
res = raw["result"]
|
||||
@@ -636,6 +699,106 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An
|
||||
return result
|
||||
|
||||
|
||||
def _fetch_exchange_positions_raw(
|
||||
client: Any,
|
||||
exchange_config: Dict[str, Any],
|
||||
*,
|
||||
symbol: str,
|
||||
market_type: str,
|
||||
) -> Any:
|
||||
"""
|
||||
Fetch raw position payload for quick-trade / close-position.
|
||||
|
||||
Many clients do not accept ``symbol=`` on ``get_positions()`` (Gate, KuCoin, Bybit, Bitfinex),
|
||||
or need extra args (Bitget ``product_type``, OKX ``inst_type``). Centralize here.
|
||||
"""
|
||||
from app.services.live_trading.binance import BinanceFuturesClient
|
||||
from app.services.live_trading.bitget import BitgetMixClient
|
||||
from app.services.live_trading.bybit import BybitClient
|
||||
from app.services.live_trading.deepcoin import DeepcoinClient
|
||||
from app.services.live_trading.gate import GateUsdtFuturesClient
|
||||
from app.services.live_trading.htx import HtxClient
|
||||
from app.services.live_trading.kucoin import KucoinFuturesClient
|
||||
from app.services.live_trading.okx import OkxClient
|
||||
from app.services.live_trading.symbols import (
|
||||
to_bybit_symbol,
|
||||
to_gate_currency_pair,
|
||||
to_kucoin_futures_symbol,
|
||||
to_okx_spot_inst_id,
|
||||
to_okx_swap_inst_id,
|
||||
)
|
||||
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
|
||||
if isinstance(client, OkxClient):
|
||||
if mt == "spot":
|
||||
inst_id = to_okx_spot_inst_id(symbol)
|
||||
inst_type = "SPOT"
|
||||
else:
|
||||
inst_id = to_okx_swap_inst_id(symbol)
|
||||
inst_type = "SWAP"
|
||||
return client.get_positions(inst_id=inst_id, inst_type=inst_type)
|
||||
|
||||
if isinstance(client, BinanceFuturesClient):
|
||||
return client.get_positions(symbol=symbol)
|
||||
|
||||
if isinstance(client, BitgetMixClient):
|
||||
pt = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
|
||||
return client.get_positions(product_type=pt, symbol=symbol)
|
||||
|
||||
if isinstance(client, BybitClient):
|
||||
raw = client.get_positions()
|
||||
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
|
||||
if not isinstance(lst, list):
|
||||
return raw
|
||||
sym_norm = to_bybit_symbol(symbol)
|
||||
filtered = [p for p in lst if isinstance(p, dict) and str(p.get("symbol") or "").strip() == sym_norm]
|
||||
if isinstance(raw, dict):
|
||||
out = dict(raw)
|
||||
res = dict((raw.get("result") or {}) if isinstance(raw.get("result"), dict) else {})
|
||||
res["list"] = filtered
|
||||
out["result"] = res
|
||||
return out
|
||||
return {"result": {"list": filtered}}
|
||||
|
||||
if isinstance(client, GateUsdtFuturesClient):
|
||||
raw = client.get_positions()
|
||||
items = raw if isinstance(raw, list) else []
|
||||
c = to_gate_currency_pair(symbol)
|
||||
filtered = [p for p in items if isinstance(p, dict) and str(p.get("contract") or "").strip() == c]
|
||||
return filtered
|
||||
|
||||
if isinstance(client, KucoinFuturesClient):
|
||||
raw = client.get_positions()
|
||||
data = raw.get("data") if isinstance(raw, dict) else []
|
||||
sym = to_kucoin_futures_symbol(symbol)
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
filtered = [p for p in data if isinstance(p, dict) and str(p.get("symbol") or "").strip() == sym]
|
||||
if isinstance(raw, dict):
|
||||
out = dict(raw)
|
||||
out["data"] = filtered
|
||||
return out
|
||||
return {"data": filtered}
|
||||
|
||||
if isinstance(client, HtxClient):
|
||||
return client.get_positions(symbol=symbol)
|
||||
|
||||
if isinstance(client, DeepcoinClient):
|
||||
return client.get_positions(symbol=symbol)
|
||||
|
||||
if hasattr(client, "get_positions"):
|
||||
try:
|
||||
return client.get_positions(symbol=symbol)
|
||||
except TypeError:
|
||||
return client.get_positions()
|
||||
|
||||
if hasattr(client, "get_position"):
|
||||
return client.get_position(symbol=symbol)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@quick_trade_bp.route('/position', methods=['GET'])
|
||||
@login_required
|
||||
def get_position():
|
||||
@@ -658,25 +821,10 @@ def get_position():
|
||||
|
||||
positions = []
|
||||
try:
|
||||
# OKX requires inst_id instead of symbol
|
||||
from app.services.live_trading.okx import OkxClient
|
||||
if isinstance(client, OkxClient):
|
||||
from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id
|
||||
if market_type == "spot":
|
||||
inst_id = to_okx_spot_inst_id(symbol)
|
||||
inst_type = "SPOT"
|
||||
else:
|
||||
inst_id = to_okx_swap_inst_id(symbol)
|
||||
inst_type = "SWAP"
|
||||
raw = client.get_positions(inst_id=inst_id, inst_type=inst_type)
|
||||
positions = _parse_positions(raw)
|
||||
logger.info(f"OKX positions query: inst_id={inst_id}, inst_type={inst_type}, found {len(positions)} positions")
|
||||
elif hasattr(client, "get_positions"):
|
||||
raw = client.get_positions(symbol=symbol)
|
||||
positions = _parse_positions(raw)
|
||||
elif hasattr(client, "get_position"):
|
||||
raw = client.get_position(symbol=symbol)
|
||||
positions = _parse_positions(raw)
|
||||
raw = _fetch_exchange_positions_raw(
|
||||
client, exchange_config, symbol=symbol, market_type=market_type
|
||||
)
|
||||
positions = _parse_positions(raw)
|
||||
except Exception as pe:
|
||||
logger.warning(f"Position fetch failed: {pe}")
|
||||
logger.warning(traceback.format_exc())
|
||||
@@ -698,33 +846,62 @@ def _parse_positions(raw: Any) -> list:
|
||||
if isinstance(raw, list):
|
||||
items = raw
|
||||
elif isinstance(raw, dict):
|
||||
data = raw.get("data") or raw.get("result") or raw.get("positions") or []
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
items = data.get("list", []) if "list" in data else [data]
|
||||
if isinstance(raw.get("raw"), list):
|
||||
items = raw["raw"]
|
||||
else:
|
||||
data = raw.get("data") or raw.get("result") or raw.get("positions") or []
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
items = data.get("list", []) if "list" in data else [data]
|
||||
else:
|
||||
items = []
|
||||
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
# For OKX, position size can be in different fields
|
||||
# SWAP: posAmt, pos
|
||||
# Binance futures: positionAmt
|
||||
# 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 item.get("volume") or 0)
|
||||
size = float(
|
||||
item.get("positionAmt")
|
||||
or item.get("posAmt")
|
||||
or item.get("pos")
|
||||
or item.get("total")
|
||||
or item.get("currentQty")
|
||||
or item.get("available")
|
||||
or item.get("size")
|
||||
or item.get("contracts")
|
||||
or item.get("bal")
|
||||
or item.get("availBal")
|
||||
or item.get("volume")
|
||||
or 0
|
||||
)
|
||||
if abs(size) < 1e-10:
|
||||
continue
|
||||
|
||||
# For spot, side is always "long" (you own the asset)
|
||||
# For swap, determine side from sign of size
|
||||
# Binance hedge: positionSide LONG/SHORT with positive positionAmt; one-way: BOTH + signed amt
|
||||
side = "long"
|
||||
if size < 0:
|
||||
psu = str(item.get("positionSide", "")).strip().upper()
|
||||
if psu == "SHORT":
|
||||
side = "short"
|
||||
elif psu == "LONG":
|
||||
side = "long"
|
||||
elif item.get("posSide"):
|
||||
# OKX may have posSide field: "long" or "short"
|
||||
pos_side = str(item.get("posSide", "")).strip().lower()
|
||||
if pos_side in ("long", "short"):
|
||||
side = pos_side
|
||||
elif str(item.get("holdSide") or "").strip().lower() == "short":
|
||||
side = "short"
|
||||
elif str(item.get("holdSide") or "").strip().lower() == "long":
|
||||
side = "long"
|
||||
elif str(item.get("side") or "").strip().lower() in ("sell", "s"):
|
||||
side = "short"
|
||||
elif str(item.get("side") or "").strip().lower() in ("buy", "b"):
|
||||
side = "long"
|
||||
elif size < 0:
|
||||
side = "short"
|
||||
elif item.get("direction"):
|
||||
dir_side = str(item.get("direction") or "").strip().lower()
|
||||
if dir_side in ("buy", "long"):
|
||||
@@ -736,10 +913,36 @@ def _parse_positions(raw: Any) -> list:
|
||||
"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("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),
|
||||
"entry_price": float(
|
||||
item.get("entryPrice")
|
||||
or item.get("openPriceAvg")
|
||||
or item.get("avgEntryPrice")
|
||||
or item.get("avgPrice")
|
||||
or item.get("avgCost")
|
||||
or item.get("avgPx")
|
||||
or item.get("cost_open")
|
||||
or item.get("trade_avg_price")
|
||||
or 0
|
||||
),
|
||||
"unrealized_pnl": float(
|
||||
item.get("unRealizedProfit")
|
||||
or item.get("unrealizedProfit")
|
||||
or item.get("unrealizedPnl")
|
||||
or item.get("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_price") 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 item.get("indexPrice")
|
||||
or 0
|
||||
),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"_parse_positions error: {e}")
|
||||
@@ -791,21 +994,10 @@ def close_position():
|
||||
# ---- get current position ----
|
||||
positions = []
|
||||
try:
|
||||
from app.services.live_trading.okx import OkxClient
|
||||
if isinstance(client, OkxClient):
|
||||
from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id
|
||||
if market_type == "spot":
|
||||
inst_id = to_okx_spot_inst_id(symbol)
|
||||
else:
|
||||
inst_id = to_okx_swap_inst_id(symbol)
|
||||
raw = client.get_positions(inst_id=inst_id)
|
||||
positions = _parse_positions(raw)
|
||||
elif hasattr(client, "get_positions"):
|
||||
raw = client.get_positions(symbol=symbol)
|
||||
positions = _parse_positions(raw)
|
||||
elif hasattr(client, "get_position"):
|
||||
raw = client.get_position(symbol=symbol)
|
||||
positions = _parse_positions(raw)
|
||||
raw = _fetch_exchange_positions_raw(
|
||||
client, exchange_config, symbol=symbol, market_type=market_type
|
||||
)
|
||||
positions = _parse_positions(raw)
|
||||
except Exception as pe:
|
||||
logger.warning(f"Position fetch failed: {pe}")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Trading Strategy API Routes
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
import time
|
||||
|
||||
@@ -770,15 +771,16 @@ def start_strategy():
|
||||
|
||||
# Get strategy type
|
||||
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
|
||||
|
||||
# Update strategy status
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'running', user_id=user_id)
|
||||
|
||||
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
|
||||
if strategy_type == 'PromptBasedStrategy':
|
||||
return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting AI strategies', 'data': None}), 400
|
||||
|
||||
# Indicator strategy
|
||||
# IndicatorStrategy and ScriptStrategy are executed by TradingExecutor.
|
||||
if strategy_type == 'PromptBasedStrategy':
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'AI strategy has been removed; local edition does not support starting AI strategies',
|
||||
'data': None
|
||||
}), 400
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'running', user_id=user_id)
|
||||
|
||||
success = get_trading_executor().start_strategy(strategy_id)
|
||||
|
||||
if not success:
|
||||
@@ -1241,12 +1243,65 @@ def verify_strategy_code():
|
||||
@strategy_bp.route('/strategies/ai-generate', methods=['POST'])
|
||||
@login_required
|
||||
def ai_generate_strategy():
|
||||
"""Generate strategy code using AI."""
|
||||
"""Generate strategy code or suggest template parameter updates using AI."""
|
||||
try:
|
||||
payload = request.get_json() or {}
|
||||
prompt = payload.get('prompt', '')
|
||||
if not prompt.strip():
|
||||
return jsonify({'code': '', 'msg': 'Prompt is empty'})
|
||||
return jsonify({'code': '', 'msg': 'Prompt is empty', 'params': None})
|
||||
|
||||
intent = (payload.get('intent') or 'generate_code').strip()
|
||||
from app.services.llm import LLMService
|
||||
llm = LLMService()
|
||||
api_key = llm.get_api_key()
|
||||
if not api_key:
|
||||
return jsonify({'code': '', 'msg': 'No LLM API key configured', 'params': None})
|
||||
|
||||
if intent == 'adjust_params':
|
||||
template_key = payload.get('template_key') or ''
|
||||
current_params = payload.get('params') or {}
|
||||
code_snapshot = (payload.get('code') or '')[:8000]
|
||||
system_prompt = """You tune quantitative strategy template parameters from the user's request.
|
||||
Return ONLY a single JSON object: keys are parameter names (strings), values are JSON numbers or booleans.
|
||||
You may return a partial object (only keys that should change) or a full object.
|
||||
Do not use markdown fences, do not add explanations before or after the JSON."""
|
||||
|
||||
user_content = (
|
||||
f"Template key: {template_key}\n"
|
||||
f"Current parameters (JSON):\n{json.dumps(current_params, ensure_ascii=False)}\n\n"
|
||||
f"Strategy code excerpt (context):\n{code_snapshot}\n\n"
|
||||
f"User request:\n{prompt.strip()}\n\n"
|
||||
"Respond with JSON only."
|
||||
)
|
||||
|
||||
content = llm.call_llm_api(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
model=llm.get_code_generation_model(),
|
||||
temperature=0.3,
|
||||
use_json_mode=False
|
||||
)
|
||||
|
||||
raw = (content or '').strip()
|
||||
if raw.startswith('```'):
|
||||
raw = re.sub(r'^```[a-zA-Z]*', '', raw).strip()
|
||||
if raw.endswith('```'):
|
||||
raw = raw[:-3].strip()
|
||||
updates = None
|
||||
try:
|
||||
updates = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r'\{[\s\S]*\}', raw)
|
||||
if m:
|
||||
try:
|
||||
updates = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
updates = None
|
||||
if not isinstance(updates, dict):
|
||||
return jsonify({'code': '', 'params': None, 'msg': 'AI did not return valid JSON parameters'})
|
||||
return jsonify({'code': '', 'params': updates, 'msg': 'success'})
|
||||
|
||||
system_prompt = """You are a quantitative trading strategy code generator.
|
||||
Generate Python strategy code that follows this framework:
|
||||
@@ -1264,16 +1319,26 @@ Generate Python strategy code that follows this framework:
|
||||
|
||||
Return ONLY the Python code, no explanations."""
|
||||
|
||||
from app.services.llm import LLMService
|
||||
llm = LLMService()
|
||||
api_key = llm.get_api_key()
|
||||
if not api_key:
|
||||
return jsonify({'code': '', 'msg': 'No LLM API key configured'})
|
||||
extra = ''
|
||||
template_key = payload.get('template_key')
|
||||
params = payload.get('params')
|
||||
code_ctx = (payload.get('code') or '').strip()
|
||||
if template_key or params is not None or code_ctx:
|
||||
extra_parts = []
|
||||
if template_key:
|
||||
extra_parts.append(f"Current template key: {template_key}")
|
||||
if isinstance(params, dict) and params:
|
||||
extra_parts.append('Current template parameters (JSON):\n' + json.dumps(params, ensure_ascii=False))
|
||||
if code_ctx:
|
||||
extra_parts.append('Current code (may be long):\n' + code_ctx[:12000])
|
||||
extra = '\n\n' + '\n\n'.join(extra_parts)
|
||||
|
||||
user_prompt = prompt.strip() + extra
|
||||
|
||||
content = llm.call_llm_api(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
model=llm.get_code_generation_model(),
|
||||
temperature=0.7,
|
||||
@@ -1290,12 +1355,12 @@ Return ONLY the Python code, no explanations."""
|
||||
content = content.strip()
|
||||
|
||||
if content:
|
||||
return jsonify({'code': content, 'msg': 'success'})
|
||||
return jsonify({'code': content, 'msg': 'success', 'params': None})
|
||||
else:
|
||||
return jsonify({'code': '', 'msg': 'AI generation returned empty result'})
|
||||
return jsonify({'code': '', 'msg': 'AI generation returned empty result', 'params': None})
|
||||
except Exception as e:
|
||||
logger.error(f"ai_generate_strategy failed: {str(e)}")
|
||||
return jsonify({'code': '', 'msg': str(e)})
|
||||
return jsonify({'code': '', 'msg': str(e), 'params': None})
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/performance', methods=['GET'])
|
||||
|
||||
@@ -504,6 +504,12 @@ class BacktestService:
|
||||
result['precision_info']['message'] = 'Using standard backtest because scale rules are not fully supported in MTF mode'
|
||||
elif fallback_reason == 'signal_timing_not_supported_in_mtf':
|
||||
result['precision_info']['message'] = 'Using standard backtest because this execution timing is not fully supported in MTF mode'
|
||||
ea = result.get('executionAssumptions') or {}
|
||||
ea['mtfRequested'] = bool(enable_mtf)
|
||||
ea['mtfActive'] = False
|
||||
if fallback_reason:
|
||||
ea['mtfFallbackReason'] = fallback_reason
|
||||
result['executionAssumptions'] = ea
|
||||
return result
|
||||
|
||||
logger.info(f"Multi-timeframe backtest: strategy_tf={timeframe}, exec_tf={exec_tf}, range={start_date} ~ {end_date}")
|
||||
@@ -554,6 +560,11 @@ class BacktestService:
|
||||
'reason': 'data_unavailable',
|
||||
'message': f'Cannot fetch {exec_tf} data, using standard backtest'
|
||||
}
|
||||
ea = result.get('executionAssumptions') or {}
|
||||
ea['mtfRequested'] = bool(enable_mtf)
|
||||
ea['mtfActive'] = False
|
||||
ea['mtfFallbackReason'] = 'data_unavailable'
|
||||
result['executionAssumptions'] = ea
|
||||
return result
|
||||
|
||||
logger.info(f"Data fetched: signal_candles={len(df_signal)}, exec_candles={len(df_exec)}")
|
||||
@@ -598,6 +609,14 @@ class BacktestService:
|
||||
result['execution_timeframe'] = exec_tf
|
||||
result['signal_candles'] = len(df_signal)
|
||||
result['execution_candles'] = len(df_exec)
|
||||
result['executionAssumptions'] = self._execution_assumptions(
|
||||
strategy_config,
|
||||
simulation_mode='mtf',
|
||||
signal_timeframe=timeframe,
|
||||
execution_timeframe=exec_tf,
|
||||
mtf_requested=True,
|
||||
mtf_active=True,
|
||||
)
|
||||
logger.info("Backtest result formatted successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to format result: {str(e)}")
|
||||
@@ -1487,6 +1506,11 @@ class BacktestService:
|
||||
'precision': 'standard',
|
||||
'message': 'Using standard strategy script backtest'
|
||||
}
|
||||
result['executionAssumptions'] = self._execution_assumptions(
|
||||
strategy_config,
|
||||
simulation_mode='standard',
|
||||
signal_timeframe=timeframe,
|
||||
)
|
||||
return result
|
||||
|
||||
def run_code_strategy(
|
||||
@@ -1607,7 +1631,13 @@ class BacktestService:
|
||||
metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission)
|
||||
|
||||
# 5. Format result
|
||||
return self._format_result(metrics, equity_curve, trades)
|
||||
result = self._format_result(metrics, equity_curve, trades)
|
||||
result['executionAssumptions'] = self._execution_assumptions(
|
||||
strategy_config,
|
||||
simulation_mode='standard',
|
||||
signal_timeframe=timeframe,
|
||||
)
|
||||
return result
|
||||
|
||||
def _fetch_kline_data(
|
||||
self,
|
||||
@@ -4740,6 +4770,46 @@ import pandas as pd
|
||||
logger.warning(f"Sharpe ratio calculation failed: {e}")
|
||||
return 0
|
||||
|
||||
def _execution_assumptions(
|
||||
self,
|
||||
strategy_config: Optional[Dict[str, Any]],
|
||||
*,
|
||||
simulation_mode: str,
|
||||
signal_timeframe: Optional[str] = None,
|
||||
execution_timeframe: Optional[str] = None,
|
||||
mtf_requested: bool = False,
|
||||
mtf_active: bool = False,
|
||||
mtf_fallback_reason: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Human-facing metadata so the UI can explain how trades were timed vs chart markers.
|
||||
Keys use camelCase for JSON consumers (frontend).
|
||||
"""
|
||||
cfg = strategy_config or {}
|
||||
raw = str((cfg.get('execution') or {}).get('signalTiming') or 'next_bar_open').strip().lower()
|
||||
is_next_open = raw in ('next_bar_open', 'next_open', 'nextopen', 'next')
|
||||
if raw in ('bar_close', 'close', 'same_bar_close', 'current_bar_close'):
|
||||
timing_key = 'same_bar_close'
|
||||
elif is_next_open:
|
||||
timing_key = 'next_bar_open'
|
||||
else:
|
||||
timing_key = raw
|
||||
default_fill = 'open' if is_next_open else 'close'
|
||||
payload: Dict[str, Any] = {
|
||||
'signalTiming': timing_key,
|
||||
'signalTimingRaw': raw,
|
||||
'defaultFillPrice': default_fill,
|
||||
'simulationMode': simulation_mode,
|
||||
'strategyTimeframe': signal_timeframe,
|
||||
'executionTimeframe': execution_timeframe,
|
||||
'engineVersion': self.ENGINE_VERSION,
|
||||
'mtfRequested': bool(mtf_requested),
|
||||
'mtfActive': bool(mtf_active),
|
||||
}
|
||||
if mtf_fallback_reason:
|
||||
payload['mtfFallbackReason'] = mtf_fallback_reason
|
||||
return payload
|
||||
|
||||
def _format_result(
|
||||
self,
|
||||
metrics: Dict,
|
||||
|
||||
@@ -40,6 +40,10 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
self._dual_side_cache: Optional[Tuple[float, bool]] = None
|
||||
self._dual_side_cache_ttl_sec = 60.0
|
||||
|
||||
# serverTime - local_ms; avoids Binance -1021 when the host clock is ahead of Binance.
|
||||
self._time_offset_ms: int = 0
|
||||
self._time_sync_monotonic: float = 0.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
@@ -163,6 +167,26 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
def _signed_headers(self) -> Dict[str, str]:
|
||||
return {"X-MBX-APIKEY": self.api_key}
|
||||
|
||||
def _ensure_server_time(self, *, force: bool = False) -> None:
|
||||
"""
|
||||
Align signed request timestamps with Binance server time (GET /fapi/v1/time).
|
||||
"""
|
||||
now_m = time.monotonic()
|
||||
if not force and (now_m - float(self._time_sync_monotonic or 0.0)) < 300.0:
|
||||
return
|
||||
try:
|
||||
code, data, _ = self._request("GET", "/fapi/v1/time")
|
||||
if code != 200 or not isinstance(data, dict):
|
||||
return
|
||||
server_ms = int(data.get("serverTime") or 0)
|
||||
if server_ms <= 0:
|
||||
return
|
||||
local_ms = int(time.time() * 1000)
|
||||
self._time_offset_ms = server_ms - local_ms
|
||||
self._time_sync_monotonic = now_m
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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()
|
||||
@@ -179,17 +203,34 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
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.
|
||||
p["timestamp"] = int(time.time() * 1000)
|
||||
qs = urlencode(p, doseq=True)
|
||||
p["signature"] = self._sign(qs)
|
||||
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"Binance error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
self._ensure_server_time()
|
||||
last_err: Optional[LiveTradingError] = None
|
||||
for attempt in range(2):
|
||||
p = dict(params or {})
|
||||
p["timestamp"] = int(time.time() * 1000) + int(self._time_offset_ms)
|
||||
if "recvWindow" not in p:
|
||||
p["recvWindow"] = 10000
|
||||
qs = urlencode(p, doseq=True)
|
||||
p["signature"] = self._sign(qs)
|
||||
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
|
||||
if code >= 400:
|
||||
err = LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
|
||||
if attempt == 0 and ("-1021" in text or "1021" in text):
|
||||
self._ensure_server_time(force=True)
|
||||
last_err = err
|
||||
continue
|
||||
raise err
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
err = LiveTradingError(f"Binance error: {data}")
|
||||
if attempt == 0 and int(data.get("code") or 0) == -1021:
|
||||
self._ensure_server_time(force=True)
|
||||
last_err = err
|
||||
continue
|
||||
raise err
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
if last_err:
|
||||
raise last_err
|
||||
raise LiveTradingError("Binance signed request failed")
|
||||
|
||||
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
||||
@@ -870,12 +911,41 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
raise LiveTradingError("Binance cancel_order requires order_id or client_order_id")
|
||||
return self._signed_request("DELETE", "/fapi/v1/order", params=params)
|
||||
|
||||
def get_positions(self) -> Any:
|
||||
def set_margin_type(self, *, symbol: str, margin_mode: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Return all futures positions (position risk endpoint).
|
||||
Set symbol margin mode on USDT-M futures.
|
||||
|
||||
Endpoint: POST /fapi/v1/marginType
|
||||
margin_mode: cross | crossed | isolated
|
||||
"""
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
m = (margin_mode or "").strip().lower()
|
||||
if m in ("cross", "crossed"):
|
||||
mt = "CROSSED"
|
||||
elif m in ("isolated", "iso"):
|
||||
mt = "ISOLATED"
|
||||
else:
|
||||
raise LiveTradingError(f"Invalid margin_mode for Binance: {margin_mode}")
|
||||
return self._signed_request("POST", "/fapi/v1/marginType", params={"symbol": sym, "marginType": mt})
|
||||
|
||||
def get_positions(self, *, symbol: str = "") -> Any:
|
||||
"""
|
||||
Futures positions (position risk). Optional ``symbol`` filters to one contract.
|
||||
|
||||
Endpoint: GET /fapi/v2/positionRisk
|
||||
"""
|
||||
return self._signed_request("GET", "/fapi/v2/positionRisk", params={})
|
||||
raw = self._signed_request("GET", "/fapi/v2/positionRisk", params={})
|
||||
rows: list
|
||||
if isinstance(raw, list):
|
||||
rows = raw
|
||||
elif isinstance(raw, dict) and isinstance(raw.get("raw"), list):
|
||||
rows = raw["raw"]
|
||||
else:
|
||||
rows = []
|
||||
want = (symbol or "").strip()
|
||||
if not want:
|
||||
return rows
|
||||
sym = to_binance_futures_symbol(want)
|
||||
return [p for p in rows if isinstance(p, dict) and str(p.get("symbol") or "") == sym]
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ class BinanceSpotClient(BaseRestClient):
|
||||
self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._sym_filter_cache_ttl_sec = 300.0
|
||||
|
||||
self._time_offset_ms: int = 0
|
||||
self._time_sync_monotonic: float = 0.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
@@ -158,6 +161,24 @@ class BinanceSpotClient(BaseRestClient):
|
||||
def _signed_headers(self) -> Dict[str, str]:
|
||||
return {"X-MBX-APIKEY": self.api_key}
|
||||
|
||||
def _ensure_server_time(self, *, force: bool = False) -> None:
|
||||
"""Align signed request timestamps with Binance (GET /api/v3/time)."""
|
||||
now_m = time.monotonic()
|
||||
if not force and (now_m - float(self._time_sync_monotonic or 0.0)) < 300.0:
|
||||
return
|
||||
try:
|
||||
code, data, _ = self._request("GET", "/api/v3/time")
|
||||
if code != 200 or not isinstance(data, dict):
|
||||
return
|
||||
server_ms = int(data.get("serverTime") or 0)
|
||||
if server_ms <= 0:
|
||||
return
|
||||
local_ms = int(time.time() * 1000)
|
||||
self._time_offset_ms = server_ms - local_ms
|
||||
self._time_sync_monotonic = now_m
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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()
|
||||
@@ -174,16 +195,34 @@ class BinanceSpotClient(BaseRestClient):
|
||||
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)
|
||||
qs = urlencode(p, doseq=True)
|
||||
p["signature"] = self._sign(qs)
|
||||
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"BinanceSpot error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
self._ensure_server_time()
|
||||
last_err: Optional[LiveTradingError] = None
|
||||
for attempt in range(2):
|
||||
p = dict(params or {})
|
||||
p["timestamp"] = int(time.time() * 1000) + int(self._time_offset_ms)
|
||||
if "recvWindow" not in p:
|
||||
p["recvWindow"] = 10000
|
||||
qs = urlencode(p, doseq=True)
|
||||
p["signature"] = self._sign(qs)
|
||||
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
|
||||
if code >= 400:
|
||||
err = LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
|
||||
if attempt == 0 and ("-1021" in text or "1021" in text):
|
||||
self._ensure_server_time(force=True)
|
||||
last_err = err
|
||||
continue
|
||||
raise err
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
err = LiveTradingError(f"BinanceSpot error: {data}")
|
||||
if attempt == 0 and int(data.get("code") or 0) == -1021:
|
||||
self._ensure_server_time(force=True)
|
||||
last_err = err
|
||||
continue
|
||||
raise err
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
if last_err:
|
||||
raise last_err
|
||||
raise LiveTradingError("BinanceSpot signed request failed")
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -61,6 +61,10 @@ class BitgetMixClient(BaseRestClient):
|
||||
self._lev_cache: Dict[str, Tuple[float, bool]] = {}
|
||||
self._lev_cache_ttl_sec = 60.0
|
||||
|
||||
# posMode from GET /api/v2/mix/account/account (hedge_mode vs one_way_mode), cached per contract.
|
||||
self._pos_mode_cache: Dict[str, Tuple[float, str]] = {}
|
||||
self._pos_mode_cache_ttl_sec = 60.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
@@ -242,6 +246,31 @@ class BitgetMixClient(BaseRestClient):
|
||||
raise LiveTradingError(f"Bitget error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def _post_mix_place_order(
|
||||
self,
|
||||
body: Dict[str, Any],
|
||||
*,
|
||||
original_side: str,
|
||||
reduce_only: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
POST place-order; on 40774 (hedge vs one-way mismatch) retry with alternate position fields.
|
||||
"""
|
||||
sd = (original_side or "").lower()
|
||||
try:
|
||||
return self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
|
||||
except LiveTradingError as e:
|
||||
if "40774" not in str(e):
|
||||
raise
|
||||
b2: Dict[str, Any] = {k: v for k, v in body.items() if k not in ("side", "tradeSide", "reduceOnly")}
|
||||
if "tradeSide" in body:
|
||||
b2["side"] = sd
|
||||
b2["reduceOnly"] = "YES" if reduce_only else "NO"
|
||||
else:
|
||||
b2["tradeSide"] = "close" if reduce_only else "open"
|
||||
b2["side"] = ("sell" if sd == "buy" else "buy") if reduce_only else sd
|
||||
return self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=b2)
|
||||
|
||||
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
||||
if code >= 400:
|
||||
@@ -252,6 +281,117 @@ class BitgetMixClient(BaseRestClient):
|
||||
raise LiveTradingError(f"Bitget error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def get_ticker(self, *, symbol: str, **kwargs: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Public mix ticker (for USDT-notional -> base size conversion in quick trade).
|
||||
|
||||
Endpoint: GET /api/v2/mix/market/ticker
|
||||
"""
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
pt = str(kwargs.get("product_type") or "USDT-FUTURES")
|
||||
if not sym:
|
||||
return {}
|
||||
try:
|
||||
raw = self._public_request(
|
||||
"GET",
|
||||
"/api/v2/mix/market/ticker",
|
||||
params={"symbol": sym, "productType": pt},
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
if isinstance(data, list) and data:
|
||||
data = data[0]
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
try:
|
||||
last = float(
|
||||
data.get("lastPr")
|
||||
or data.get("last")
|
||||
or data.get("close")
|
||||
or data.get("markPrice")
|
||||
or data.get("indexPrice")
|
||||
or 0
|
||||
)
|
||||
except Exception:
|
||||
last = 0.0
|
||||
if last <= 0:
|
||||
return {}
|
||||
return {"last": last, "price": last, "close": last}
|
||||
|
||||
def get_account_pos_mode(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
margin_coin: str = "USDT",
|
||||
product_type: str = "USDT-FUTURES",
|
||||
) -> str:
|
||||
"""
|
||||
Returns Bitget posMode for the contract account: 'hedge_mode', 'one_way_mode', or '' if unknown.
|
||||
|
||||
GET /api/v2/mix/account/account
|
||||
"""
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
if not sym:
|
||||
return ""
|
||||
mc = (margin_coin or "USDT").strip().upper()
|
||||
pt = str(product_type or "USDT-FUTURES")
|
||||
key = f"{pt}:{sym}:{mc}"
|
||||
now = time.time()
|
||||
cached = self._pos_mode_cache.get(key)
|
||||
if cached:
|
||||
ts, mode = cached
|
||||
if (now - float(ts or 0.0)) <= float(self._pos_mode_cache_ttl_sec or 60.0) and mode is not None:
|
||||
return str(mode)
|
||||
|
||||
try:
|
||||
resp = self._signed_request(
|
||||
"GET",
|
||||
"/api/v2/mix/account/account",
|
||||
params={
|
||||
"symbol": sym.lower(),
|
||||
"productType": pt,
|
||||
"marginCoin": mc.lower() or "usdt",
|
||||
},
|
||||
)
|
||||
d = resp.get("data") if isinstance(resp, dict) else None
|
||||
mode = ""
|
||||
if isinstance(d, dict):
|
||||
mode = str(d.get("posMode") or "").strip().lower()
|
||||
self._pos_mode_cache[key] = (now, mode)
|
||||
return mode
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _mix_order_position_fields(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
reduce_only: bool,
|
||||
margin_coin: str,
|
||||
product_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Bitget mix place-order: hedge_mode requires tradeSide open/close; one_way_mode requires reduceOnly YES/NO
|
||||
and must not send tradeSide (see Bitget API doc + CCXT bitget.py).
|
||||
"""
|
||||
sd = (side or "").lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
pos_mode = self.get_account_pos_mode(
|
||||
symbol=symbol, margin_coin=margin_coin, product_type=product_type
|
||||
)
|
||||
hedge = pos_mode == "hedge_mode"
|
||||
if hedge:
|
||||
# Mirror CCXT: hedge close flips side; hedge open keeps side + tradeSide open.
|
||||
out: Dict[str, Any] = {
|
||||
"tradeSide": "close" if reduce_only else "open",
|
||||
"side": ("sell" if sd == "buy" else "buy") if reduce_only else sd,
|
||||
}
|
||||
return out
|
||||
return {"side": sd, "reduceOnly": "YES" if reduce_only else "NO"}
|
||||
|
||||
def get_contract(self, *, symbol: str, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch contract metadata (best-effort) from public endpoint.
|
||||
@@ -354,13 +494,34 @@ class BitgetMixClient(BaseRestClient):
|
||||
"""
|
||||
return self._signed_request("GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")})
|
||||
|
||||
def get_positions(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
|
||||
def get_positions(self, *, product_type: str = "USDT-FUTURES", symbol: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
Get all positions (best-effort).
|
||||
Get positions (best-effort).
|
||||
|
||||
Endpoint: GET /api/v2/mix/position/all-position
|
||||
When ``symbol`` is set (e.g. ETH/USDT), filters the response list to that contract only.
|
||||
"""
|
||||
return self._signed_request("GET", "/api/v2/mix/position/all-position", params={"productType": str(product_type or "USDT-FUTURES")})
|
||||
resp = self._signed_request(
|
||||
"GET",
|
||||
"/api/v2/mix/position/all-position",
|
||||
params={"productType": str(product_type or "USDT-FUTURES")},
|
||||
)
|
||||
want = (symbol or "").strip()
|
||||
if not want:
|
||||
return resp
|
||||
sym_key = to_bitget_um_symbol(want).upper()
|
||||
if not isinstance(resp, dict):
|
||||
return resp
|
||||
data = resp.get("data")
|
||||
if not isinstance(data, list):
|
||||
return resp
|
||||
filtered = [
|
||||
p for p in data
|
||||
if isinstance(p, dict) and str(p.get("symbol") or "").strip().upper() == sym_key
|
||||
]
|
||||
out = dict(resp)
|
||||
out["data"] = filtered
|
||||
return out
|
||||
|
||||
def set_leverage(
|
||||
self,
|
||||
@@ -444,16 +605,22 @@ class BitgetMixClient(BaseRestClient):
|
||||
"productType": str(product_type or "USDT-FUTURES"),
|
||||
"marginCoin": str(margin_coin or "USDT"),
|
||||
"marginMode": self._normalize_margin_mode(margin_mode),
|
||||
"side": sd,
|
||||
"orderType": "market",
|
||||
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
|
||||
}
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = "YES"
|
||||
body.update(
|
||||
self._mix_order_position_fields(
|
||||
symbol=symbol,
|
||||
side=sd,
|
||||
reduce_only=reduce_only,
|
||||
margin_coin=str(margin_coin or "USDT"),
|
||||
product_type=str(product_type or "USDT-FUTURES"),
|
||||
)
|
||||
)
|
||||
if client_order_id:
|
||||
body["clientOid"] = str(client_order_id)
|
||||
|
||||
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
|
||||
raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only)
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
exchange_order_id = ""
|
||||
if isinstance(data, dict):
|
||||
@@ -498,21 +665,27 @@ class BitgetMixClient(BaseRestClient):
|
||||
"productType": str(product_type or "USDT-FUTURES"),
|
||||
"marginCoin": str(margin_coin or "USDT"),
|
||||
"marginMode": self._normalize_margin_mode(margin_mode),
|
||||
"side": sd,
|
||||
"orderType": "limit",
|
||||
"price": str(px),
|
||||
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
|
||||
}
|
||||
body.update(
|
||||
self._mix_order_position_fields(
|
||||
symbol=symbol,
|
||||
side=sd,
|
||||
reduce_only=reduce_only,
|
||||
margin_coin=str(margin_coin or "USDT"),
|
||||
product_type=str(product_type or "USDT-FUTURES"),
|
||||
)
|
||||
)
|
||||
# Force maker behavior when requested (avoid taker fills).
|
||||
if post_only:
|
||||
body["force"] = "post_only"
|
||||
else:
|
||||
body["force"] = "gtc"
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = "YES"
|
||||
if client_order_id:
|
||||
body["clientOid"] = str(client_order_id)
|
||||
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
|
||||
raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only)
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") if isinstance(data, dict) else ""
|
||||
return LiveOrderResult(exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)
|
||||
|
||||
@@ -489,16 +489,35 @@ class StrategyService:
|
||||
f"but fails for market_type={market_type}. This is almost always a permissions/product mismatch."
|
||||
)
|
||||
msg = f"{msg} | {hint}"
|
||||
hint_cn = (
|
||||
"币安接口返回 -2015(密钥/IP/权限不匹配)。请逐项核对:"
|
||||
"① API Key 是否勾选与当前测试一致的业务(现货选现货权限,合约选合约/U 本位权限);"
|
||||
"② 若启用 IP 白名单,是否包含当前服务器出口 IP(见下方 egress_ip);"
|
||||
"③ base_url 与密钥环境一致(主网密钥配 api.binance.com / fapi,模拟盘配 demo 域名与 demo Key);"
|
||||
"④ 无多余空格、复制完整 Secret。"
|
||||
)
|
||||
if alt_ok:
|
||||
hint_cn += (
|
||||
f" 自动探测:同一密钥在 market_type={alt_market_type} 可通过,"
|
||||
f"当前选择的 {market_type} 与密钥权限不一致的可能性很大。"
|
||||
)
|
||||
else:
|
||||
hint_cn = ""
|
||||
|
||||
fail_payload = {
|
||||
'exchange': safe_cfg,
|
||||
'client': client_kind,
|
||||
'market_type': market_type,
|
||||
'egress_ip': egress_ip,
|
||||
'base_url': getattr(client, "base_url", "") or "",
|
||||
}
|
||||
if hint_cn:
|
||||
fail_payload['hint_cn'] = hint_cn
|
||||
|
||||
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 "",
|
||||
},
|
||||
'data': fail_payload,
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Python 策略脚本(on_init / on_bar + ctx.buy/sell/close_position)运行时。
|
||||
与回测逻辑对齐,供 TradingExecutor 实盘逐根 K 线调用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ScriptBar(dict):
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
try:
|
||||
return self[name]
|
||||
except KeyError as exc:
|
||||
raise AttributeError(name) from exc
|
||||
|
||||
|
||||
class ScriptPosition(dict):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.clear_position()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
try:
|
||||
return self[name]
|
||||
except KeyError as exc:
|
||||
raise AttributeError(name) from exc
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.get('side')) and float(self.get('size') or 0) > 0
|
||||
|
||||
def __int__(self) -> int:
|
||||
return int(self.get('direction') or 0)
|
||||
|
||||
def __float__(self) -> float:
|
||||
return float(self.get('direction') or 0)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
try:
|
||||
return int(self) == int(other)
|
||||
except Exception:
|
||||
return dict.__eq__(self, other)
|
||||
|
||||
def __lt__(self, other: Any) -> bool:
|
||||
return int(self) < int(other)
|
||||
|
||||
def __le__(self, other: Any) -> bool:
|
||||
return int(self) <= int(other)
|
||||
|
||||
def __gt__(self, other: Any) -> bool:
|
||||
return int(self) > int(other)
|
||||
|
||||
def __ge__(self, other: Any) -> bool:
|
||||
return int(self) >= int(other)
|
||||
|
||||
def clear_position(self) -> None:
|
||||
self.clear()
|
||||
self.update({
|
||||
'side': '',
|
||||
'size': 0.0,
|
||||
'entry_price': 0.0,
|
||||
'direction': 0,
|
||||
'amount': 0.0,
|
||||
})
|
||||
|
||||
def open_position(self, side: str, entry_price: float, amount: float) -> None:
|
||||
direction = 1 if side == 'long' else (-1 if side == 'short' else 0)
|
||||
size = float(amount or 0.0)
|
||||
price = float(entry_price or 0.0)
|
||||
self.clear()
|
||||
self.update({
|
||||
'side': side,
|
||||
'size': size,
|
||||
'entry_price': price,
|
||||
'direction': direction,
|
||||
'amount': size,
|
||||
})
|
||||
|
||||
def add_position(self, entry_price: float, amount: float) -> None:
|
||||
extra = float(amount or 0.0)
|
||||
if extra <= 0:
|
||||
return
|
||||
current_size = float(self.get('size') or 0.0)
|
||||
current_price = float(self.get('entry_price') or 0.0)
|
||||
next_size = current_size + extra
|
||||
next_price = float(entry_price or current_price or 0.0)
|
||||
if current_size > 0 and current_price > 0 and next_size > 0:
|
||||
next_price = ((current_price * current_size) + (float(entry_price or current_price) * extra)) / next_size
|
||||
self['size'] = next_size
|
||||
self['amount'] = next_size
|
||||
self['entry_price'] = next_price
|
||||
|
||||
|
||||
class StrategyScriptContext:
|
||||
"""与回测 ScriptBacktestContext 行为一致,供实盘按根推进。"""
|
||||
|
||||
def __init__(self, bars_df: pd.DataFrame, initial_balance: float):
|
||||
self._bars_df = bars_df
|
||||
self._params: Dict[str, Any] = {}
|
||||
self._orders: List[Dict[str, Any]] = []
|
||||
self._logs: List[str] = []
|
||||
self.current_index = -1
|
||||
self.position = ScriptPosition()
|
||||
self.balance = float(initial_balance)
|
||||
self.equity = float(initial_balance)
|
||||
|
||||
def param(self, name: str, default: Any = None) -> Any:
|
||||
if name not in self._params:
|
||||
self._params[name] = default
|
||||
return self._params[name]
|
||||
|
||||
def bars(self, n: int = 1):
|
||||
start = max(0, self.current_index - int(n) + 1)
|
||||
out = []
|
||||
for _, row in self._bars_df.iloc[start:self.current_index + 1].iterrows():
|
||||
out.append(ScriptBar(
|
||||
open=float(row.get('open') or 0),
|
||||
high=float(row.get('high') or 0),
|
||||
low=float(row.get('low') or 0),
|
||||
close=float(row.get('close') or 0),
|
||||
volume=float(row.get('volume') or 0),
|
||||
timestamp=row.get('time')
|
||||
))
|
||||
return out
|
||||
|
||||
def log(self, message: Any):
|
||||
self._logs.append(str(message))
|
||||
|
||||
def buy(self, price: Any = None, amount: Any = None):
|
||||
self._orders.append({'action': 'buy', 'price': price, 'amount': amount})
|
||||
|
||||
def sell(self, price: Any = None, amount: Any = None):
|
||||
self._orders.append({'action': 'sell', 'price': price, 'amount': amount})
|
||||
|
||||
def close_position(self):
|
||||
self._orders.append({'action': 'close'})
|
||||
|
||||
|
||||
def compile_strategy_script_handlers(code: str) -> Tuple[Optional[Callable], Optional[Callable]]:
|
||||
"""
|
||||
校验并编译策略脚本,返回 (on_init, on_bar)。
|
||||
on_bar 不可缺省;on_init 可选。
|
||||
"""
|
||||
if not code or not str(code).strip():
|
||||
raise ValueError("Strategy script is empty")
|
||||
|
||||
import builtins
|
||||
|
||||
def safe_import(name, *args, **kwargs):
|
||||
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time']
|
||||
if name in allowed_modules or name.split('.')[0] in allowed_modules:
|
||||
return builtins.__import__(name, *args, **kwargs)
|
||||
raise ImportError(f"Import not allowed: {name}")
|
||||
|
||||
safe_builtins = {k: getattr(builtins, k) for k in dir(builtins)
|
||||
if not k.startswith('_') and k not in ['eval', 'exec', 'compile', 'open', 'input', 'help', 'exit', 'quit']}
|
||||
safe_builtins['__import__'] = safe_import
|
||||
|
||||
exec_env = {
|
||||
'__builtins__': safe_builtins,
|
||||
'np': np,
|
||||
'pd': pd,
|
||||
}
|
||||
|
||||
from app.utils.safe_exec import validate_code_safety, safe_exec_code
|
||||
|
||||
is_safe, error_msg = validate_code_safety(code)
|
||||
if not is_safe:
|
||||
raise ValueError(f"Code contains unsafe operations: {error_msg}")
|
||||
|
||||
exec_result = safe_exec_code(
|
||||
code=code,
|
||||
exec_globals=exec_env,
|
||||
exec_locals=exec_env,
|
||||
timeout=60
|
||||
)
|
||||
if not exec_result['success']:
|
||||
raise RuntimeError(f"Code execution failed: {exec_result.get('error')}")
|
||||
|
||||
on_init = exec_env.get('on_init')
|
||||
on_bar = exec_env.get('on_bar')
|
||||
if not callable(on_bar):
|
||||
raise ValueError("Strategy script must define on_bar(ctx, bar)")
|
||||
if on_init is not None and not callable(on_init):
|
||||
on_init = None
|
||||
return (on_init if callable(on_init) else None), on_bar
|
||||
@@ -1,5 +1,8 @@
|
||||
"""
|
||||
实时交易执行服务
|
||||
实时交易执行服务。
|
||||
|
||||
策略线程:拉 K 线/价格、算信号,将订单写入 pending_orders。
|
||||
实盘成交由 PendingOrderWorker + app.services.live_trading(各所直连 REST)完成,不在此模块使用 ccxt 下单。
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
@@ -15,13 +18,17 @@ import json
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import ccxt
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.data_sources import DataSourceFactory
|
||||
from app.services.kline import KlineService
|
||||
from app.services.indicator_params import IndicatorParamsParser, IndicatorCaller
|
||||
from app.services.strategy_script_runtime import (
|
||||
ScriptBar,
|
||||
StrategyScriptContext,
|
||||
compile_strategy_script_handlers,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -465,6 +472,212 @@ class TradingExecutor:
|
||||
logger.error(f"Failed to stop strategy {strategy_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
def _df_to_script_exec_df(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
out = df.reset_index()
|
||||
c0 = out.columns[0]
|
||||
if c0 != 'time':
|
||||
out.rename(columns={c0: 'time'}, inplace=True)
|
||||
return out
|
||||
|
||||
def _script_default_position_ratio(self, trading_config: Dict[str, Any]) -> float:
|
||||
try:
|
||||
ep = (trading_config or {}).get('entry_pct')
|
||||
if ep is not None:
|
||||
return float(self._to_ratio(ep, default=0.06))
|
||||
except Exception:
|
||||
pass
|
||||
return 0.06
|
||||
|
||||
def _hydrate_script_ctx_from_positions(self, ctx: StrategyScriptContext, strategy_id: int, symbol: str) -> None:
|
||||
ctx.position.clear_position()
|
||||
pl = self._get_current_positions(strategy_id, symbol)
|
||||
if not pl:
|
||||
return
|
||||
p = pl[0]
|
||||
side = (p.get('side') or 'long').strip().lower()
|
||||
if side not in ('long', 'short'):
|
||||
return
|
||||
size = float(p.get('size') or 0)
|
||||
ep = float(p.get('entry_price') or 0)
|
||||
if size > 0:
|
||||
ctx.position.open_position(side, ep, size)
|
||||
|
||||
def _init_script_strategy_context(
|
||||
self,
|
||||
strategy_id: int,
|
||||
df: pd.DataFrame,
|
||||
trading_config: Dict[str, Any],
|
||||
initial_capital: float,
|
||||
) -> Tuple[StrategyScriptContext, Optional[pd.Timestamp]]:
|
||||
df_exec = self._df_to_script_exec_df(df)
|
||||
ctx = StrategyScriptContext(df_exec, float(initial_capital or 0))
|
||||
raw = (trading_config or {}).get('script_runtime_state') or {}
|
||||
params = raw.get('params') if isinstance(raw, dict) else {}
|
||||
if isinstance(params, dict):
|
||||
ctx._params = dict(params)
|
||||
last_ts = None
|
||||
ts_s = raw.get('last_closed_bar_ts') if isinstance(raw, dict) else None
|
||||
if ts_s:
|
||||
try:
|
||||
last_ts = pd.Timestamp(ts_s)
|
||||
if last_ts.tzinfo is None:
|
||||
last_ts = last_ts.tz_localize('UTC')
|
||||
else:
|
||||
last_ts = last_ts.tz_convert('UTC')
|
||||
except Exception:
|
||||
last_ts = None
|
||||
return ctx, last_ts
|
||||
|
||||
def _persist_script_runtime_state(self, strategy_id: int, closed_ts: Any, params: Dict[str, Any]) -> None:
|
||||
try:
|
||||
safe_params = json.loads(json.dumps(params or {}, default=str))
|
||||
except Exception:
|
||||
safe_params = {}
|
||||
ts_str = ''
|
||||
try:
|
||||
if closed_ts is not None:
|
||||
ts_str = pd.Timestamp(closed_ts).isoformat()
|
||||
except Exception:
|
||||
ts_str = ''
|
||||
state = {'last_closed_bar_ts': ts_str, 'params': safe_params}
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT trading_config FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return
|
||||
tc = row.get('trading_config')
|
||||
if isinstance(tc, str) and tc.strip():
|
||||
try:
|
||||
tc = json.loads(tc)
|
||||
except Exception:
|
||||
tc = {}
|
||||
elif not isinstance(tc, dict):
|
||||
tc = {}
|
||||
tc['script_runtime_state'] = state
|
||||
cur.execute(
|
||||
"UPDATE qd_strategies_trading SET trading_config = %s WHERE id = %s",
|
||||
(json.dumps(tc, ensure_ascii=False), strategy_id),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Persist script runtime state failed: {e}")
|
||||
|
||||
def _script_orders_to_execution_signals(
|
||||
self,
|
||||
ctx: StrategyScriptContext,
|
||||
trade_direction: str,
|
||||
bar_close: float,
|
||||
closed_ts: pd.Timestamp,
|
||||
trading_config: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
td = str(trade_direction or 'both').lower()
|
||||
if td not in ('long', 'short', 'both'):
|
||||
td = 'both'
|
||||
default_ratio = self._script_default_position_ratio(trading_config)
|
||||
try:
|
||||
ts_i = int(closed_ts.timestamp())
|
||||
except Exception:
|
||||
ts_i = int(time.time())
|
||||
out: List[Dict[str, Any]] = []
|
||||
trig = float(bar_close or 0)
|
||||
for order in list(ctx._orders or []):
|
||||
action = str(order.get('action') or '').lower()
|
||||
try:
|
||||
order_price = float(order.get('price') or bar_close or 0)
|
||||
except Exception:
|
||||
order_price = trig
|
||||
raw_amt = order.get('amount')
|
||||
pos_ratio = default_ratio
|
||||
if raw_amt is not None:
|
||||
try:
|
||||
v = float(raw_amt)
|
||||
if v > 0:
|
||||
pos_ratio = v
|
||||
except Exception:
|
||||
pass
|
||||
if action == 'close':
|
||||
if ctx.position > 0:
|
||||
out.append({'type': 'close_long', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
|
||||
ctx.position.clear_position()
|
||||
elif ctx.position < 0:
|
||||
out.append({'type': 'close_short', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
|
||||
ctx.position.clear_position()
|
||||
continue
|
||||
if action == 'buy':
|
||||
if ctx.position < 0:
|
||||
out.append({'type': 'close_short', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
|
||||
ctx.position.clear_position()
|
||||
if td in ('long', 'both'):
|
||||
if ctx.position == 0:
|
||||
out.append({'type': 'open_long', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
|
||||
ctx.position.open_position('long', order_price or trig, pos_ratio)
|
||||
else:
|
||||
out.append({'type': 'add_long', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
|
||||
ctx.position.add_position(order_price or trig, pos_ratio)
|
||||
continue
|
||||
if action == 'sell':
|
||||
if ctx.position > 0:
|
||||
out.append({'type': 'close_long', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
|
||||
ctx.position.clear_position()
|
||||
if td in ('short', 'both'):
|
||||
if ctx.position == 0:
|
||||
out.append({'type': 'open_short', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
|
||||
ctx.position.open_position('short', order_price or trig, pos_ratio)
|
||||
else:
|
||||
out.append({'type': 'add_short', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
|
||||
ctx.position.add_position(order_price or trig, pos_ratio)
|
||||
return out
|
||||
|
||||
def _script_evaluate_new_closed_bar(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
ctx: StrategyScriptContext,
|
||||
on_bar,
|
||||
trade_direction: str,
|
||||
last_closed_ts: Optional[pd.Timestamp],
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
trading_config: Dict[str, Any],
|
||||
) -> Tuple[List[Dict[str, Any]], Optional[pd.Timestamp]]:
|
||||
if df is None or len(df) < 2:
|
||||
return [], last_closed_ts
|
||||
closed_ts = df.index[-2]
|
||||
try:
|
||||
if last_closed_ts is not None and closed_ts <= last_closed_ts:
|
||||
return [], last_closed_ts
|
||||
except Exception:
|
||||
pass
|
||||
df_exec = self._df_to_script_exec_df(df)
|
||||
ctx._bars_df = df_exec
|
||||
pos = len(df) - 2
|
||||
ctx.current_index = int(pos)
|
||||
row = df_exec.iloc[pos]
|
||||
self._hydrate_script_ctx_from_positions(ctx, strategy_id, symbol)
|
||||
ctx._orders = []
|
||||
bar = ScriptBar(
|
||||
open=float(row.get('open') or 0),
|
||||
high=float(row.get('high') or 0),
|
||||
low=float(row.get('low') or 0),
|
||||
close=float(row.get('close') or 0),
|
||||
volume=float(row.get('volume') or 0),
|
||||
timestamp=row.get('time'),
|
||||
)
|
||||
try:
|
||||
on_bar(ctx, bar)
|
||||
except Exception as e:
|
||||
logger.error(f"Strategy {strategy_id} script on_bar error: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
return [], last_closed_ts
|
||||
bar_close = float(row.get('close') or 0)
|
||||
pending = self._script_orders_to_execution_signals(ctx, trade_direction, bar_close, closed_ts, trading_config)
|
||||
self._persist_script_runtime_state(strategy_id, closed_ts, ctx._params)
|
||||
logger.info(f"Strategy {strategy_id} script closed bar {closed_ts} -> {len(pending)} signal(s)")
|
||||
return pending, closed_ts
|
||||
|
||||
def _run_strategy_loop(self, strategy_id: int):
|
||||
"""
|
||||
@@ -483,13 +696,15 @@ class TradingExecutor:
|
||||
logger.error(f"Strategy {strategy_id} not found")
|
||||
return
|
||||
|
||||
if strategy['strategy_type'] != 'IndicatorStrategy':
|
||||
logger.error(f"Strategy {strategy_id} has unsupported strategy_type for realtime execution: {strategy['strategy_type']}")
|
||||
stype = strategy.get('strategy_type') or ''
|
||||
if stype not in ('IndicatorStrategy', 'ScriptStrategy'):
|
||||
logger.error(f"Strategy {strategy_id} has unsupported strategy_type for realtime execution: {stype}")
|
||||
return
|
||||
|
||||
is_script = stype == 'ScriptStrategy'
|
||||
|
||||
# 初始化策略状态
|
||||
trading_config = strategy['trading_config']
|
||||
indicator_config = strategy['indicator_config']
|
||||
indicator_config = strategy.get('indicator_config') or {}
|
||||
ai_model_config = strategy.get('ai_model_config') or {}
|
||||
execution_mode = (strategy.get('execution_mode') or 'signal').strip().lower()
|
||||
if execution_mode not in ['signal', 'live']:
|
||||
@@ -545,68 +760,84 @@ class TradingExecutor:
|
||||
market_category = (strategy.get('market_category') or 'Crypto').strip()
|
||||
logger.info(f"Strategy {strategy_id} market_category: {market_category}")
|
||||
|
||||
# Check if this is a cross-sectional strategy
|
||||
cs_strategy_type = trading_config.get('cs_strategy_type', 'single')
|
||||
if cs_strategy_type == 'cross_sectional':
|
||||
# Run cross-sectional strategy loop
|
||||
self._run_cross_sectional_strategy_loop(
|
||||
strategy_id, strategy, trading_config, indicator_config,
|
||||
ai_model_config, execution_mode, notification_config,
|
||||
strategy_name, market_category, market_type, leverage,
|
||||
initial_capital, indicator_code, indicator_id
|
||||
)
|
||||
return
|
||||
|
||||
# 初始化交易所连接(信号模式下无需真实连接)
|
||||
exchange = None
|
||||
|
||||
# 安全获取 initial_capital
|
||||
# 安全获取 initial_capital(横截面分支也需要)
|
||||
try:
|
||||
initial_capital_val = strategy.get('initial_capital', 1000)
|
||||
if isinstance(initial_capital_val, (list, tuple)):
|
||||
initial_capital_val = initial_capital_val[0] if initial_capital_val else 1000
|
||||
initial_capital = float(initial_capital_val)
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(f"Strategy {strategy_id} invalid initial_capital format, reset to 1000: {strategy.get('initial_capital')}")
|
||||
initial_capital = 1000.0
|
||||
|
||||
# 净值会在首次更新持仓时自动计算和更新
|
||||
|
||||
# 获取指标代码
|
||||
indicator_id = indicator_config.get('indicator_id')
|
||||
indicator_code = indicator_config.get('indicator_code', '')
|
||||
|
||||
# 如果代码为空,尝试从数据库获取
|
||||
if not indicator_code and indicator_id:
|
||||
indicator_code = self._get_indicator_code_from_db(indicator_id)
|
||||
|
||||
if not indicator_code:
|
||||
logger.error(f"Strategy {strategy_id} indicator_code is empty")
|
||||
return
|
||||
|
||||
# 确保 indicator_code 是字符串(处理 JSON 转义问题)
|
||||
if not isinstance(indicator_code, str):
|
||||
indicator_code = str(indicator_code)
|
||||
|
||||
# 处理可能的 JSON 转义问题
|
||||
if '\\n' in indicator_code and '\n' not in indicator_code:
|
||||
|
||||
indicator_id = None
|
||||
indicator_code = ''
|
||||
strategy_code = ''
|
||||
on_init_script = None
|
||||
on_bar_script = None
|
||||
|
||||
if is_script:
|
||||
strategy_code = (strategy.get('strategy_code') or '').strip()
|
||||
if not strategy_code:
|
||||
logger.error(f"Strategy {strategy_id} strategy_code is empty")
|
||||
return
|
||||
if '\\n' in strategy_code and '\n' not in strategy_code:
|
||||
try:
|
||||
decoded = json.loads(f'"{strategy_code}"')
|
||||
if isinstance(decoded, str):
|
||||
strategy_code = decoded
|
||||
except Exception:
|
||||
strategy_code = (
|
||||
strategy_code.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r')
|
||||
.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
|
||||
)
|
||||
try:
|
||||
import json
|
||||
decoded = json.loads(f'"{indicator_code}"')
|
||||
if isinstance(decoded, str):
|
||||
indicator_code = decoded
|
||||
logger.info(f"Strategy {strategy_id} decoded escaped indicator_code")
|
||||
on_init_script, on_bar_script = compile_strategy_script_handlers(strategy_code)
|
||||
except Exception as e:
|
||||
logger.warning(f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}")
|
||||
indicator_code = (
|
||||
indicator_code
|
||||
.replace('\\n', '\n')
|
||||
.replace('\\t', '\t')
|
||||
.replace('\\r', '\r')
|
||||
.replace('\\"', '"')
|
||||
.replace("\\'", "'")
|
||||
.replace('\\\\', '\\')
|
||||
)
|
||||
logger.error(f"Strategy {strategy_id} script compile failed: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
return
|
||||
else:
|
||||
indicator_config = strategy['indicator_config']
|
||||
indicator_id = indicator_config.get('indicator_id')
|
||||
indicator_code = indicator_config.get('indicator_code', '')
|
||||
if not indicator_code and indicator_id:
|
||||
indicator_code = self._get_indicator_code_from_db(indicator_id)
|
||||
if not indicator_code:
|
||||
logger.error(f"Strategy {strategy_id} indicator_code is empty")
|
||||
return
|
||||
if not isinstance(indicator_code, str):
|
||||
indicator_code = str(indicator_code)
|
||||
if '\\n' in indicator_code and '\n' not in indicator_code:
|
||||
try:
|
||||
decoded = json.loads(f'"{indicator_code}"')
|
||||
if isinstance(decoded, str):
|
||||
indicator_code = decoded
|
||||
logger.info(f"Strategy {strategy_id} decoded escaped indicator_code")
|
||||
except Exception as e:
|
||||
logger.warning(f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}")
|
||||
indicator_code = (
|
||||
indicator_code.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r')
|
||||
.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
|
||||
)
|
||||
|
||||
# Check if this is a cross-sectional strategy(仅指标策略支持)
|
||||
cs_strategy_type = trading_config.get('cs_strategy_type', 'single')
|
||||
if (not is_script) and cs_strategy_type == 'cross_sectional':
|
||||
self._run_cross_sectional_strategy_loop(
|
||||
strategy_id, strategy, trading_config, strategy['indicator_config'],
|
||||
ai_model_config, execution_mode, notification_config,
|
||||
strategy_name, market_category, market_type, leverage,
|
||||
initial_capital, indicator_code, indicator_id
|
||||
)
|
||||
return
|
||||
|
||||
if is_script and cs_strategy_type == 'cross_sectional':
|
||||
logger.error(f"Strategy {strategy_id} ScriptStrategy does not support cross_sectional mode")
|
||||
return
|
||||
|
||||
# 初始化交易所连接(信号模式下无需真实连接)
|
||||
exchange = None
|
||||
|
||||
# ============================================
|
||||
# 初始化阶段:获取历史K线并计算指标
|
||||
@@ -658,29 +889,47 @@ class TradingExecutor:
|
||||
initial_position_count = 1 # 简化处理,假设是单笔持仓
|
||||
initial_last_add_price = initial_avg_entry_price
|
||||
|
||||
# 关键诊断日志:确认指标是否拿到了持仓状态
|
||||
logger.info(
|
||||
f"策略 {strategy_id} 指标注入持仓状态: count={len(current_pos_list)}, "
|
||||
f"策略 {strategy_id} 持仓快照: count={len(current_pos_list)}, "
|
||||
f"position={initial_position}, entry_price={initial_avg_entry_price}, highest={initial_highest}"
|
||||
)
|
||||
|
||||
# 执行指标代码,获取信号和触发价格
|
||||
indicator_result = self._execute_indicator_with_prices(
|
||||
indicator_code, df, trading_config,
|
||||
initial_highest_price=initial_highest,
|
||||
initial_position=initial_position,
|
||||
initial_avg_entry_price=initial_avg_entry_price,
|
||||
initial_position_count=initial_position_count,
|
||||
initial_last_add_price=initial_last_add_price
|
||||
)
|
||||
if indicator_result is None:
|
||||
logger.error(f"Strategy {strategy_id} indicator execution failed")
|
||||
return
|
||||
|
||||
# 提取信号和触发价格
|
||||
pending_signals = indicator_result.get('pending_signals', []) # 待触发的信号列表
|
||||
last_kline_time = indicator_result.get('last_kline_time', 0) # 最后一根K线的时间
|
||||
|
||||
script_ctx = None
|
||||
last_script_closed_ts = None
|
||||
if is_script:
|
||||
script_ctx, last_script_closed_ts = self._init_script_strategy_context(
|
||||
strategy_id, df, trading_config, initial_capital
|
||||
)
|
||||
if on_init_script:
|
||||
self._hydrate_script_ctx_from_positions(script_ctx, strategy_id, symbol)
|
||||
try:
|
||||
on_init_script(script_ctx)
|
||||
except Exception as e:
|
||||
logger.error(f"Strategy {strategy_id} on_init error: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
pending_signals, last_script_closed_ts = self._script_evaluate_new_closed_bar(
|
||||
df, script_ctx, on_bar_script, trade_direction,
|
||||
last_script_closed_ts, strategy_id, symbol, trading_config,
|
||||
)
|
||||
try:
|
||||
last_kline_time = int(df.index[-1].timestamp())
|
||||
except Exception:
|
||||
last_kline_time = int(time.time())
|
||||
else:
|
||||
indicator_result = self._execute_indicator_with_prices(
|
||||
indicator_code, df, trading_config,
|
||||
initial_highest_price=initial_highest,
|
||||
initial_position=initial_position,
|
||||
initial_avg_entry_price=initial_avg_entry_price,
|
||||
initial_position_count=initial_position_count,
|
||||
initial_last_add_price=initial_last_add_price
|
||||
)
|
||||
if indicator_result is None:
|
||||
logger.error(f"Strategy {strategy_id} indicator execution failed")
|
||||
return
|
||||
pending_signals = indicator_result.get('pending_signals', [])
|
||||
last_kline_time = indicator_result.get('last_kline_time', 0)
|
||||
|
||||
logger.info(f"Strategy {strategy_id} initialized; pending_signals={len(pending_signals)}")
|
||||
if pending_signals:
|
||||
logger.info(f"Initial signals: {pending_signals}")
|
||||
@@ -744,52 +993,63 @@ class TradingExecutor:
|
||||
if klines and len(klines) >= 2:
|
||||
df = self._klines_to_dataframe(klines)
|
||||
if len(df) > 0:
|
||||
current_pos_list = self._get_current_positions(strategy_id, symbol)
|
||||
initial_highest = 0.0
|
||||
initial_position = 0
|
||||
initial_avg_entry_price = 0.0
|
||||
initial_position_count = 0
|
||||
initial_last_add_price = 0.0
|
||||
|
||||
if current_pos_list:
|
||||
pos = current_pos_list[0]
|
||||
initial_highest = float(pos.get('highest_price', 0) or 0)
|
||||
pos_side = pos.get('side', 'long')
|
||||
initial_position = 1 if pos_side == 'long' else -1
|
||||
initial_avg_entry_price = float(pos.get('entry_price', 0) or 0)
|
||||
initial_position_count = 1
|
||||
initial_last_add_price = initial_avg_entry_price
|
||||
|
||||
indicator_result = self._execute_indicator_with_prices(
|
||||
indicator_code, df, trading_config,
|
||||
initial_highest_price=initial_highest,
|
||||
initial_position=initial_position,
|
||||
initial_avg_entry_price=initial_avg_entry_price,
|
||||
initial_position_count=initial_position_count,
|
||||
initial_last_add_price=initial_last_add_price
|
||||
)
|
||||
if indicator_result:
|
||||
pending_signals = indicator_result.get('pending_signals', [])
|
||||
last_kline_time = indicator_result.get('last_kline_time', 0)
|
||||
new_hp = indicator_result.get('new_highest_price', 0)
|
||||
|
||||
if is_script:
|
||||
new_sig, last_script_closed_ts = self._script_evaluate_new_closed_bar(
|
||||
df, script_ctx, on_bar_script, trade_direction,
|
||||
last_script_closed_ts, strategy_id, symbol, trading_config,
|
||||
)
|
||||
pending_signals = new_sig
|
||||
try:
|
||||
last_kline_time = int(df.index[-1].timestamp())
|
||||
except Exception:
|
||||
last_kline_time = int(time.time())
|
||||
last_kline_update_time = current_time
|
||||
else:
|
||||
current_pos_list = self._get_current_positions(strategy_id, symbol)
|
||||
initial_highest = 0.0
|
||||
initial_position = 0
|
||||
initial_avg_entry_price = 0.0
|
||||
initial_position_count = 0
|
||||
initial_last_add_price = 0.0
|
||||
|
||||
# 更新 highest_price(使用最新 close 作为 current_price 的近似)
|
||||
if new_hp > 0 and current_pos_list:
|
||||
current_close = float(df['close'].iloc[-1])
|
||||
for p in current_pos_list:
|
||||
self._update_position(
|
||||
strategy_id, p['symbol'], p['side'],
|
||||
float(p['size']), float(p['entry_price']),
|
||||
current_close,
|
||||
highest_price=new_hp
|
||||
)
|
||||
if current_pos_list:
|
||||
pos = current_pos_list[0]
|
||||
initial_highest = float(pos.get('highest_price', 0) or 0)
|
||||
pos_side = pos.get('side', 'long')
|
||||
initial_position = 1 if pos_side == 'long' else -1
|
||||
initial_avg_entry_price = float(pos.get('entry_price', 0) or 0)
|
||||
initial_position_count = 1
|
||||
initial_last_add_price = initial_avg_entry_price
|
||||
|
||||
indicator_result = self._execute_indicator_with_prices(
|
||||
indicator_code, df, trading_config,
|
||||
initial_highest_price=initial_highest,
|
||||
initial_position=initial_position,
|
||||
initial_avg_entry_price=initial_avg_entry_price,
|
||||
initial_position_count=initial_position_count,
|
||||
initial_last_add_price=initial_last_add_price
|
||||
)
|
||||
if indicator_result:
|
||||
pending_signals = indicator_result.get('pending_signals', [])
|
||||
last_kline_time = indicator_result.get('last_kline_time', 0)
|
||||
new_hp = indicator_result.get('new_highest_price', 0)
|
||||
|
||||
last_kline_update_time = current_time
|
||||
|
||||
if new_hp > 0 and current_pos_list:
|
||||
current_close = float(df['close'].iloc[-1])
|
||||
for p in current_pos_list:
|
||||
self._update_position(
|
||||
strategy_id, p['symbol'], p['side'],
|
||||
float(p['size']), float(p['entry_price']),
|
||||
current_close,
|
||||
highest_price=new_hp
|
||||
)
|
||||
else:
|
||||
# ============================================
|
||||
# 3. 非K线更新tick:用当前价更新最后一根K线并重算指标(统一tick节奏)
|
||||
# 3. 非K线更新 tick:脚本策略不在这里重算(仅在新 K 收盘时 on_bar)
|
||||
# ============================================
|
||||
if 'df' in locals() and df is not None and len(df) > 0:
|
||||
if (not is_script) and 'df' in locals() and df is not None and len(df) > 0:
|
||||
try:
|
||||
realtime_df = df.copy()
|
||||
realtime_df = self._update_dataframe_with_current_price(realtime_df, current_price, timeframe)
|
||||
@@ -1055,7 +1315,7 @@ class TradingExecutor:
|
||||
initial_capital, leverage, decide_interval,
|
||||
execution_mode, notification_config,
|
||||
indicator_config, exchange_config, trading_config, ai_model_config,
|
||||
market_category
|
||||
market_category, strategy_code
|
||||
FROM qd_strategies_trading
|
||||
WHERE id = %s
|
||||
"""
|
||||
@@ -1144,8 +1404,14 @@ class TradingExecutor:
|
||||
market_type: str = None,
|
||||
leverage: float = None,
|
||||
strategy_id: int = None
|
||||
) -> Optional[ccxt.Exchange]:
|
||||
"""(Mock) 信号模式不需要真实交易所连接"""
|
||||
) -> Any:
|
||||
"""
|
||||
占位:策略线程内不创建交易所 SDK 实例。
|
||||
|
||||
实盘下单不经过本方法。信号经 _execute_exchange_order 写入 pending_orders,
|
||||
由 PendingOrderWorker 使用 app.services.live_trading 下的直连 REST 客户端执行。
|
||||
K 线/现价由 KlineService、DataSourceFactory 等数据层提供(该层可能使用 ccxt 拉行情,与下单解耦)。
|
||||
"""
|
||||
return None
|
||||
|
||||
def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500, market_category: str = 'Crypto') -> List[Dict[str, Any]]:
|
||||
@@ -2434,14 +2700,13 @@ class TradingExecutor:
|
||||
signal_ts: int = 0,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Convert a signal into a concrete pending order and enqueue it into DB.
|
||||
将信号转为 pending_orders 队列记录(本方法不直连交易所、不使用 ccxt)。
|
||||
|
||||
A separate worker will poll `pending_orders` and dispatch:
|
||||
- execution_mode='signal': dispatch notifications (no real trading).
|
||||
- execution_mode='live': reserved for future live trading execution (not implemented).
|
||||
PendingOrderWorker 轮询执行:
|
||||
- execution_mode='signal':仅通知/模拟路径。
|
||||
- execution_mode='live':通过 live_trading 包内的各交易所 REST 客户端下单(非 ccxt)。
|
||||
|
||||
Note: Order execution settings (order_mode, maker_wait_sec, maker_offset_bps) are now
|
||||
configured via environment variables and not passed from strategy config.
|
||||
行情/K 线不在此处拉取;order_mode 等由环境变量配置。
|
||||
"""
|
||||
try:
|
||||
# Reference price at enqueue time: use current tick price if provided to avoid extra fetch.
|
||||
|
||||
Reference in New Issue
Block a user