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:
TIANHE
2026-01-24 03:22:14 +08:00
parent 7de1570b3a
commit f4e5a9f8e0
22 changed files with 1358 additions and 464 deletions
@@ -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")
+385 -68
View File
@@ -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(