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:
@@ -137,7 +137,8 @@ class AgentTools:
|
||||
|
||||
elif market == 'Crypto':
|
||||
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())
|
||||
# CCXT timeframes: 1d, 1h, 4h ...
|
||||
ccxt_tf = tf if tf in ["1d", "1h", "4h"] else "1d"
|
||||
@@ -233,7 +234,8 @@ class AgentTools:
|
||||
}
|
||||
elif market == 'Crypto':
|
||||
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)
|
||||
if ticker:
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ Uses ib_insync library to connect to TWS or IB Gateway for trading.
|
||||
|
||||
import time
|
||||
import threading
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
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__)
|
||||
|
||||
|
||||
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
|
||||
ib_insync = None
|
||||
|
||||
@@ -99,6 +119,9 @@ class IBKRClient:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Ensure event loop exists in this thread (required by ib_insync)
|
||||
_ensure_event_loop()
|
||||
|
||||
_ensure_ib_insync()
|
||||
|
||||
if self._ib is None:
|
||||
@@ -145,6 +168,8 @@ class IBKRClient:
|
||||
|
||||
def _ensure_connected(self):
|
||||
"""Ensure connection is established."""
|
||||
# Ensure event loop exists (may be called from different threads)
|
||||
_ensure_event_loop()
|
||||
if not self.connected:
|
||||
if not self.connect():
|
||||
raise ConnectionError("Cannot connect to IBKR")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.config import APIKeys
|
||||
@@ -14,102 +16,394 @@ from app.utils.config_loader import load_addon_config
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""LLM provider wrapper."""
|
||||
class LLMProvider(Enum):
|
||||
"""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.
|
||||
pass
|
||||
|
||||
# Provider configurations
|
||||
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
|
||||
def api_key(self):
|
||||
return APIKeys.OPENROUTER_API_KEY
|
||||
return self.get_api_key()
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
config = load_addon_config()
|
||||
# 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")
|
||||
return self.get_base_url()
|
||||
|
||||
def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str:
|
||||
"""Call OpenRouter API, with optional fallback models."""
|
||||
config = load_addon_config()
|
||||
openrouter_config = config.get('openrouter', {})
|
||||
|
||||
default_model = openrouter_config.get('model', 'openai/gpt-4o')
|
||||
|
||||
if model is None:
|
||||
model = default_model
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
def _call_openai_compatible(self, messages: list, model: str, temperature: float,
|
||||
api_key: str, base_url: str, timeout: int,
|
||||
use_json_mode: bool = True) -> str:
|
||||
"""Call OpenAI-compatible API (OpenAI, DeepSeek, Grok, OpenRouter)."""
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"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]
|
||||
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
|
||||
|
||||
timeout = int(openrouter_config.get('timeout', 120))
|
||||
|
||||
for current_model in models_to_try:
|
||||
try:
|
||||
data = {
|
||||
"model": current_model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"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
|
||||
if p == LLMProvider.GOOGLE:
|
||||
return self._call_google_gemini(
|
||||
messages, current_model, temperature,
|
||||
api_key, base_url, timeout
|
||||
)
|
||||
else:
|
||||
logger.error(f"OpenRouter API returned unexpected structure ({current_model}): {json.dumps(result)}")
|
||||
raise ValueError("OpenRouter API response is missing 'choices'")
|
||||
# OpenAI-compatible providers
|
||||
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:
|
||||
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)
|
||||
|
||||
# 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]:
|
||||
raise
|
||||
|
||||
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)
|
||||
if not use_fallback or current_model == models_to_try[-1]:
|
||||
raise
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Model {current_model} returned invalid data: {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]:
|
||||
raise
|
||||
|
||||
@@ -117,14 +411,20 @@ class LLMService:
|
||||
logger.error(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."""
|
||||
response_text = ""
|
||||
try:
|
||||
response_text = self.call_openrouter_api([
|
||||
response_text = self.call_llm_api([
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
], model=model)
|
||||
], model=model, provider=provider)
|
||||
|
||||
# Strip markdown fences if present
|
||||
clean_text = response_text.strip()
|
||||
@@ -159,3 +459,20 @@ class LLMService:
|
||||
logger.error(f"LLM call failed: {str(e)}")
|
||||
default_structure['report'] = f"Analysis failed: {str(e)}"
|
||||
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'))
|
||||
)
|
||||
|
||||
# Update last_login_at for new OAuth users
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET last_login_at = NOW() WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
@@ -948,6 +948,98 @@ class PendingOrderWorker:
|
||||
def _current_avg() -> float:
|
||||
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.
|
||||
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
|
||||
try:
|
||||
stsig = int(signal_ts or 0)
|
||||
# Strict "same candle" de-dup should ONLY apply to open signals.
|
||||
# Rationale: on higher timeframes (e.g. 1D), scale-in signals (add_*) may legitimately trigger
|
||||
# multiple times within the same candle/day as price evolves; we must not block them by candle key.
|
||||
# Strict "same candle" de-dup applies to open and close signals.
|
||||
# Rationale:
|
||||
# - 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()
|
||||
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:
|
||||
cur.execute(
|
||||
|
||||
Reference in New Issue
Block a user