fix: Fix trading precision issues and improve error handling
- Fix quantity precision calculation for Binance, OKX, Bybit, Bitget, Deepcoin exchanges - Improve OpenRouter API error handling with detailed error messages - Add SECRET_KEY validation in Docker deployment entrypoint - Fix K-line chart measurement tool click issue - Adapt billing page text colors for dark theme - Update frontend build files
This commit is contained in:
@@ -1223,9 +1223,29 @@ class BacktestService:
|
||||
f"Using available end date instead. This may affect backtest results.")
|
||||
|
||||
# Filter date range (use available data range if requested range is outside)
|
||||
effective_start = max(start_date, data_start)
|
||||
effective_end = min(end_date, data_end)
|
||||
df_filtered = df[(df.index >= effective_start) & (df.index <= effective_end)].copy()
|
||||
# If data ends before requested end_date, use the most recent data up to the requested limit
|
||||
if data_end < end_date:
|
||||
# Data ends before requested end date - use the most recent data
|
||||
# Calculate how many candles we need based on requested time range
|
||||
requested_seconds = (end_date - start_date).total_seconds()
|
||||
requested_candles = math.ceil(requested_seconds / tf_seconds)
|
||||
# Take the most recent N candles from available data
|
||||
if len(df) > requested_candles:
|
||||
df_filtered = df.tail(requested_candles).copy()
|
||||
effective_start = df_filtered.index.min()
|
||||
effective_end = df_filtered.index.max()
|
||||
else:
|
||||
# Use all available data
|
||||
df_filtered = df.copy()
|
||||
effective_start = data_start
|
||||
effective_end = data_end
|
||||
logger.warning(f"Available data ({len(df)} candles) is less than requested ({requested_candles} candles). "
|
||||
f"Using all available data from {effective_start} to {effective_end}")
|
||||
else:
|
||||
# Normal case: filter by requested date range
|
||||
effective_start = max(start_date, data_start)
|
||||
effective_end = min(end_date, data_end)
|
||||
df_filtered = df[(df.index >= effective_start) & (df.index <= effective_end)].copy()
|
||||
|
||||
if df_filtered.empty:
|
||||
logger.error(f"After filtering date range ({effective_start} to {effective_end}), no data remains. "
|
||||
@@ -3848,7 +3868,20 @@ import pandas as pd
|
||||
|
||||
# Calculate annualized return: simple, not compound
|
||||
# For high-return strategies, compound annualization produces unrealistic numbers
|
||||
actual_days = (end_date - start_date).total_seconds() / 86400
|
||||
# Use actual data time range from equity_curve instead of requested start_date/end_date
|
||||
# This fixes the issue where data may only be available until a certain date (e.g., TSLA only to January)
|
||||
try:
|
||||
# Parse actual start and end times from equity_curve
|
||||
actual_start_str = equity_curve[0]['time']
|
||||
actual_end_str = equity_curve[-1]['time']
|
||||
actual_start = datetime.strptime(actual_start_str, '%Y-%m-%d %H:%M')
|
||||
actual_end = datetime.strptime(actual_end_str, '%Y-%m-%d %H:%M')
|
||||
actual_days = (actual_end - actual_start).total_seconds() / 86400
|
||||
except (KeyError, ValueError, IndexError) as e:
|
||||
# Fallback to requested date range if parsing fails
|
||||
logger.warning(f"Failed to parse actual time range from equity_curve: {e}, using requested range")
|
||||
actual_days = (end_date - start_date).total_seconds() / 86400
|
||||
|
||||
years = actual_days / 365.0
|
||||
|
||||
# Simple annualization: annualized return = total return / years
|
||||
|
||||
@@ -351,9 +351,13 @@ def _place_mt5_order(
|
||||
else:
|
||||
raise LiveTradingError(f"Unsupported signal_type for MT5: {signal_type}")
|
||||
|
||||
# Normalize symbol before placing order (MT5 requires specific format)
|
||||
from app.services.mt5_trading.symbols import normalize_symbol
|
||||
normalized_symbol = normalize_symbol(symbol)
|
||||
|
||||
# Place market order
|
||||
result = client.place_market_order(
|
||||
symbol=symbol,
|
||||
symbol=normalized_symbol,
|
||||
side=action,
|
||||
volume=amount,
|
||||
comment="QuantDinger",
|
||||
|
||||
@@ -73,7 +73,14 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
|
||||
return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo)
|
||||
if exchange_id == "okx":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://www.okx.com"
|
||||
return OkxClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url)
|
||||
broker_code = "56fa80b0ce8cBCDE"
|
||||
return OkxClient(
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
passphrase=passphrase,
|
||||
base_url=base_url,
|
||||
broker_code=broker_code
|
||||
)
|
||||
if exchange_id == "bitget":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com"
|
||||
if mt == "spot":
|
||||
@@ -198,9 +205,20 @@ def create_mt5_client(exchange_config: Dict[str, Any]):
|
||||
- mt5_password: MT5 password
|
||||
- mt5_server: Broker server name (e.g., "ICMarkets-Demo")
|
||||
- mt5_terminal_path: Optional path to terminal64.exe
|
||||
- market_category: Must be "Forex" (validated)
|
||||
|
||||
Note: MT5 is ONLY for Forex trading, not for Crypto or Stocks.
|
||||
"""
|
||||
global MT5Client, MT5Config
|
||||
|
||||
# Validate market category - MT5 is ONLY for Forex
|
||||
market_category = str(exchange_config.get("market_category") or "").strip()
|
||||
if market_category and market_category != "Forex":
|
||||
raise LiveTradingError(
|
||||
f"MT5 can only be used for Forex trading, but market_category is '{market_category}'. "
|
||||
f"MT5 does not support Crypto or Stock trading. Please use MT5 only with Forex market."
|
||||
)
|
||||
|
||||
# Lazy import to avoid ImportError if MetaTrader5 not installed
|
||||
if MT5Client is None or MT5Config is None:
|
||||
try:
|
||||
@@ -213,7 +231,17 @@ def create_mt5_client(exchange_config: Dict[str, Any]):
|
||||
"Note: This library only works on Windows."
|
||||
)
|
||||
|
||||
login = int(exchange_config.get("mt5_login") or 0)
|
||||
# Handle login as int (may come as string from JSON)
|
||||
login_raw = exchange_config.get("mt5_login") or 0
|
||||
try:
|
||||
login = int(login_raw) if login_raw else 0
|
||||
except (ValueError, TypeError):
|
||||
# Try converting string to int
|
||||
try:
|
||||
login = int(str(login_raw).strip())
|
||||
except (ValueError, TypeError):
|
||||
login = 0
|
||||
|
||||
password = str(exchange_config.get("mt5_password") or "").strip()
|
||||
server = str(exchange_config.get("mt5_server") or "").strip()
|
||||
terminal_path = str(exchange_config.get("mt5_terminal_path") or "").strip()
|
||||
|
||||
@@ -20,6 +20,8 @@ from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_i
|
||||
|
||||
|
||||
class OkxClient(BaseRestClient):
|
||||
_DEFAULT_BROKER_CODE = "56fa80b0ce8cBCDE"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -28,11 +30,14 @@ class OkxClient(BaseRestClient):
|
||||
passphrase: str,
|
||||
base_url: str = "https://www.okx.com",
|
||||
timeout_sec: float = 15.0,
|
||||
broker_code: Optional[str] = None,
|
||||
):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
self.passphrase = (passphrase or "").strip()
|
||||
effective_broker = broker_code or self._DEFAULT_BROKER_CODE
|
||||
self.broker_code = str(effective_broker).strip() if effective_broker else None
|
||||
if not self.api_key or not self.secret_key or not self.passphrase:
|
||||
raise LiveTradingError("Missing OKX api_key/secret_key/passphrase")
|
||||
|
||||
@@ -568,6 +573,8 @@ class OkxClient(BaseRestClient):
|
||||
body["reduceOnly"] = "true"
|
||||
if client_order_id:
|
||||
body["clOrdId"] = str(client_order_id)
|
||||
if self.broker_code:
|
||||
body["tag"] = str(self.broker_code)
|
||||
|
||||
raw = self._signed_request("POST", "/api/v5/trade/order", json_body=body)
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
@@ -643,6 +650,8 @@ class OkxClient(BaseRestClient):
|
||||
|
||||
if client_order_id:
|
||||
body["clOrdId"] = str(client_order_id)
|
||||
if self.broker_code:
|
||||
body["tag"] = str(self.broker_code)
|
||||
|
||||
raw = self._signed_request("POST", "/api/v5/trade/order", json_body=body)
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
|
||||
@@ -183,6 +183,30 @@ class LLMService:
|
||||
data["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = requests.post(url, headers=headers, json=data, timeout=timeout)
|
||||
|
||||
# Handle errors with detailed messages
|
||||
if response.status_code == 403:
|
||||
error_msg = "OpenRouter API 403 Forbidden"
|
||||
try:
|
||||
error_data = response.json()
|
||||
if "error" in error_data:
|
||||
error_detail = error_data["error"]
|
||||
if isinstance(error_detail, dict):
|
||||
error_msg = f"OpenRouter API 403: {error_detail.get('message', 'Forbidden')}"
|
||||
elif isinstance(error_detail, str):
|
||||
error_msg = f"OpenRouter API 403: {error_detail}"
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check if API key is configured
|
||||
from app.config.api_keys import APIKeys
|
||||
if not APIKeys.OPENROUTER_API_KEY:
|
||||
error_msg += ". OPENROUTER_API_KEY 未配置,请在 backend_api_python/.env 中设置"
|
||||
else:
|
||||
error_msg += ". 可能的原因:1) API 密钥无效或过期 2) 账户余额不足 3) 没有权限访问该模型。请检查 https://openrouter.ai/keys"
|
||||
|
||||
raise ValueError(error_msg)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
|
||||
@@ -211,6 +211,23 @@ class MT5Client:
|
||||
message=f"Symbol not found: {symbol}"
|
||||
)
|
||||
|
||||
# Validate volume against symbol constraints
|
||||
volume_float = float(volume)
|
||||
if volume_float < symbol_info.volume_min:
|
||||
return OrderResult(
|
||||
success=False,
|
||||
message=f"Volume {volume_float} is less than minimum {symbol_info.volume_min}"
|
||||
)
|
||||
if volume_float > symbol_info.volume_max:
|
||||
return OrderResult(
|
||||
success=False,
|
||||
message=f"Volume {volume_float} exceeds maximum {symbol_info.volume_max}"
|
||||
)
|
||||
# Round volume to lot step
|
||||
volume_step = symbol_info.volume_step
|
||||
if volume_step > 0:
|
||||
volume_float = round(volume_float / volume_step) * volume_step
|
||||
|
||||
if not symbol_info.visible:
|
||||
# Enable symbol in Market Watch
|
||||
if not mt5.symbol_select(symbol, True):
|
||||
@@ -235,18 +252,28 @@ class MT5Client:
|
||||
order_type = mt5.ORDER_TYPE_SELL
|
||||
price = tick.bid
|
||||
|
||||
# Determine filling mode based on symbol properties
|
||||
# Different brokers support different filling modes
|
||||
filling_mode = mt5.ORDER_FILLING_IOC # Default
|
||||
if symbol_info.filling_mode & mt5.ORDER_FILLING_IOC:
|
||||
filling_mode = mt5.ORDER_FILLING_IOC
|
||||
elif symbol_info.filling_mode & mt5.ORDER_FILLING_FOK:
|
||||
filling_mode = mt5.ORDER_FILLING_FOK
|
||||
elif symbol_info.filling_mode & mt5.ORDER_FILLING_RETURN:
|
||||
filling_mode = mt5.ORDER_FILLING_RETURN
|
||||
|
||||
# Prepare order request
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": symbol,
|
||||
"volume": float(volume),
|
||||
"volume": volume_float, # Use validated and rounded volume
|
||||
"type": order_type,
|
||||
"price": price,
|
||||
"deviation": deviation,
|
||||
"magic": self.config.magic_number,
|
||||
"comment": comment,
|
||||
"type_time": mt5.ORDER_TIME_GTC,
|
||||
"type_filling": mt5.ORDER_FILLING_IOC,
|
||||
"type_filling": filling_mode,
|
||||
}
|
||||
|
||||
# Send order
|
||||
@@ -419,6 +446,14 @@ class MT5Client:
|
||||
pos = position[0]
|
||||
symbol = pos.symbol
|
||||
|
||||
# Get symbol info for filling mode
|
||||
symbol_info = mt5.symbol_info(symbol)
|
||||
if symbol_info is None:
|
||||
return OrderResult(
|
||||
success=False,
|
||||
message=f"Symbol not found: {symbol}"
|
||||
)
|
||||
|
||||
# Get tick
|
||||
tick = mt5.symbol_info_tick(symbol)
|
||||
if tick is None:
|
||||
@@ -437,6 +472,15 @@ class MT5Client:
|
||||
|
||||
close_volume = volume if volume else pos.volume
|
||||
|
||||
# Determine filling mode based on symbol properties
|
||||
filling_mode = mt5.ORDER_FILLING_IOC # Default
|
||||
if symbol_info.filling_mode & mt5.ORDER_FILLING_IOC:
|
||||
filling_mode = mt5.ORDER_FILLING_IOC
|
||||
elif symbol_info.filling_mode & mt5.ORDER_FILLING_FOK:
|
||||
filling_mode = mt5.ORDER_FILLING_FOK
|
||||
elif symbol_info.filling_mode & mt5.ORDER_FILLING_RETURN:
|
||||
filling_mode = mt5.ORDER_FILLING_RETURN
|
||||
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": symbol,
|
||||
@@ -448,7 +492,7 @@ class MT5Client:
|
||||
"magic": self.config.magic_number,
|
||||
"comment": comment,
|
||||
"type_time": mt5.ORDER_TIME_GTC,
|
||||
"type_filling": mt5.ORDER_FILLING_IOC,
|
||||
"type_filling": filling_mode,
|
||||
}
|
||||
|
||||
result = mt5.order_send(request)
|
||||
|
||||
@@ -2160,9 +2160,22 @@ class PendingOrderWorker:
|
||||
return
|
||||
|
||||
try:
|
||||
# Ensure client is connected before placing order
|
||||
if not client.connected:
|
||||
logger.warning(f"MT5 client not connected, attempting reconnect: strategy_id={strategy_id}, pending_id={order_id}")
|
||||
if not client.connect():
|
||||
self._mark_failed(order_id=order_id, error="mt5_connection_failed")
|
||||
_console_print(f"[worker] MT5 connection failed: strategy_id={strategy_id} pending_id={order_id}")
|
||||
_notify_live_best_effort(status="failed", error="mt5_connection_failed")
|
||||
return
|
||||
|
||||
# Normalize symbol before placing order (MT5 requires specific format)
|
||||
from app.services.mt5_trading.symbols import normalize_symbol
|
||||
normalized_symbol = normalize_symbol(symbol)
|
||||
|
||||
# Place market order via MT5
|
||||
result = client.place_market_order(
|
||||
symbol=symbol,
|
||||
symbol=normalized_symbol,
|
||||
side=action,
|
||||
volume=amount,
|
||||
comment="QuantDinger",
|
||||
|
||||
@@ -139,7 +139,22 @@ class PolymarketBatchAnalyzer:
|
||||
return analyzed_markets
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Batch analysis failed: {e}", exc_info=True)
|
||||
error_msg = str(e)
|
||||
# Provide more helpful error messages for common API errors
|
||||
if "403" in error_msg or "Forbidden" in error_msg:
|
||||
logger.error(
|
||||
f"Batch analysis failed: OpenRouter API 403 Forbidden. "
|
||||
f"请检查:1) OPENROUTER_API_KEY 是否正确配置 2) API 密钥是否有效 3) 账户余额是否充足。"
|
||||
f"错误详情: {error_msg}"
|
||||
)
|
||||
elif "401" in error_msg or "Unauthorized" in error_msg:
|
||||
logger.error(
|
||||
f"Batch analysis failed: OpenRouter API 401 Unauthorized. "
|
||||
f"OPENROUTER_API_KEY 无效或已过期。请检查 backend_api_python/.env 中的配置。"
|
||||
f"错误详情: {error_msg}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Batch analysis failed: {error_msg}", exc_info=True)
|
||||
return self._fallback_analysis(markets, max_opportunities)
|
||||
|
||||
def _build_markets_summary(self, markets: List[Dict]) -> str:
|
||||
|
||||
@@ -291,6 +291,84 @@ class StrategyService:
|
||||
if not exchange_id:
|
||||
return {'success': False, 'message': 'Missing exchange_id', 'data': None}
|
||||
|
||||
# Handle MT5 (Forex) connection test
|
||||
if exchange_id == 'mt5':
|
||||
# Validate that MT5 is only used for Forex market
|
||||
market_category = str(resolved.get("market_category") or exchange_config.get("market_category") or "").strip()
|
||||
if market_category and market_category != "Forex":
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'MT5 can only be used for Forex trading, but market_category is {market_category}. Please use MT5 only with Forex market.',
|
||||
'data': {'exchange': safe_cfg}
|
||||
}
|
||||
|
||||
try:
|
||||
from app.services.live_trading.factory import create_mt5_client
|
||||
mt5_client = create_mt5_client(resolved)
|
||||
if mt5_client and mt5_client.connected:
|
||||
# Get account info if available
|
||||
account_info = None
|
||||
try:
|
||||
account_info = mt5_client.get_account_info()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'MT5 connection successful',
|
||||
'data': {
|
||||
'exchange': safe_cfg,
|
||||
'account': account_info
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Failed to connect to MT5. Please check credentials and ensure terminal is running.',
|
||||
'data': {'exchange': safe_cfg}
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'MT5 connection failed: {error_msg}',
|
||||
'data': {'exchange': safe_cfg}
|
||||
}
|
||||
|
||||
# Handle IBKR (US Stocks) connection test
|
||||
if exchange_id == 'ibkr':
|
||||
try:
|
||||
from app.services.live_trading.factory import create_ibkr_client
|
||||
ibkr_client = create_ibkr_client(resolved)
|
||||
# create_ibkr_client already connects, so if it returns, connection is successful
|
||||
if ibkr_client and ibkr_client.connected():
|
||||
# Get account summary if available
|
||||
account_summary = None
|
||||
try:
|
||||
account_summary = ibkr_client.get_account_summary()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'IBKR connection successful',
|
||||
'data': {
|
||||
'exchange': safe_cfg,
|
||||
'account': account_summary
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Failed to connect to IBKR. Please check TWS/Gateway is running and credentials are correct.',
|
||||
'data': {'exchange': safe_cfg}
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'IBKR connection failed: {error_msg}',
|
||||
'data': {'exchange': safe_cfg}
|
||||
}
|
||||
|
||||
# IMPORTANT:
|
||||
# Test connection should respect configured market_type (spot vs swap).
|
||||
# Otherwise Binance will default to futures endpoints (fapi) and spot-only keys will fail with -2015.
|
||||
@@ -549,6 +627,14 @@ class StrategyService:
|
||||
trading_config = payload.get('trading_config') or {}
|
||||
exchange_config = payload.get('exchange_config') or {}
|
||||
|
||||
# Validate MT5 can only be used for Forex trading
|
||||
exchange_id = (exchange_config.get('exchange_id') or '').strip().lower() if isinstance(exchange_config, dict) else ''
|
||||
if exchange_id == 'mt5' and market_category != 'Forex':
|
||||
raise ValueError(
|
||||
f"MT5 can only be used for Forex trading, but market_category is '{market_category}'. "
|
||||
f"MT5 does not support Crypto or Stock trading. Please use MT5 only with Forex market."
|
||||
)
|
||||
|
||||
# When credential_id is present, strip raw API keys to avoid
|
||||
# storing secrets in the strategy record — they live in qd_exchange_credentials.
|
||||
if isinstance(exchange_config, dict) and exchange_config.get('credential_id'):
|
||||
@@ -643,6 +729,16 @@ class StrategyService:
|
||||
if not base_name:
|
||||
raise ValueError("strategy_name is required")
|
||||
|
||||
# Validate MT5 can only be used for Forex trading
|
||||
market_category = payload.get('market_category') or 'Crypto'
|
||||
exchange_config = payload.get('exchange_config') or {}
|
||||
exchange_id = (exchange_config.get('exchange_id') or '').strip().lower() if isinstance(exchange_config, dict) else ''
|
||||
if exchange_id == 'mt5' and market_category != 'Forex':
|
||||
raise ValueError(
|
||||
f"MT5 can only be used for Forex trading, but market_category is '{market_category}'. "
|
||||
f"MT5 does not support Crypto or Stock trading. Please use MT5 only with Forex market."
|
||||
)
|
||||
|
||||
# Generate strategy group ID
|
||||
strategy_group_id = str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user