fix: Multiple bug fixes and improvements
- Fix Invalid Date display in Dashboard notifications - Fix timezone offset (8 hours) in Trading Records time display - Fix position closing failures due to commission discrepancies (fetch actual exchange position size for reduce_only orders) - Fix IBKR connection error 'no current event loop in thread' by ensuring asyncio event loop exists - Fix duplicate orders on same candle by extending signal deduplication to close signals - Add responsive design for Profile page (mobile-friendly) - Remove unused strategy_code module and database table - Fix LLM service to support multiple providers (OpenRouter, OpenAI, DeepSeek, Grok, Google) - Add auto-detection of configured LLM provider based on API key availability - Fix AI code generation to use unified LLMService with proper provider selection - Fix crypto symbol format handling (ETH/USDT no longer becomes ETH/USDT/USDT) - Fix Commission display showing '0E-8' in Trading Records - Fix P&L display for signal-only trades (show '--' for unrealized P&L) - Fix OAuth login not updating last_login_at for new users - Add migration script for notification_settings column - Update env.example with new LLM provider configurations - Remove ESLint rule that was not defined in config
This commit is contained in:
@@ -21,9 +21,53 @@ class MetaAPIKeys(type):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def OPENROUTER_API_KEY(cls):
|
def OPENROUTER_API_KEY(cls):
|
||||||
|
# Always check env var first to avoid stale cache issues
|
||||||
|
env_val = os.getenv('OPENROUTER_API_KEY', '').strip()
|
||||||
|
if env_val:
|
||||||
|
return env_val
|
||||||
from app.utils.config_loader import load_addon_config
|
from app.utils.config_loader import load_addon_config
|
||||||
val = load_addon_config().get('openrouter', {}).get('api_key')
|
val = load_addon_config().get('openrouter', {}).get('api_key')
|
||||||
return val if val else os.getenv('OPENROUTER_API_KEY', '')
|
return val if val else ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def OPENAI_API_KEY(cls):
|
||||||
|
"""OpenAI direct API key"""
|
||||||
|
env_val = os.getenv('OPENAI_API_KEY', '').strip()
|
||||||
|
if env_val:
|
||||||
|
return env_val
|
||||||
|
from app.utils.config_loader import load_addon_config
|
||||||
|
val = load_addon_config().get('openai', {}).get('api_key')
|
||||||
|
return val if val else ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GOOGLE_API_KEY(cls):
|
||||||
|
"""Google Gemini API key"""
|
||||||
|
env_val = os.getenv('GOOGLE_API_KEY', '').strip()
|
||||||
|
if env_val:
|
||||||
|
return env_val
|
||||||
|
from app.utils.config_loader import load_addon_config
|
||||||
|
val = load_addon_config().get('google', {}).get('api_key')
|
||||||
|
return val if val else ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def DEEPSEEK_API_KEY(cls):
|
||||||
|
"""DeepSeek API key"""
|
||||||
|
env_val = os.getenv('DEEPSEEK_API_KEY', '').strip()
|
||||||
|
if env_val:
|
||||||
|
return env_val
|
||||||
|
from app.utils.config_loader import load_addon_config
|
||||||
|
val = load_addon_config().get('deepseek', {}).get('api_key')
|
||||||
|
return val if val else ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GROK_API_KEY(cls):
|
||||||
|
"""xAI Grok API key"""
|
||||||
|
env_val = os.getenv('GROK_API_KEY', '').strip()
|
||||||
|
if env_val:
|
||||||
|
return env_val
|
||||||
|
from app.utils.config_loader import load_addon_config
|
||||||
|
val = load_addon_config().get('grok', {}).get('api_key')
|
||||||
|
return val if val else ''
|
||||||
|
|
||||||
|
|
||||||
class APIKeys(metaclass=MetaAPIKeys):
|
class APIKeys(metaclass=MetaAPIKeys):
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ def register_routes(app: Flask):
|
|||||||
from app.routes.ibkr import ibkr_bp
|
from app.routes.ibkr import ibkr_bp
|
||||||
from app.routes.mt5 import mt5_bp
|
from app.routes.mt5 import mt5_bp
|
||||||
from app.routes.user import user_bp
|
from app.routes.user import user_bp
|
||||||
from app.routes.strategy_code import strategy_code_bp
|
|
||||||
|
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
|
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
|
||||||
@@ -40,4 +39,3 @@ def register_routes(app: Flask):
|
|||||||
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
|
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
|
||||||
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
|
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
|
||||||
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
|
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
|
||||||
app.register_blueprint(strategy_code_bp, url_prefix='/api/strategy-code')
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ backtest_service = BacktestService()
|
|||||||
|
|
||||||
|
|
||||||
def _openrouter_base_and_key() -> tuple[str, str]:
|
def _openrouter_base_and_key() -> tuple[str, str]:
|
||||||
key = os.getenv("OPENROUTER_API_KEY", "").strip()
|
from app.config import APIKeys
|
||||||
|
# Use APIKeys to get the key (handles env var + config cache properly)
|
||||||
|
key = APIKeys.OPENROUTER_API_KEY or ""
|
||||||
base = os.getenv("OPENROUTER_BASE_URL", "").strip()
|
base = os.getenv("OPENROUTER_BASE_URL", "").strip()
|
||||||
if not base:
|
if not base:
|
||||||
api_url = os.getenv("OPENROUTER_API_URL", "").strip()
|
api_url = os.getenv("OPENROUTER_API_URL", "").strip()
|
||||||
|
|||||||
@@ -493,31 +493,25 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
|
|||||||
header = "# Existing code was provided as context.\n" + header
|
header = "# Existing code was provided as context.\n" + header
|
||||||
return header + body
|
return header + body
|
||||||
|
|
||||||
def _openrouter_base_and_key() -> tuple[str, str]:
|
def _generate_code_via_llm() -> str:
|
||||||
"""
|
"""Use unified LLMService to support all configured providers (OpenRouter, OpenAI, Grok, etc.)."""
|
||||||
Support both:
|
from app.services.llm import LLMService
|
||||||
- OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
|
||||||
- OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
|
llm = LLMService()
|
||||||
"""
|
|
||||||
key = os.getenv("OPENROUTER_API_KEY", "").strip()
|
# Get provider and model from env config (no frontend override)
|
||||||
base = os.getenv("OPENROUTER_BASE_URL", "").strip()
|
current_provider = llm.provider
|
||||||
if not base:
|
current_model = llm.get_default_model()
|
||||||
api_url = os.getenv("OPENROUTER_API_URL", "").strip()
|
current_api_key = llm.get_api_key()
|
||||||
if api_url.endswith("/chat/completions"):
|
base_url = llm.get_base_url()
|
||||||
base = api_url[: -len("/chat/completions")]
|
|
||||||
if not base:
|
logger.info(f"AI Code Generation - Provider: {current_provider.value}, Model: {current_model}, Base URL: {base_url}, API Key configured: {bool(current_api_key)}")
|
||||||
base = "https://openrouter.ai/api/v1"
|
|
||||||
return base, key
|
# Check if any LLM provider is configured
|
||||||
|
if not current_api_key:
|
||||||
def _generate_code_via_openrouter() -> str:
|
logger.warning("No LLM API key configured, using template code")
|
||||||
base_url, api_key = _openrouter_base_and_key()
|
|
||||||
if not api_key:
|
|
||||||
return _template_code()
|
return _template_code()
|
||||||
|
|
||||||
model = os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini").strip() or "openai/gpt-4o-mini"
|
|
||||||
# Match legacy PHP default more closely
|
|
||||||
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7)
|
|
||||||
|
|
||||||
# Build user prompt (match PHP behavior)
|
# Build user prompt (match PHP behavior)
|
||||||
user_prompt = prompt
|
user_prompt = prompt
|
||||||
if existing:
|
if existing:
|
||||||
@@ -529,36 +523,36 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
|
|||||||
+ "\n\nPlease generate complete new Python code based on the existing code above and my modification requirements. Output the complete Python code directly, without explanations, without segmentation."
|
+ "\n\nPlease generate complete new Python code based on the existing code above and my modification requirements. Output the complete Python code directly, without explanations, without segmentation."
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = {
|
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7)
|
||||||
"model": model,
|
|
||||||
"temperature": temperature,
|
# Call LLM using the unified API (auto-selects provider based on LLM_PROVIDER env)
|
||||||
"stream": False,
|
# use_json_mode=False because we want raw Python code output
|
||||||
"messages": [
|
content = llm.call_llm_api(
|
||||||
|
messages=[
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
{"role": "user", "content": user_prompt},
|
{"role": "user", "content": user_prompt},
|
||||||
],
|
],
|
||||||
}
|
temperature=temperature,
|
||||||
|
use_json_mode=False # Code generation doesn't need JSON mode
|
||||||
resp = requests.post(
|
|
||||||
f"{base_url}/chat/completions",
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json=payload,
|
|
||||||
timeout=120,
|
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
|
||||||
j = resp.json()
|
# Clean up markdown code blocks if present
|
||||||
content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or ""
|
content = content.strip()
|
||||||
|
if content.startswith("```python"):
|
||||||
|
content = content[9:]
|
||||||
|
elif content.startswith("```"):
|
||||||
|
content = content[3:]
|
||||||
|
if content.endswith("```"):
|
||||||
|
content = content[:-3]
|
||||||
|
|
||||||
return content.strip() or _template_code()
|
return content.strip() or _template_code()
|
||||||
|
|
||||||
def stream():
|
def stream():
|
||||||
# 不扣任何 QDT:开源本地版直接生成/返回代码
|
# 不扣任何 QDT:开源本地版直接生成/返回代码
|
||||||
try:
|
try:
|
||||||
code_text = _generate_code_via_openrouter()
|
code_text = _generate_code_via_llm()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"ai_generate openrouter failed, fallback to template: {e}")
|
logger.error(f"ai_generate LLM failed, fallback to template. Error: {type(e).__name__}: {e}")
|
||||||
code_text = _template_code()
|
code_text = _template_code()
|
||||||
|
|
||||||
# Stream in chunks (front-end appends).
|
# Stream in chunks (front-end appends).
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ CONFIG_SCHEMA = {
|
|||||||
'icon': 'robot',
|
'icon': 'robot',
|
||||||
'order': 3,
|
'order': 3,
|
||||||
'items': [
|
'items': [
|
||||||
|
{
|
||||||
|
'key': 'LLM_PROVIDER',
|
||||||
|
'label': 'LLM Provider',
|
||||||
|
'type': 'select',
|
||||||
|
'default': 'openrouter',
|
||||||
|
'options': [
|
||||||
|
{'value': 'openrouter', 'label': 'OpenRouter (Multi-model gateway)'},
|
||||||
|
{'value': 'openai', 'label': 'OpenAI Direct'},
|
||||||
|
{'value': 'google', 'label': 'Google Gemini'},
|
||||||
|
{'value': 'deepseek', 'label': 'DeepSeek'},
|
||||||
|
{'value': 'grok', 'label': 'xAI Grok'},
|
||||||
|
],
|
||||||
|
'description': 'Select your preferred LLM provider'
|
||||||
|
},
|
||||||
|
# OpenRouter
|
||||||
{
|
{
|
||||||
'key': 'OPENROUTER_API_KEY',
|
'key': 'OPENROUTER_API_KEY',
|
||||||
'label': 'OpenRouter API Key',
|
'label': 'OpenRouter API Key',
|
||||||
@@ -99,37 +114,126 @@ CONFIG_SCHEMA = {
|
|||||||
'required': False,
|
'required': False,
|
||||||
'link': 'https://openrouter.ai/keys',
|
'link': 'https://openrouter.ai/keys',
|
||||||
'link_text': 'settings.link.getApiKey',
|
'link_text': 'settings.link.getApiKey',
|
||||||
'description': 'OpenRouter API key for AI model access. Supports multiple LLM providers'
|
'description': 'OpenRouter API key. Supports 100+ models via single API',
|
||||||
},
|
'group': 'openrouter'
|
||||||
{
|
|
||||||
'key': 'OPENROUTER_API_URL',
|
|
||||||
'label': 'OpenRouter API URL',
|
|
||||||
'type': 'text',
|
|
||||||
'default': 'https://openrouter.ai/api/v1/chat/completions',
|
|
||||||
'description': 'OpenRouter API endpoint URL'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'key': 'OPENROUTER_MODEL',
|
'key': 'OPENROUTER_MODEL',
|
||||||
'label': 'Default Model',
|
'label': 'OpenRouter Model',
|
||||||
'type': 'text',
|
'type': 'text',
|
||||||
'default': 'openai/gpt-4o',
|
'default': 'openai/gpt-4o',
|
||||||
'link': 'https://openrouter.ai/models',
|
'link': 'https://openrouter.ai/models',
|
||||||
'link_text': 'settings.link.viewModels',
|
'link_text': 'settings.link.viewModels',
|
||||||
'description': 'Default LLM model ID, e.g. openai/gpt-4o, anthropic/claude-3.5-sonnet'
|
'description': 'Model ID, e.g. openai/gpt-4o, anthropic/claude-3.5-sonnet',
|
||||||
|
'group': 'openrouter'
|
||||||
},
|
},
|
||||||
|
# OpenAI Direct
|
||||||
|
{
|
||||||
|
'key': 'OPENAI_API_KEY',
|
||||||
|
'label': 'OpenAI API Key',
|
||||||
|
'type': 'password',
|
||||||
|
'required': False,
|
||||||
|
'link': 'https://platform.openai.com/api-keys',
|
||||||
|
'link_text': 'settings.link.getApiKey',
|
||||||
|
'description': 'OpenAI official API key',
|
||||||
|
'group': 'openai'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'OPENAI_MODEL',
|
||||||
|
'label': 'OpenAI Model',
|
||||||
|
'type': 'text',
|
||||||
|
'default': 'gpt-4o',
|
||||||
|
'description': 'Model name: gpt-4o, gpt-4o-mini, gpt-4-turbo, etc.',
|
||||||
|
'group': 'openai'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'OPENAI_BASE_URL',
|
||||||
|
'label': 'OpenAI Base URL',
|
||||||
|
'type': 'text',
|
||||||
|
'default': 'https://api.openai.com/v1',
|
||||||
|
'description': 'Custom API endpoint (for proxies or Azure)',
|
||||||
|
'group': 'openai'
|
||||||
|
},
|
||||||
|
# Google Gemini
|
||||||
|
{
|
||||||
|
'key': 'GOOGLE_API_KEY',
|
||||||
|
'label': 'Google API Key',
|
||||||
|
'type': 'password',
|
||||||
|
'required': False,
|
||||||
|
'link': 'https://aistudio.google.com/apikey',
|
||||||
|
'link_text': 'settings.link.getApiKey',
|
||||||
|
'description': 'Google AI Studio API key for Gemini',
|
||||||
|
'group': 'google'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'GOOGLE_MODEL',
|
||||||
|
'label': 'Gemini Model',
|
||||||
|
'type': 'text',
|
||||||
|
'default': 'gemini-1.5-flash',
|
||||||
|
'description': 'Model: gemini-1.5-flash, gemini-1.5-pro, gemini-2.0-flash-exp',
|
||||||
|
'group': 'google'
|
||||||
|
},
|
||||||
|
# DeepSeek
|
||||||
|
{
|
||||||
|
'key': 'DEEPSEEK_API_KEY',
|
||||||
|
'label': 'DeepSeek API Key',
|
||||||
|
'type': 'password',
|
||||||
|
'required': False,
|
||||||
|
'link': 'https://platform.deepseek.com/api_keys',
|
||||||
|
'link_text': 'settings.link.getApiKey',
|
||||||
|
'description': 'DeepSeek API key',
|
||||||
|
'group': 'deepseek'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'DEEPSEEK_MODEL',
|
||||||
|
'label': 'DeepSeek Model',
|
||||||
|
'type': 'text',
|
||||||
|
'default': 'deepseek-chat',
|
||||||
|
'description': 'Model: deepseek-chat, deepseek-coder',
|
||||||
|
'group': 'deepseek'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'DEEPSEEK_BASE_URL',
|
||||||
|
'label': 'DeepSeek Base URL',
|
||||||
|
'type': 'text',
|
||||||
|
'default': 'https://api.deepseek.com/v1',
|
||||||
|
'description': 'DeepSeek API endpoint',
|
||||||
|
'group': 'deepseek'
|
||||||
|
},
|
||||||
|
# xAI Grok
|
||||||
|
{
|
||||||
|
'key': 'GROK_API_KEY',
|
||||||
|
'label': 'Grok API Key',
|
||||||
|
'type': 'password',
|
||||||
|
'required': False,
|
||||||
|
'link': 'https://console.x.ai/',
|
||||||
|
'link_text': 'settings.link.getApiKey',
|
||||||
|
'description': 'xAI Grok API key',
|
||||||
|
'group': 'grok'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'GROK_MODEL',
|
||||||
|
'label': 'Grok Model',
|
||||||
|
'type': 'text',
|
||||||
|
'default': 'grok-beta',
|
||||||
|
'description': 'Model: grok-beta, grok-2',
|
||||||
|
'group': 'grok'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'GROK_BASE_URL',
|
||||||
|
'label': 'Grok Base URL',
|
||||||
|
'type': 'text',
|
||||||
|
'default': 'https://api.x.ai/v1',
|
||||||
|
'description': 'xAI Grok API endpoint',
|
||||||
|
'group': 'grok'
|
||||||
|
},
|
||||||
|
# Common settings
|
||||||
{
|
{
|
||||||
'key': 'OPENROUTER_TEMPERATURE',
|
'key': 'OPENROUTER_TEMPERATURE',
|
||||||
'label': 'Temperature',
|
'label': 'Temperature',
|
||||||
'type': 'number',
|
'type': 'number',
|
||||||
'default': '0.7',
|
'default': '0.7',
|
||||||
'description': 'Model creativity (0-1). Lower = more deterministic, Higher = more creative'
|
'description': 'Model creativity (0-1). Lower = more deterministic'
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'OPENROUTER_MAX_TOKENS',
|
|
||||||
'label': 'Max Tokens',
|
|
||||||
'type': 'number',
|
|
||||||
'default': '4000',
|
|
||||||
'description': 'Maximum output tokens per request'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'key': 'OPENROUTER_TIMEOUT',
|
'key': 'OPENROUTER_TIMEOUT',
|
||||||
@@ -138,13 +242,6 @@ CONFIG_SCHEMA = {
|
|||||||
'default': '300',
|
'default': '300',
|
||||||
'description': 'API request timeout in seconds'
|
'description': 'API request timeout in seconds'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'key': 'OPENROUTER_CONNECT_TIMEOUT',
|
|
||||||
'label': 'Connect Timeout (sec)',
|
|
||||||
'type': 'number',
|
|
||||||
'default': '30',
|
|
||||||
'description': 'Connection establishment timeout in seconds'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
'key': 'AI_MODELS_JSON',
|
'key': 'AI_MODELS_JSON',
|
||||||
'label': 'Custom Models (JSON)',
|
'label': 'Custom Models (JSON)',
|
||||||
|
|||||||
@@ -316,8 +316,29 @@ def get_trades():
|
|||||||
)
|
)
|
||||||
rows = cur.fetchall() or []
|
rows = cur.fetchall() or []
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
|
# Convert created_at to UTC timestamp (seconds) for frontend
|
||||||
|
# This ensures consistent timezone handling
|
||||||
|
processed_rows = []
|
||||||
|
for row in rows:
|
||||||
|
trade = dict(row)
|
||||||
|
created_at = trade.get('created_at')
|
||||||
|
if created_at:
|
||||||
|
if hasattr(created_at, 'timestamp'):
|
||||||
|
# datetime object - convert to UTC timestamp
|
||||||
|
trade['created_at'] = int(created_at.timestamp())
|
||||||
|
elif isinstance(created_at, str):
|
||||||
|
# ISO string - parse and convert
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
dt = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||||
|
trade['created_at'] = int(dt.timestamp())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
processed_rows.append(trade)
|
||||||
|
|
||||||
# Frontend expects data.trades; keep data.items for compatibility with list-style components.
|
# Frontend expects data.trades; keep data.items for compatibility with list-style components.
|
||||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'trades': rows, 'items': rows}})
|
return jsonify({'code': 1, 'msg': 'success', 'data': {'trades': processed_rows, 'items': processed_rows}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"get_trades failed: {str(e)}")
|
logger.error(f"get_trades failed: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
@@ -833,7 +854,24 @@ def get_strategy_notifications():
|
|||||||
rows = cur.fetchall() or []
|
rows = cur.fetchall() or []
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': rows}})
|
# Convert created_at to UTC timestamp (seconds) for frontend
|
||||||
|
processed_rows = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
created_at = item.get('created_at')
|
||||||
|
if created_at:
|
||||||
|
if hasattr(created_at, 'timestamp'):
|
||||||
|
item['created_at'] = int(created_at.timestamp())
|
||||||
|
elif isinstance(created_at, str):
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
dt = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||||
|
item['created_at'] = int(dt.timestamp())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
processed_rows.append(item)
|
||||||
|
|
||||||
|
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': processed_rows}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"get_strategy_notifications failed: {str(e)}")
|
logger.error(f"get_strategy_notifications failed: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
"""
|
|
||||||
Indicator-analysis Strategy APIs (local-first).
|
|
||||||
|
|
||||||
These "strategies" are user-authored Python scripts used on `/indicator-analysis`:
|
|
||||||
- visualize signals on Kline (via output.plots/output.signals)
|
|
||||||
- optionally support backtest engine expectations (df signal columns)
|
|
||||||
|
|
||||||
They are different from the live trading executor strategies in `app/routes/strategy.py`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
from app.utils.db import get_db_connection
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
strategy_code_bp = Blueprint("strategy_code", __name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _now_ts() -> int:
|
|
||||||
return int(time.time())
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_meta_from_code(code: str) -> Dict[str, str]:
|
|
||||||
if not code or not isinstance(code, str):
|
|
||||||
return {"name": "", "description": ""}
|
|
||||||
name_match = re.search(r'^\s*my_indicator_name\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE)
|
|
||||||
desc_match = re.search(r'^\s*my_indicator_description\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE)
|
|
||||||
name = (name_match.group(2).strip() if name_match else "")[:100]
|
|
||||||
description = (desc_match.group(2).strip() if desc_match else "")[:500]
|
|
||||||
return {"name": name, "description": description}
|
|
||||||
|
|
||||||
|
|
||||||
@strategy_code_bp.route("/strategy/getStrategies", methods=["GET"])
|
|
||||||
def get_strategies():
|
|
||||||
try:
|
|
||||||
user_id = int(request.args.get("userid") or 1)
|
|
||||||
with get_db_connection() as db:
|
|
||||||
cur = db.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id, user_id, name, code, description, createtime, updatetime FROM qd_strategy_codes WHERE user_id = ? ORDER BY id DESC",
|
|
||||||
(user_id,),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall() or []
|
|
||||||
cur.close()
|
|
||||||
return jsonify({"code": 1, "msg": "success", "data": rows})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"get_strategies failed: {e}", exc_info=True)
|
|
||||||
return jsonify({"code": 0, "msg": str(e), "data": []}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@strategy_code_bp.route("/strategy/saveStrategy", methods=["POST"])
|
|
||||||
def save_strategy():
|
|
||||||
try:
|
|
||||||
data = request.get_json() or {}
|
|
||||||
user_id = int(data.get("userid") or 1)
|
|
||||||
strategy_id = int(data.get("id") or 0)
|
|
||||||
code = data.get("code") or ""
|
|
||||||
if not str(code).strip():
|
|
||||||
return jsonify({"code": 0, "msg": "code is required", "data": None}), 400
|
|
||||||
|
|
||||||
name = (data.get("name") or "").strip()
|
|
||||||
description = (data.get("description") or "").strip()
|
|
||||||
if not name or not description:
|
|
||||||
meta = _extract_meta_from_code(code)
|
|
||||||
if not name:
|
|
||||||
name = meta.get("name") or ""
|
|
||||||
if not description:
|
|
||||||
description = meta.get("description") or ""
|
|
||||||
if not name:
|
|
||||||
name = "Custom Strategy"
|
|
||||||
|
|
||||||
now = _now_ts()
|
|
||||||
with get_db_connection() as db:
|
|
||||||
cur = db.cursor()
|
|
||||||
if strategy_id and strategy_id > 0:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE qd_strategy_codes SET name = ?, code = ?, description = ?, updatetime = ? WHERE id = ? AND user_id = ?",
|
|
||||||
(name, code, description, now, strategy_id, user_id),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO qd_strategy_codes (user_id, name, code, description, createtime, updatetime) VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
(user_id, name, code, description, now, now),
|
|
||||||
)
|
|
||||||
strategy_id = int(cur.lastrowid or 0)
|
|
||||||
db.commit()
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
return jsonify({"code": 1, "msg": "success", "data": {"id": strategy_id, "userid": user_id}})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"save_strategy failed: {e}", exc_info=True)
|
|
||||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@strategy_code_bp.route("/strategy/deleteStrategy", methods=["POST"])
|
|
||||||
def delete_strategy():
|
|
||||||
try:
|
|
||||||
data = request.get_json() or {}
|
|
||||||
user_id = int(data.get("userid") or 1)
|
|
||||||
strategy_id = int(data.get("id") or 0)
|
|
||||||
if not strategy_id:
|
|
||||||
return jsonify({"code": 0, "msg": "id is required", "data": None}), 400
|
|
||||||
with get_db_connection() as db:
|
|
||||||
cur = db.cursor()
|
|
||||||
cur.execute("DELETE FROM qd_strategy_codes WHERE id = ? AND user_id = ?", (strategy_id, user_id))
|
|
||||||
db.commit()
|
|
||||||
cur.close()
|
|
||||||
return jsonify({"code": 1, "msg": "success", "data": None})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"delete_strategy failed: {e}", exc_info=True)
|
|
||||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@strategy_code_bp.route("/strategy/aiGenerate", methods=["POST"])
|
|
||||||
def ai_generate_strategy():
|
|
||||||
"""
|
|
||||||
SSE code generation for strategy scripts (local-first, no QDT deduction).
|
|
||||||
"""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
prompt = (data.get("prompt") or "").strip()
|
|
||||||
existing = (data.get("existingCode") or "").strip()
|
|
||||||
|
|
||||||
if not prompt:
|
|
||||||
def _err_stream():
|
|
||||||
yield "data: " + json.dumps({"error": "提示词不能为空"}, ensure_ascii=False) + "\n\n"
|
|
||||||
yield "data: [DONE]\n\n"
|
|
||||||
|
|
||||||
return Response(_err_stream(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """# Role
|
|
||||||
|
|
||||||
You are an expert Python quantitative trading developer.
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
- Runs in browser (Pyodide): NO network access, no pip, no requests.
|
|
||||||
- pandas is already imported as pd, numpy as np. DO NOT import them.
|
|
||||||
- Input: df with columns time/open/high/low/close/volume.
|
|
||||||
|
|
||||||
# Required output (STRICT)
|
|
||||||
- You MUST define:
|
|
||||||
- my_indicator_name = "..."
|
|
||||||
- my_indicator_description = "..."
|
|
||||||
- output = {"name":..., "plots":[...], "signals":[...]}
|
|
||||||
|
|
||||||
# Chart signal rules (MUST)
|
|
||||||
- output["signals"] MAY exist, but if present it MUST contain ONLY two types: "buy" and "sell".
|
|
||||||
- Signals must be aligned with df length: signals[].data length == len(df), use None for "no signal".
|
|
||||||
- Default signal text MUST be English (recommended "B"/"S" or "Buy"/"Sell"). Do NOT output Chinese text.
|
|
||||||
|
|
||||||
# Execution/backtest compatibility (MUST)
|
|
||||||
- You MUST set boolean columns:
|
|
||||||
- df["buy"] and df["sell"]
|
|
||||||
- Backend will normalize buy/sell into open/close long/short actions based on trade_direction and current position.
|
|
||||||
- Do NOT emit open_long/close_long/open_short/close_short/add_* in output["signals"].
|
|
||||||
- Do NOT implement position sizing, TP/SL, trailing, pyramiding in the script. Those belong to strategy_config / backend.
|
|
||||||
- Signals are typically confirmed on bar close and executed by backtest on the next bar open (to avoid look-ahead bias).
|
|
||||||
|
|
||||||
# Robustness requirements (IMPORTANT)
|
|
||||||
- Always handle division-by-zero and NaN/inf when computing indicators (e.g., RSV denominator can be 0).
|
|
||||||
- Avoid overly restrictive entry conditions that result in zero buys or zero sells. Prefer crossover/event-based signals.
|
|
||||||
- For multi-indicator strategies, avoid requiring a crossover AND extreme RSI/BB condition on the same bar unless explicitly requested.
|
|
||||||
- Prefer edge-triggered signals (one-shot) to avoid repeated consecutive buy/sell bars:
|
|
||||||
buy = raw_buy & ~raw_buy.shift(1).fillna(False)
|
|
||||||
sell = raw_sell & ~raw_sell.shift(1).fillna(False)
|
|
||||||
|
|
||||||
# Execution rule (IMPORTANT)
|
|
||||||
- The backtest engine may apply parameterized scaling (scale-in/out) from strategy_config.
|
|
||||||
- If a candle has a main signal (buy/sell mapped to open/close/reverse), scaling in/out is skipped on the same candle.
|
|
||||||
|
|
||||||
# Output style
|
|
||||||
- Output Python code only. No markdown code blocks. No extra explanations.
|
|
||||||
- Keep code comments and default strings in English.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _openrouter_base_and_key() -> tuple[str, str]:
|
|
||||||
key = os.getenv("OPENROUTER_API_KEY", "").strip()
|
|
||||||
base = os.getenv("OPENROUTER_BASE_URL", "").strip()
|
|
||||||
if not base:
|
|
||||||
api_url = os.getenv("OPENROUTER_API_URL", "").strip()
|
|
||||||
if api_url.endswith("/chat/completions"):
|
|
||||||
base = api_url[: -len("/chat/completions")]
|
|
||||||
if not base:
|
|
||||||
base = "https://openrouter.ai/api/v1"
|
|
||||||
return base, key
|
|
||||||
|
|
||||||
def _template_code() -> str:
|
|
||||||
return (
|
|
||||||
f'my_indicator_name = "Custom Strategy"\n'
|
|
||||||
f'my_indicator_description = "{prompt.replace("\\n", " ")[:200]}"\n\n'
|
|
||||||
"# Buy/Sell only. Execution is normalized in backend.\n"
|
|
||||||
"df = df.copy()\n"
|
|
||||||
"sma = df['close'].rolling(14).mean()\n"
|
|
||||||
"raw_buy = (df['close'] > sma) & (df['close'].shift(1) <= sma.shift(1))\n"
|
|
||||||
"raw_sell = (df['close'] < sma) & (df['close'].shift(1) >= sma.shift(1))\n"
|
|
||||||
"# Edge-triggered signals (avoid repeated consecutive signals)\n"
|
|
||||||
"buy = raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))\n"
|
|
||||||
"sell = raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))\n"
|
|
||||||
"df['buy'] = buy.astype(bool)\n"
|
|
||||||
"df['sell'] = sell.astype(bool)\n"
|
|
||||||
"\n"
|
|
||||||
"buy_marks = [df['low'].iloc[i]*0.995 if bool(df['buy'].iloc[i]) else None for i in range(len(df))]\n"
|
|
||||||
"sell_marks = [df['high'].iloc[i]*1.005 if bool(df['sell'].iloc[i]) else None for i in range(len(df))]\n"
|
|
||||||
"output = {\n"
|
|
||||||
" 'name': my_indicator_name,\n"
|
|
||||||
" 'plots': [ {'name':'SMA 14','data': sma.tolist(),'color':'#1890ff','overlay': True} ],\n"
|
|
||||||
" 'signals': [\n"
|
|
||||||
" {'type':'buy','text':'B','data': buy_marks,'color':'#00E676'},\n"
|
|
||||||
" {'type':'sell','text':'S','data': sell_marks,'color':'#FF5252'}\n"
|
|
||||||
" ]\n"
|
|
||||||
"}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _generate() -> str:
|
|
||||||
base_url, api_key = _openrouter_base_and_key()
|
|
||||||
if not api_key:
|
|
||||||
return _template_code()
|
|
||||||
|
|
||||||
model = (os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini") or "").strip() or "openai/gpt-4o-mini"
|
|
||||||
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7)
|
|
||||||
|
|
||||||
user_prompt = prompt
|
|
||||||
if existing:
|
|
||||||
user_prompt = (
|
|
||||||
"# Existing Code (modify based on this):\n\n```python\n"
|
|
||||||
+ existing.strip()
|
|
||||||
+ "\n```\n\n# Modification Requirements:\n\n"
|
|
||||||
+ prompt
|
|
||||||
+ "\n\nPlease generate complete new Python code based on the existing code above and my modification requirements. Output the complete Python code directly, without explanations, without segmentation."
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = requests.post(
|
|
||||||
f"{base_url}/chat/completions",
|
|
||||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
||||||
json={
|
|
||||||
"model": model,
|
|
||||||
"temperature": temperature,
|
|
||||||
"stream": False,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
|
||||||
{"role": "user", "content": user_prompt},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
j = resp.json()
|
|
||||||
content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or ""
|
|
||||||
return content.strip() or _template_code()
|
|
||||||
|
|
||||||
def stream():
|
|
||||||
try:
|
|
||||||
code_text = _generate()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"strategy aiGenerate failed, fallback template: {e}")
|
|
||||||
code_text = _template_code()
|
|
||||||
|
|
||||||
chunk_size = 200
|
|
||||||
for i in range(0, len(code_text), chunk_size):
|
|
||||||
yield "data: " + json.dumps({"content": code_text[i : i + chunk_size]}, ensure_ascii=False) + "\n\n"
|
|
||||||
yield "data: [DONE]\n\n"
|
|
||||||
|
|
||||||
return Response(stream(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
||||||
|
|
||||||
|
|
||||||
@@ -137,7 +137,8 @@ class AgentTools:
|
|||||||
|
|
||||||
elif market == 'Crypto':
|
elif market == 'Crypto':
|
||||||
exchange = self._ccxt_exchange()
|
exchange = self._ccxt_exchange()
|
||||||
symbol_pair = f'{symbol}/USDT'
|
# Handle symbol format: ETH/USDT -> ETH/USDT, ETH -> ETH/USDT
|
||||||
|
symbol_pair = symbol if '/' in symbol else f'{symbol}/USDT'
|
||||||
start_time = int((datetime.now() - timedelta(days=days)).timestamp())
|
start_time = int((datetime.now() - timedelta(days=days)).timestamp())
|
||||||
# CCXT timeframes: 1d, 1h, 4h ...
|
# CCXT timeframes: 1d, 1h, 4h ...
|
||||||
ccxt_tf = tf if tf in ["1d", "1h", "4h"] else "1d"
|
ccxt_tf = tf if tf in ["1d", "1h", "4h"] else "1d"
|
||||||
@@ -233,7 +234,8 @@ class AgentTools:
|
|||||||
}
|
}
|
||||||
elif market == 'Crypto':
|
elif market == 'Crypto':
|
||||||
exchange = self._ccxt_exchange()
|
exchange = self._ccxt_exchange()
|
||||||
symbol_pair = f'{symbol}/USDT'
|
# Handle symbol format: ETH/USDT -> ETH/USDT, ETH -> ETH/USDT
|
||||||
|
symbol_pair = symbol if '/' in symbol else f'{symbol}/USDT'
|
||||||
ticker = exchange.fetch_ticker(symbol_pair)
|
ticker = exchange.fetch_ticker(symbol_pair)
|
||||||
if ticker:
|
if ticker:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Uses ib_insync library to connect to TWS or IB Gateway for trading.
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
import asyncio
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
@@ -14,6 +15,25 @@ from app.services.ibkr_trading.symbols import normalize_symbol, format_display_s
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_event_loop():
|
||||||
|
"""
|
||||||
|
Ensure there is an event loop in the current thread.
|
||||||
|
|
||||||
|
ib_insync requires an asyncio event loop to function.
|
||||||
|
When called from Flask request threads, there may not be one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_closed():
|
||||||
|
raise RuntimeError("Event loop is closed")
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop exists in this thread, create one
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
logger.debug("Created new event loop for IBKR client")
|
||||||
|
return loop
|
||||||
|
|
||||||
# Lazy import ib_insync to allow other features to work without it installed
|
# Lazy import ib_insync to allow other features to work without it installed
|
||||||
ib_insync = None
|
ib_insync = None
|
||||||
|
|
||||||
@@ -99,6 +119,9 @@ class IBKRClient:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Ensure event loop exists in this thread (required by ib_insync)
|
||||||
|
_ensure_event_loop()
|
||||||
|
|
||||||
_ensure_ib_insync()
|
_ensure_ib_insync()
|
||||||
|
|
||||||
if self._ib is None:
|
if self._ib is None:
|
||||||
@@ -145,6 +168,8 @@ class IBKRClient:
|
|||||||
|
|
||||||
def _ensure_connected(self):
|
def _ensure_connected(self):
|
||||||
"""Ensure connection is established."""
|
"""Ensure connection is established."""
|
||||||
|
# Ensure event loop exists (may be called from different threads)
|
||||||
|
_ensure_event_loop()
|
||||||
if not self.connected:
|
if not self.connected:
|
||||||
if not self.connect():
|
if not self.connect():
|
||||||
raise ConnectionError("Cannot connect to IBKR")
|
raise ConnectionError("Cannot connect to IBKR")
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
LLM service.
|
LLM service.
|
||||||
Wraps OpenRouter API calls and robust JSON parsing.
|
Supports multiple providers: OpenRouter, OpenAI, Google Gemini, DeepSeek, Grok.
|
||||||
Kept separate from AnalysisService to avoid circular imports.
|
Kept separate from AnalysisService to avoid circular imports.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.config import APIKeys
|
from app.config import APIKeys
|
||||||
@@ -14,102 +16,394 @@ from app.utils.config_loader import load_addon_config
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LLMService:
|
class LLMProvider(Enum):
|
||||||
"""LLM provider wrapper."""
|
"""Supported LLM providers"""
|
||||||
|
OPENROUTER = "openrouter"
|
||||||
|
OPENAI = "openai"
|
||||||
|
GOOGLE = "google"
|
||||||
|
DEEPSEEK = "deepseek"
|
||||||
|
GROK = "grok"
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
# Config may not be loaded yet during import time; we resolve lazily via properties.
|
# Provider configurations
|
||||||
pass
|
PROVIDER_CONFIGS = {
|
||||||
|
LLMProvider.OPENROUTER: {
|
||||||
|
"base_url": "https://openrouter.ai/api/v1",
|
||||||
|
"default_model": "openai/gpt-4o",
|
||||||
|
"fallback_model": "openai/gpt-4o-mini",
|
||||||
|
},
|
||||||
|
LLMProvider.OPENAI: {
|
||||||
|
"base_url": "https://api.openai.com/v1",
|
||||||
|
"default_model": "gpt-4o",
|
||||||
|
"fallback_model": "gpt-4o-mini",
|
||||||
|
},
|
||||||
|
LLMProvider.GOOGLE: {
|
||||||
|
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
"default_model": "gemini-1.5-flash",
|
||||||
|
"fallback_model": "gemini-1.5-flash",
|
||||||
|
},
|
||||||
|
LLMProvider.DEEPSEEK: {
|
||||||
|
"base_url": "https://api.deepseek.com/v1",
|
||||||
|
"default_model": "deepseek-chat",
|
||||||
|
"fallback_model": "deepseek-chat",
|
||||||
|
},
|
||||||
|
LLMProvider.GROK: {
|
||||||
|
"base_url": "https://api.x.ai/v1",
|
||||||
|
"default_model": "grok-beta",
|
||||||
|
"fallback_model": "grok-beta",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LLMService:
|
||||||
|
"""LLM provider wrapper with multi-provider support."""
|
||||||
|
|
||||||
|
def __init__(self, provider: str = None):
|
||||||
|
"""
|
||||||
|
Initialize LLM service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: Override the default provider (openrouter, openai, google, deepseek, grok)
|
||||||
|
"""
|
||||||
|
self._provider_override = provider
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider(self) -> LLMProvider:
|
||||||
|
"""Get the active LLM provider."""
|
||||||
|
if self._provider_override:
|
||||||
|
try:
|
||||||
|
return LLMProvider(self._provider_override.lower())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check env/config for provider selection
|
||||||
|
config = load_addon_config()
|
||||||
|
provider_name = config.get('llm', {}).get('provider') or os.getenv('LLM_PROVIDER', '')
|
||||||
|
|
||||||
|
if provider_name:
|
||||||
|
try:
|
||||||
|
selected = LLMProvider(provider_name.lower())
|
||||||
|
# Verify this provider has an API key configured
|
||||||
|
if self.get_api_key(selected):
|
||||||
|
return selected
|
||||||
|
logger.warning(f"LLM_PROVIDER={provider_name} but no API key configured, auto-detecting...")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Auto-detect: find any provider with a configured API key
|
||||||
|
# Priority: DeepSeek > Grok > OpenAI > Google > OpenRouter
|
||||||
|
priority_order = [
|
||||||
|
LLMProvider.DEEPSEEK,
|
||||||
|
LLMProvider.GROK,
|
||||||
|
LLMProvider.OPENAI,
|
||||||
|
LLMProvider.GOOGLE,
|
||||||
|
LLMProvider.OPENROUTER,
|
||||||
|
]
|
||||||
|
|
||||||
|
for p in priority_order:
|
||||||
|
if self.get_api_key(p):
|
||||||
|
logger.info(f"Auto-detected LLM provider: {p.value}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
# Fallback to OpenRouter (will fail later if no key)
|
||||||
|
return LLMProvider.OPENROUTER
|
||||||
|
|
||||||
|
def get_api_key(self, provider: LLMProvider = None) -> str:
|
||||||
|
"""Get API key for the specified provider."""
|
||||||
|
p = provider or self.provider
|
||||||
|
|
||||||
|
key_map = {
|
||||||
|
LLMProvider.OPENROUTER: APIKeys.OPENROUTER_API_KEY,
|
||||||
|
LLMProvider.OPENAI: APIKeys.OPENAI_API_KEY,
|
||||||
|
LLMProvider.GOOGLE: APIKeys.GOOGLE_API_KEY,
|
||||||
|
LLMProvider.DEEPSEEK: APIKeys.DEEPSEEK_API_KEY,
|
||||||
|
LLMProvider.GROK: APIKeys.GROK_API_KEY,
|
||||||
|
}
|
||||||
|
return key_map.get(p, "") or ""
|
||||||
|
|
||||||
|
def get_base_url(self, provider: LLMProvider = None) -> str:
|
||||||
|
"""Get base URL for the specified provider."""
|
||||||
|
p = provider or self.provider
|
||||||
|
config = load_addon_config()
|
||||||
|
|
||||||
|
# Check for custom base URL in config
|
||||||
|
provider_config = config.get(p.value, {})
|
||||||
|
custom_url = provider_config.get('base_url') or os.getenv(f'{p.value.upper()}_BASE_URL', '').strip()
|
||||||
|
|
||||||
|
if custom_url:
|
||||||
|
return custom_url.rstrip('/')
|
||||||
|
|
||||||
|
return PROVIDER_CONFIGS[p]["base_url"]
|
||||||
|
|
||||||
|
def get_default_model(self, provider: LLMProvider = None) -> str:
|
||||||
|
"""Get default model for the specified provider."""
|
||||||
|
p = provider or self.provider
|
||||||
|
config = load_addon_config()
|
||||||
|
|
||||||
|
provider_config = config.get(p.value, {})
|
||||||
|
custom_model = provider_config.get('model') or os.getenv(f'{p.value.upper()}_MODEL', '').strip()
|
||||||
|
|
||||||
|
if custom_model:
|
||||||
|
return custom_model
|
||||||
|
|
||||||
|
return PROVIDER_CONFIGS[p]["default_model"]
|
||||||
|
|
||||||
|
# Legacy properties for backward compatibility
|
||||||
@property
|
@property
|
||||||
def api_key(self):
|
def api_key(self):
|
||||||
return APIKeys.OPENROUTER_API_KEY
|
return self.get_api_key()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def base_url(self):
|
def base_url(self):
|
||||||
config = load_addon_config()
|
return self.get_base_url()
|
||||||
# Keep compatible with old/new config keys.
|
|
||||||
import os
|
|
||||||
return config.get('openrouter', {}).get('base_url') or os.getenv('OPENROUTER_BASE_URL', "https://openrouter.ai/api/v1")
|
|
||||||
|
|
||||||
def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str:
|
def _call_openai_compatible(self, messages: list, model: str, temperature: float,
|
||||||
"""Call OpenRouter API, with optional fallback models."""
|
api_key: str, base_url: str, timeout: int,
|
||||||
config = load_addon_config()
|
use_json_mode: bool = True) -> str:
|
||||||
openrouter_config = config.get('openrouter', {})
|
"""Call OpenAI-compatible API (OpenAI, DeepSeek, Grok, OpenRouter)."""
|
||||||
|
url = f"{base_url}/chat/completions"
|
||||||
default_model = openrouter_config.get('model', 'openai/gpt-4o')
|
|
||||||
|
|
||||||
if model is None:
|
|
||||||
model = default_model
|
|
||||||
|
|
||||||
url = f"{self.base_url}/chat/completions"
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"HTTP-Referer": "https://quantdinger.com",
|
|
||||||
"X-Title": "QuantDinger Analysis"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# OpenRouter specific headers
|
||||||
|
if "openrouter" in base_url:
|
||||||
|
headers["HTTP-Referer"] = "https://quantdinger.com"
|
||||||
|
headers["X-Title"] = "QuantDinger Analysis"
|
||||||
|
|
||||||
# Build model candidates (primary + optional fallbacks).
|
data = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
}
|
||||||
|
|
||||||
|
if use_json_mode:
|
||||||
|
data["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, json=data, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if "choices" in result and len(result["choices"]) > 0:
|
||||||
|
content = result["choices"][0]["message"]["content"]
|
||||||
|
if not content:
|
||||||
|
raise ValueError(f"Model {model} returned empty content")
|
||||||
|
return content
|
||||||
|
else:
|
||||||
|
raise ValueError("API response is missing 'choices'")
|
||||||
|
|
||||||
|
def _call_google_gemini(self, messages: list, model: str, temperature: float,
|
||||||
|
api_key: str, base_url: str, timeout: int) -> str:
|
||||||
|
"""Call Google Gemini API."""
|
||||||
|
url = f"{base_url}/models/{model}:generateContent?key={api_key}"
|
||||||
|
|
||||||
|
# Convert OpenAI message format to Gemini format
|
||||||
|
contents = []
|
||||||
|
system_instruction = None
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg["role"]
|
||||||
|
content = msg["content"]
|
||||||
|
|
||||||
|
if role == "system":
|
||||||
|
system_instruction = content
|
||||||
|
elif role == "user":
|
||||||
|
contents.append({"role": "user", "parts": [{"text": content}]})
|
||||||
|
elif role == "assistant":
|
||||||
|
contents.append({"role": "model", "parts": [{"text": content}]})
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"contents": contents,
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": temperature,
|
||||||
|
"responseMimeType": "application/json",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if system_instruction:
|
||||||
|
data["systemInstruction"] = {"parts": [{"text": system_instruction}]}
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, json=data, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if "candidates" in result and len(result["candidates"]) > 0:
|
||||||
|
candidate = result["candidates"][0]
|
||||||
|
if "content" in candidate and "parts" in candidate["content"]:
|
||||||
|
text = candidate["content"]["parts"][0].get("text", "")
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
raise ValueError("Gemini API response is missing content")
|
||||||
|
|
||||||
|
def _normalize_model_for_provider(self, model: str, provider: LLMProvider) -> str:
|
||||||
|
"""
|
||||||
|
Normalize model name for the target provider.
|
||||||
|
|
||||||
|
Frontend may send OpenRouter-style model names (e.g., 'openai/gpt-4o').
|
||||||
|
This converts them to the correct format for each provider.
|
||||||
|
"""
|
||||||
|
if not model:
|
||||||
|
return self.get_default_model(provider)
|
||||||
|
|
||||||
|
model = model.strip()
|
||||||
|
|
||||||
|
# If using OpenRouter, keep the original format
|
||||||
|
if provider == LLMProvider.OPENROUTER:
|
||||||
|
return model
|
||||||
|
|
||||||
|
# For direct providers, extract the model name from OpenRouter format
|
||||||
|
# e.g., 'openai/gpt-4o' -> 'gpt-4o'
|
||||||
|
# 'google/gemini-1.5-flash' -> 'gemini-1.5-flash'
|
||||||
|
# 'deepseek/deepseek-chat' -> 'deepseek-chat'
|
||||||
|
# 'x-ai/grok-beta' -> 'grok-beta'
|
||||||
|
|
||||||
|
if '/' in model:
|
||||||
|
prefix, actual_model = model.split('/', 1)
|
||||||
|
prefix_lower = prefix.lower()
|
||||||
|
|
||||||
|
# Map OpenRouter prefixes to providers
|
||||||
|
prefix_to_provider = {
|
||||||
|
'openai': LLMProvider.OPENAI,
|
||||||
|
'google': LLMProvider.GOOGLE,
|
||||||
|
'deepseek': LLMProvider.DEEPSEEK,
|
||||||
|
'x-ai': LLMProvider.GROK,
|
||||||
|
'xai': LLMProvider.GROK,
|
||||||
|
}
|
||||||
|
|
||||||
|
# If the model prefix matches the current provider, use the extracted model name
|
||||||
|
matched_provider = prefix_to_provider.get(prefix_lower)
|
||||||
|
if matched_provider == provider:
|
||||||
|
return actual_model
|
||||||
|
|
||||||
|
# If model prefix doesn't match current provider, use provider's default model
|
||||||
|
# This prevents sending 'gpt-4o' to DeepSeek, etc.
|
||||||
|
logger.warning(f"Model '{model}' doesn't match provider '{provider.value}', using default model")
|
||||||
|
return self.get_default_model(provider)
|
||||||
|
|
||||||
|
# Model name without prefix - use as is
|
||||||
|
return model
|
||||||
|
|
||||||
|
def _detect_provider_from_model(self, model: str) -> Optional[LLMProvider]:
|
||||||
|
"""
|
||||||
|
Detect which provider a model belongs to based on its name.
|
||||||
|
Returns None if detection fails.
|
||||||
|
"""
|
||||||
|
if not model or '/' not in model:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prefix = model.split('/')[0].lower()
|
||||||
|
|
||||||
|
prefix_to_provider = {
|
||||||
|
'openai': LLMProvider.OPENAI,
|
||||||
|
'google': LLMProvider.GOOGLE,
|
||||||
|
'deepseek': LLMProvider.DEEPSEEK,
|
||||||
|
'x-ai': LLMProvider.GROK,
|
||||||
|
'xai': LLMProvider.GROK,
|
||||||
|
'anthropic': LLMProvider.OPENROUTER, # Anthropic only via OpenRouter
|
||||||
|
'meta': LLMProvider.OPENROUTER, # Meta/Llama only via OpenRouter
|
||||||
|
'mistral': LLMProvider.OPENROUTER, # Mistral only via OpenRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix_to_provider.get(prefix)
|
||||||
|
|
||||||
|
def call_llm_api(self, messages: list, model: str = None, temperature: float = 0.7,
|
||||||
|
use_fallback: bool = True, provider: LLMProvider = None,
|
||||||
|
use_json_mode: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Call LLM API with the specified or default provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of message dicts with 'role' and 'content'
|
||||||
|
model: Model name (uses provider default if not specified). Supports OpenRouter format (e.g., 'openai/gpt-4o')
|
||||||
|
temperature: Sampling temperature
|
||||||
|
use_fallback: Whether to try fallback model on failure
|
||||||
|
provider: Override the service's default provider
|
||||||
|
use_json_mode: Whether to request JSON output format (default True for analysis, False for code generation)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated text content
|
||||||
|
|
||||||
|
Model Resolution Priority:
|
||||||
|
1. If model is specified and matches a direct provider (openai/, google/, deepseek/, x-ai/),
|
||||||
|
use that provider directly if its API key is configured
|
||||||
|
2. Otherwise, use the configured LLM_PROVIDER with normalized model name
|
||||||
|
3. Fall back to provider's default model if model name is incompatible
|
||||||
|
"""
|
||||||
|
# Smart provider detection: if model specifies a provider and we have its API key, use it
|
||||||
|
if model and not provider:
|
||||||
|
detected_provider = self._detect_provider_from_model(model)
|
||||||
|
if detected_provider and detected_provider != LLMProvider.OPENROUTER:
|
||||||
|
# Check if we have API key for the detected provider
|
||||||
|
if self.get_api_key(detected_provider):
|
||||||
|
provider = detected_provider
|
||||||
|
logger.debug(f"Auto-detected provider '{provider.value}' from model '{model}'")
|
||||||
|
|
||||||
|
p = provider or self.provider
|
||||||
|
api_key = self.get_api_key(p)
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError(f"API key not configured for provider: {p.value}")
|
||||||
|
|
||||||
|
base_url = self.get_base_url(p)
|
||||||
|
|
||||||
|
# Normalize model name for the provider
|
||||||
|
model = self._normalize_model_for_provider(model, p)
|
||||||
|
|
||||||
|
config = load_addon_config()
|
||||||
|
timeout = int(config.get(p.value, {}).get('timeout', 120))
|
||||||
|
|
||||||
|
# Build model candidates
|
||||||
models_to_try = [model]
|
models_to_try = [model]
|
||||||
|
provider_default_model = PROVIDER_CONFIGS[p]["default_model"]
|
||||||
|
if use_fallback:
|
||||||
|
fallback = PROVIDER_CONFIGS[p].get("fallback_model")
|
||||||
|
if fallback and fallback != model:
|
||||||
|
models_to_try.append(fallback)
|
||||||
|
|
||||||
# Fallback models are currently hard-coded for local mode.
|
|
||||||
fallback_models = ["openai/gpt-4o-mini"]
|
|
||||||
|
|
||||||
if use_fallback and model == default_model:
|
|
||||||
models_to_try.extend(fallback_models)
|
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|
||||||
timeout = int(openrouter_config.get('timeout', 120))
|
|
||||||
|
|
||||||
for current_model in models_to_try:
|
for current_model in models_to_try:
|
||||||
try:
|
try:
|
||||||
data = {
|
if p == LLMProvider.GOOGLE:
|
||||||
"model": current_model,
|
return self._call_google_gemini(
|
||||||
"messages": messages,
|
messages, current_model, temperature,
|
||||||
"temperature": temperature,
|
api_key, base_url, timeout
|
||||||
"response_format": {"type": "json_object"}
|
)
|
||||||
}
|
|
||||||
# logger.debug(f"Trying model: {current_model}")
|
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, json=data, timeout=timeout)
|
|
||||||
|
|
||||||
if response.status_code == 402:
|
|
||||||
logger.warning(f"OpenRouter returned 402 for model {current_model}; trying fallback model...")
|
|
||||||
last_error = f"402 Payment Required for model {current_model}"
|
|
||||||
continue
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
result = response.json()
|
|
||||||
if "choices" in result and len(result["choices"]) > 0:
|
|
||||||
content = result["choices"][0]["message"]["content"]
|
|
||||||
if not content:
|
|
||||||
raise ValueError(f"Model {current_model} returned empty content")
|
|
||||||
|
|
||||||
if current_model != model:
|
|
||||||
logger.info(f"Fallback model succeeded: {current_model}")
|
|
||||||
return content
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"OpenRouter API returned unexpected structure ({current_model}): {json.dumps(result)}")
|
# OpenAI-compatible providers
|
||||||
raise ValueError("OpenRouter API response is missing 'choices'")
|
return self._call_openai_compatible(
|
||||||
|
messages, current_model, temperature,
|
||||||
|
api_key, base_url, timeout,
|
||||||
|
use_json_mode=use_json_mode
|
||||||
|
)
|
||||||
|
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
logger.error(f"OpenRouter API HTTP error ({current_model}): {e.response.text if e.response else str(e)}")
|
error_detail = e.response.text if e.response else str(e)
|
||||||
|
logger.error(f"{p.value} API HTTP error ({current_model}): {error_detail}")
|
||||||
last_error = str(e)
|
last_error = str(e)
|
||||||
|
|
||||||
|
# Check for payment/quota errors
|
||||||
|
if e.response and e.response.status_code in (402, 429):
|
||||||
|
logger.warning(f"{p.value} returned {e.response.status_code} for model {current_model}; trying fallback...")
|
||||||
|
continue
|
||||||
|
|
||||||
if not use_fallback or current_model == models_to_try[-1]:
|
if not use_fallback or current_model == models_to_try[-1]:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error(f"OpenRouter API request error ({current_model}): {str(e)}")
|
logger.error(f"{p.value} API request error ({current_model}): {str(e)}")
|
||||||
last_error = str(e)
|
last_error = str(e)
|
||||||
if not use_fallback or current_model == models_to_try[-1]:
|
if not use_fallback or current_model == models_to_try[-1]:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Model {current_model} returned invalid data: {str(e)}")
|
logger.warning(f"Model {current_model} returned invalid data: {str(e)}")
|
||||||
last_error = str(e)
|
last_error = str(e)
|
||||||
# If this is not the last candidate model, try the next one
|
|
||||||
if current_model == models_to_try[-1]:
|
if current_model == models_to_try[-1]:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -117,14 +411,20 @@ class LLMService:
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
def safe_call_llm(self, system_prompt: str, user_prompt: str, default_structure: Dict[str, Any], model: str = None) -> Dict[str, Any]:
|
# Legacy method for backward compatibility
|
||||||
|
def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str:
|
||||||
|
"""Call LLM API (legacy method name for backward compatibility)."""
|
||||||
|
return self.call_llm_api(messages, model, temperature, use_fallback)
|
||||||
|
|
||||||
|
def safe_call_llm(self, system_prompt: str, user_prompt: str, default_structure: Dict[str, Any],
|
||||||
|
model: str = None, provider: LLMProvider = None) -> Dict[str, Any]:
|
||||||
"""Safe LLM call with robust JSON parsing and fallback structure."""
|
"""Safe LLM call with robust JSON parsing and fallback structure."""
|
||||||
response_text = ""
|
response_text = ""
|
||||||
try:
|
try:
|
||||||
response_text = self.call_openrouter_api([
|
response_text = self.call_llm_api([
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_prompt}
|
{"role": "user", "content": user_prompt}
|
||||||
], model=model)
|
], model=model, provider=provider)
|
||||||
|
|
||||||
# Strip markdown fences if present
|
# Strip markdown fences if present
|
||||||
clean_text = response_text.strip()
|
clean_text = response_text.strip()
|
||||||
@@ -159,3 +459,20 @@ class LLMService:
|
|||||||
logger.error(f"LLM call failed: {str(e)}")
|
logger.error(f"LLM call failed: {str(e)}")
|
||||||
default_structure['report'] = f"Analysis failed: {str(e)}"
|
default_structure['report'] = f"Analysis failed: {str(e)}"
|
||||||
return default_structure
|
return default_structure
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_available_providers(cls) -> List[Dict[str, Any]]:
|
||||||
|
"""Get list of available (configured) providers."""
|
||||||
|
providers = []
|
||||||
|
|
||||||
|
for p in LLMProvider:
|
||||||
|
service = cls()
|
||||||
|
api_key = service.get_api_key(p)
|
||||||
|
providers.append({
|
||||||
|
"id": p.value,
|
||||||
|
"name": p.value.title(),
|
||||||
|
"configured": bool(api_key),
|
||||||
|
"default_model": PROVIDER_CONFIGS[p]["default_model"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return providers
|
||||||
|
|||||||
@@ -429,6 +429,12 @@ class OAuthService:
|
|||||||
oauth_info.get('refresh_token'))
|
oauth_info.get('refresh_token'))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Update last_login_at for new OAuth users
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE qd_users SET last_login_at = NOW() WHERE id = ?",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
|
|||||||
@@ -948,6 +948,98 @@ class PendingOrderWorker:
|
|||||||
def _current_avg() -> float:
|
def _current_avg() -> float:
|
||||||
return float(total_quote / total_base) if total_base > 0 else 0.0
|
return float(total_quote / total_base) if total_base > 0 else 0.0
|
||||||
|
|
||||||
|
# For close/reduce signals, query actual exchange position to avoid insufficient balance due to fees
|
||||||
|
# The exchange position may be smaller than our recorded amount due to trading fees
|
||||||
|
if reduce_only and market_type == "swap":
|
||||||
|
try:
|
||||||
|
actual_pos_size = 0.0
|
||||||
|
if isinstance(client, OkxClient):
|
||||||
|
inst_id = to_okx_swap_inst_id(str(symbol))
|
||||||
|
pos_resp = client.get_positions(inst_id=inst_id)
|
||||||
|
pos_data = (pos_resp.get("data") or []) if isinstance(pos_resp, dict) else []
|
||||||
|
for pos in pos_data:
|
||||||
|
if not isinstance(pos, dict):
|
||||||
|
continue
|
||||||
|
pos_inst = str(pos.get("instId") or "").strip()
|
||||||
|
pos_ps = str(pos.get("posSide") or "").strip().lower()
|
||||||
|
# Match instrument and position side
|
||||||
|
if pos_inst == inst_id and pos_ps == pos_side:
|
||||||
|
# OKX pos field is signed for net mode; use abs for simplicity
|
||||||
|
pos_qty = abs(float(pos.get("pos") or 0.0))
|
||||||
|
# Convert contracts to base amount using ctVal
|
||||||
|
ct_val = float(pos.get("ctVal") or 0.0)
|
||||||
|
if ct_val > 0:
|
||||||
|
actual_pos_size = pos_qty * ct_val
|
||||||
|
else:
|
||||||
|
actual_pos_size = pos_qty
|
||||||
|
break
|
||||||
|
elif isinstance(client, BinanceFuturesClient):
|
||||||
|
pos_resp = client.get_positions() or []
|
||||||
|
pos_list = pos_resp if isinstance(pos_resp, list) else []
|
||||||
|
# Normalize symbol for matching (remove / or -)
|
||||||
|
norm_sym = str(symbol or "").replace("/", "").replace("-", "").upper()
|
||||||
|
for pos in pos_list:
|
||||||
|
if not isinstance(pos, dict):
|
||||||
|
continue
|
||||||
|
pos_sym = str(pos.get("symbol") or "").upper()
|
||||||
|
if pos_sym != norm_sym:
|
||||||
|
continue
|
||||||
|
# Match position side
|
||||||
|
p_side = str(pos.get("positionSide") or "").strip().lower()
|
||||||
|
if p_side == pos_side or (p_side == "both" and pos_side in ("long", "short")):
|
||||||
|
pos_amt = abs(float(pos.get("positionAmt") or 0.0))
|
||||||
|
if pos_amt > 0:
|
||||||
|
actual_pos_size = pos_amt
|
||||||
|
break
|
||||||
|
elif isinstance(client, BybitClient):
|
||||||
|
pos_resp = client.get_positions() or {}
|
||||||
|
pos_list = (pos_resp.get("result") or {}).get("list") or [] if isinstance(pos_resp, dict) else []
|
||||||
|
for pos in pos_list:
|
||||||
|
if not isinstance(pos, dict):
|
||||||
|
continue
|
||||||
|
pos_sym = str(pos.get("symbol") or "")
|
||||||
|
if pos_sym != str(symbol or "").replace("/", ""):
|
||||||
|
continue
|
||||||
|
p_side = str(pos.get("side") or "").strip().lower()
|
||||||
|
if (p_side == "buy" and pos_side == "long") or (p_side == "sell" and pos_side == "short"):
|
||||||
|
pos_sz = abs(float(pos.get("size") or 0.0))
|
||||||
|
if pos_sz > 0:
|
||||||
|
actual_pos_size = pos_sz
|
||||||
|
break
|
||||||
|
elif isinstance(client, BitgetMixClient):
|
||||||
|
product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
|
||||||
|
pos_resp = client.get_positions(product_type=product_type) or {}
|
||||||
|
pos_list = (pos_resp.get("data") or []) if isinstance(pos_resp, dict) else []
|
||||||
|
for pos in pos_list:
|
||||||
|
if not isinstance(pos, dict):
|
||||||
|
continue
|
||||||
|
pos_sym = str(pos.get("symbol") or "")
|
||||||
|
if pos_sym != str(symbol or ""):
|
||||||
|
continue
|
||||||
|
p_side = str(pos.get("holdSide") or "").strip().lower()
|
||||||
|
if p_side == pos_side:
|
||||||
|
pos_sz = abs(float(pos.get("total") or pos.get("available") or 0.0))
|
||||||
|
if pos_sz > 0:
|
||||||
|
actual_pos_size = pos_sz
|
||||||
|
break
|
||||||
|
|
||||||
|
# If we found actual position and it's smaller than requested, use actual size
|
||||||
|
if actual_pos_size > 0 and actual_pos_size < float(amount or 0.0):
|
||||||
|
logger.info(
|
||||||
|
f"Close position adjustment: pending_id={order_id}, strategy_id={strategy_id}, "
|
||||||
|
f"requested={amount}, actual_pos={actual_pos_size}, using actual"
|
||||||
|
)
|
||||||
|
phases["pos_adjustment"] = {
|
||||||
|
"requested": float(amount or 0.0),
|
||||||
|
"actual_position": actual_pos_size,
|
||||||
|
"using": actual_pos_size,
|
||||||
|
}
|
||||||
|
amount = actual_pos_size
|
||||||
|
except Exception as e:
|
||||||
|
# Best-effort only; log and continue with original amount
|
||||||
|
logger.warning(f"Failed to query position for close adjustment: pending_id={order_id}, err={e}")
|
||||||
|
phases["pos_query_error"] = str(e)
|
||||||
|
|
||||||
# Decide if we should use limit-first flow.
|
# Decide if we should use limit-first flow.
|
||||||
use_limit_first = order_mode in ("maker", "limit", "limit_first", "maker_then_market")
|
use_limit_first = order_mode in ("maker", "limit", "limit_first", "maker_then_market")
|
||||||
|
|
||||||
|
|||||||
@@ -2370,11 +2370,14 @@ class TradingExecutor:
|
|||||||
cooldown_sec = 30 # keep small; worker already retries the claimed order via attempts/max_attempts
|
cooldown_sec = 30 # keep small; worker already retries the claimed order via attempts/max_attempts
|
||||||
try:
|
try:
|
||||||
stsig = int(signal_ts or 0)
|
stsig = int(signal_ts or 0)
|
||||||
# Strict "same candle" de-dup should ONLY apply to open signals.
|
# Strict "same candle" de-dup applies to open and close signals.
|
||||||
# Rationale: on higher timeframes (e.g. 1D), scale-in signals (add_*) may legitimately trigger
|
# Rationale:
|
||||||
# multiple times within the same candle/day as price evolves; we must not block them by candle key.
|
# - open_* signals should only trigger once per candle (prevents repeated entries)
|
||||||
|
# - close_* signals should only trigger once per candle (prevents repeated close attempts)
|
||||||
|
# - add_*/reduce_* signals may legitimately trigger multiple times within same candle
|
||||||
|
# as price evolves for DCA/scaling strategies
|
||||||
sig_norm = str(signal_type or "").strip().lower()
|
sig_norm = str(signal_type or "").strip().lower()
|
||||||
strict_candle_dedup = stsig > 0 and sig_norm in ("open_long", "open_short")
|
strict_candle_dedup = stsig > 0 and sig_norm in ("open_long", "open_short", "close_long", "close_short")
|
||||||
|
|
||||||
if strict_candle_dedup:
|
if strict_candle_dedup:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|||||||
@@ -70,6 +70,28 @@ def load_addon_config() -> Dict[str, Any]:
|
|||||||
('OPENROUTER_TIMEOUT', 'openrouter.timeout', 'int'),
|
('OPENROUTER_TIMEOUT', 'openrouter.timeout', 'int'),
|
||||||
('OPENROUTER_CONNECT_TIMEOUT', 'openrouter.connect_timeout', 'int'),
|
('OPENROUTER_CONNECT_TIMEOUT', 'openrouter.connect_timeout', 'int'),
|
||||||
('AI_MODELS_JSON', 'ai.models', 'json'),
|
('AI_MODELS_JSON', 'ai.models', 'json'),
|
||||||
|
|
||||||
|
# OpenAI Direct
|
||||||
|
('OPENAI_API_KEY', 'openai.api_key', 'string'),
|
||||||
|
('OPENAI_BASE_URL', 'openai.base_url', 'string'),
|
||||||
|
('OPENAI_MODEL', 'openai.model', 'string'),
|
||||||
|
|
||||||
|
# Google Gemini
|
||||||
|
('GOOGLE_API_KEY', 'google.api_key', 'string'),
|
||||||
|
('GOOGLE_MODEL', 'google.model', 'string'),
|
||||||
|
|
||||||
|
# DeepSeek
|
||||||
|
('DEEPSEEK_API_KEY', 'deepseek.api_key', 'string'),
|
||||||
|
('DEEPSEEK_BASE_URL', 'deepseek.base_url', 'string'),
|
||||||
|
('DEEPSEEK_MODEL', 'deepseek.model', 'string'),
|
||||||
|
|
||||||
|
# xAI Grok
|
||||||
|
('GROK_API_KEY', 'grok.api_key', 'string'),
|
||||||
|
('GROK_BASE_URL', 'grok.base_url', 'string'),
|
||||||
|
('GROK_MODEL', 'grok.model', 'string'),
|
||||||
|
|
||||||
|
# LLM Provider Selection
|
||||||
|
('LLM_PROVIDER', 'llm.provider', 'string'),
|
||||||
|
|
||||||
# App
|
# App
|
||||||
('CORS_ORIGINS', 'app.cors_origins', 'string'),
|
('CORS_ORIGINS', 'app.cors_origins', 'string'),
|
||||||
|
|||||||
@@ -145,7 +145,13 @@ ENABLE_REFLECTION_WORKER=false
|
|||||||
REFLECTION_WORKER_INTERVAL_SEC=86400
|
REFLECTION_WORKER_INTERVAL_SEC=86400
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# OpenRouter / LLM
|
# LLM Provider Selection
|
||||||
|
# =========================
|
||||||
|
# Choose your LLM provider: openrouter, openai, google, deepseek, grok
|
||||||
|
LLM_PROVIDER=openrouter
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# OpenRouter (Multi-model gateway, recommended)
|
||||||
# =========================
|
# =========================
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
|
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
|
||||||
@@ -155,6 +161,33 @@ OPENROUTER_MAX_TOKENS=4000
|
|||||||
OPENROUTER_TIMEOUT=300
|
OPENROUTER_TIMEOUT=300
|
||||||
OPENROUTER_CONNECT_TIMEOUT=30
|
OPENROUTER_CONNECT_TIMEOUT=30
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# OpenAI Direct
|
||||||
|
# =========================
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=gpt-4o
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Google Gemini
|
||||||
|
# =========================
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
GOOGLE_MODEL=gemini-1.5-flash
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# DeepSeek
|
||||||
|
# =========================
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# xAI Grok
|
||||||
|
# =========================
|
||||||
|
GROK_API_KEY=
|
||||||
|
GROK_BASE_URL=https://api.x.ai/v1
|
||||||
|
GROK_MODEL=grok-beta
|
||||||
|
|
||||||
# Optional: override model list shown in UI (JSON object: {"model_id":"Display Name", ...})
|
# Optional: override model list shown in UI (JSON object: {"model_id":"Display Name", ...})
|
||||||
AI_MODELS_JSON={}
|
AI_MODELS_JSON={}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: Add notification_settings column to qd_users table
|
||||||
|
-- Run this if you see error: column "notification_settings" does not exist
|
||||||
|
|
||||||
|
-- Add notification_settings column (if not exists)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'qd_users' AND column_name = 'notification_settings'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE qd_users ADD COLUMN notification_settings TEXT DEFAULT '';
|
||||||
|
RAISE NOTICE 'Column notification_settings added to qd_users';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Column notification_settings already exists';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -296,25 +296,7 @@ CREATE TABLE IF NOT EXISTS qd_indicator_codes (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_indicator_codes_user_id ON qd_indicator_codes(user_id);
|
CREATE INDEX IF NOT EXISTS idx_indicator_codes_user_id ON qd_indicator_codes(user_id);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 8. Strategy Codes
|
-- 8. AI Decisions
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_strategy_codes (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
code TEXT,
|
|
||||||
description TEXT DEFAULT '',
|
|
||||||
createtime BIGINT,
|
|
||||||
updatetime BIGINT,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_strategy_codes_user_id ON qd_strategy_codes(user_id);
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- 9. AI Decisions
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_ai_decisions (
|
CREATE TABLE IF NOT EXISTS qd_ai_decisions (
|
||||||
@@ -329,7 +311,7 @@ CREATE TABLE IF NOT EXISTS qd_ai_decisions (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_ai_decisions_user_id ON qd_ai_decisions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_ai_decisions_user_id ON qd_ai_decisions(user_id);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 10. Addon Config
|
-- 9. Addon Config
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_addon_config (
|
CREATE TABLE IF NOT EXISTS qd_addon_config (
|
||||||
@@ -339,7 +321,7 @@ CREATE TABLE IF NOT EXISTS qd_addon_config (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 11. Watchlist
|
-- 10. Watchlist
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_watchlist (
|
CREATE TABLE IF NOT EXISTS qd_watchlist (
|
||||||
@@ -356,7 +338,7 @@ CREATE TABLE IF NOT EXISTS qd_watchlist (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_watchlist_user_id ON qd_watchlist(user_id);
|
CREATE INDEX IF NOT EXISTS idx_watchlist_user_id ON qd_watchlist(user_id);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 12. Analysis Tasks
|
-- 11. Analysis Tasks
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_analysis_tasks (
|
CREATE TABLE IF NOT EXISTS qd_analysis_tasks (
|
||||||
@@ -376,7 +358,7 @@ CREATE TABLE IF NOT EXISTS qd_analysis_tasks (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_analysis_tasks_user_id ON qd_analysis_tasks(user_id);
|
CREATE INDEX IF NOT EXISTS idx_analysis_tasks_user_id ON qd_analysis_tasks(user_id);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 13. Backtest Runs
|
-- 12. Backtest Runs
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_backtest_runs (
|
CREATE TABLE IF NOT EXISTS qd_backtest_runs (
|
||||||
@@ -404,7 +386,7 @@ CREATE INDEX IF NOT EXISTS idx_backtest_runs_user_id ON qd_backtest_runs(user_id
|
|||||||
CREATE INDEX IF NOT EXISTS idx_backtest_runs_indicator_id ON qd_backtest_runs(indicator_id);
|
CREATE INDEX IF NOT EXISTS idx_backtest_runs_indicator_id ON qd_backtest_runs(indicator_id);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 14. Exchange Credentials
|
-- 13. Exchange Credentials
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_exchange_credentials (
|
CREATE TABLE IF NOT EXISTS qd_exchange_credentials (
|
||||||
@@ -421,7 +403,7 @@ CREATE TABLE IF NOT EXISTS qd_exchange_credentials (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_exchange_credentials_user_id ON qd_exchange_credentials(user_id);
|
CREATE INDEX IF NOT EXISTS idx_exchange_credentials_user_id ON qd_exchange_credentials(user_id);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 15. Manual Positions
|
-- 14. Manual Positions
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_manual_positions (
|
CREATE TABLE IF NOT EXISTS qd_manual_positions (
|
||||||
@@ -445,7 +427,7 @@ CREATE TABLE IF NOT EXISTS qd_manual_positions (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_manual_positions_user_id ON qd_manual_positions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_manual_positions_user_id ON qd_manual_positions(user_id);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 16. Position Alerts
|
-- 15. Position Alerts
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_position_alerts (
|
CREATE TABLE IF NOT EXISTS qd_position_alerts (
|
||||||
@@ -471,7 +453,7 @@ CREATE INDEX IF NOT EXISTS idx_position_alerts_user_id ON qd_position_alerts(use
|
|||||||
CREATE INDEX IF NOT EXISTS idx_position_alerts_position_id ON qd_position_alerts(position_id);
|
CREATE INDEX IF NOT EXISTS idx_position_alerts_position_id ON qd_position_alerts(position_id);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 17. Position Monitors
|
-- 16. Position Monitors
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_position_monitors (
|
CREATE TABLE IF NOT EXISTS qd_position_monitors (
|
||||||
@@ -494,7 +476,7 @@ CREATE TABLE IF NOT EXISTS qd_position_monitors (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_position_monitors_user_id ON qd_position_monitors(user_id);
|
CREATE INDEX IF NOT EXISTS idx_position_monitors_user_id ON qd_position_monitors(user_id);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 18. Market Symbols (Seed Data)
|
-- 17. Market Symbols (Seed Data)
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qd_market_symbols (
|
CREATE TABLE IF NOT EXISTS qd_market_symbols (
|
||||||
@@ -585,7 +567,7 @@ INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_acti
|
|||||||
ON CONFLICT (market, symbol) DO NOTHING;
|
ON CONFLICT (market, symbol) DO NOTHING;
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 19. Agent Memories (AI Learning System)
|
-- 18. Agent Memories (AI Learning System)
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Stores agent decision experiences for RAG-style retrieval during analysis.
|
-- Stores agent decision experiences for RAG-style retrieval during analysis.
|
||||||
-- Each agent (trader, risk_analyst, etc.) shares this table but is identified by agent_name.
|
-- Each agent (trader, risk_analyst, etc.) shares this table but is identified by agent_name.
|
||||||
@@ -611,7 +593,7 @@ CREATE INDEX IF NOT EXISTS idx_agent_memories_created ON qd_agent_memories(agent
|
|||||||
CREATE INDEX IF NOT EXISTS idx_agent_memories_market ON qd_agent_memories(agent_name, market, symbol);
|
CREATE INDEX IF NOT EXISTS idx_agent_memories_market ON qd_agent_memories(agent_name, market, symbol);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 20. Reflection Records (AI Auto-Verification System)
|
-- 19. Reflection Records (AI Auto-Verification System)
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Records analysis predictions for future auto-verification and closed-loop learning.
|
-- Records analysis predictions for future auto-verification and closed-loop learning.
|
||||||
|
|
||||||
|
|||||||
@@ -290,7 +290,30 @@ export default {
|
|||||||
},
|
},
|
||||||
formatTime (timestamp) {
|
formatTime (timestamp) {
|
||||||
if (!timestamp) return ''
|
if (!timestamp) return ''
|
||||||
const date = new Date(timestamp * 1000)
|
// 支持多种时间格式:ISO字符串、秒级时间戳、毫秒级时间戳
|
||||||
|
let date
|
||||||
|
if (typeof timestamp === 'number') {
|
||||||
|
// 数字类型:判断是秒级还是毫秒级时间戳
|
||||||
|
date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp)
|
||||||
|
} else if (typeof timestamp === 'string') {
|
||||||
|
// 字符串类型
|
||||||
|
if (/^\d+$/.test(timestamp)) {
|
||||||
|
// 纯数字字符串(时间戳)
|
||||||
|
const ts = parseInt(timestamp, 10)
|
||||||
|
date = new Date(ts < 1e12 ? ts * 1000 : ts)
|
||||||
|
} else {
|
||||||
|
// ISO 日期字符串或其他格式
|
||||||
|
date = new Date(timestamp)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查日期是否有效
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diff = now - date
|
const diff = now - date
|
||||||
const minutes = Math.floor(diff / 60000)
|
const minutes = Math.floor(diff / 60000)
|
||||||
@@ -311,7 +334,24 @@ export default {
|
|||||||
},
|
},
|
||||||
formatFullTime (timestamp) {
|
formatFullTime (timestamp) {
|
||||||
if (!timestamp) return ''
|
if (!timestamp) return ''
|
||||||
const date = new Date(timestamp * 1000)
|
// 支持多种时间格式:ISO字符串、秒级时间戳、毫秒级时间戳
|
||||||
|
let date
|
||||||
|
if (typeof timestamp === 'number') {
|
||||||
|
date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp)
|
||||||
|
} else if (typeof timestamp === 'string') {
|
||||||
|
if (/^\d+$/.test(timestamp)) {
|
||||||
|
const ts = parseInt(timestamp, 10)
|
||||||
|
date = new Date(ts < 1e12 ? ts * 1000 : ts)
|
||||||
|
} else {
|
||||||
|
date = new Date(timestamp)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
},
|
},
|
||||||
formatMessageHtml (message) {
|
formatMessageHtml (message) {
|
||||||
|
|||||||
@@ -40,11 +40,9 @@ if (typeof window !== 'undefined') {
|
|||||||
// mount axios to `Vue.$http` and `this.$http`
|
// mount axios to `Vue.$http` and `this.$http`
|
||||||
Vue.use(VueAxios)
|
Vue.use(VueAxios)
|
||||||
// use pro-layout components
|
// use pro-layout components
|
||||||
/* eslint-disable vue/component-definition-name-casing */
|
|
||||||
Vue.component('pro-layout', ProLayout)
|
Vue.component('pro-layout', ProLayout)
|
||||||
Vue.component('page-container', PageHeaderWrapper)
|
Vue.component('page-container', PageHeaderWrapper)
|
||||||
Vue.component('page-header-wrapper', PageHeaderWrapper)
|
Vue.component('page-header-wrapper', PageHeaderWrapper)
|
||||||
/* eslint-enable vue/component-definition-name-casing */
|
|
||||||
|
|
||||||
window.umi_plugin_ant_themeVar = themePluginConfig.theme
|
window.umi_plugin_ant_themeVar = themePluginConfig.theme
|
||||||
|
|
||||||
|
|||||||
@@ -379,9 +379,9 @@
|
|||||||
{{ getSignalTypeText(text) }}
|
{{ getSignalTypeText(text) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template slot="profit" slot-scope="text">
|
<template slot="profit" slot-scope="text, record">
|
||||||
<span :class="text >= 0 ? 'positive' : 'negative'">
|
<span :class="formatProfitValue(text, record) !== '--' ? (text >= 0 ? 'positive' : 'negative') : ''">
|
||||||
{{ text >= 0 ? '+' : '' }}${{ formatNumber(text) }}
|
{{ formatProfitValue(text, record) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template slot="time" slot-scope="text">
|
<template slot="time" slot-scope="text">
|
||||||
@@ -958,6 +958,30 @@ export default {
|
|||||||
if (num === undefined || num === null) return '0.00'
|
if (num === undefined || num === null) return '0.00'
|
||||||
return Number(num).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })
|
return Number(num).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })
|
||||||
},
|
},
|
||||||
|
// 格式化盈亏值(处理信号模式下没有实盘的情况)
|
||||||
|
formatProfitValue (value, record) {
|
||||||
|
if (value === null || value === undefined) return '--'
|
||||||
|
|
||||||
|
const numValue = parseFloat(value)
|
||||||
|
|
||||||
|
// 如果值为0且是开仓信号(open_long/open_short),显示--
|
||||||
|
const openTypes = ['open_long', 'open_short', 'add_long', 'add_short']
|
||||||
|
if (numValue === 0 && record && openTypes.includes(record.type)) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果值极小(科学计数法如0E-8),视为0
|
||||||
|
if (Math.abs(numValue) < 0.000001) {
|
||||||
|
if (record && openTypes.includes(record.type)) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
return '$0.00'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常显示
|
||||||
|
const sign = numValue >= 0 ? '+' : ''
|
||||||
|
return `${sign}$${this.formatNumber(numValue)}`
|
||||||
|
},
|
||||||
formatCompactNumber (num) {
|
formatCompactNumber (num) {
|
||||||
if (num === undefined || num === null) return '0'
|
if (num === undefined || num === null) return '0'
|
||||||
const abs = Math.abs(num)
|
const abs = Math.abs(num)
|
||||||
|
|||||||
@@ -1640,4 +1640,396 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Mobile Responsive Styles ====================
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.profile-page {
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile cards row - stack vertically
|
||||||
|
.profile-cards-row {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.profile-card-col {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-cards-col {
|
||||||
|
.right-cards-row {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.ant-col {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile card adjustments
|
||||||
|
.profile-card {
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
.avatar-section {
|
||||||
|
padding: 16px 0;
|
||||||
|
|
||||||
|
/deep/ .ant-avatar {
|
||||||
|
width: 80px !important;
|
||||||
|
height: 80px !important;
|
||||||
|
line-height: 80px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
.info-item {
|
||||||
|
padding: 10px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credits card adjustments
|
||||||
|
.credits-card {
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
.credits-header {
|
||||||
|
.credits-title {
|
||||||
|
font-size: 15px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-body {
|
||||||
|
padding: 16px 0;
|
||||||
|
|
||||||
|
.credits-amount {
|
||||||
|
.amount-value {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-status {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-actions {
|
||||||
|
.ant-btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referral card adjustments
|
||||||
|
.referral-card {
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
.referral-header {
|
||||||
|
.referral-title {
|
||||||
|
font-size: 15px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-body {
|
||||||
|
padding: 10px 0;
|
||||||
|
|
||||||
|
.referral-stats {
|
||||||
|
.stat-item {
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-link-section {
|
||||||
|
.link-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-box {
|
||||||
|
/deep/ .ant-input {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit card adjustments
|
||||||
|
.edit-card {
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 12px !important;
|
||||||
|
|
||||||
|
/deep/ .ant-card-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/deep/ .ant-tabs-nav {
|
||||||
|
.ant-tabs-tab {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow horizontal scroll for tabs on mobile
|
||||||
|
/deep/ .ant-tabs-nav-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form,
|
||||||
|
.password-form {
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
/deep/ .ant-form-item-label {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/deep/ .ant-input,
|
||||||
|
/deep/ .ant-input-password {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password form - verification code section
|
||||||
|
.password-form {
|
||||||
|
/deep/ .ant-alert {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification settings form
|
||||||
|
.notification-settings-form {
|
||||||
|
/deep/ .ant-alert {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/deep/ .ant-form {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/deep/ .ant-checkbox-group {
|
||||||
|
.ant-row {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
|
||||||
|
.ant-col {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
padding-right: 8px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-checkbox-wrapper {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/deep/ .ant-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
/deep/ .ant-form-item:last-child {
|
||||||
|
.ant-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
& + .ant-btn {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tables in tabs
|
||||||
|
/deep/ .ant-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
.ant-table {
|
||||||
|
min-width: 500px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referral user cell in table
|
||||||
|
.referral-user-cell {
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
/deep/ .ant-avatar {
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
line-height: 28px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
.nickname {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra small devices (phones in portrait)
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.profile-page {
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 18px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credits card
|
||||||
|
.credits-card {
|
||||||
|
.credits-body {
|
||||||
|
.credits-amount {
|
||||||
|
.amount-value {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-actions {
|
||||||
|
.ant-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referral card
|
||||||
|
.referral-card {
|
||||||
|
.referral-body {
|
||||||
|
.referral-stats {
|
||||||
|
.stat-item {
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit card
|
||||||
|
.edit-card {
|
||||||
|
/deep/ .ant-tabs-nav {
|
||||||
|
.ant-tabs-tab {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification settings - stack checkboxes
|
||||||
|
.notification-settings-form {
|
||||||
|
/deep/ .ant-checkbox-group {
|
||||||
|
.ant-row {
|
||||||
|
.ant-col {
|
||||||
|
flex: 0 0 50%;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,11 +27,14 @@
|
|||||||
<template slot="value" slot-scope="text">
|
<template slot="value" slot-scope="text">
|
||||||
${{ parseFloat(text).toFixed(2) }}
|
${{ parseFloat(text).toFixed(2) }}
|
||||||
</template>
|
</template>
|
||||||
<template slot="profit" slot-scope="text">
|
<template slot="profit" slot-scope="text, record">
|
||||||
<span :style="{ color: text > 0 ? '#52c41a' : text < 0 ? '#f5222d' : '#666' }">
|
<span :style="{ color: text > 0 ? '#52c41a' : text < 0 ? '#f5222d' : '#666' }">
|
||||||
{{ formatMoney(text) }}
|
{{ formatProfit(text, record) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template slot="commission" slot-scope="text">
|
||||||
|
{{ formatCommission(text) }}
|
||||||
|
</template>
|
||||||
<template slot="time" slot-scope="text, record">
|
<template slot="time" slot-scope="text, record">
|
||||||
{{ formatTime(record.created_at || text) }}
|
{{ formatTime(record.created_at || text) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -103,7 +106,8 @@ export default {
|
|||||||
title: this.$t('trading-assistant.table.commission'),
|
title: this.$t('trading-assistant.table.commission'),
|
||||||
dataIndex: 'commission',
|
dataIndex: 'commission',
|
||||||
key: 'commission',
|
key: 'commission',
|
||||||
width: 100
|
width: 100,
|
||||||
|
scopedSlots: { customRender: 'commission' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -242,6 +246,46 @@ export default {
|
|||||||
// 正数显示+,负数显示-
|
// 正数显示+,负数显示-
|
||||||
const sign = value >= 0 ? '+' : '-'
|
const sign = value >= 0 ? '+' : '-'
|
||||||
return `${sign}$${Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
return `${sign}$${Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
},
|
||||||
|
// 格式化盈亏(处理信号模式下没有实盘的情况)
|
||||||
|
formatProfit (value, record) {
|
||||||
|
// 如果是信号模式(没有实盘交易),profit为0或null时显示--
|
||||||
|
// 判断依据:如果是开仓信号且profit为0,或者record.is_signal_only为true
|
||||||
|
if (value === null || value === undefined) return '--'
|
||||||
|
|
||||||
|
const numValue = parseFloat(value)
|
||||||
|
|
||||||
|
// 如果值为0且是开仓信号(open_long/open_short),显示--
|
||||||
|
// 因为开仓时还没有盈亏
|
||||||
|
const openTypes = ['open_long', 'open_short', 'add_long', 'add_short']
|
||||||
|
if (numValue === 0 && record && openTypes.includes(record.type)) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果值极小(科学计数法如0E-8),视为0
|
||||||
|
if (Math.abs(numValue) < 0.000001) {
|
||||||
|
// 开仓类型显示--,平仓类型显示$0.00
|
||||||
|
if (record && openTypes.includes(record.type)) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
return '$0.00'
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.formatMoney(numValue)
|
||||||
|
},
|
||||||
|
// 格式化手续费(避免科学计数法如0E-8)
|
||||||
|
formatCommission (value) {
|
||||||
|
if (value === null || value === undefined) return '--'
|
||||||
|
|
||||||
|
const numValue = parseFloat(value)
|
||||||
|
|
||||||
|
// 如果值极小(科学计数法如0E-8),显示为0或--
|
||||||
|
if (isNaN(numValue) || Math.abs(numValue) < 0.000001) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常格式化显示
|
||||||
|
return `$${numValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 })}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user