feat: AI analysis engine refactor, dark theme polish & virtual position management
Core changes: - Refactor FastAnalysisService: single LLM multi-factor analysis replaces 7-agent pipeline; add multi-timeframe consensus, threshold calibration, confidence calibration, multi-model ensemble voting - Add RAG memory injection and reflection validation (analysis_memory + reflection worker) - Simplify billing config: remove unused strategy_run/backtest/portfolio_monitor, add ai_code_gen separate billing (different token consumption scale) - Settings hot-reload after save, no backend restart needed Frontend: - Global dark theme overhaul: pure black palette replacing blue-tinted colors across sidebar/header/dashboard/analysis/K-line/user-manage/profile/settings/billing - Fix USDT payment modal dark theme (portal rendering broke CSS selectors) - Refactor position modal: direction + quantity + entry price, remove add/reduce logic, show raw DB values on re-open, save exactly what user inputs - Fix Polymarket prediction market dark text - i18n for position modal title Backend: - Position management: one record per symbol (DELETE+INSERT replacing ON CONFLICT with side), fixes PnL showing 0 when switching long/short - MarketDataCollector data fetching optimization - portfolio_monitor scheduled monitoring improvements - env.example reorganized: common config first, advanced config last Documentation: - README architecture diagram updated to FastAnalysisService flow - Add virtual position, AI tuning config, billing items documentation - Add INDICATOR_DEFINITIONS_CN.md, FRONTEND_FAST_ANALYSIS.md Made-with: Cursor
This commit is contained in:
@@ -22,6 +22,72 @@ _analysis_inflight_lock = threading.Lock()
|
||||
_analysis_inflight = {} # key -> expire_ts
|
||||
|
||||
|
||||
def _try_refund_credits(user_id: int, amount: int, remark: str):
|
||||
"""Best-effort async refund when task fails after pre-charge."""
|
||||
try:
|
||||
if int(amount or 0) <= 0:
|
||||
return
|
||||
billing = get_billing_service()
|
||||
billing.add_credits(
|
||||
user_id=int(user_id),
|
||||
amount=int(amount),
|
||||
action='refund',
|
||||
remark=remark
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Async auto refund failed: {e}", exc_info=True)
|
||||
|
||||
|
||||
def _run_async_analysis_task(task_memory_id: int, market: str, symbol: str, language: str,
|
||||
model: str, timeframe: str, user_id: int, inflight_key: str,
|
||||
credits_charged: int = 0):
|
||||
"""
|
||||
Background worker: execute analysis and update pending history record.
|
||||
"""
|
||||
try:
|
||||
service = get_fast_analysis_service()
|
||||
memory = get_analysis_memory()
|
||||
result = service.analyze(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
language=language,
|
||||
model=model,
|
||||
timeframe=timeframe,
|
||||
user_id=user_id
|
||||
)
|
||||
memory.finalize_pending_task(task_memory_id, result)
|
||||
if result.get("error"):
|
||||
_try_refund_credits(
|
||||
user_id=int(user_id),
|
||||
amount=int(credits_charged or 0),
|
||||
remark=f'Auto refund: async fast-analysis failed ({market}:{symbol}:{timeframe})'
|
||||
)
|
||||
|
||||
# analyze() already stores a separate memory row; remove it to avoid duplicates.
|
||||
auto_memory_id = result.get("memory_id")
|
||||
if auto_memory_id and int(auto_memory_id) != int(task_memory_id):
|
||||
try:
|
||||
memory.delete_history(int(auto_memory_id), user_id=user_id)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Async analysis task failed: {e}", exc_info=True)
|
||||
_try_refund_credits(
|
||||
user_id=int(user_id),
|
||||
amount=int(credits_charged or 0),
|
||||
remark=f'Auto refund: async fast-analysis exception ({market}:{symbol}:{timeframe})'
|
||||
)
|
||||
try:
|
||||
get_analysis_memory().fail_pending_task(task_memory_id, str(e))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
_release_inflight(inflight_key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _build_inflight_key(user_id: int, market: str, symbol: str, timeframe: str) -> str:
|
||||
return f"{int(user_id)}|{str(market or '').strip().upper()}|{str(symbol or '').strip().upper()}|{str(timeframe or '').strip().upper()}"
|
||||
|
||||
@@ -70,6 +136,7 @@ def analyze():
|
||||
language = data.get('language', 'en-US')
|
||||
model = data.get('model')
|
||||
timeframe = data.get('timeframe', '1D')
|
||||
async_submit = bool(data.get('async_submit', False))
|
||||
|
||||
if not market or not symbol:
|
||||
return jsonify({
|
||||
@@ -134,6 +201,45 @@ def analyze():
|
||||
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
|
||||
|
||||
service = get_fast_analysis_service()
|
||||
|
||||
# Async submit mode: record "processing" immediately and return task id.
|
||||
if async_submit:
|
||||
memory = get_analysis_memory()
|
||||
pending_id = memory.create_pending_task(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
language=language,
|
||||
model=model or "",
|
||||
timeframe=timeframe,
|
||||
user_id=user_id
|
||||
)
|
||||
if not pending_id:
|
||||
return jsonify({'code': 0, 'msg': 'Failed to create analysis task', 'data': None}), 500
|
||||
|
||||
t = threading.Thread(
|
||||
target=_run_async_analysis_task,
|
||||
args=(int(pending_id), market, symbol, language, model, timeframe, int(user_id), inflight_key, int(credits_charged or 0)),
|
||||
daemon=True
|
||||
)
|
||||
t.start()
|
||||
# worker owns inflight release
|
||||
inflight_key = None
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'submitted',
|
||||
'data': {
|
||||
'task_id': int(pending_id),
|
||||
'memory_id': int(pending_id),
|
||||
'status': 'processing',
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'timeframe': timeframe,
|
||||
'credits_charged': credits_charged,
|
||||
'remaining_credits': remaining_credits,
|
||||
}
|
||||
})
|
||||
|
||||
result = service.analyze(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
|
||||
@@ -424,12 +424,37 @@ def _fetch_commodities() -> List[Dict[str, Any]]:
|
||||
"category": "commodity"
|
||||
})
|
||||
elif len(hist) == 1:
|
||||
current = _safe_float(hist["Close"].iloc[-1], 0)
|
||||
change = 0.0
|
||||
# Fallback: some futures symbols only return one row in short history windows.
|
||||
try:
|
||||
fast_info = getattr(ticker, "fast_info", {}) or {}
|
||||
prev_close = _safe_float(fast_info.get("previousClose"), 0)
|
||||
if prev_close > 0 and current > 0:
|
||||
change = ((current - prev_close) / prev_close) * 100
|
||||
except Exception:
|
||||
pass
|
||||
if change == 0.0:
|
||||
try:
|
||||
info = getattr(ticker, "info", {}) or {}
|
||||
# yfinance may expose either regularMarketChangePercent (ratio)
|
||||
# or regularMarketChange (absolute). Prefer percent when present.
|
||||
rcp = info.get("regularMarketChangePercent")
|
||||
if rcp is not None:
|
||||
change = _safe_float(rcp, 0) * 100
|
||||
else:
|
||||
rmc = _safe_float(info.get("regularMarketChange"), 0)
|
||||
prev_close = _safe_float(info.get("regularMarketPreviousClose"), 0)
|
||||
if prev_close > 0:
|
||||
change = (rmc / prev_close) * 100
|
||||
except Exception:
|
||||
pass
|
||||
result.append({
|
||||
"symbol": commodity["symbol"],
|
||||
"name_cn": commodity["name_cn"],
|
||||
"name_en": commodity["name_en"],
|
||||
"price": round(hist["Close"].iloc[-1], 2),
|
||||
"change": 0,
|
||||
"price": round(current, 2),
|
||||
"change": round(change, 2),
|
||||
"unit": commodity["unit"],
|
||||
"category": "commodity"
|
||||
})
|
||||
|
||||
@@ -698,7 +698,18 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
|
||||
return content.strip() or _template_code()
|
||||
|
||||
def stream():
|
||||
# 不扣任何 QDT:开源本地版直接生成/返回代码
|
||||
from app.services.billing_service import get_billing_service
|
||||
billing = get_billing_service()
|
||||
ok, msg = billing.check_and_consume(
|
||||
user_id=g.user_id,
|
||||
feature='ai_code_gen',
|
||||
reference_id=f"ai_code_gen_{g.user_id}_{int(time.time())}"
|
||||
)
|
||||
if not ok:
|
||||
yield "data: " + json.dumps({"error": f"积分不足: {msg}"}, ensure_ascii=False) + "\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
try:
|
||||
code_text = _generate_code_via_llm()
|
||||
except Exception as e:
|
||||
|
||||
@@ -250,20 +250,17 @@ def add_position():
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# Delete any existing positions for this symbol (regardless of side),
|
||||
# ensuring only one position per symbol per user per group.
|
||||
cur.execute(
|
||||
"DELETE FROM qd_manual_positions WHERE user_id = ? AND market = ? AND symbol = ? AND group_name = ?",
|
||||
(user_id, market, symbol, group_name)
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_manual_positions
|
||||
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
ON CONFLICT(user_id, market, symbol, side, group_name) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
quantity = excluded.quantity,
|
||||
entry_price = excluded.entry_price,
|
||||
entry_time = excluded.entry_time,
|
||||
notes = excluded.notes,
|
||||
tags = excluded.tags,
|
||||
group_name = excluded.group_name,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name)
|
||||
)
|
||||
@@ -557,8 +554,8 @@ def add_monitor():
|
||||
if monitor_type not in ('ai', 'price_alert', 'pnl_alert'):
|
||||
monitor_type = 'ai'
|
||||
|
||||
# Calculate next_run_at based on interval
|
||||
interval_minutes = int(config.get('interval_minutes') or 60)
|
||||
# Calculate next_run_at based on interval (frontend sends run_interval_minutes)
|
||||
interval_minutes = int(config.get('run_interval_minutes') or config.get('interval_minutes') or 60)
|
||||
|
||||
position_ids_json = json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False)
|
||||
config_json = json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False)
|
||||
@@ -616,8 +613,8 @@ def update_monitor(monitor_id):
|
||||
updates.append('config = ?')
|
||||
params.append(json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False))
|
||||
|
||||
# Recalculate next_run_at if interval changed (handled separately for PostgreSQL)
|
||||
next_run_interval = int(config.get('interval_minutes') or 60)
|
||||
# Recalculate next_run_at if interval changed
|
||||
next_run_interval = int(config.get('run_interval_minutes') or config.get('interval_minutes') or 60)
|
||||
|
||||
if 'notification_config' in data:
|
||||
notification_config = data.get('notification_config') or {}
|
||||
|
||||
@@ -5,10 +5,12 @@ Admin-only endpoints for system configuration management.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import importlib
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.config_loader import clear_config_cache
|
||||
from app.utils.auth import login_required, admin_required
|
||||
from dotenv import load_dotenv
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -17,6 +19,55 @@ settings_bp = Blueprint('settings', __name__)
|
||||
# .env 文件路径
|
||||
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
|
||||
|
||||
|
||||
def _reload_runtime_env() -> None:
|
||||
"""
|
||||
Reload .env into current process so settings take effect immediately.
|
||||
Priority keeps backend_api_python/.env over repo-root/.env.
|
||||
"""
|
||||
backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
root_dir = os.path.dirname(backend_dir)
|
||||
|
||||
# Load root first, then backend .env to keep backend file higher priority
|
||||
load_dotenv(os.path.join(root_dir, '.env'), override=True)
|
||||
load_dotenv(os.path.join(backend_dir, '.env'), override=True)
|
||||
|
||||
|
||||
def _refresh_runtime_services() -> None:
|
||||
"""
|
||||
Reset singleton services so new env/config is picked up lazily
|
||||
on next request without restarting the Python process.
|
||||
"""
|
||||
# Prefer dedicated reset function where available.
|
||||
try:
|
||||
search_mod = importlib.import_module('app.services.search')
|
||||
if hasattr(search_mod, 'reset_search_service'):
|
||||
search_mod.reset_search_service()
|
||||
except Exception as e:
|
||||
logger.warning(f"reset_search_service skipped: {e}")
|
||||
|
||||
# Generic singleton fields used across services.
|
||||
singleton_fields = [
|
||||
('app.services.fast_analysis', '_fast_analysis_service'),
|
||||
('app.services.billing_service', '_billing_service'),
|
||||
('app.services.security_service', '_security_service'),
|
||||
('app.services.oauth_service', '_oauth_service'),
|
||||
('app.services.user_service', '_user_service'),
|
||||
('app.services.email_service', '_email_service'),
|
||||
('app.services.community_service', '_community_service'),
|
||||
('app.services.usdt_payment_service', '_svc'),
|
||||
('app.services.usdt_payment_service', '_worker'),
|
||||
('app.services.analysis_memory', '_memory_instance'),
|
||||
]
|
||||
|
||||
for module_name, field_name in singleton_fields:
|
||||
try:
|
||||
mod = importlib.import_module(module_name)
|
||||
if hasattr(mod, field_name):
|
||||
setattr(mod, field_name, None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Singleton reset skipped: {module_name}.{field_name}: {e}")
|
||||
|
||||
# 配置项定义(分组)- 按功能模块划分,每个配置项包含描述
|
||||
# ---------------------------------------------------------------
|
||||
# 精简原则:
|
||||
@@ -439,19 +490,75 @@ CONFIG_SCHEMA = {
|
||||
'icon': 'experiment',
|
||||
'order': 7,
|
||||
'items': [
|
||||
{
|
||||
'key': 'ENABLE_AGENT_MEMORY',
|
||||
'label': 'Enable Agent Memory',
|
||||
'type': 'boolean',
|
||||
'default': 'True',
|
||||
'description': 'Enable AI agent memory for learning from past trades'
|
||||
},
|
||||
{
|
||||
'key': 'ENABLE_REFLECTION_WORKER',
|
||||
'label': 'Enable Auto Reflection',
|
||||
'type': 'boolean',
|
||||
'default': 'False',
|
||||
'description': 'Enable background worker for automatic trade reflection'
|
||||
'description': 'Enable background worker for automatic trade reflection and calibration'
|
||||
},
|
||||
{
|
||||
'key': 'REFLECTION_WORKER_INTERVAL_SEC',
|
||||
'label': 'Reflection Interval (sec)',
|
||||
'type': 'number',
|
||||
'default': '86400',
|
||||
'description': 'Reflection worker run interval in seconds (86400 = 1 day)'
|
||||
},
|
||||
{
|
||||
'key': 'REFLECTION_MIN_AGE_DAYS',
|
||||
'label': 'Min Age for Validation (days)',
|
||||
'type': 'number',
|
||||
'default': '7',
|
||||
'description': 'Only validate analyses older than N days'
|
||||
},
|
||||
{
|
||||
'key': 'REFLECTION_VALIDATE_LIMIT',
|
||||
'label': 'Validation Batch Limit',
|
||||
'type': 'number',
|
||||
'default': '200',
|
||||
'description': 'Max records to validate per reflection cycle'
|
||||
},
|
||||
{
|
||||
'key': 'ENABLE_CONFIDENCE_CALIBRATION',
|
||||
'label': 'Enable Confidence Calibration',
|
||||
'type': 'boolean',
|
||||
'default': 'False',
|
||||
'description': 'Adjust confidence by historical accuracy in each bucket'
|
||||
},
|
||||
{
|
||||
'key': 'ENABLE_AI_ENSEMBLE',
|
||||
'label': 'Enable Multi-Model Voting',
|
||||
'type': 'boolean',
|
||||
'default': 'False',
|
||||
'description': 'Use 2-3 models and majority vote for more stable decisions'
|
||||
},
|
||||
{
|
||||
'key': 'AI_ENSEMBLE_MODELS',
|
||||
'label': 'Ensemble Models',
|
||||
'type': 'text',
|
||||
'default': 'openai/gpt-4o,openai/gpt-4o-mini',
|
||||
'description': 'Comma-separated model IDs for ensemble voting'
|
||||
},
|
||||
{
|
||||
'key': 'AI_CALIBRATION_MARKETS',
|
||||
'label': 'Calibration Markets',
|
||||
'type': 'text',
|
||||
'default': 'Crypto',
|
||||
'description': 'Comma-separated markets to run threshold calibration'
|
||||
},
|
||||
{
|
||||
'key': 'AI_CALIBRATION_LOOKBACK_DAYS',
|
||||
'label': 'Calibration Lookback (days)',
|
||||
'type': 'number',
|
||||
'default': '30',
|
||||
'description': 'Days of validated data for calibration'
|
||||
},
|
||||
{
|
||||
'key': 'AI_CALIBRATION_MIN_SAMPLES',
|
||||
'label': 'Calibration Min Samples',
|
||||
'type': 'number',
|
||||
'default': '80',
|
||||
'description': 'Minimum validated samples required for calibration'
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -661,31 +768,17 @@ CONFIG_SCHEMA = {
|
||||
},
|
||||
{
|
||||
'key': 'BILLING_COST_AI_ANALYSIS',
|
||||
'label': 'AI Analysis Cost',
|
||||
'label': 'AI Analysis Cost (per symbol)',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Credits per AI analysis request'
|
||||
'description': 'Credits per symbol (instant analysis, AI filter, scheduled tasks all use this price)'
|
||||
},
|
||||
{
|
||||
'key': 'BILLING_COST_STRATEGY_RUN',
|
||||
'label': 'Strategy Run Cost',
|
||||
'key': 'BILLING_COST_AI_CODE_GEN',
|
||||
'label': 'AI Code Generation Cost',
|
||||
'type': 'number',
|
||||
'default': '5',
|
||||
'description': 'Credits per strategy start'
|
||||
},
|
||||
{
|
||||
'key': 'BILLING_COST_BACKTEST',
|
||||
'label': 'Backtest Cost',
|
||||
'type': 'number',
|
||||
'default': '3',
|
||||
'description': 'Credits per backtest run'
|
||||
},
|
||||
{
|
||||
'key': 'BILLING_COST_PORTFOLIO_MONITOR',
|
||||
'label': 'Portfolio Monitor Cost',
|
||||
'type': 'number',
|
||||
'default': '8',
|
||||
'description': 'Credits per portfolio AI monitoring run'
|
||||
'default': '30',
|
||||
'description': 'Credits per AI strategy/indicator code generation (higher token usage)'
|
||||
},
|
||||
{
|
||||
'key': 'CREDITS_REGISTER_BONUS',
|
||||
@@ -873,13 +966,19 @@ def save_settings():
|
||||
if write_env_file(current_env):
|
||||
# 清除配置缓存
|
||||
clear_config_cache()
|
||||
# 热重载运行时环境变量(无需重启进程)
|
||||
_reload_runtime_env()
|
||||
# 重置依赖配置的服务单例(下次请求自动按新配置重建)
|
||||
_refresh_runtime_services()
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Settings saved successfully',
|
||||
'data': {
|
||||
'updated_keys': list(updates.keys()),
|
||||
'requires_restart': True # 标记需要重启
|
||||
'requires_restart': False,
|
||||
'hot_reloaded': True,
|
||||
'services_refreshed': True
|
||||
}
|
||||
})
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user