Signed-off-by: Dinger <quantdinger@gmail.com>
This commit is contained in:
Dinger
2026-04-06 23:19:41 +08:00
parent 3ca291a346
commit de3fd0945b
91 changed files with 1504 additions and 405 deletions
+255 -63
View File
@@ -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}")
+84 -19
View File
@@ -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'])
+71 -1
View File
@@ -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)
+26 -7
View File
@@ -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 线/现价由 KlineServiceDataSourceFactory 等数据层提供该层可能使用 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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,9 +39,9 @@ body.dark .ant-pagination-item-active,body.realdark .ant-pagination-item-active{
body.dark .ant-btn-default:hover,body.realdark .ant-btn-default:hover{border-color:#1890ff!important;color:#1890ff!important}
body.dark .ant-tabs-tab-active,body.realdark .ant-tabs-tab-active{color:#1890ff!important}
body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-selected,body.realdark .ant-select-dropdown .ant-select-dropdown-menu-item-selected{background:rgba(24,144,255,.1)!important;color:#1890ff!important}
.qt-header .qt-header-left .qt-icon[data-v-989be352]{color:#1890ff}
.theme-dark[data-v-989be352] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:#1890ff;border-color:#1890ff}
.theme-dark[data-v-989be352] .ant-slider-track{background:#1890ff}
.profile-exchange-modal .exchange-account-form .exchange-api-doc-callout{border:1px solid rgba(24,144,255,.18);background:-webkit-gradient(linear,left top,right top,from(rgba(24,144,255,.06)),to(rgba(82,196,26,.04)));background:linear-gradient(90deg,rgba(24,144,255,.06),rgba(82,196,26,.04))}
.profile-exchange-modal .exchange-account-form .exchange-api-doc-callout__icon{background:rgba(24,144,255,.12);color:#1890ff}
.profile-exchange-modal--dark .exchange-account-form .exchange-api-doc-callout{background:-webkit-gradient(linear,left top,right top,from(rgba(24,144,255,.12)),to(rgba(82,196,26,.08)));background:linear-gradient(90deg,rgba(24,144,255,.12),rgba(82,196,26,.08))}
.polymarket-analysis-modal .result-section .analysis-result .probability-comparison .prob-item .prob-value.ai-prob[data-v-67e891d2]{color:#1890ff}
.polymarket-analysis-modal .result-section .analysis-result .recommendation-section .rec-card .rec-value[data-v-67e891d2]{color:#1890ff}
.code-section .section-header .section-title[data-v-4fea1865]:before{background:#1890ff}
@@ -92,24 +92,26 @@ body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-selected,body.real
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.edit-icon[data-v-6bd239a6]:hover,body.dark,body.realdark{color:#40a9ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-6bd239a6],body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-6bd239a6]:hover,body.dark,body.realdark{color:#40a9ff}
.ai-markdown-content[data-v-2a402d06] h3{border-left:3px solid #1890ff}
.ai-markdown-content[data-v-2a402d06] blockquote{border-left:4px solid #91d5ff}
.page-header .page-title .title-icon[data-v-6853ba40]{-webkit-text-fill-color:#1890ff}
[data-v-6853ba40] .empty-result .empty-icon{background:linear-gradient(135deg,#e6f7ff,#f0f5ff);color:#1890ff}
[data-v-6853ba40] .running-spinner .spinner-fill{border-top-color:#1890ff}
[data-v-6853ba40] .running-elapsed{color:#1890ff}
[data-v-6853ba40] .running-steps .step-item.active{background:#e6f7ff;border-color:#91d5ff}
[data-v-6853ba40] .running-steps .step-item.active .step-icon{color:#1890ff}
[data-v-6853ba40] .running-steps .step-item.active .step-label{color:#1890ff}
[data-v-6853ba40] .result-ai-markdown-content/deep/h3{border-left:3px solid #1890ff}
[data-v-6853ba40] .result-ai-markdown-content/deep/blockquote{border-left:3px solid #91d5ff}
.add-item-active[data-v-6853ba40]{background:#e6f7ff!important}
.theme-dark .page-header .page-title .title-icon[data-v-6853ba40]{color:#40a9ff!important}
.trading-records[data-v-a5bdef7e] .ant-tag[color=blue]{background:linear-gradient(135deg,rgba(24,144,255,.15),rgba(24,144,255,.08));color:#1890ff;border:1px solid rgba(24,144,255,.3)}
.trading-records[data-v-a5bdef7e] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
.trading-records[data-v-a5bdef7e] .ant-pagination .ant-pagination-item:hover a{color:#1890ff}
.trading-records[data-v-a5bdef7e] .ant-pagination .ant-pagination-item.ant-pagination-item-active{background:linear-gradient(135deg,#1890ff,#40a9ff);border-color:#1890ff}
.trading-records[data-v-a5bdef7e] .ant-pagination .ant-pagination-next .ant-pagination-item-link:hover,.trading-records[data-v-a5bdef7e] .ant-pagination .ant-pagination-prev .ant-pagination-item-link:hover{border-color:#1890ff;color:#1890ff}
.ai-markdown-content[data-v-9c4d8af2] h3{border-left:3px solid #1890ff}
.ai-markdown-content[data-v-9c4d8af2] blockquote{border-left:4px solid #91d5ff}
.backtest-history-drawer-wrap--dark .drawer-toolbar .ant-btn-primary.ant-btn-background-ghost{color:#69c0ff}
.backtest-history-drawer-wrap--dark .drawer-toolbar .ant-btn-primary.ant-btn-background-ghost:focus:not(:disabled),.backtest-history-drawer-wrap--dark .drawer-toolbar .ant-btn-primary.ant-btn-background-ghost:hover:not(:disabled){color:#91d5ff}
.page-header .page-title .title-icon[data-v-1daf6ac6]{-webkit-text-fill-color:#1890ff}
[data-v-1daf6ac6] .empty-result .empty-icon{background:linear-gradient(135deg,#e6f7ff,#f0f5ff);color:#1890ff}
[data-v-1daf6ac6] .running-spinner .spinner-fill{border-top-color:#1890ff}
[data-v-1daf6ac6] .running-elapsed{color:#1890ff}
[data-v-1daf6ac6] .running-steps .step-item.active{background:#e6f7ff;border-color:#91d5ff}
[data-v-1daf6ac6] .running-steps .step-item.active .step-icon{color:#1890ff}
[data-v-1daf6ac6] .running-steps .step-item.active .step-label{color:#1890ff}
[data-v-1daf6ac6] .result-ai-markdown-content/deep/h3{border-left:3px solid #1890ff}
[data-v-1daf6ac6] .result-ai-markdown-content/deep/blockquote{border-left:3px solid #91d5ff}
.add-item-active[data-v-1daf6ac6]{background:#e6f7ff!important}
.theme-dark .page-header .page-title .title-icon[data-v-1daf6ac6]{color:#40a9ff!important}
.trading-records[data-v-bdf54cd6] .ant-tag[color=blue]{background:linear-gradient(135deg,rgba(24,144,255,.15),rgba(24,144,255,.08));color:#1890ff;border:1px solid rgba(24,144,255,.3)}
.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-item:hover a{color:#1890ff}
.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-item.ant-pagination-item-active{background:linear-gradient(135deg,#1890ff,#40a9ff);border-color:#1890ff}
.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-next .ant-pagination-item-link:hover,.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-prev .ant-pagination-item-link:hover{border-color:#1890ff;color:#1890ff}
.theme-dark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover,body.dark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover,body.realdark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover{border-color:#1890ff!important}
.theme-dark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover a,body.dark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover a,body.realdark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover a{color:#1890ff!important}
.theme-dark .trading-records[data-v-8a68b65a] .ant-pagination-item-active,body.dark .trading-records[data-v-8a68b65a] .ant-pagination-item-active,body.realdark .trading-records[data-v-8a68b65a] .ant-pagination-item-active{background:#1890ff!important;border-color:#1890ff!important}
@@ -130,70 +132,77 @@ body.dark .trading-records ::v-deep .ant-pagination .ant-pagination-next:hover .
.theme-dark .trading-records * ::v-deep .ant-pagination .ant-pagination-item:hover a,.theme-dark .trading-records ::v-deep .ant-pagination .ant-pagination-item:hover a,body.dark .trading-records * ::v-deep .ant-pagination .ant-pagination-item:hover a,body.dark .trading-records ::v-deep .ant-pagination .ant-pagination-item:hover a,body.realdark .trading-records * ::v-deep .ant-pagination .ant-pagination-item:hover a,body.realdark .trading-records ::v-deep .ant-pagination .ant-pagination-item:hover a{color:#1890ff!important}
.theme-dark .trading-records * ::v-deep .ant-pagination .ant-pagination-item-active,.theme-dark .trading-records ::v-deep .ant-pagination .ant-pagination-item-active,body.dark .trading-records * ::v-deep .ant-pagination .ant-pagination-item-active,body.dark .trading-records ::v-deep .ant-pagination .ant-pagination-item-active,body.realdark .trading-records * ::v-deep .ant-pagination .ant-pagination-item-active,body.realdark .trading-records ::v-deep .ant-pagination .ant-pagination-item-active{background:#1890ff!important;border-color:#1890ff!important}
.theme-dark .trading-records * ::v-deep .ant-pagination .ant-pagination-next:hover .ant-pagination-item-link,.theme-dark .trading-records * ::v-deep .ant-pagination .ant-pagination-prev:hover .ant-pagination-item-link,.theme-dark .trading-records ::v-deep .ant-pagination .ant-pagination-next:hover .ant-pagination-item-link,.theme-dark .trading-records ::v-deep .ant-pagination .ant-pagination-prev:hover .ant-pagination-item-link,body.dark .trading-records * ::v-deep .ant-pagination .ant-pagination-next:hover .ant-pagination-item-link,body.dark .trading-records * ::v-deep .ant-pagination .ant-pagination-prev:hover .ant-pagination-item-link,body.dark .trading-records ::v-deep .ant-pagination .ant-pagination-next:hover .ant-pagination-item-link,body.dark .trading-records ::v-deep .ant-pagination .ant-pagination-prev:hover .ant-pagination-item-link,body.realdark .trading-records * ::v-deep .ant-pagination .ant-pagination-next:hover .ant-pagination-item-link,body.realdark .trading-records * ::v-deep .ant-pagination .ant-pagination-prev:hover .ant-pagination-item-link,body.realdark .trading-records ::v-deep .ant-pagination .ant-pagination-next:hover .ant-pagination-item-link,body.realdark .trading-records ::v-deep .ant-pagination .ant-pagination-prev:hover .ant-pagination-item-link{border-color:#1890ff!important;color:#1890ff!important}
.mode-card[data-v-1cfbbafc]:hover{border-color:#1890ff;-webkit-box-shadow:0 4px 16px rgba(24,144,255,.12);box-shadow:0 4px 16px rgba(24,144,255,.12)}
.mode-card.selected[data-v-1cfbbafc]{border-color:#1890ff;background:rgba(24,144,255,.02)}
.mode-card.selected[data-v-1cfbbafc]:after{color:#1890ff}
.card-icon.signal-icon[data-v-1cfbbafc]{background:linear-gradient(135deg,#e6f7ff,#bae7ff);color:#1890ff}
.card-body .card-badge[data-v-1cfbbafc]{background:rgba(24,144,255,.08);color:#1890ff}
.template-tag[data-v-1cfbbafc]:hover{color:#1890ff;border-color:#1890ff;background:#e6f7ff}
.theme-dark .mode-card.selected[data-v-1cfbbafc]:after{color:#40a9ff}
.theme-dark .card-icon.signal-icon[data-v-1cfbbafc]{background:linear-gradient(135deg,rgba(24,144,255,.15),rgba(24,144,255,.08));color:#40a9ff}
.theme-dark .card-body .card-badge[data-v-1cfbbafc]{color:#69c0ff}
.theme-dark .card-btn[data-v-1cfbbafc]{border-color:rgba(24,144,255,.4);color:#40a9ff}
.theme-dark .card-btn[data-v-1cfbbafc]:hover{border-color:#1890ff;color:#1890ff}
.theme-dark .template-tag[data-v-1cfbbafc]:hover{color:#40a9ff}
.code-editor-container[data-v-6c7c8660] .CodeMirror-cursor{border-left:2px solid #1890ff}
.template-item.active[data-v-6c7c8660],.template-item[data-v-6c7c8660]:hover{border-color:#1890ff}
.ai-status[data-v-6c7c8660]{color:#1890ff}
.docs-content[data-v-6c7c8660] h5{color:#1890ff}
.theme-dark .side-tabs[data-v-6c7c8660] .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:#1890ff}
.theme-dark .ai-status[data-v-6c7c8660]{color:#40a9ff}
.theme-dark .docs-content[data-v-6c7c8660] h5{color:#40a9ff}
.theme-dark .logs-toolbar[data-v-129fed65] .ant-radio-group .ant-radio-button-wrapper:hover{color:#40a9ff}
.theme-dark .logs-toolbar[data-v-129fed65] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:#1890ff;border-color:#1890ff}
.assistant-guide-bar[data-v-bbcac474]{border:1px solid rgba(24,144,255,.14);background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(114,46,209,.06))}
.assistant-guide-bar .assistant-guide-eyebrow[data-v-bbcac474]{background:rgba(24,144,255,.12)}
.trading-assistant .strategy-list-col .strategy-list-card .card-title .ant-btn-primary[data-v-bbcac474]{background:linear-gradient(135deg,#1890ff,#40a9ff);-webkit-box-shadow:0 4px 12px rgba(24,144,255,.35);box-shadow:0 4px 12px rgba(24,144,255,.35)}
.trading-assistant .strategy-list-col .strategy-list-card .card-title .ant-btn-primary[data-v-bbcac474]:hover{-webkit-box-shadow:0 6px 16px rgba(24,144,255,.45);box-shadow:0 6px 16px rgba(24,144,255,.45)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-header .group-header-left .group-icon[data-v-bbcac474]{color:#1890ff}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item[data-v-bbcac474]:hover{border-left-color:#1890ff}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item.active[data-v-bbcac474]{border-left-color:#1890ff}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-list-item[data-v-bbcac474]:hover{border-color:rgba(24,144,255,.2);-webkit-box-shadow:0 2px 12px rgba(24,144,255,.1);box-shadow:0 2px 12px rgba(24,144,255,.1)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-list-item.active[data-v-bbcac474]{border-color:#1890ff;border-left:4px solid #1890ff;-webkit-box-shadow:0 4px 16px rgba(24,144,255,.15);box-shadow:0 4px 16px rgba(24,144,255,.15)}
.trading-assistant .strategy-detail-col .empty-detail[data-v-bbcac474]:hover{border-color:#1890ff;background:linear-gradient(135deg,rgba(24,144,255,.02),rgba(24,144,255,.05))}
.trading-assistant .strategy-detail-col .strategy-detail-panel .strategy-header-card .strategy-header .header-left .strategy-tags .tag-item .anticon[data-v-bbcac474]{color:#1890ff}
.trading-assistant.theme-dark .creation-mode-toggle[data-v-bbcac474]{background:rgba(24,144,255,.08);border-color:rgba(24,144,255,.2)}
.trading-assistant.theme-dark .strategy-list-col .strategy-list-item[data-v-bbcac474]:hover{background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(24,144,255,.04));border-color:rgba(24,144,255,.2)}
.trading-assistant.theme-dark .strategy-list-col .strategy-list-item.active[data-v-bbcac474]{background:linear-gradient(135deg,rgba(24,144,255,.12),rgba(24,144,255,.06));border-color:#1890ff;-webkit-box-shadow:0 4px 20px rgba(24,144,255,.2);box-shadow:0 4px 20px rgba(24,144,255,.2)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-header-card .strategy-tags .tag-item[data-v-bbcac474]:hover{background:rgba(24,144,255,.1);border-color:rgba(24,144,255,.3)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-content-card[data-v-bbcac474] .ant-tabs .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:#1890ff}
.trading-assistant.theme-dark .strategy-detail-col .strategy-content-card[data-v-bbcac474] .ant-tabs .ant-tabs-nav .ant-tabs-ink-bar{background:-webkit-gradient(linear,left top,right top,from(#1890ff),to(#40a9ff));background:linear-gradient(90deg,#1890ff,#40a9ff)}
.theme-dark .assistant-guide-bar .assistant-guide-eyebrow[data-v-bbcac474]{color:#69c0ff}
.ai-filter-title .anticon[data-v-bbcac474]{color:#1890ff}
.creation-mode-toggle[data-v-bbcac474]{background:rgba(24,144,255,.04);border:1px solid rgba(24,144,255,.12)}
.simple-mode-hero[data-v-bbcac474]{background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(114,46,209,.06));border:1px solid rgba(24,144,255,.14)}
.simple-mode-kicker[data-v-bbcac474]{color:#1890ff}
.advanced-settings-shell[data-v-bbcac474],.selected-indicator-card[data-v-bbcac474],.simple-essentials-card[data-v-bbcac474]{border:1px solid rgba(24,144,255,.12)}
.execution-step-hero[data-v-bbcac474]{background:linear-gradient(135deg,rgba(82,196,26,.08),rgba(24,144,255,.08));border:1px solid rgba(24,144,255,.14)}
.execution-section-card[data-v-bbcac474]{border:1px solid rgba(24,144,255,.12)}
.execution-mode-card[data-v-bbcac474]{border:1px solid rgba(24,144,255,.12)}
.execution-mode-card[data-v-bbcac474]:hover{border-color:rgba(24,144,255,.35);-webkit-box-shadow:0 8px 24px rgba(24,144,255,.08);box-shadow:0 8px 24px rgba(24,144,255,.08)}
.execution-mode-card.active[data-v-bbcac474]{border-color:#1890ff;background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(24,144,255,.03));-webkit-box-shadow:0 10px 28px rgba(24,144,255,.12);box-shadow:0 10px 28px rgba(24,144,255,.12)}
.execution-mode-card.disabled[data-v-bbcac474]:hover{border-color:rgba(24,144,255,.12)}
.execution-mode-card-icon.signal[data-v-bbcac474]{color:#1890ff;background:rgba(24,144,255,.1)}
.execution-mode-card-check[data-v-bbcac474]{color:#1890ff}
.strategy-type-selector .strategy-type-card[data-v-bbcac474]:hover{border-color:#1890ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.2);box-shadow:0 2px 8px rgba(24,144,255,.2)}
.strategy-type-selector .strategy-type-card.selected[data-v-bbcac474]{border-color:#1890ff;background-color:#e6f7ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.3);box-shadow:0 2px 8px rgba(24,144,255,.3)}
.strategy-type-selector .strategy-type-card .strategy-type-content .strategy-type-icon[data-v-bbcac474]{color:#1890ff}
.theme-dark .simple-mode-hero[data-v-bbcac474]{background:linear-gradient(135deg,rgba(24,144,255,.14),rgba(114,46,209,.12))}
.theme-dark .execution-step-hero[data-v-bbcac474]{background:linear-gradient(135deg,rgba(82,196,26,.14),rgba(24,144,255,.14))}
.theme-dark .execution-mode-card[data-v-bbcac474]:hover{-webkit-box-shadow:0 8px 24px rgba(24,144,255,.12);box-shadow:0 8px 24px rgba(24,144,255,.12)}
.theme-dark .execution-mode-card.active[data-v-bbcac474]{background:linear-gradient(135deg,rgba(24,144,255,.16),rgba(24,144,255,.08));border-color:#1890ff}
.theme-dark .simple-mode-kicker[data-v-bbcac474]{color:#69c0ff}
.ip-whitelist-tip[data-v-bbcac474]{background-color:#e6f7ff;border:1px solid #91d5ff;color:#1890ff}
.ip-whitelist-tip .anticon[data-v-bbcac474]{color:#1890ff}
.mode-card[data-v-8ec7ab14]:hover{border-color:#1890ff;-webkit-box-shadow:0 4px 16px rgba(24,144,255,.12);box-shadow:0 4px 16px rgba(24,144,255,.12)}
.mode-card.selected[data-v-8ec7ab14]{border-color:#1890ff;background:rgba(24,144,255,.02)}
.mode-card.selected[data-v-8ec7ab14]:after{color:#1890ff}
.card-icon.signal-icon[data-v-8ec7ab14]{background:linear-gradient(135deg,#e6f7ff,#bae7ff);color:#1890ff}
.card-body .card-badge[data-v-8ec7ab14]{background:rgba(24,144,255,.08);color:#1890ff}
.template-tag[data-v-8ec7ab14]:hover{color:#1890ff;border-color:#1890ff;background:#e6f7ff}
.theme-dark .mode-card.selected[data-v-8ec7ab14]:after{color:#40a9ff}
.theme-dark .card-icon.signal-icon[data-v-8ec7ab14]{background:linear-gradient(135deg,rgba(24,144,255,.15),rgba(24,144,255,.08));color:#40a9ff}
.theme-dark .card-body .card-badge[data-v-8ec7ab14]{color:#69c0ff}
.theme-dark .card-btn[data-v-8ec7ab14]{border-color:rgba(24,144,255,.4);color:#40a9ff}
.theme-dark .card-btn[data-v-8ec7ab14]:hover{border-color:#1890ff;color:#1890ff}
.theme-dark .template-tag[data-v-8ec7ab14]:hover{color:#40a9ff}
.code-editor-container[data-v-35a0a386] .CodeMirror-cursor{border-left:2px solid #1890ff}
.template-item.active[data-v-35a0a386],.template-item[data-v-35a0a386]:hover{border-color:#1890ff}
.ai-status[data-v-35a0a386]{color:#1890ff}
.theme-dark .side-tabs[data-v-35a0a386] .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:#1890ff}
.theme-dark .ai-status[data-v-35a0a386]{color:#40a9ff}
.theme-dark[data-v-35a0a386] .ant-alert-info{background:rgba(24,144,255,.08);border-color:rgba(24,144,255,.2)}
.theme-dark .logs-toolbar[data-v-73589ce4] .ant-radio-group .ant-radio-button-wrapper:hover{color:#40a9ff}
.theme-dark .logs-toolbar[data-v-73589ce4] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:#1890ff;border-color:#1890ff}
.assistant-guide-bar[data-v-533cb131]{border:1px solid rgba(24,144,255,.14);background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(114,46,209,.06))}
.assistant-guide-bar .assistant-guide-eyebrow[data-v-533cb131]{background:rgba(24,144,255,.12)}
.assistant-guide-bar .assistant-guide-actions .assistant-guide-close[data-v-533cb131]:focus,.assistant-guide-bar .assistant-guide-actions .assistant-guide-close[data-v-533cb131]:hover{border-color:rgba(24,144,255,.35)}
.trading-assistant .strategy-list-col .strategy-list-card .card-title .ant-btn-primary[data-v-533cb131]{background:linear-gradient(135deg,#1890ff,#40a9ff);-webkit-box-shadow:0 4px 12px rgba(24,144,255,.35);box-shadow:0 4px 12px rgba(24,144,255,.35)}
.trading-assistant .strategy-list-col .strategy-list-card .card-title .ant-btn-primary[data-v-533cb131]:hover{-webkit-box-shadow:0 6px 16px rgba(24,144,255,.45);box-shadow:0 6px 16px rgba(24,144,255,.45)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-header .group-header-left .group-icon[data-v-533cb131]{color:#1890ff}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item[data-v-533cb131]:hover{border-left-color:#1890ff;border-color:rgba(24,144,255,.22);-webkit-box-shadow:0 2px 8px rgba(24,144,255,.08);box-shadow:0 2px 8px rgba(24,144,255,.08)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item.active[data-v-533cb131]{border-color:rgba(24,144,255,.35);border-left-color:#1890ff;-webkit-box-shadow:0 2px 12px rgba(24,144,255,.12);box-shadow:0 2px 12px rgba(24,144,255,.12)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-list-item[data-v-533cb131]:hover{border-color:rgba(24,144,255,.2);-webkit-box-shadow:0 2px 12px rgba(24,144,255,.1);box-shadow:0 2px 12px rgba(24,144,255,.1)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-list-item.active[data-v-533cb131]{border-color:#1890ff;border-left:4px solid #1890ff;-webkit-box-shadow:0 4px 16px rgba(24,144,255,.15);box-shadow:0 4px 16px rgba(24,144,255,.15)}
.trading-assistant .strategy-detail-col .strategy-empty-detail-icon[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.12),rgba(64,169,255,.08));color:#1890ff}
.trading-assistant .strategy-detail-col .strategy-detail-panel .strategy-header-card .strategy-header .header-left .strategy-tags .tag-item .anticon[data-v-533cb131]{color:#1890ff}
.trading-assistant.theme-dark .creation-mode-toggle[data-v-533cb131]{background:rgba(24,144,255,.08);border-color:rgba(24,144,255,.2)}
.trading-assistant.theme-dark .strategy-list-col .group-mode-switch[data-v-533cb131] .ant-radio-button-wrapper:hover{color:#69c0ff}
.trading-assistant.theme-dark .strategy-list-col .group-mode-switch[data-v-533cb131] .ant-radio-button-wrapper-checked{background:rgba(24,144,255,.25)!important;border-color:#1890ff!important}
.trading-assistant.theme-dark .strategy-list-col .strategy-group-content .strategy-list-item[data-v-533cb131]:hover{border-left-color:#1890ff}
.trading-assistant.theme-dark .strategy-list-col .strategy-group-content .strategy-list-item.active[data-v-533cb131]{background:rgba(24,144,255,.12);border-color:rgba(24,144,255,.28);border-left-color:#1890ff}
.trading-assistant.theme-dark .strategy-list-col .strategy-list-item.active[data-v-533cb131]{background:rgba(24,144,255,.1);border-color:#1890ff;-webkit-box-shadow:0 4px 20px rgba(24,144,255,.2);box-shadow:0 4px 20px rgba(24,144,255,.2)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-header-card .strategy-tags .tag-item[data-v-533cb131]:hover{background:rgba(24,144,255,.1);border-color:rgba(24,144,255,.3)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-content-card[data-v-533cb131] .ant-tabs .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:#1890ff}
.trading-assistant.theme-dark .strategy-detail-col .strategy-content-card[data-v-533cb131] .ant-tabs .ant-tabs-nav .ant-tabs-ink-bar{background:-webkit-gradient(linear,left top,right top,from(#1890ff),to(#40a9ff));background:linear-gradient(90deg,#1890ff,#40a9ff)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-empty-detail-icon[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.22),rgba(64,169,255,.1));color:#69c0ff}
.theme-dark .assistant-guide-bar .assistant-guide-eyebrow[data-v-533cb131]{color:#69c0ff}
.theme-dark .assistant-guide-bar .assistant-guide-close[data-v-533cb131]:focus,.theme-dark .assistant-guide-bar .assistant-guide-close[data-v-533cb131]:hover{color:#91d5ff}
.ai-filter-title .anticon[data-v-533cb131]{color:#1890ff}
.creation-mode-toggle[data-v-533cb131]{background:rgba(24,144,255,.04);border:1px solid rgba(24,144,255,.12)}
.simple-mode-hero[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(114,46,209,.06));border:1px solid rgba(24,144,255,.14)}
.simple-mode-kicker[data-v-533cb131]{color:#1890ff}
.advanced-settings-shell[data-v-533cb131],.selected-indicator-card[data-v-533cb131],.simple-essentials-card[data-v-533cb131]{border:1px solid rgba(24,144,255,.12)}
.execution-step-hero[data-v-533cb131]{background:linear-gradient(135deg,rgba(82,196,26,.08),rgba(24,144,255,.08));border:1px solid rgba(24,144,255,.14)}
.execution-section-card[data-v-533cb131]{border:1px solid rgba(24,144,255,.12)}
.execution-mode-card[data-v-533cb131]{border:1px solid rgba(24,144,255,.12)}
.execution-mode-card[data-v-533cb131]:hover{border-color:rgba(24,144,255,.35);-webkit-box-shadow:0 8px 24px rgba(24,144,255,.08);box-shadow:0 8px 24px rgba(24,144,255,.08)}
.execution-mode-card.active[data-v-533cb131]{border-color:#1890ff;background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(24,144,255,.03));-webkit-box-shadow:0 10px 28px rgba(24,144,255,.12);box-shadow:0 10px 28px rgba(24,144,255,.12)}
.execution-mode-card.disabled[data-v-533cb131]:hover{border-color:rgba(24,144,255,.12)}
.execution-mode-card-icon.signal[data-v-533cb131]{color:#1890ff;background:rgba(24,144,255,.1)}
.execution-mode-card-check[data-v-533cb131]{color:#1890ff}
.strategy-type-selector .strategy-type-card[data-v-533cb131]:hover{border-color:#1890ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.2);box-shadow:0 2px 8px rgba(24,144,255,.2)}
.strategy-type-selector .strategy-type-card.selected[data-v-533cb131]{border-color:#1890ff;background-color:#e6f7ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.3);box-shadow:0 2px 8px rgba(24,144,255,.3)}
.strategy-type-selector .strategy-type-card .strategy-type-content .strategy-type-icon[data-v-533cb131]{color:#1890ff}
.theme-dark .simple-mode-hero[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.14),rgba(114,46,209,.12))}
.theme-dark .execution-step-hero[data-v-533cb131]{background:linear-gradient(135deg,rgba(82,196,26,.14),rgba(24,144,255,.14))}
.theme-dark .execution-mode-card[data-v-533cb131]:hover{-webkit-box-shadow:0 8px 24px rgba(24,144,255,.12);box-shadow:0 8px 24px rgba(24,144,255,.12)}
.theme-dark .execution-mode-card.active[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.16),rgba(24,144,255,.08));border-color:#1890ff}
.theme-dark .simple-mode-kicker[data-v-533cb131]{color:#69c0ff}
.ip-whitelist-tip[data-v-533cb131]{background-color:#e6f7ff;border:1px solid #91d5ff;color:#1890ff}
.ip-whitelist-tip .anticon[data-v-533cb131]{color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-modal-content .creation-mode-toggle{background:rgba(24,144,255,.08);border-color:rgba(24,144,255,.2)}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-steps .ant-steps-item-finish .ant-steps-item-icon,body.dark .strategy-form-modal.strategy-form-modal-dark .ant-steps .ant-steps-item-process .ant-steps-item-icon{border-color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-steps .ant-steps-item-process .ant-steps-item-icon{background:#1890ff;border-color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-steps .ant-steps-item-finish .ant-steps-item-icon{border-color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-steps .ant-steps-item-finish .ant-steps-item-icon .ant-steps-icon{color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-input-number:focus,body.dark .strategy-form-modal.strategy-form-modal-dark .ant-input-number:hover,body.dark .strategy-form-modal.strategy-form-modal-dark .ant-input:focus,body.dark .strategy-form-modal.strategy-form-modal-dark .ant-input:hover{border-color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-input-number-handler-wrap .ant-input-number-handler:hover{color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-select .ant-select-selection:hover{border-color:#1890ff}
@@ -203,6 +212,10 @@ body.dark .strategy-form-modal.strategy-form-modal-dark .ant-switch.ant-switch-c
body.dark .strategy-form-modal.strategy-form-modal-dark .simple-mode-hero{background:linear-gradient(135deg,rgba(24,144,255,.16),rgba(114,46,209,.14))}
body.dark .strategy-form-modal.strategy-form-modal-dark .execution-step-hero{background:linear-gradient(135deg,rgba(82,196,26,.16),rgba(24,144,255,.14))}
body.dark .strategy-form-modal.strategy-form-modal-dark .ip-whitelist-tip{background:rgba(24,144,255,.08);border-color:rgba(24,144,255,.2);color:#40a9ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .execution-mode-card:hover{-webkit-box-shadow:0 8px 24px rgba(24,144,255,.12);box-shadow:0 8px 24px rgba(24,144,255,.12)}
body.dark .strategy-form-modal.strategy-form-modal-dark .execution-mode-card.active{background:linear-gradient(135deg,rgba(24,144,255,.16),rgba(24,144,255,.08));border-color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .execution-mode-card-icon.signal{color:#69c0ff;background:rgba(24,144,255,.15)}
body.dark .strategy-form-modal.strategy-form-modal-dark .simple-mode-kicker{color:#69c0ff}
body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-active,body.dark .ant-select-dropdown .ant-select-dropdown-menu-item:hover{background:rgba(24,144,255,.1)}
body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-selected{background:rgba(24,144,255,.15);color:#1890ff}
body.dark .ant-modal-wrap .ant-alert{background:rgba(24,144,255,.06);border-color:rgba(24,144,255,.2)}
@@ -297,25 +310,22 @@ body.dark .ant-modal-wrap .ant-alert{background:rgba(24,144,255,.06);border-colo
.theme-dark .position-checkbox-item[data-v-398d904c]:hover{background:rgba(24,144,255,.1)}
.theme-dark .alert-symbol-info .current-price-info[data-v-398d904c]{background:linear-gradient(135deg,rgba(24,144,255,.15),rgba(114,46,209,.1))}
.theme-dark .alert-symbol-info .current-price-info .price[data-v-398d904c]{color:#40a9ff}
.profile-page .page-header .page-title .anticon[data-v-06c79ed5]{color:#1890ff}
.profile-page .profile-card .profile-info .info-item .anticon[data-v-06c79ed5]{color:#1890ff}
.profile-page.theme-dark .edit-card[data-v-06c79ed5] .ant-tabs-tab-active{color:#1890ff}
.profile-page.theme-dark .edit-card[data-v-06c79ed5] .ant-input-password:focus,.profile-page.theme-dark .edit-card[data-v-06c79ed5] .ant-input-password:hover,.profile-page.theme-dark .edit-card[data-v-06c79ed5] .ant-input:focus,.profile-page.theme-dark .edit-card[data-v-06c79ed5] .ant-input:hover{border-color:#1890ff}
.profile-page.theme-dark[data-v-06c79ed5] .ant-table-wrapper .ant-tag-blue{background:rgba(24,144,255,.25);border-color:#1890ff;color:#69c0ff}
.profile-page.theme-dark .notification-settings-form[data-v-06c79ed5] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .notification-settings-form[data-v-06c79ed5] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .password-form[data-v-06c79ed5] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .password-form[data-v-06c79ed5] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .profile-form[data-v-06c79ed5] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .profile-form[data-v-06c79ed5] .ant-radio-checked .ant-radio-inner{background-color:#1890ff;border-color:#1890ff}
.profile-page.theme-dark[data-v-06c79ed5] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
.profile-page.theme-dark[data-v-06c79ed5] .ant-pagination .ant-pagination-item-active{background:#1890ff;border-color:#1890ff}
.profile-page.theme-dark[data-v-06c79ed5] .ant-btn.ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
.profile-exchange-modal .exchange-account-form .exchange-api-doc-callout{border:1px solid rgba(24,144,255,.18);background:-webkit-gradient(linear,left top,right top,from(rgba(24,144,255,.06)),to(rgba(82,196,26,.04)));background:linear-gradient(90deg,rgba(24,144,255,.06),rgba(82,196,26,.04))}
.profile-exchange-modal .exchange-account-form .exchange-api-doc-callout__icon{background:rgba(24,144,255,.12);color:#1890ff}
.profile-exchange-modal--dark .exchange-account-form .exchange-api-doc-callout{background:-webkit-gradient(linear,left top,right top,from(rgba(24,144,255,.12)),to(rgba(82,196,26,.08)));background:linear-gradient(90deg,rgba(24,144,255,.12),rgba(82,196,26,.08))}
.profile-page .page-header .page-title .anticon[data-v-2151383b]{color:#1890ff}
.profile-page .profile-card .profile-info .info-item .anticon[data-v-2151383b]{color:#1890ff}
.profile-page.theme-dark .edit-card[data-v-2151383b] .ant-tabs-tab-active{color:#1890ff}
.profile-page.theme-dark .edit-card[data-v-2151383b] .ant-input-password:focus,.profile-page.theme-dark .edit-card[data-v-2151383b] .ant-input-password:hover,.profile-page.theme-dark .edit-card[data-v-2151383b] .ant-input:focus,.profile-page.theme-dark .edit-card[data-v-2151383b] .ant-input:hover{border-color:#1890ff}
.profile-page.theme-dark[data-v-2151383b] .ant-table-wrapper .ant-tag-blue{background:rgba(24,144,255,.25);border-color:#1890ff;color:#69c0ff}
.profile-page.theme-dark .notification-settings-form[data-v-2151383b] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .notification-settings-form[data-v-2151383b] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .password-form[data-v-2151383b] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .password-form[data-v-2151383b] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .profile-form[data-v-2151383b] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .profile-form[data-v-2151383b] .ant-radio-checked .ant-radio-inner{background-color:#1890ff;border-color:#1890ff}
.profile-page.theme-dark[data-v-2151383b] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
.profile-page.theme-dark[data-v-2151383b] .ant-pagination .ant-pagination-item-active{background:#1890ff;border-color:#1890ff}
.profile-page.theme-dark[data-v-2151383b] .ant-btn.ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
.billing-page .plan-card.highlight[data-v-714c882a]{border:1px solid rgba(24,144,255,.35)}
.usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,.usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover{background:rgba(24,144,255,.08);color:#1890ff}
body.dark .usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,body.dark .usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover,body.realdark .usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,body.realdark .usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover{background:rgba(24,144,255,.15);color:#40a9ff}
.user-manage-page .page-header .page-title .anticon[data-v-4e5dc5e0]{color:#1890ff}
.user-manage-page .address-text[data-v-4e5dc5e0],.user-manage-page .hash-text[data-v-4e5dc5e0]{color:#1890ff}
.user-manage-page.theme-dark .manage-tabs[data-v-4e5dc5e0] .ant-tabs-tab-active{color:#1890ff}
.user-manage-page .current-credits-info .value[data-v-4e5dc5e0],.user-manage-page .current-vip-info .value[data-v-4e5dc5e0]{color:#1890ff}
.user-manage-page .page-header .page-title .anticon[data-v-7a022132]{color:#1890ff}
.user-manage-page .address-text[data-v-7a022132],.user-manage-page .hash-text[data-v-7a022132]{color:#1890ff}
.user-manage-page.theme-dark .manage-tabs[data-v-7a022132] .ant-tabs-tab-active{color:#1890ff}
.user-manage-page .current-credits-info .value[data-v-7a022132],.user-manage-page .current-vip-info .value[data-v-7a022132]{color:#1890ff}
.settings-page .settings-header .page-title .anticon[data-v-15e1ba02]{color:#1890ff}
.settings-page .openrouter-balance-card .ant-card[data-v-15e1ba02]{background:linear-gradient(135deg,#e6f7ff,#f0f5ff);border:1px solid #91d5ff}
.settings-page .openrouter-balance-card .balance-header .balance-title[data-v-15e1ba02]{color:#1890ff}
+1 -1
View File
@@ -421,4 +421,4 @@
.brand-text {
font-size: 20px;
}
}</style><script defer="defer" src="/js/chunk-vendors.ebabb02b.js" type="module"></script><script defer="defer" src="/js/app.5b7d4d9e.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.9dca4aef.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.f624f594.js" nomodule></script><script defer="defer" src="/js/app-legacy.5c46ff8f.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
}</style><script defer="defer" src="/js/chunk-vendors.7a650f0b.js" type="module"></script><script defer="defer" src="/js/app.1b0410d5.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.9dca4aef.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.e8633248.js" nomodule></script><script defer="defer" src="/js/app-legacy.abf8ca7f.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long