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:
TIANHE
2026-03-12 00:02:53 +08:00
parent b21777e3a0
commit b7451c63fb
80 changed files with 634 additions and 113 deletions
+37 -4
View File
@@ -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 []
+24
View File
@@ -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]