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
@@ -22,7 +22,6 @@ def register_routes(app: Flask):
from app.routes.ibkr import ibkr_bp
from app.routes.mt5 import mt5_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(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(ibkr_bp, url_prefix='/api/ibkr')
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
app.register_blueprint(strategy_code_bp, url_prefix='/api/strategy-code')
+3 -1
View File
@@ -21,7 +21,9 @@ backtest_service = BacktestService()
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()
if not base:
api_url = os.getenv("OPENROUTER_API_URL", "").strip()
+37 -43
View File
@@ -493,31 +493,25 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
header = "# Existing code was provided as context.\n" + header
return header + body
def _openrouter_base_and_key() -> tuple[str, str]:
"""
Support both:
- OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
- OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
"""
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 _generate_code_via_openrouter() -> str:
base_url, api_key = _openrouter_base_and_key()
if not api_key:
def _generate_code_via_llm() -> str:
"""Use unified LLMService to support all configured providers (OpenRouter, OpenAI, Grok, etc.)."""
from app.services.llm import LLMService
llm = LLMService()
# Get provider and model from env config (no frontend override)
current_provider = llm.provider
current_model = llm.get_default_model()
current_api_key = llm.get_api_key()
base_url = llm.get_base_url()
logger.info(f"AI Code Generation - Provider: {current_provider.value}, Model: {current_model}, Base URL: {base_url}, API Key configured: {bool(current_api_key)}")
# Check if any LLM provider is configured
if not current_api_key:
logger.warning("No LLM API key configured, using 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)
user_prompt = prompt
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."
)
payload = {
"model": model,
"temperature": temperature,
"stream": False,
"messages": [
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7)
# Call LLM using the unified API (auto-selects provider based on LLM_PROVIDER env)
# use_json_mode=False because we want raw Python code output
content = llm.call_llm_api(
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
}
resp = requests.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=120,
temperature=temperature,
use_json_mode=False # Code generation doesn't need JSON mode
)
resp.raise_for_status()
j = resp.json()
content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or ""
# Clean up markdown code blocks if present
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()
def stream():
# 不扣任何 QDT:开源本地版直接生成/返回代码
try:
code_text = _generate_code_via_openrouter()
code_text = _generate_code_via_llm()
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()
# Stream in chunks (front-end appends).
+122 -25
View File
@@ -92,6 +92,21 @@ CONFIG_SCHEMA = {
'icon': 'robot',
'order': 3,
'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',
'label': 'OpenRouter API Key',
@@ -99,37 +114,126 @@ CONFIG_SCHEMA = {
'required': False,
'link': 'https://openrouter.ai/keys',
'link_text': 'settings.link.getApiKey',
'description': 'OpenRouter API key for AI model access. Supports multiple LLM providers'
},
{
'key': 'OPENROUTER_API_URL',
'label': 'OpenRouter API URL',
'type': 'text',
'default': 'https://openrouter.ai/api/v1/chat/completions',
'description': 'OpenRouter API endpoint URL'
'description': 'OpenRouter API key. Supports 100+ models via single API',
'group': 'openrouter'
},
{
'key': 'OPENROUTER_MODEL',
'label': 'Default Model',
'label': 'OpenRouter Model',
'type': 'text',
'default': 'openai/gpt-4o',
'link': 'https://openrouter.ai/models',
'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',
'label': 'Temperature',
'type': 'number',
'default': '0.7',
'description': 'Model creativity (0-1). Lower = more deterministic, Higher = more creative'
},
{
'key': 'OPENROUTER_MAX_TOKENS',
'label': 'Max Tokens',
'type': 'number',
'default': '4000',
'description': 'Maximum output tokens per request'
'description': 'Model creativity (0-1). Lower = more deterministic'
},
{
'key': 'OPENROUTER_TIMEOUT',
@@ -138,13 +242,6 @@ CONFIG_SCHEMA = {
'default': '300',
'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',
'label': 'Custom Models (JSON)',
+40 -2
View File
@@ -316,8 +316,29 @@ def get_trades():
)
rows = cur.fetchall() or []
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.
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:
logger.error(f"get_trades failed: {str(e)}")
logger.error(traceback.format_exc())
@@ -833,7 +854,24 @@ def get_strategy_notifications():
rows = cur.fetchall() or []
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:
logger.error(f"get_strategy_notifications failed: {str(e)}")
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"})