Merge branch 'brokermr810:main' into main

This commit is contained in:
PengfaGuo
2026-01-24 14:36:20 +08:00
committed by GitHub
22 changed files with 1358 additions and 464 deletions
+45 -1
View File
@@ -21,9 +21,53 @@ class MetaAPIKeys(type):
@property
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
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):
@@ -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"})
@@ -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(
@@ -70,6 +70,28 @@ def load_addon_config() -> Dict[str, Any]:
('OPENROUTER_TIMEOUT', 'openrouter.timeout', 'int'),
('OPENROUTER_CONNECT_TIMEOUT', 'openrouter.connect_timeout', 'int'),
('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
('CORS_ORIGINS', 'app.cors_origins', 'string'),
+34 -1
View File
@@ -145,7 +145,13 @@ ENABLE_REFLECTION_WORKER=false
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_URL=https://openrouter.ai/api/v1/chat/completions
@@ -155,6 +161,33 @@ OPENROUTER_MAX_TOKENS=4000
OPENROUTER_TIMEOUT=300
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", ...})
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 $$;
+12 -30
View File
@@ -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);
-- =============================================================================
-- 8. Strategy Codes
-- =============================================================================
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
-- 8. 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);
-- =============================================================================
-- 10. Addon Config
-- 9. 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 (
@@ -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);
-- =============================================================================
-- 12. Analysis Tasks
-- 11. 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);
-- =============================================================================
-- 13. Backtest Runs
-- 12. 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);
-- =============================================================================
-- 14. Exchange Credentials
-- 13. 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);
-- =============================================================================
-- 15. Manual Positions
-- 14. 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);
-- =============================================================================
-- 16. Position Alerts
-- 15. 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);
-- =============================================================================
-- 17. Position Monitors
-- 16. 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);
-- =============================================================================
-- 18. Market Symbols (Seed Data)
-- 17. Market Symbols (Seed Data)
-- =============================================================================
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;
-- =============================================================================
-- 19. Agent Memories (AI Learning System)
-- 18. Agent Memories (AI Learning System)
-- =============================================================================
-- 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.
@@ -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);
-- =============================================================================
-- 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.