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:
@@ -73,11 +73,24 @@ cd QuantDinger
|
||||
# 2. Configure (edit admin password & AI API key)
|
||||
cp backend_api_python/env.example backend_api_python/.env
|
||||
|
||||
# 3. Launch!
|
||||
# 3. IMPORTANT: Generate and set a secure SECRET_KEY
|
||||
# Linux/Mac:
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))" | xargs -I {} sed -i 's|SECRET_KEY=.*|SECRET_KEY={}|' backend_api_python/.env
|
||||
|
||||
# Or manually edit backend_api_python/.env and replace SECRET_KEY value
|
||||
# Windows PowerShell:
|
||||
# $key = python -c "import secrets; print(secrets.token_hex(32))"
|
||||
# (Get-Content backend_api_python\.env) -replace 'SECRET_KEY=.*', "SECRET_KEY=$key" | Set-Content backend_api_python\.env
|
||||
|
||||
# 4. Launch!
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
> **Windows PowerShell**: use `Copy-Item backend_api_python\env.example -Destination backend_api_python\.env`
|
||||
> **Windows PowerShell**:
|
||||
> - Copy: `Copy-Item backend_api_python\env.example -Destination backend_api_python\.env`
|
||||
> - Generate SECRET_KEY: `python -c "import secrets; print(secrets.token_hex(32))"` then edit `.env` manually
|
||||
|
||||
> **⚠️ Security Note**: The container will **NOT start** if `SECRET_KEY` is using the default value. This prevents insecure deployments.
|
||||
|
||||
🎉 **Done!** Open **http://localhost:8888** | Login: `quantdinger` / `123456`
|
||||
|
||||
@@ -88,7 +101,7 @@ docker-compose up -d --build
|
||||
# Required — Change for production!
|
||||
ADMIN_USER=quantdinger
|
||||
ADMIN_PASSWORD=your_secure_password
|
||||
SECRET_KEY=your_random_secret_key
|
||||
SECRET_KEY=your_random_secret_key # ⚠️ MUST change! Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
|
||||
# Optional — Enable AI features (pick one)
|
||||
OPENROUTER_API_KEY=your_key # Recommended: 100+ models
|
||||
@@ -99,6 +112,27 @@ GOOGLE_GEMINI_API_KEY=your_key # Gemini
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🔐 Generate SECRET_KEY (Required for Docker)</b></summary>
|
||||
|
||||
**Before starting Docker, you MUST set a secure SECRET_KEY:**
|
||||
|
||||
```bash
|
||||
# Option 1: Use helper script (Linux/Mac)
|
||||
./scripts/generate-secret-key.sh
|
||||
|
||||
# Option 2: Use helper script (Windows PowerShell)
|
||||
.\scripts\generate-secret-key.ps1
|
||||
|
||||
# Option 3: Manual generation
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
# Then edit backend_api_python/.env and replace SECRET_KEY value
|
||||
```
|
||||
|
||||
**⚠️ The container will NOT start if SECRET_KEY is using the default value!**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🔧 Common Docker Commands</b></summary>
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ RUN pip install --no-cache-dir --prefer-binary --timeout 120 -r requirements.txt
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Copy and set up entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Create log and data directories
|
||||
RUN mkdir -p logs data/memory
|
||||
|
||||
@@ -40,5 +44,8 @@ ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHON_API_HOST=0.0.0.0
|
||||
ENV PYTHON_API_PORT=5000
|
||||
|
||||
# Use entrypoint script to check SECRET_KEY before starting
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# Start command
|
||||
CMD ["python", "run.py"]
|
||||
|
||||
@@ -16,6 +16,7 @@ import re
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Dict, List
|
||||
import builtins
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, g
|
||||
import pandas as pd
|
||||
@@ -25,6 +26,7 @@ from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.auth import login_required
|
||||
from app.services.indicator_params import IndicatorCaller
|
||||
from app.utils.safe_exec import validate_code_safety, safe_exec_code
|
||||
import requests
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -385,37 +387,79 @@ def verify_code():
|
||||
# 1. Generate mock data
|
||||
df = _generate_mock_df()
|
||||
|
||||
# 2. Prepare execution environment
|
||||
# 2. Prepare execution environment (sandboxed)
|
||||
exec_env = {
|
||||
'df': df.copy(),
|
||||
'pd': pd,
|
||||
'np': np,
|
||||
'output': None
|
||||
}
|
||||
|
||||
# 3. Execute code
|
||||
|
||||
# 2.1 Create restricted __import__ that only allows safe modules
|
||||
def safe_import(name, *args, **kwargs):
|
||||
"""Only allow importing a small set of safe modules inside indicator scripts."""
|
||||
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',
|
||||
'copyright', 'credits', 'license'
|
||||
]
|
||||
}
|
||||
safe_builtins['__import__'] = safe_import
|
||||
|
||||
exec_env_sandbox = exec_env.copy()
|
||||
exec_env_sandbox['__builtins__'] = safe_builtins
|
||||
|
||||
# 2.2 Pre-import commonly used modules into the sandbox
|
||||
pre_import_code = """
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
"""
|
||||
try:
|
||||
exec(code, exec_env)
|
||||
except SyntaxError as e:
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": f"Syntax Error at line {e.lineno}: {e.msg}",
|
||||
"data": {"type": "SyntaxError", "line": e.lineno, "details": str(e)}
|
||||
})
|
||||
exec(pre_import_code, exec_env_sandbox)
|
||||
except Exception as e:
|
||||
# Capture traceback for better debugging
|
||||
tb = traceback.format_exc()
|
||||
# Extract the line number from the exec() call in the traceback if possible
|
||||
# This is tricky because the traceback includes the backend frames.
|
||||
# We'll just return the exception message.
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": f"Runtime Error: {str(e)}",
|
||||
"code": 0,
|
||||
"msg": f"Runtime Error during pre-import: {str(e)}",
|
||||
"data": {"type": type(e).__name__, "details": tb}
|
||||
})
|
||||
|
||||
# 3. Static safety check
|
||||
is_safe, error_msg = validate_code_safety(code)
|
||||
if not is_safe:
|
||||
logger.error(f"Indicator verifyCode security check failed: {error_msg}")
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": f"Code contains unsafe operations: {error_msg}",
|
||||
"data": {"type": "SecurityError", "details": error_msg}
|
||||
})
|
||||
|
||||
# 4. Execute code with timeout in sandbox
|
||||
exec_result = safe_exec_code(
|
||||
code=code,
|
||||
exec_globals=exec_env_sandbox,
|
||||
exec_locals=exec_env_sandbox,
|
||||
timeout=20 # indicator verification should be quick
|
||||
)
|
||||
|
||||
if not exec_result.get('success'):
|
||||
error_detail = exec_result.get('error') or 'Unknown error'
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": f"Runtime Error: {error_detail}",
|
||||
"data": {"type": "RuntimeError", "details": error_detail}
|
||||
})
|
||||
|
||||
# 4. Check output
|
||||
output = exec_env.get('output')
|
||||
# 5. Check output
|
||||
output = exec_env_sandbox.get('output')
|
||||
|
||||
if output is None:
|
||||
return jsonify({
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -283,16 +283,13 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
|
||||
if isinstance(node.args[1], ast.Constant) and node.args[1].value in dangerous_functions:
|
||||
return False, f"检测到通过 getattr 绕过限制: getattr({node.args[0].id}, '{node.args[1].value}')"
|
||||
|
||||
# 检查导入语句
|
||||
# 检查导入语句:用户脚本中一律禁止使用 import(统一由平台注入安全依赖)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name in dangerous_modules:
|
||||
return False, f"检测到危险模块导入: {alias.name}"
|
||||
return False, "不允许在脚本中使用 import 语句,请直接使用平台提供的 pd/np 等对象"
|
||||
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
if node.module and node.module.split('.')[0] in dangerous_modules:
|
||||
return False, f"检测到危险模块导入: {node.module}"
|
||||
return False, "不允许在脚本中使用 import 语句,请直接使用平台提供的 pd/np 等对象"
|
||||
|
||||
# 检查是否有访问 __builtins__ 的尝试
|
||||
for node in ast.walk(tree):
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# QuantDinger Docker Entrypoint Script
|
||||
# Checks and validates SECRET_KEY before starting the application
|
||||
|
||||
set -e
|
||||
|
||||
echo "============================================"
|
||||
echo " QuantDinger Backend - Starting..."
|
||||
echo "============================================"
|
||||
|
||||
# Check if .env file exists
|
||||
if [ ! -f /app/.env ]; then
|
||||
echo "[WARNING] .env file not found at /app/.env"
|
||||
echo "Creating .env from env.example..."
|
||||
if [ -f /app/env.example ]; then
|
||||
cp /app/env.example /app/.env
|
||||
echo "[INFO] Created .env from env.example"
|
||||
echo "[IMPORTANT] Please edit /app/.env and set a secure SECRET_KEY before restarting!"
|
||||
else
|
||||
echo "[ERROR] env.example not found. Cannot create .env automatically."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check SECRET_KEY configuration
|
||||
DEFAULT_SECRET="quantdinger-secret-key-change-me"
|
||||
CURRENT_SECRET=$(grep -E "^SECRET_KEY=" /app/.env 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d "'" | xargs || echo "")
|
||||
|
||||
if [ -z "$CURRENT_SECRET" ]; then
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " [SECURITY WARNING]"
|
||||
echo "============================================"
|
||||
echo "SECRET_KEY is not set in /app/.env"
|
||||
echo ""
|
||||
echo "Please set a secure SECRET_KEY before starting:"
|
||||
echo ""
|
||||
echo " Option 1: Generate a random key:"
|
||||
echo " docker exec -it quantdinger-backend python -c \"import secrets; print(secrets.token_hex(32))\""
|
||||
echo ""
|
||||
echo " Option 2: Edit .env file:"
|
||||
echo " SECRET_KEY=your-generated-secret-key-here"
|
||||
echo ""
|
||||
echo "Then restart the container:"
|
||||
echo " docker-compose restart backend"
|
||||
echo ""
|
||||
echo "============================================"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if using default secret key
|
||||
if [ "$CURRENT_SECRET" = "$DEFAULT_SECRET" ]; then
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " [SECURITY ERROR]"
|
||||
echo "============================================"
|
||||
echo "SECRET_KEY is using the default example value!"
|
||||
echo "This is INSECURE and MUST be changed before running in production."
|
||||
echo ""
|
||||
echo "Current value: $CURRENT_SECRET"
|
||||
echo ""
|
||||
echo "To fix this:"
|
||||
echo ""
|
||||
echo " 1. Generate a secure random key:"
|
||||
echo " docker exec -it quantdinger-backend python -c \"import secrets; print(secrets.token_hex(32))\""
|
||||
echo ""
|
||||
echo " 2. Edit backend_api_python/.env and replace SECRET_KEY:"
|
||||
echo " SECRET_KEY=<generated-key-here>"
|
||||
echo ""
|
||||
echo " 3. Restart the container:"
|
||||
echo " docker-compose restart backend"
|
||||
echo ""
|
||||
echo "Or use this one-liner (Linux/Mac):"
|
||||
echo " sed -i 's|SECRET_KEY=.*|SECRET_KEY='\$(python3 -c \"import secrets; print(secrets.token_hex(32))\")\'|' backend_api_python/.env"
|
||||
echo ""
|
||||
echo "============================================"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] SECRET_KEY is configured"
|
||||
echo ""
|
||||
|
||||
# Start the application
|
||||
exec "$@"
|
||||
@@ -84,6 +84,24 @@ def main():
|
||||
# Keep startup messages ASCII-only and short.
|
||||
print("QuantDinger Python API v2.2.2")
|
||||
|
||||
# ========== Critical Security Check for SECRET_KEY ==========
|
||||
# In production (DEBUG=False), the SECRET_KEY MUST NOT use the default example value.
|
||||
# This prevents attackers from forging JWT tokens with admin privileges.
|
||||
default_secret = "quantdinger-secret-key-change-me"
|
||||
current_secret = Config.SECRET_KEY
|
||||
if not Config.DEBUG and current_secret == default_secret:
|
||||
msg = (
|
||||
"\n[SECURITY ERROR] SECRET_KEY is using the default example value.\n"
|
||||
"You MUST change SECRET_KEY in backend_api_python/.env before running in production.\n"
|
||||
"Example:\n"
|
||||
" SECRET_KEY=$(python - << 'EOF'\n"
|
||||
"import secrets; print(secrets.token_hex(32))\n"
|
||||
"EOF\n"
|
||||
)
|
||||
# Print to both stdout and raise to stop the server
|
||||
print(msg)
|
||||
raise RuntimeError("Insecure SECRET_KEY configuration: using default example value in non-debug mode")
|
||||
|
||||
# Check demo mode status for debugging
|
||||
demo_status = os.getenv('IS_DEMO_MODE', 'false').lower()
|
||||
print(f"Status Check: IS_DEMO_MODE={demo_status}")
|
||||
|
||||
+10
-3
@@ -1,8 +1,15 @@
|
||||
# QuantDinger Docker Compose - One-Click Deployment
|
||||
# Usage:
|
||||
# 1. Copy .env.example to .env and edit your settings
|
||||
# 2. docker-compose up -d
|
||||
# 3. Open http://localhost:8888
|
||||
# 1. Copy env.example to .env and edit your settings
|
||||
# cp backend_api_python/env.example backend_api_python/.env
|
||||
# 2. IMPORTANT: Generate and set a secure SECRET_KEY in backend_api_python/.env
|
||||
# SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
# Or edit backend_api_python/.env and replace SECRET_KEY value
|
||||
# 3. docker-compose up -d --build
|
||||
# 4. Open http://localhost:8888
|
||||
#
|
||||
# Note: The container will NOT start if SECRET_KEY is using the default value.
|
||||
# This is a security measure to prevent insecure deployments.
|
||||
|
||||
version: '3.8'
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
-1
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
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -421,4 +421,4 @@
|
||||
.brand-text {
|
||||
font-size: 20px;
|
||||
}
|
||||
}</style><script defer="defer" src="/js/chunk-vendors.ba8f72a0.js" type="module"></script><script defer="defer" src="/js/app.7e78ef2e.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.b8761398.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.2873ce19.js" nomodule></script><script defer="defer" src="/js/app-legacy.17b20dc1.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.2941c6b5.js" type="module"></script><script defer="defer" src="/js/app.cbd0f891.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.b8761398.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.fd9e013e.js" nomodule></script><script defer="defer" src="/js/app-legacy.920baa8b.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>
|
||||
Vendored
-18
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
-18
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+18
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
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
-1
File diff suppressed because one or more lines are too long
+1
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
-1
File diff suppressed because one or more lines are too long
+1
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
+1
File diff suppressed because one or more lines are too long
-1
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
-1
File diff suppressed because one or more lines are too long
+1
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
-1
File diff suppressed because one or more lines are too long
+1
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
+1
File diff suppressed because one or more lines are too long
-1
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
+1
File diff suppressed because one or more lines are too long
-1
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
-1
File diff suppressed because one or more lines are too long
+1
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
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
||||
# Helper script to generate a secure SECRET_KEY for QuantDinger (Windows PowerShell)
|
||||
# Usage: .\scripts\generate-secret-key.ps1
|
||||
|
||||
$envFile = "backend_api_python\.env"
|
||||
|
||||
# Check if .env exists
|
||||
if (-not (Test-Path $envFile)) {
|
||||
Write-Host "Error: $envFile not found" -ForegroundColor Red
|
||||
Write-Host "Please run: Copy-Item backend_api_python\env.example -Destination backend_api_python\.env"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Generate a secure random key
|
||||
$newKey = python -c "import secrets; print(secrets.token_hex(32))"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Error: Failed to generate SECRET_KEY. Make sure Python is installed." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Update SECRET_KEY in .env file
|
||||
$content = Get-Content $envFile
|
||||
$content = $content -replace '^SECRET_KEY=.*', "SECRET_KEY=$newKey"
|
||||
$content | Set-Content $envFile
|
||||
|
||||
Write-Host "✅ SECRET_KEY generated and updated in $envFile" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Generated key: $newKey" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "You can now start the application:"
|
||||
Write-Host " docker-compose up -d --build"
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Helper script to generate a secure SECRET_KEY for QuantDinger
|
||||
# Usage: ./scripts/generate-secret-key.sh
|
||||
|
||||
set -e
|
||||
|
||||
ENV_FILE="backend_api_python/.env"
|
||||
|
||||
# Check if .env exists
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Error: $ENV_FILE not found"
|
||||
echo "Please run: cp backend_api_python/env.example backend_api_python/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate a secure random key
|
||||
NEW_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
|
||||
# Update SECRET_KEY in .env file
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
sed -i '' "s|^SECRET_KEY=.*|SECRET_KEY=$NEW_KEY|" "$ENV_FILE"
|
||||
else
|
||||
# Linux
|
||||
sed -i "s|^SECRET_KEY=.*|SECRET_KEY=$NEW_KEY|" "$ENV_FILE"
|
||||
fi
|
||||
|
||||
echo "✅ SECRET_KEY generated and updated in $ENV_FILE"
|
||||
echo ""
|
||||
echo "Generated key: $NEW_KEY"
|
||||
echo ""
|
||||
echo "You can now start the application:"
|
||||
echo " docker-compose up -d --build"
|
||||
Reference in New Issue
Block a user