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:
@@ -177,20 +177,19 @@ POST /api/users/change-password - Change own password
|
||||
```text
|
||||
GET /api/health
|
||||
GET /api/indicator/kline
|
||||
POST /api/analysis/multi
|
||||
POST /api/fast-analysis/analyze - Fast AI analysis (main entry)
|
||||
GET /api/fast-analysis/history - Analysis history
|
||||
GET /api/fast-analysis/similar-patterns - RAG similar patterns
|
||||
POST /api/fast-analysis/feedback - User feedback on analysis
|
||||
```
|
||||
|
||||
## AI memory augmentation
|
||||
## AI analysis & memory
|
||||
|
||||
This backend includes a lightweight, privacy-first **memory-augmented multi-agent** system:
|
||||
Uses **FastAnalysisService** (single LLM call, multi-factor):
|
||||
|
||||
- Memory DBs stored in PostgreSQL
|
||||
- API hooks:
|
||||
- `POST /api/analysis/multi` (main entry)
|
||||
- `POST /api/analysis/reflect` (manual learn from post-trade outcomes)
|
||||
- Controls in `.env`:
|
||||
- `ENABLE_AGENT_MEMORY`, `AGENT_MEMORY_*`
|
||||
- `ENABLE_REFLECTION_WORKER`, `REFLECTION_WORKER_INTERVAL_SEC`
|
||||
- Memory: `qd_analysis_memory` in PostgreSQL
|
||||
- API: `POST /api/fast-analysis/analyze` (main), `/history`, `/similar-patterns`, `/feedback`
|
||||
- Calibration: `AICalibrationService` tunes BUY/SELL thresholds from validated outcomes
|
||||
|
||||
## Frontend integration
|
||||
|
||||
|
||||
@@ -279,6 +279,12 @@ def create_app(config_name='default'):
|
||||
start_ai_calibration_worker()
|
||||
except Exception:
|
||||
pass
|
||||
# Reflection worker: validate past decisions, run calibration periodically.
|
||||
try:
|
||||
from app.services.reflection import start_reflection_worker
|
||||
start_reflection_worker()
|
||||
except Exception:
|
||||
pass
|
||||
restore_running_strategies()
|
||||
|
||||
return app
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
@@ -136,19 +136,35 @@ class BaseDataSource(ABC):
|
||||
klines: List[Dict[str, Any]],
|
||||
timeframe: str
|
||||
):
|
||||
"""记录获取结果日志"""
|
||||
"""记录获取结果日志。
|
||||
|
||||
延迟判断:
|
||||
- K 线 time 为 Unix 秒(UTC),与 datetime.now(UTC) 比较,避免本地时区误差。
|
||||
- 日线/周线:最后一根通常是「上一交易日收盘」,周末/节假日可达 3~4 天,
|
||||
原先用 2×86400s(48h)会在周一早盘误报;改为日线最多容忍约 5 个自然日,周线更宽。
|
||||
"""
|
||||
if klines:
|
||||
latest_time = datetime.fromtimestamp(klines[-1]['time'])
|
||||
time_diff = (datetime.now() - latest_time).total_seconds()
|
||||
# logger.info(
|
||||
# f"{self.name}: {symbol} 获取 {len(klines)} 条数据, "
|
||||
# f"最新时间: {latest_time}, 延迟: {time_diff:.0f}秒"
|
||||
# )
|
||||
|
||||
# 检查数据是否过旧
|
||||
max_diff = TIMEFRAME_SECONDS.get(timeframe, 3600) * 2
|
||||
latest_ts = int(klines[-1]["time"])
|
||||
latest_utc = datetime.fromtimestamp(latest_ts, tz=timezone.utc)
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
time_diff = (now_utc - latest_utc).total_seconds()
|
||||
|
||||
tf_sec = TIMEFRAME_SECONDS.get(timeframe, 3600)
|
||||
if tf_sec < 86400:
|
||||
# 分钟/小时级:超过约 2 根 K 未更新则告警
|
||||
max_diff = tf_sec * 2
|
||||
elif tf_sec == 86400:
|
||||
# 日线:覆盖周末 + 短假期(约 5 个自然日)
|
||||
max_diff = 5 * 86400
|
||||
else:
|
||||
# 周线:允许跨多周数据滞后
|
||||
max_diff = max(tf_sec * 2, 21 * 86400)
|
||||
|
||||
if time_diff > max_diff:
|
||||
logger.warning(f"Warning: {symbol} data is delayed ({time_diff:.0f}s)")
|
||||
logger.warning(
|
||||
f"Warning: {symbol} data is delayed ({time_diff:.0f}s, "
|
||||
f"latest_bar_utc={latest_utc.isoformat()}, threshold={max_diff:.0f}s, tf={timeframe})"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"{self.name}: no data for {symbol}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -67,6 +67,9 @@ class AnalysisMemory:
|
||||
consensus_abs DECIMAL(24, 8),
|
||||
agreement_ratio DECIMAL(10, 6),
|
||||
quality_multiplier DECIMAL(10, 6),
|
||||
task_status VARCHAR(20) DEFAULT 'completed',
|
||||
task_error TEXT,
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
validated_at TIMESTAMP,
|
||||
actual_outcome VARCHAR(20),
|
||||
@@ -124,6 +127,27 @@ class AnalysisMemory:
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN quality_multiplier DECIMAL(10, 6);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'task_status'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN task_status VARCHAR(20) DEFAULT 'completed';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'task_error'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN task_error TEXT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'updated_at'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN updated_at TIMESTAMP DEFAULT NOW();
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
@@ -185,14 +209,16 @@ class AnalysisMemory:
|
||||
INSERT INTO qd_analysis_memory (
|
||||
user_id, market, symbol, decision, confidence,
|
||||
price_at_analysis, summary, reasons, scores, indicators_snapshot, raw_result,
|
||||
consensus_score, consensus_abs, agreement_ratio, quality_multiplier
|
||||
consensus_score, consensus_abs, agreement_ratio, quality_multiplier,
|
||||
task_status, task_error, updated_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s)
|
||||
%s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING id
|
||||
""", (
|
||||
user_id, market, symbol, decision, confidence,
|
||||
price, summary, reasons, scores, indicators, raw,
|
||||
consensus_score, consensus_abs, agreement_ratio, quality_multiplier,
|
||||
"completed", "",
|
||||
))
|
||||
|
||||
# 使用 lastrowid 属性获取 ID(execute 内部已经处理了 RETURNING)
|
||||
@@ -227,7 +253,8 @@ class AnalysisMemory:
|
||||
SELECT
|
||||
id, decision, confidence, price_at_analysis,
|
||||
summary, reasons, scores,
|
||||
created_at, validated_at, was_correct, actual_return_pct
|
||||
created_at, validated_at, was_correct, actual_return_pct,
|
||||
task_status, task_error, updated_at
|
||||
FROM qd_analysis_memory
|
||||
WHERE market = %s AND symbol = %s
|
||||
AND created_at > NOW() - INTERVAL '{int(days)} days'
|
||||
@@ -248,7 +275,10 @@ class AnalysisMemory:
|
||||
"summary": row['summary'],
|
||||
"reasons": _safe_json_parse(row['reasons'], []),
|
||||
"scores": _safe_json_parse(row['scores'], {}),
|
||||
"status": row.get('task_status') or 'completed',
|
||||
"error_message": row.get('task_error') or '',
|
||||
"created_at": row['created_at'].isoformat() if row['created_at'] else None,
|
||||
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
|
||||
"was_correct": row['was_correct'],
|
||||
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
|
||||
})
|
||||
@@ -292,7 +322,8 @@ class AnalysisMemory:
|
||||
SELECT
|
||||
id, market, symbol, decision, confidence, price_at_analysis,
|
||||
summary, reasons, scores, indicators_snapshot, raw_result,
|
||||
created_at, validated_at, was_correct, actual_return_pct
|
||||
created_at, validated_at, was_correct, actual_return_pct,
|
||||
task_status, task_error, updated_at
|
||||
FROM qd_analysis_memory
|
||||
{where_clause}
|
||||
ORDER BY created_at DESC
|
||||
@@ -316,7 +347,10 @@ class AnalysisMemory:
|
||||
"scores": _safe_json_parse(row['scores'], {}),
|
||||
"indicators": _safe_json_parse(row['indicators_snapshot'], {}),
|
||||
"full_result": _safe_json_parse(row['raw_result'], None),
|
||||
"status": row.get('task_status') or 'completed',
|
||||
"error_message": row.get('task_error') or '',
|
||||
"created_at": row['created_at'].isoformat() if row['created_at'] else None,
|
||||
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
|
||||
"was_correct": row['was_correct'],
|
||||
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
|
||||
})
|
||||
@@ -358,27 +392,143 @@ class AnalysisMemory:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete memory {memory_id}: {e}")
|
||||
return False
|
||||
|
||||
def create_pending_task(self, market: str, symbol: str, language: str, model: str, timeframe: str,
|
||||
user_id: int = None) -> Optional[int]:
|
||||
"""Create a processing record in history before long-running analysis starts."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
summary = f"Analysis submitted ({timeframe})..."
|
||||
reasons = json.dumps([])
|
||||
scores = json.dumps({})
|
||||
indicators = json.dumps({})
|
||||
raw = json.dumps({
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"language": language,
|
||||
"model": model,
|
||||
"timeframe": timeframe,
|
||||
"task_status": "processing",
|
||||
})
|
||||
cur.execute("""
|
||||
INSERT INTO qd_analysis_memory (
|
||||
user_id, market, symbol, decision, confidence,
|
||||
summary, reasons, scores, indicators_snapshot, raw_result,
|
||||
task_status, task_error, updated_at, created_at
|
||||
) VALUES (%s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s,
|
||||
%s, %s, NOW(), NOW())
|
||||
RETURNING id
|
||||
""", (
|
||||
user_id, market, symbol, "HOLD", 0,
|
||||
summary, reasons, scores, indicators, raw,
|
||||
"processing", "",
|
||||
))
|
||||
# PostgresCursor.execute() 会在 INSERT 时提前 fetchone() 消耗 RETURNING 结果,
|
||||
# 所以这里不要再 cur.fetchone(),直接取 lastrowid。
|
||||
memory_id = cur.lastrowid
|
||||
db.commit()
|
||||
cur.close()
|
||||
return memory_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create pending task: {e}")
|
||||
return None
|
||||
|
||||
def finalize_pending_task(self, memory_id: int, result: Dict[str, Any]) -> bool:
|
||||
"""Overwrite pending record with final analysis result."""
|
||||
try:
|
||||
consensus = result.get("consensus") or {}
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("""
|
||||
UPDATE qd_analysis_memory
|
||||
SET decision = %s,
|
||||
confidence = %s,
|
||||
price_at_analysis = %s,
|
||||
summary = %s,
|
||||
reasons = %s,
|
||||
scores = %s,
|
||||
indicators_snapshot = %s,
|
||||
raw_result = %s,
|
||||
consensus_score = %s,
|
||||
consensus_abs = %s,
|
||||
agreement_ratio = %s,
|
||||
quality_multiplier = %s,
|
||||
task_status = %s,
|
||||
task_error = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (
|
||||
result.get("decision"),
|
||||
result.get("confidence"),
|
||||
result.get("market_data", {}).get("current_price"),
|
||||
result.get("summary"),
|
||||
json.dumps(result.get("reasons", [])),
|
||||
json.dumps(result.get("scores", {})),
|
||||
json.dumps(result.get("indicators", {})),
|
||||
json.dumps(result),
|
||||
consensus.get("consensus_score"),
|
||||
consensus.get("consensus_abs"),
|
||||
consensus.get("agreement_ratio"),
|
||||
consensus.get("quality_multiplier"),
|
||||
"completed" if not result.get("error") else "failed",
|
||||
str(result.get("error") or ""),
|
||||
int(memory_id),
|
||||
))
|
||||
ok = cur.rowcount > 0
|
||||
db.commit()
|
||||
cur.close()
|
||||
return ok
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to finalize pending task {memory_id}: {e}")
|
||||
return False
|
||||
|
||||
def fail_pending_task(self, memory_id: int, error_message: str) -> bool:
|
||||
"""Mark pending task as failed."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("""
|
||||
UPDATE qd_analysis_memory
|
||||
SET task_status = 'failed',
|
||||
task_error = %s,
|
||||
summary = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (
|
||||
str(error_message or "analysis failed"),
|
||||
f"Analysis failed: {str(error_message or '')}",
|
||||
int(memory_id),
|
||||
))
|
||||
ok = cur.rowcount > 0
|
||||
db.commit()
|
||||
cur.close()
|
||||
return ok
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark task failed {memory_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_similar_patterns(self, market: str, symbol: str,
|
||||
current_indicators: Dict, limit: int = 3) -> List[Dict]:
|
||||
"""
|
||||
Find historical analyses with similar technical patterns.
|
||||
|
||||
This is a simplified version - can be enhanced with vector similarity later.
|
||||
Currently matches based on:
|
||||
- Same symbol
|
||||
- Similar RSI range (±10)
|
||||
- Same MACD signal direction
|
||||
- Validated outcomes preferred
|
||||
Multi-indicator weighted similarity:
|
||||
- RSI: ±15 range, weighted 0.3
|
||||
- MACD signal: exact match, weighted 0.3
|
||||
- MA trend: exact match, weighted 0.25
|
||||
- Volatility level: similar band, weighted 0.15
|
||||
- Time decay: prefer recent validated outcomes
|
||||
"""
|
||||
try:
|
||||
rsi = current_indicators.get("rsi", {}).get("value", 50)
|
||||
macd_signal = current_indicators.get("macd", {}).get("signal", "neutral")
|
||||
rsi = float(current_indicators.get("rsi", {}).get("value") or 50)
|
||||
macd_signal = str(current_indicators.get("macd", {}).get("signal") or "neutral").lower()
|
||||
ma_trend = str(current_indicators.get("moving_averages", {}).get("trend") or "sideways").lower()
|
||||
vol_level = str(current_indicators.get("volatility", {}).get("level") or "normal").lower()
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Simple pattern matching query
|
||||
cur.execute("""
|
||||
SELECT
|
||||
id, decision, confidence, price_at_analysis,
|
||||
@@ -388,49 +538,50 @@ class AnalysisMemory:
|
||||
WHERE market = %s AND symbol = %s
|
||||
AND validated_at IS NOT NULL
|
||||
AND was_correct IS NOT NULL
|
||||
ORDER BY
|
||||
CASE WHEN was_correct = true THEN 0 ELSE 1 END,
|
||||
created_at DESC
|
||||
ORDER BY validated_at DESC NULLS LAST, created_at DESC
|
||||
LIMIT %s
|
||||
""", (market, symbol, limit * 2)) # Get more for filtering
|
||||
""", (market, symbol, limit * 5))
|
||||
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
results = []
|
||||
scored = []
|
||||
for row in rows:
|
||||
indicators = _safe_json_parse(row['indicators_snapshot'], {})
|
||||
hist_rsi = indicators.get("rsi", {}).get("value", 50)
|
||||
hist_macd = indicators.get("macd", {}).get("signal", "neutral")
|
||||
ind = _safe_json_parse(row['indicators_snapshot'], {})
|
||||
hist_rsi = float(ind.get("rsi", {}).get("value") or 50)
|
||||
hist_macd = str(ind.get("macd", {}).get("signal") or "neutral").lower()
|
||||
hist_ma = str(ind.get("moving_averages", {}).get("trend") or "sideways").lower()
|
||||
hist_vol = str(ind.get("volatility", {}).get("level") or "normal").lower()
|
||||
|
||||
# Simple similarity check
|
||||
rsi_similar = abs(hist_rsi - rsi) <= 15
|
||||
macd_similar = hist_macd == macd_signal
|
||||
rsi_diff = abs(hist_rsi - rsi)
|
||||
rsi_score = max(0, 1 - rsi_diff / 30) * 0.3
|
||||
macd_score = 0.3 if hist_macd == macd_signal else 0
|
||||
ma_score = 0.25 if hist_ma == ma_trend else 0
|
||||
vol_score = 0.15 if hist_vol == vol_level else (0.08 if _vol_bands_similar(vol_level, hist_vol) else 0)
|
||||
|
||||
if rsi_similar or macd_similar:
|
||||
results.append({
|
||||
"id": row['id'],
|
||||
"decision": row['decision'],
|
||||
"confidence": row['confidence'],
|
||||
"price": float(row['price_at_analysis']) if row['price_at_analysis'] else None,
|
||||
"summary": row['summary'],
|
||||
"was_correct": row['was_correct'],
|
||||
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
|
||||
"similarity": {
|
||||
"rsi_match": rsi_similar,
|
||||
"macd_match": macd_similar,
|
||||
}
|
||||
})
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
sim = rsi_score + macd_score + ma_score + vol_score
|
||||
if sim < 0.25:
|
||||
continue
|
||||
|
||||
bonus = 0.1 if row['was_correct'] else 0
|
||||
scored.append((sim + bonus, {
|
||||
"id": row['id'],
|
||||
"decision": row['decision'],
|
||||
"confidence": row['confidence'],
|
||||
"price": float(row['price_at_analysis']) if row['price_at_analysis'] else None,
|
||||
"summary": row['summary'],
|
||||
"was_correct": row['was_correct'],
|
||||
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
|
||||
"similarity_score": round(sim + bonus, 3),
|
||||
}))
|
||||
|
||||
return results
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
return [p[1] for p in scored[:limit]]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get similar patterns: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def record_feedback(self, memory_id: int, feedback: str) -> bool:
|
||||
"""
|
||||
Record user feedback on an analysis.
|
||||
@@ -493,8 +644,8 @@ class AnalysisMemory:
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
# Get current price using MarketDataCollector
|
||||
current_price = collector._get_price(row['market'], row['symbol'])
|
||||
price_data = collector._get_price(row['market'], row['symbol'])
|
||||
current_price = float(price_data.get('price', 0)) if price_data else None
|
||||
if not current_price or current_price <= 0:
|
||||
continue
|
||||
analysis_price = float(row['price_at_analysis'])
|
||||
@@ -580,7 +731,8 @@ class AnalysisMemory:
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
current_price = collector._get_price(row["market"], row["symbol"])
|
||||
price_data = collector._get_price(row["market"], row["symbol"])
|
||||
current_price = float(price_data.get("price", 0)) if price_data else None
|
||||
if not current_price or current_price <= 0:
|
||||
continue
|
||||
analysis_price = float(row.get("price_at_analysis") or 0.0)
|
||||
@@ -625,6 +777,74 @@ class AnalysisMemory:
|
||||
|
||||
return stats
|
||||
|
||||
def get_confidence_accuracy_by_bucket(
|
||||
self, market: str = None, symbol: str = None, days: int = 90
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Compute actual accuracy by confidence bucket for calibration.
|
||||
Buckets: (50,60), (60,70), (70,80), (80,90), (90,100).
|
||||
Returns e.g. {"60_70": 0.58, "70_80": 0.62} - bucket_key -> accuracy.
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
where = ["validated_at IS NOT NULL", "was_correct IS NOT NULL", "confidence IS NOT NULL"]
|
||||
params = []
|
||||
if market:
|
||||
where.append("market = %s")
|
||||
params.append(market)
|
||||
if symbol:
|
||||
where.append("symbol = %s")
|
||||
params.append(symbol)
|
||||
where.append(f"created_at > NOW() - INTERVAL '{int(days)} days'")
|
||||
params = tuple(params) if params else ()
|
||||
cur.execute(f"""
|
||||
SELECT confidence, was_correct
|
||||
FROM qd_analysis_memory
|
||||
WHERE {' AND '.join(where)}
|
||||
""", params)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
buckets = [(50, 60), (60, 70), (70, 80), (80, 90), (90, 101)]
|
||||
out = {}
|
||||
for lo, hi in buckets:
|
||||
subset = [r for r in rows if lo <= (r.get("confidence") or 0) < hi]
|
||||
if len(subset) < 5:
|
||||
continue
|
||||
correct = sum(1 for r in subset if r.get("was_correct"))
|
||||
out[f"{lo}_{hi}"] = correct / len(subset)
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.warning(f"get_confidence_accuracy_by_bucket failed: {e}")
|
||||
return {}
|
||||
|
||||
def get_adjusted_confidence(
|
||||
self, raw_confidence: int, market: str = None, symbol: str = None
|
||||
) -> int:
|
||||
"""
|
||||
Adjust confidence based on historical accuracy in that bucket.
|
||||
If model is overconfident (low actual accuracy), dampen. Underconfident -> boost slightly.
|
||||
"""
|
||||
buckets = [(50, 60, "50_60"), (60, 70, "60_70"), (70, 80, "70_80"), (80, 90, "80_90"), (90, 101, "90_100")]
|
||||
bucket_key = None
|
||||
for lo, hi, key in buckets:
|
||||
if lo <= raw_confidence < hi:
|
||||
bucket_key = key
|
||||
break
|
||||
if not bucket_key:
|
||||
return max(1, min(99, int(raw_confidence)))
|
||||
acc_map = self.get_confidence_accuracy_by_bucket(market=market, symbol=symbol)
|
||||
acc = acc_map.get(bucket_key)
|
||||
if acc is None or acc <= 0:
|
||||
return max(1, min(99, int(raw_confidence)))
|
||||
expected = 0.5 + (raw_confidence - 50) / 100
|
||||
if expected <= 0:
|
||||
return raw_confidence
|
||||
factor = acc / expected
|
||||
adjusted = int(raw_confidence * factor)
|
||||
return max(1, min(99, adjusted))
|
||||
|
||||
def get_performance_stats(self, market: str = None, symbol: str = None,
|
||||
days: int = 30) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -705,6 +925,18 @@ class AnalysisMemory:
|
||||
}
|
||||
|
||||
|
||||
def _vol_bands_similar(a: str, b: str) -> bool:
|
||||
"""Check if two volatility levels are in similar band."""
|
||||
low = {"low", "normal", "normal_low"}
|
||||
high = {"high", "elevated", "volatile", "very_high"}
|
||||
a, b = a.lower(), b.lower()
|
||||
if a in low and b in low:
|
||||
return True
|
||||
if a in high and b in high:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Singleton
|
||||
_memory_instance = None
|
||||
|
||||
|
||||
@@ -30,21 +30,16 @@ DEFAULT_BILLING_CONFIG = {
|
||||
'enabled': False, # 是否启用计费
|
||||
|
||||
# 各功能积分消耗(0表示免费)
|
||||
'cost_ai_analysis': 10, # AI分析 每次消耗积分
|
||||
'cost_strategy_run': 5, # 策略运行 每次消耗积分(启动时)
|
||||
'cost_backtest': 3, # 回测 每次消耗积分
|
||||
'cost_portfolio_monitor': 8, # Portfolio AI监控 每次消耗积分
|
||||
'cost_indicator_create': 0, # 创建指标 免费
|
||||
'cost_polymarket_deep_analysis': 15, # Polymarket深度分析 每次消耗积分
|
||||
# ai_analysis 统一单价:即时分析 / AI过滤 / 定时任务 均按此单价 × 标的数扣费
|
||||
'cost_ai_analysis': 10,
|
||||
'cost_ai_code_gen': 30,
|
||||
'cost_polymarket_deep_analysis': 15,
|
||||
}
|
||||
|
||||
# Feature name mapping (for log recording)
|
||||
FEATURE_NAMES = {
|
||||
'ai_analysis': 'AI Analysis',
|
||||
'strategy_run': 'Strategy Run',
|
||||
'backtest': 'Backtest',
|
||||
'portfolio_monitor': 'Portfolio Monitor',
|
||||
'indicator_create': 'Indicator Create',
|
||||
'ai_code_gen': 'AI Code Generation',
|
||||
'polymarket_deep_analysis': 'Polymarket Deep Analysis',
|
||||
}
|
||||
|
||||
@@ -458,7 +453,7 @@ class BillingService:
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
feature: 功能名称(ai_analysis/strategy_run/backtest/portfolio_monitor等)
|
||||
feature: 功能名称(ai_analysis / polymarket_deep_analysis)
|
||||
reference_id: 关联ID(可选)
|
||||
|
||||
Returns:
|
||||
@@ -716,12 +711,10 @@ class BillingService:
|
||||
'is_vip': is_vip,
|
||||
'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None,
|
||||
'billing_enabled': config.get('enabled', False),
|
||||
# 功能费用(供前端显示)
|
||||
'feature_costs': {
|
||||
'ai_analysis': config.get('cost_ai_analysis', 0),
|
||||
'strategy_run': config.get('cost_strategy_run', 0),
|
||||
'backtest': config.get('cost_backtest', 0),
|
||||
'portfolio_monitor': config.get('cost_portfolio_monitor', 0),
|
||||
'ai_code_gen': config.get('cost_ai_code_gen', 0),
|
||||
'polymarket_deep_analysis': config.get('cost_polymarket_deep_analysis', 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ Fast Analysis Service 3.0
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
@@ -21,6 +22,167 @@ from app.services.market_data_collector import get_market_data_collector
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _safe_float_price(value: Any, default: Optional[float] = None) -> Optional[float]:
|
||||
"""Coerce LLM/string prices to float; invalid -> default."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
if isinstance(value, float) and (value != value): # NaN
|
||||
return default
|
||||
return float(value)
|
||||
try:
|
||||
s = str(value).strip().replace(",", "")
|
||||
if not s:
|
||||
return default
|
||||
return float(s)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _build_trend_outlook_summary(trend_outlook: Dict[str, Any], language: str) -> str:
|
||||
"""Human-readable multi-horizon outlook for API / legacy clients."""
|
||||
if not trend_outlook:
|
||||
return ""
|
||||
is_zh = str(language or "").lower().startswith("zh")
|
||||
|
||||
def _lbl(trend: str) -> str:
|
||||
t = str(trend or "HOLD").upper()
|
||||
if is_zh:
|
||||
return {"BUY": "看多", "SELL": "看空", "HOLD": "震荡/中性"}.get(t, "震荡/中性")
|
||||
return {"BUY": "bullish", "SELL": "bearish", "HOLD": "neutral / range"}.get(t, "neutral / range")
|
||||
|
||||
n24 = trend_outlook.get("next_24h") or {}
|
||||
d3 = trend_outlook.get("next_3d") or {}
|
||||
w1 = trend_outlook.get("next_1w") or {}
|
||||
m1 = trend_outlook.get("next_1m") or {}
|
||||
|
||||
if is_zh:
|
||||
parts = [
|
||||
f"约24小时:{_lbl(n24.get('trend'))}(强度 {n24.get('strength', 'neutral')})",
|
||||
f"约3天:{_lbl(d3.get('trend'))}(强度 {d3.get('strength', 'neutral')})",
|
||||
f"约1周:{_lbl(w1.get('trend'))}(强度 {w1.get('strength', 'neutral')})",
|
||||
f"约1月:{_lbl(m1.get('trend'))}(强度 {m1.get('strength', 'neutral')})",
|
||||
]
|
||||
return ";".join(parts)
|
||||
parts = [
|
||||
f"~24h: {_lbl(n24.get('trend'))} ({n24.get('strength', 'neutral')})",
|
||||
f"~3d: {_lbl(d3.get('trend'))} ({d3.get('strength', 'neutral')})",
|
||||
f"~1w: {_lbl(w1.get('trend'))} ({w1.get('strength', 'neutral')})",
|
||||
f"~1m: {_lbl(m1.get('trend'))} ({m1.get('strength', 'neutral')})",
|
||||
]
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Geopolitical / major-conflict detection (word boundaries + tiers)
|
||||
# Avoid false positives: "war" in "toward/award", "tension" in "extension",
|
||||
# "us" in "focus/status", bare country names without conflict context, etc.
|
||||
# -----------------------------------------------------------------------------
|
||||
_GEO_SEVERE_PATTERNS: List[re.Pattern] = [
|
||||
re.compile(r"\b(?:war|wars|warfare|wartime)\b", re.I),
|
||||
re.compile(r"\b(?:invasion|invaded|invading|invade)\b", re.I),
|
||||
re.compile(r"\b(?:airstrike|air\s*strikes?|missile\s+strike|drone\s+strike)\b", re.I),
|
||||
re.compile(r"\b(?:military\s+attack|armed\s+attack|troops?\s+(?:fire|attack|invade))\b", re.I),
|
||||
re.compile(r"\b(?:declare[sd]?\s+war|state\s+of\s+war|act\s+of\s+war)\b", re.I),
|
||||
re.compile(r"\b(?:martial\s+law|military\s+coup|coup\s+d['\u2019]?etat)\b", re.I),
|
||||
re.compile(r"\b(?:terror(?:ist)?\s+attack|mass\s+shooting\s+at)\b", re.I),
|
||||
]
|
||||
_GEO_MODERATE_PATTERNS: List[re.Pattern] = [
|
||||
re.compile(r"\bgeopolitical\b", re.I),
|
||||
re.compile(r"\b(?:armed|military)\s+conflict\b", re.I),
|
||||
re.compile(r"\b(?:international\s+)?sanctions?\s+(?:on|against|targeting|hit)\b", re.I),
|
||||
re.compile(r"\b(?:naval\s+blockade|border\s+clash|ceasefire\s+(?:broken|violated))\b", re.I),
|
||||
re.compile(r"\b(?:evacuat\w+\s+(?:the\s+)?embassy|embassy\s+evacuation)\b", re.I),
|
||||
re.compile(r"\b(?:nuclear\s+(?:threat|strike|weapon)|nuclear\s+war)\b", re.I),
|
||||
]
|
||||
# "Crisis" / "tension" only in clearly geopolitical phrases (not substring of "extension")
|
||||
_GEO_CONTEXT_MODERATE: List[re.Pattern] = [
|
||||
re.compile(r"\b(?:geopolitical|diplomatic|border)\s+(?:crisis|tension|standoff)\b", re.I),
|
||||
re.compile(r"\b(?:tensions?\s+(?:rise|escalat|flare|mount)\s+(?:with|between))\b", re.I),
|
||||
re.compile(r"\b(?:middle\s+east|south\s+china\s+sea|taiwan\s+strait)\s+(?:crisis|tension|conflict)\b", re.I),
|
||||
]
|
||||
_GEO_ZH_SEVERE = (
|
||||
"宣战", "战争爆发", "全面战争", "武装冲突", "军事打击", "军事入侵", "空袭", "导弹袭击",
|
||||
"开战", "交火", "战火",
|
||||
)
|
||||
_GEO_ZH_MODERATE = (
|
||||
"地缘政治危机", "国际制裁升级", "断交", "撤侨", "军事对峙", "地区冲突升级",
|
||||
)
|
||||
|
||||
# Optional: country/region + conflict verb (single pattern, avoids "NYSE" noise)
|
||||
_GEO_REGION_CONFLICT: List[re.Pattern] = [
|
||||
re.compile(
|
||||
r"\b(?:russia|ukraine|iran|israel|gaza|hamas|taiwan|north\s+korea|dprk|"
|
||||
r"syria|yemen|lebanon|nato)\b.{0,40}\b(?:invade|attack|strike|war|conflict|sanction)\b",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\b(?:invade|attack|strike|war|conflict|sanction)\b.{0,40}\b(?:russia|ukraine|iran|israel|"
|
||||
r"gaza|hamas|taiwan|north\s+korea|dprk|syria|nato)\b",
|
||||
re.I,
|
||||
),
|
||||
]
|
||||
|
||||
_GEO_MAJOR_NEWS_SEVERE = [
|
||||
re.compile(r"\b(?:war|wars|warfare)\b", re.I),
|
||||
re.compile(r"\b(?:invasion|invaded|military\s+attack|airstrike)\b", re.I),
|
||||
re.compile(r"\b(?:armed\s+conflict|military\s+conflict)\b", re.I),
|
||||
]
|
||||
|
||||
|
||||
def _geopolitical_match_level(combined_text: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Returns (level, reason_tag) where level is 'none'|'severe'|'moderate'.
|
||||
combined_text: title + summary (original case OK; English patterns use lower via regex I flag).
|
||||
"""
|
||||
if not combined_text or len(combined_text.strip()) < 4:
|
||||
return "none", None
|
||||
low = combined_text.lower()
|
||||
for pat in _GEO_SEVERE_PATTERNS:
|
||||
if pat.search(low):
|
||||
return "severe", pat.pattern[:48]
|
||||
for z in _GEO_ZH_SEVERE:
|
||||
if z in combined_text:
|
||||
return "severe", z
|
||||
for pat in _GEO_REGION_CONFLICT:
|
||||
if pat.search(low):
|
||||
return "severe", "region+conflict"
|
||||
for pat in _GEO_MODERATE_PATTERNS:
|
||||
if pat.search(low):
|
||||
return "moderate", pat.pattern[:48]
|
||||
for pat in _GEO_CONTEXT_MODERATE:
|
||||
if pat.search(low):
|
||||
return "moderate", pat.pattern[:48]
|
||||
for z in _GEO_ZH_MODERATE:
|
||||
if z in combined_text:
|
||||
return "moderate", z
|
||||
return "none", None
|
||||
|
||||
|
||||
def _geopolitical_sentiment_penalty_delta(level: str) -> int:
|
||||
if level == "severe":
|
||||
return -42
|
||||
if level == "moderate":
|
||||
return -18
|
||||
return 0
|
||||
|
||||
|
||||
def _is_major_geopolitical_news_text(combined_text: str) -> bool:
|
||||
"""Stricter than sentiment: only clear conflict / war signals for _has_major_news."""
|
||||
if not combined_text:
|
||||
return False
|
||||
low = combined_text.lower()
|
||||
for pat in _GEO_MAJOR_NEWS_SEVERE:
|
||||
if pat.search(low):
|
||||
return True
|
||||
for z in _GEO_ZH_SEVERE:
|
||||
if z in combined_text:
|
||||
return True
|
||||
if any(p.search(low) for p in _GEO_REGION_CONFLICT):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FastAnalysisService:
|
||||
"""
|
||||
快速分析服务 3.0
|
||||
@@ -364,10 +526,12 @@ You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not spe
|
||||
|
||||
⚠️ CRITICAL PRICE RULES:
|
||||
1. Current price: ${current_price}
|
||||
2. Your stop_loss MUST be near ${suggested_stop_loss:.4f} (range: ${price_lower_bound:.4f} ~ ${current_price})
|
||||
3. Your take_profit MUST be near ${suggested_take_profit:.4f} (range: ${current_price} ~ ${price_upper_bound:.4f})
|
||||
4. Entry price: ${entry_range_low:.4f} ~ ${entry_range_high:.4f}
|
||||
5. These levels are based on ATR and support/resistance analysis - use them as reference!
|
||||
2. If decision=BUY: stop_loss should be below current price, take_profit above current price.
|
||||
3. If decision=SELL (short): stop_loss MUST be above current price; take_profit MUST be below current price.
|
||||
4. BUY stop_loss reference: near ${suggested_stop_loss:.4f} (range: ${price_lower_bound:.4f} ~ ${current_price})
|
||||
5. BUY take_profit reference: near ${suggested_take_profit:.4f} (range: ${current_price} ~ ${price_upper_bound:.4f})
|
||||
6. Entry price: ${entry_range_low:.4f} ~ ${entry_range_high:.4f}
|
||||
7. These levels are based on ATR and support/resistance analysis - use them as reference!
|
||||
|
||||
📊 YOUR ANALYSIS MUST INCLUDE (ALL factors are important):
|
||||
1. **Technical Analysis**: Objectively interpret RSI, MACD, MA, support/resistance. Be honest about conflicting signals.
|
||||
@@ -500,6 +664,9 @@ When the score is neutral (-20 to +20), you can use your judgment, but still con
|
||||
📈 EARNINGS DATA:
|
||||
{self._format_earnings_data(fundamental.get('earnings', {}))}
|
||||
|
||||
📚 HISTORICAL PATTERNS (similar conditions in the past):
|
||||
{self._get_memory_context(data.get('market', ''), data.get('symbol', ''), indicators)}
|
||||
|
||||
IMPORTANT:
|
||||
1. **CRITICAL**: Check for GEOPOLITICAL EVENTS (wars, conflicts, military actions) in the news section. These events have HIGHEST PRIORITY and can override all technical indicators.
|
||||
2. Consider the macro environment (especially DXY, VIX, rates, geopolitical events) when making your recommendation.
|
||||
@@ -817,6 +984,55 @@ IMPORTANT:
|
||||
weighted_score_sum += overall_score * w
|
||||
weighted_score_w_sum += w
|
||||
|
||||
# Extra horizon score (not used in consensus override):
|
||||
# add 1W objective score for short/medium trend outlook.
|
||||
if "1W" not in objective_by_tf:
|
||||
try:
|
||||
d_1w = self._collect_market_data(
|
||||
market,
|
||||
symbol,
|
||||
"1W",
|
||||
include_macro=False,
|
||||
include_news=False,
|
||||
include_polymarket=False,
|
||||
timeout=25,
|
||||
)
|
||||
cp_1w = _extract_current_price(d_1w) or 0.0
|
||||
obj_1w = self._calculate_objective_score(d_1w, cp_1w)
|
||||
sc_1w = float(obj_1w.get("overall_score", 0.0) or 0.0)
|
||||
objective_by_tf["1W"] = {
|
||||
"objective_score": obj_1w,
|
||||
"overall_score": sc_1w,
|
||||
"decision": self._score_to_decision(sc_1w, market=market),
|
||||
"abs_score": abs(sc_1w),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"1W outlook score skipped: {e}")
|
||||
|
||||
# Short-horizon outlook: 1H bar (24h-style), not 1D close
|
||||
if "1H" not in objective_by_tf:
|
||||
try:
|
||||
d_1h = self._collect_market_data(
|
||||
market,
|
||||
symbol,
|
||||
"1H",
|
||||
include_macro=False,
|
||||
include_news=False,
|
||||
include_polymarket=False,
|
||||
timeout=18,
|
||||
)
|
||||
cp_1h = _extract_current_price(d_1h) or 0.0
|
||||
obj_1h = self._calculate_objective_score(d_1h, cp_1h)
|
||||
sc_1h = float(obj_1h.get("overall_score", 0.0) or 0.0)
|
||||
objective_by_tf["1H"] = {
|
||||
"objective_score": obj_1h,
|
||||
"overall_score": sc_1h,
|
||||
"decision": self._score_to_decision(sc_1h, market=market),
|
||||
"abs_score": abs(sc_1h),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"1H outlook score skipped: {e}")
|
||||
|
||||
consensus_score = weighted_score_sum / weighted_score_w_sum if weighted_score_w_sum > 0 else 0.0
|
||||
consensus_decision = self._score_to_decision(consensus_score, market=market)
|
||||
consensus_abs = abs(consensus_score)
|
||||
@@ -892,32 +1108,52 @@ IMPORTANT:
|
||||
|
||||
# Phase 2: Build prompt
|
||||
system_prompt, user_prompt = self._build_analysis_prompt(data, language)
|
||||
|
||||
# Phase 3: Single LLM call
|
||||
logger.info(f"Calling LLM for analysis...")
|
||||
|
||||
default_struct = {
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"summary": "Analysis failed",
|
||||
"entry_price": current_price,
|
||||
"stop_loss": current_price * 0.95,
|
||||
"take_profit": current_price * 1.05,
|
||||
"position_size_pct": 10,
|
||||
"timeframe": "medium",
|
||||
"key_reasons": ["Unable to analyze"],
|
||||
"risks": ["Analysis error"],
|
||||
"technical_score": 50,
|
||||
"fundamental_score": 50,
|
||||
"sentiment_score": 50,
|
||||
}
|
||||
|
||||
# Phase 3: LLM call(s) - single or ensemble voting
|
||||
logger.info("Calling LLM for analysis...")
|
||||
llm_start = time.time()
|
||||
|
||||
analysis = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
default_structure={
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"summary": "Analysis failed",
|
||||
"entry_price": current_price,
|
||||
"stop_loss": current_price * 0.95,
|
||||
"take_profit": current_price * 1.05,
|
||||
"position_size_pct": 10,
|
||||
"timeframe": "medium",
|
||||
"key_reasons": ["Unable to analyze"],
|
||||
"risks": ["Analysis error"],
|
||||
"technical_score": 50,
|
||||
"fundamental_score": 50,
|
||||
"sentiment_score": 50,
|
||||
},
|
||||
model=model
|
||||
)
|
||||
|
||||
ensemble_models = []
|
||||
if os.getenv("ENABLE_AI_ENSEMBLE", "false").lower() == "true":
|
||||
env_models = (os.getenv("AI_ENSEMBLE_MODELS") or "").strip()
|
||||
if env_models:
|
||||
ensemble_models = [m.strip() for m in env_models.split(",") if m.strip()]
|
||||
|
||||
if len(ensemble_models) >= 2:
|
||||
analyses_list = []
|
||||
for em in ensemble_models[:3]:
|
||||
a = self.llm_service.safe_call_llm(
|
||||
system_prompt, user_prompt, default_structure=default_struct, model=em
|
||||
)
|
||||
analyses_list.append(a)
|
||||
decisions = [str(a.get("decision", "HOLD") or "HOLD").upper() for a in analyses_list]
|
||||
from collections import Counter
|
||||
vote = Counter(decisions).most_common(1)[0][0]
|
||||
idx = decisions.index(vote)
|
||||
analysis = analyses_list[idx].copy()
|
||||
analysis["decision"] = vote
|
||||
analysis["_ensemble_vote"] = dict(Counter(decisions))
|
||||
analysis["_ensemble_models"] = ensemble_models[:3]
|
||||
else:
|
||||
analysis = self.llm_service.safe_call_llm(
|
||||
system_prompt, user_prompt, default_structure=default_struct, model=model
|
||||
)
|
||||
|
||||
llm_time = int((time.time() - llm_start) * 1000)
|
||||
logger.info(f"LLM call completed in {llm_time}ms")
|
||||
|
||||
@@ -932,6 +1168,50 @@ IMPORTANT:
|
||||
score_based_decision = self._score_to_decision(objective_score["overall_score"], market=market)
|
||||
llm_decision = str(analysis.get("decision", "HOLD") or "HOLD").upper()
|
||||
|
||||
# Horizon trend outlook for users (short/medium/long decision reference)
|
||||
score_1d = float((objective_by_tf.get("1D") or {}).get("overall_score", objective_score.get("overall_score", 0.0)) or 0.0)
|
||||
score_4h = float((objective_by_tf.get("4H") or {}).get("overall_score", score_1d) or score_1d)
|
||||
score_1h = float((objective_by_tf.get("1H") or {}).get("overall_score", score_4h) or score_4h)
|
||||
# ~24h: prefer 1H bar objective; fall back 4H -> 1D
|
||||
score_24h = float(score_1h)
|
||||
score_1w = float((objective_by_tf.get("1W") or {}).get("overall_score", score_1d) or score_1d)
|
||||
score_3d = score_1d * 0.7 + score_4h * 0.3
|
||||
score_1m = score_1w * 0.55 + float(objective_score.get("fundamental_score", 0.0)) * 0.30 + float(objective_score.get("macro_score", 0.0)) * 0.15
|
||||
|
||||
def _trend_strength(score_val: float) -> str:
|
||||
a = abs(float(score_val))
|
||||
if a >= 70:
|
||||
return "strong"
|
||||
if a >= 40:
|
||||
return "moderate"
|
||||
if a >= 20:
|
||||
return "mild"
|
||||
return "neutral"
|
||||
|
||||
trend_outlook = {
|
||||
"next_24h": {
|
||||
"score": round(score_24h, 2),
|
||||
"trend": self._score_to_decision(score_24h, market=market),
|
||||
"strength": _trend_strength(score_24h),
|
||||
},
|
||||
"next_3d": {
|
||||
"score": round(score_3d, 2),
|
||||
"trend": self._score_to_decision(score_3d, market=market),
|
||||
"strength": _trend_strength(score_3d),
|
||||
},
|
||||
"next_1w": {
|
||||
"score": round(score_1w, 2),
|
||||
"trend": self._score_to_decision(score_1w, market=market),
|
||||
"strength": _trend_strength(score_1w),
|
||||
},
|
||||
"next_1m": {
|
||||
"score": round(score_1m, 2),
|
||||
"trend": self._score_to_decision(score_1m, market=market),
|
||||
"strength": _trend_strength(score_1m),
|
||||
},
|
||||
}
|
||||
trend_outlook_summary = _build_trend_outlook_summary(trend_outlook, language)
|
||||
|
||||
# Consensus confidence:
|
||||
consensus_conf = int(max(40, min(98, 50 + consensus_abs * 0.35)))
|
||||
# Agreement boosts, disagreement reduces
|
||||
@@ -942,6 +1222,9 @@ IMPORTANT:
|
||||
cfg = self._get_ai_calibration(market=market)
|
||||
min_abs_override = float(cfg.get("min_consensus_abs_override") or 15.0)
|
||||
quality_hold_thr = float(cfg.get("quality_hold_threshold") or 0.7)
|
||||
regime = self._detect_market_regime(data.get("indicators") or {})
|
||||
if regime == "ranging":
|
||||
min_abs_override *= 1.2
|
||||
|
||||
if consensus_abs >= min_abs_override:
|
||||
final_decision = consensus_decision
|
||||
@@ -953,11 +1236,21 @@ IMPORTANT:
|
||||
analysis["decision"] = final_decision
|
||||
analysis["confidence"] = consensus_conf
|
||||
original_summary = analysis.get("summary", "")
|
||||
level = "强烈" if consensus_abs >= 70 else "明显" if consensus_abs >= 40 else "轻微"
|
||||
analysis["summary"] = (
|
||||
f"{original_summary} [多周期客观共识:综合评分{consensus_score:.1f}分("
|
||||
f"{level}{'利多' if consensus_score > 0 else '利空'}),建议{final_decision}]"
|
||||
)
|
||||
is_zh = str(language or "").lower().startswith("zh")
|
||||
if is_zh:
|
||||
level = "强烈" if consensus_abs >= 70 else "明显" if consensus_abs >= 40 else "轻微"
|
||||
bias = "利多" if consensus_score > 0 else "利空"
|
||||
consensus_note = (
|
||||
f"[多周期客观共识:综合评分{consensus_score:.1f}分({level}{bias}),建议{final_decision}]"
|
||||
)
|
||||
else:
|
||||
level = "strong" if consensus_abs >= 70 else "moderate" if consensus_abs >= 40 else "mild"
|
||||
bias = "bullish" if consensus_score > 0 else "bearish"
|
||||
consensus_note = (
|
||||
f"[Multi-timeframe objective consensus: score {consensus_score:.1f} "
|
||||
f"({level} {bias}), suggested decision {final_decision}]"
|
||||
)
|
||||
analysis["summary"] = f"{original_summary} {consensus_note}".strip()
|
||||
else:
|
||||
# Near-neutral: keep LLM but shrink confidence by quality and enforce HOLD if quality is poor
|
||||
analysis["confidence"] = int(max(0, min(100, int(analysis.get("confidence", 50) or 50) * quality_multiplier)))
|
||||
@@ -983,6 +1276,7 @@ IMPORTANT:
|
||||
"consensus_abs": consensus_abs,
|
||||
"agreement_ratio": agreement_ratio,
|
||||
"quality_multiplier": quality_multiplier,
|
||||
"market_regime": regime,
|
||||
}
|
||||
|
||||
# Phase 5: Validate and constrain output (pass indicators for decision validation)
|
||||
@@ -1014,6 +1308,17 @@ IMPORTANT:
|
||||
except Exception:
|
||||
# Keep model-provided position_size_pct
|
||||
pass
|
||||
|
||||
# Confidence calibration: adjust by historical accuracy in bucket
|
||||
if os.getenv("ENABLE_CONFIDENCE_CALIBRATION", "false").lower() == "true":
|
||||
try:
|
||||
from app.services.analysis_memory import get_analysis_memory
|
||||
raw_conf = int(analysis.get("confidence", 50) or 50)
|
||||
analysis["confidence"] = get_analysis_memory().get_adjusted_confidence(
|
||||
raw_conf, market=market, symbol=symbol
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Confidence calibration skipped: {e}")
|
||||
|
||||
# Build final result
|
||||
total_time = int((time.time() - start_time) * 1000)
|
||||
@@ -1041,6 +1346,15 @@ IMPORTANT:
|
||||
"take_profit": analysis.get("take_profit"),
|
||||
"position_size_pct": analysis.get("position_size_pct", 10),
|
||||
"timeframe": analysis.get("timeframe", "medium"),
|
||||
# camelCase + 语义别名:供私有前端/旧版组件绑定(勿用 indicators.trading_levels 充当计划)
|
||||
"entryPrice": analysis.get("entry_price"),
|
||||
"stopLoss": analysis.get("stop_loss"),
|
||||
"takeProfit": analysis.get("take_profit"),
|
||||
"positionSizePct": analysis.get("position_size_pct", 10),
|
||||
"decision": str(analysis.get("decision", "HOLD") or "HOLD").upper(),
|
||||
# 与 stop_loss / take_profit 数值相同;命名强调「亏损离场 / 盈利目标」避免与多单参考线混淆
|
||||
"loss_exit_price": analysis.get("stop_loss"),
|
||||
"profit_target_price": analysis.get("take_profit"),
|
||||
},
|
||||
"reasons": analysis.get("key_reasons", []),
|
||||
"risks": analysis.get("risks", []),
|
||||
@@ -1060,6 +1374,10 @@ IMPORTANT:
|
||||
},
|
||||
"indicators": data.get("indicators", {}),
|
||||
"consensus": analysis.get("consensus", {}),
|
||||
"trend_outlook": trend_outlook,
|
||||
"trend_outlook_summary": trend_outlook_summary,
|
||||
"trendOutlook": trend_outlook,
|
||||
"trendOutlookSummary": trend_outlook_summary,
|
||||
"analysis_time_ms": total_time,
|
||||
"llm_time_ms": llm_time,
|
||||
"data_collection_time_ms": data.get("collection_time_ms", 0),
|
||||
@@ -1149,51 +1467,45 @@ IMPORTANT:
|
||||
"""
|
||||
检查是否有重大新闻事件。
|
||||
重大新闻包括:监管变化、重大合作、丑闻、重大政策、地缘政治事件等。
|
||||
地缘类使用词边界与分级,避免 toward/extension/us 等子串误判。
|
||||
"""
|
||||
if not news_data:
|
||||
return False
|
||||
|
||||
# 检查新闻标题中的关键词(扩展了地缘政治相关关键词)
|
||||
|
||||
# 子串关键词(较长词或中文,避免过短英文误匹配)
|
||||
major_keywords = [
|
||||
# 监管和政策
|
||||
"regulation", "regulatory", "ban", "approval", "policy", "government", "central bank",
|
||||
"regulation", "regulatory", "approval", "policy", "government", "central bank",
|
||||
"监管", "禁令", "批准", "政策", "政府", "央行",
|
||||
# 商业事件
|
||||
"partnership", "merger", "acquisition", "scandal", "lawsuit", "investigation",
|
||||
"合作", "合并", "收购", "丑闻", "诉讼", "调查",
|
||||
# 地缘政治事件(新增)
|
||||
"war", "conflict", "military", "attack", "strike", "sanctions", "tension", "crisis",
|
||||
"geopolitical", "iran", "israel", "russia", "ukraine", "china", "taiwan", "north korea",
|
||||
"middle east", "gulf", "nato", "united states", "us", "usa", "america",
|
||||
"战争", "冲突", "军事", "袭击", "打击", "制裁", "紧张", "危机",
|
||||
"地缘政治", "伊朗", "以色列", "俄罗斯", "乌克兰", "中国", "台湾", "朝鲜",
|
||||
"中东", "海湾", "北约", "美国"
|
||||
"sanctions", "embargo", "制裁", "中东", "海湾", "北约",
|
||||
"united states", "middle east",
|
||||
]
|
||||
|
||||
for news in news_data[:10]: # 检查前10条最新新闻(增加检查范围)
|
||||
title = (news.get("title") or news.get("headline") or "").lower()
|
||||
summary = (news.get("summary") or "").lower()
|
||||
# 短英文词用词边界匹配(不用裸子串)
|
||||
major_short_patterns = [
|
||||
re.compile(r"\b(?:ban|banned|banning)\b", re.I),
|
||||
re.compile(r"\b(?:crisis|crises)\b", re.I),
|
||||
re.compile(r"\b(?:catastrophe|meltdown)\b", re.I),
|
||||
]
|
||||
|
||||
for news in news_data[:10]:
|
||||
title = news.get("title") or news.get("headline") or ""
|
||||
summary = news.get("summary") or ""
|
||||
sentiment = news.get("sentiment", "neutral")
|
||||
|
||||
# 检查标题和摘要中是否包含重大关键词
|
||||
text_to_check = f"{title} {summary}"
|
||||
|
||||
# 地缘政治事件通常很严重,即使情绪是中性也要识别
|
||||
geopolitical_keywords = [
|
||||
"war", "conflict", "military", "attack", "strike", "geopolitical",
|
||||
"战争", "冲突", "军事", "袭击", "打击", "地缘政治"
|
||||
]
|
||||
|
||||
# 如果是地缘政治相关,直接认为是重大新闻
|
||||
if any(keyword in text_to_check for keyword in geopolitical_keywords):
|
||||
logger.info(f"Detected major geopolitical event in news: {title[:60]}")
|
||||
low = text_to_check.lower()
|
||||
|
||||
if _is_major_geopolitical_news_text(text_to_check):
|
||||
logger.info(f"Detected major geopolitical event in news: {low[:80]}")
|
||||
return True
|
||||
|
||||
# 其他重大关键词且情绪强烈(非中性),认为是重大新闻
|
||||
if any(keyword in text_to_check for keyword in major_keywords) and sentiment != "neutral":
|
||||
logger.info(f"Detected major news event: {title[:60]}")
|
||||
|
||||
if any(kw in low for kw in major_keywords) and sentiment != "neutral":
|
||||
logger.info(f"Detected major news event: {low[:80]}")
|
||||
return True
|
||||
|
||||
if sentiment != "neutral" and any(p.search(low) for p in major_short_patterns):
|
||||
logger.info(f"Detected major news event (pattern): {low[:80]}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _has_macro_event(self, macro_data: Dict, market: str) -> bool:
|
||||
@@ -1227,6 +1539,88 @@ IMPORTANT:
|
||||
|
||||
return False
|
||||
|
||||
def _finalize_trading_plan_for_decision(
|
||||
self, analysis: Dict, current_price: float, indicators: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
After decision is final: force correct stop/take-profit geometry and mirror long levels for shorts.
|
||||
BUY: stop_loss < current < take_profit
|
||||
SELL: take_profit < current < stop_loss (short: stop above, TP below)
|
||||
"""
|
||||
if not current_price or current_price <= 0:
|
||||
return analysis
|
||||
indicators = indicators or {}
|
||||
decision = str(analysis.get("decision", "HOLD")).upper()
|
||||
if decision not in ("BUY", "SELL"):
|
||||
return analysis
|
||||
|
||||
min_price = current_price * 0.90
|
||||
max_price = current_price * 1.10
|
||||
eps = max(abs(current_price) * 1e-6, 1e-8)
|
||||
|
||||
tl = indicators.get("trading_levels") or {}
|
||||
sl_long = _safe_float_price(tl.get("suggested_stop_loss"))
|
||||
tp_long = _safe_float_price(tl.get("suggested_take_profit"))
|
||||
long_ok = (
|
||||
sl_long is not None
|
||||
and tp_long is not None
|
||||
and sl_long < current_price - eps
|
||||
and tp_long > current_price + eps
|
||||
)
|
||||
|
||||
if decision == "SELL":
|
||||
if long_ok:
|
||||
mirrored_sl = round(2 * current_price - sl_long, 6)
|
||||
mirrored_tp = round(2 * current_price - tp_long, 6)
|
||||
mirrored_sl = min(max(mirrored_sl, current_price + eps), max_price)
|
||||
mirrored_tp = max(min(mirrored_tp, current_price - eps), min_price)
|
||||
if mirrored_sl > current_price and mirrored_tp < current_price:
|
||||
analysis["stop_loss"] = mirrored_sl
|
||||
analysis["take_profit"] = mirrored_tp
|
||||
else:
|
||||
analysis["stop_loss"] = round(min(max_price, current_price * 1.05), 6)
|
||||
analysis["take_profit"] = round(max(min_price, current_price * 0.95), 6)
|
||||
else:
|
||||
sl_f = _safe_float_price(analysis.get("stop_loss"))
|
||||
tp_f = _safe_float_price(analysis.get("take_profit"))
|
||||
if sl_f is not None and tp_f is not None and tp_f < current_price < sl_f:
|
||||
analysis["stop_loss"] = round(min(max(sl_f, current_price + eps), max_price), 6)
|
||||
analysis["take_profit"] = round(max(min(tp_f, current_price - eps), min_price), 6)
|
||||
else:
|
||||
analysis["stop_loss"] = round(min(max_price, current_price * 1.05), 6)
|
||||
analysis["take_profit"] = round(max(min_price, current_price * 0.95), 6)
|
||||
else: # BUY
|
||||
if long_ok:
|
||||
sl = max(min(sl_long, current_price - eps), min_price)
|
||||
tp = min(max(tp_long, current_price + eps), max_price)
|
||||
analysis["stop_loss"] = round(sl, 6)
|
||||
analysis["take_profit"] = round(tp, 6)
|
||||
else:
|
||||
sl_f = _safe_float_price(analysis.get("stop_loss"))
|
||||
tp_f = _safe_float_price(analysis.get("take_profit"))
|
||||
if sl_f is not None and tp_f is not None and sl_f < current_price < tp_f:
|
||||
analysis["stop_loss"] = round(max(min(sl_f, current_price - eps), min_price), 6)
|
||||
analysis["take_profit"] = round(min(max(tp_f, current_price + eps), max_price), 6)
|
||||
else:
|
||||
analysis["stop_loss"] = round(max(min_price, current_price * 0.95), 6)
|
||||
analysis["take_profit"] = round(min(max_price, current_price * 1.05), 6)
|
||||
|
||||
# Last-resort: fix inverted or equal levels
|
||||
sl_f = _safe_float_price(analysis.get("stop_loss"), current_price)
|
||||
tp_f = _safe_float_price(analysis.get("take_profit"), current_price)
|
||||
if sl_f is None or tp_f is None:
|
||||
return analysis
|
||||
if decision == "SELL":
|
||||
if not (tp_f < current_price < sl_f):
|
||||
analysis["stop_loss"] = round(min(max_price, current_price * 1.05), 6)
|
||||
analysis["take_profit"] = round(max(min_price, current_price * 0.95), 6)
|
||||
else:
|
||||
if not (sl_f < current_price < tp_f):
|
||||
analysis["stop_loss"] = round(max(min_price, current_price * 0.95), 6)
|
||||
analysis["take_profit"] = round(min(max_price, current_price * 1.05), 6)
|
||||
|
||||
return analysis
|
||||
|
||||
def _validate_and_constrain(self, analysis: Dict, current_price: float, indicators: Dict = None,
|
||||
has_major_news: bool = False, has_macro_event: bool = False) -> Dict:
|
||||
"""
|
||||
@@ -1239,22 +1633,45 @@ IMPORTANT:
|
||||
# Price bounds
|
||||
min_price = current_price * 0.90
|
||||
max_price = current_price * 1.10
|
||||
decision = str(analysis.get("decision", "HOLD")).upper()
|
||||
|
||||
# Constrain entry price
|
||||
entry = analysis.get("entry_price", current_price)
|
||||
if entry and (entry < min_price or entry > max_price):
|
||||
entry = _safe_float_price(analysis.get("entry_price"), current_price)
|
||||
if entry is not None and (entry < min_price or entry > max_price):
|
||||
logger.warning(f"Entry price {entry} out of bounds, constraining to current price {current_price}")
|
||||
analysis["entry_price"] = round(current_price, 6)
|
||||
elif entry is not None:
|
||||
analysis["entry_price"] = round(entry, 6)
|
||||
|
||||
# Constrain stop loss
|
||||
stop_loss = analysis.get("stop_loss", current_price * 0.95)
|
||||
if stop_loss and (stop_loss < min_price or stop_loss > current_price):
|
||||
analysis["stop_loss"] = round(current_price * 0.95, 6)
|
||||
|
||||
# Constrain take profit
|
||||
take_profit = analysis.get("take_profit", current_price * 1.05)
|
||||
if take_profit and (take_profit < current_price or take_profit > max_price):
|
||||
analysis["take_profit"] = round(current_price * 1.05, 6)
|
||||
# Constrain stop loss / take profit by direction (numeric-safe).
|
||||
# BUY: stop_loss < current < take_profit
|
||||
# SELL: take_profit < current < stop_loss
|
||||
if decision == "SELL":
|
||||
stop_default = round(current_price * 1.05, 6)
|
||||
tp_default = round(current_price * 0.95, 6)
|
||||
stop_loss = _safe_float_price(analysis.get("stop_loss"), stop_default)
|
||||
take_profit = _safe_float_price(analysis.get("take_profit"), tp_default)
|
||||
if stop_loss is None or stop_loss <= current_price or stop_loss > max_price:
|
||||
analysis["stop_loss"] = stop_default
|
||||
else:
|
||||
analysis["stop_loss"] = round(stop_loss, 6)
|
||||
if take_profit is None or take_profit >= current_price or take_profit < min_price:
|
||||
analysis["take_profit"] = tp_default
|
||||
else:
|
||||
analysis["take_profit"] = round(take_profit, 6)
|
||||
else:
|
||||
stop_default = round(current_price * 0.95, 6)
|
||||
tp_default = round(current_price * 1.05, 6)
|
||||
stop_loss = _safe_float_price(analysis.get("stop_loss"), stop_default)
|
||||
take_profit = _safe_float_price(analysis.get("take_profit"), tp_default)
|
||||
if stop_loss is None or stop_loss < min_price or stop_loss >= current_price:
|
||||
analysis["stop_loss"] = stop_default
|
||||
else:
|
||||
analysis["stop_loss"] = round(stop_loss, 6)
|
||||
if take_profit is None or take_profit <= current_price or take_profit > max_price:
|
||||
analysis["take_profit"] = tp_default
|
||||
else:
|
||||
analysis["take_profit"] = round(take_profit, 6)
|
||||
|
||||
# Constrain confidence
|
||||
confidence = analysis.get("confidence", 50)
|
||||
@@ -1266,7 +1683,6 @@ IMPORTANT:
|
||||
analysis[score_key] = max(0, min(100, int(score)))
|
||||
|
||||
# Validate decision
|
||||
decision = str(analysis.get("decision", "HOLD")).upper()
|
||||
if decision not in ["BUY", "SELL", "HOLD"]:
|
||||
analysis["decision"] = "HOLD"
|
||||
else:
|
||||
@@ -1279,6 +1695,9 @@ IMPORTANT:
|
||||
has_major_news=has_major_news,
|
||||
has_macro_event=has_macro_event
|
||||
)
|
||||
|
||||
# Final geometry after any decision change (e.g. forced HOLD skips finalize in caller — still safe)
|
||||
analysis = self._finalize_trading_plan_for_decision(analysis, current_price, indicators)
|
||||
|
||||
return analysis
|
||||
|
||||
@@ -1729,71 +2148,64 @@ IMPORTANT:
|
||||
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
|
||||
"""
|
||||
计算新闻情绪评分 (-100 to +100)
|
||||
包含地缘政治事件的特殊处理
|
||||
地缘/冲突类:词边界 + 分级惩罚,单条封顶,避免 extension/toward 等误判叠加。
|
||||
"""
|
||||
if not news:
|
||||
return 0.0 # 无新闻,中性
|
||||
|
||||
|
||||
positive_count = 0
|
||||
negative_count = 0
|
||||
neutral_count = 0
|
||||
geopolitical_penalty = 0 # 地缘政治事件惩罚分数
|
||||
geopolitical_count = 0 # 地缘政治事件数量
|
||||
|
||||
# 地缘政治关键词
|
||||
geopolitical_keywords = [
|
||||
"war", "conflict", "military", "attack", "strike", "sanctions",
|
||||
"geopolitical", "crisis", "tension", "iran", "israel", "russia",
|
||||
"ukraine", "middle east", "nato", "united states",
|
||||
"战争", "冲突", "军事", "袭击", "制裁", "地缘政治", "危机"
|
||||
]
|
||||
|
||||
for item in news[:15]: # 检查前15条新闻
|
||||
title = (item.get("headline") or item.get("title") or "").lower()
|
||||
summary = (item.get("summary") or "").lower()
|
||||
geopolitical_penalty = 0
|
||||
max_geo_total = int(os.getenv("SENTIMENT_GEO_PENALTY_CAP", "-55"))
|
||||
|
||||
for item in news[:15]:
|
||||
title = item.get("headline") or item.get("title") or ""
|
||||
summary = item.get("summary") or ""
|
||||
text = f"{title} {summary}"
|
||||
sentiment = item.get("sentiment", "neutral")
|
||||
is_global_event = item.get("is_global_event", False)
|
||||
|
||||
# 检查是否是地缘政治事件
|
||||
is_geopolitical = is_global_event or any(keyword in text for keyword in geopolitical_keywords)
|
||||
|
||||
if is_geopolitical:
|
||||
geopolitical_count += 1
|
||||
# 地缘政治事件通常是利空的,给予严重惩罚
|
||||
if any(kw in text for kw in ["war", "conflict", "attack", "strike", "战争", "冲突", "袭击", "打击"]):
|
||||
geopolitical_penalty -= 50 # 战争/冲突事件严重利空
|
||||
elif any(kw in text for kw in ["sanctions", "crisis", "tension", "制裁", "危机", "紧张"]):
|
||||
geopolitical_penalty -= 30 # 制裁/危机事件利空
|
||||
else:
|
||||
geopolitical_penalty -= 20 # 其他地缘政治事件利空
|
||||
logger.info(f"Detected geopolitical event in sentiment scoring: {title[:60]}, penalty: {geopolitical_penalty}")
|
||||
|
||||
# 统计普通新闻情绪
|
||||
|
||||
level, tag = _geopolitical_match_level(text)
|
||||
if is_global_event and level == "none":
|
||||
level, tag = "moderate", "is_global_event"
|
||||
|
||||
if level != "none":
|
||||
delta = _geopolitical_sentiment_penalty_delta(level)
|
||||
new_total = geopolitical_penalty + delta
|
||||
if new_total < max_geo_total:
|
||||
delta = max_geo_total - geopolitical_penalty
|
||||
geopolitical_penalty += delta
|
||||
preview = (title or summary or "")[:72]
|
||||
logger.info(
|
||||
f"Geopolitical sentiment ({level}, {tag}): {preview!r}, "
|
||||
f"delta={delta}, cumulative={geopolitical_penalty}"
|
||||
)
|
||||
|
||||
if sentiment == "positive":
|
||||
positive_count += 1
|
||||
elif sentiment == "negative":
|
||||
negative_count += 1
|
||||
else:
|
||||
neutral_count += 1
|
||||
|
||||
|
||||
total = positive_count + negative_count + neutral_count
|
||||
|
||||
# 计算净情绪(普通新闻)
|
||||
|
||||
if total > 0:
|
||||
net_sentiment = (positive_count - negative_count) / total
|
||||
base_score = net_sentiment * 60 # 基础情绪分数(-60到+60)
|
||||
base_score = net_sentiment * 60
|
||||
else:
|
||||
base_score = 0
|
||||
|
||||
# 地缘政治事件惩罚(如果有地缘政治事件,直接应用惩罚)
|
||||
if geopolitical_count > 0:
|
||||
# 地缘政治事件的影响权重很高,直接叠加惩罚
|
||||
|
||||
if geopolitical_penalty != 0:
|
||||
final_score = base_score + geopolitical_penalty
|
||||
logger.info(f"Sentiment score: base={base_score:.1f}, geopolitical_penalty={geopolitical_penalty}, final={final_score:.1f}")
|
||||
logger.info(
|
||||
f"Sentiment score: base={base_score:.1f}, "
|
||||
f"geopolitical_penalty={geopolitical_penalty}, final={final_score:.1f}"
|
||||
)
|
||||
else:
|
||||
final_score = base_score
|
||||
|
||||
|
||||
return max(-100, min(100, final_score))
|
||||
|
||||
def _calculate_macro_score(self, macro: Dict, market: str) -> float:
|
||||
@@ -1908,6 +2320,14 @@ IMPORTANT:
|
||||
|
||||
return max(-100, min(100, score))
|
||||
|
||||
def _detect_market_regime(self, indicators: Dict) -> str:
|
||||
"""Detect trending vs ranging from MA trend. trending | ranging"""
|
||||
ma = indicators.get("moving_averages") or {}
|
||||
trend = str(ma.get("trend", "sideways")).lower()
|
||||
if "uptrend" in trend or "downtrend" in trend or "strong" in trend:
|
||||
return "trending"
|
||||
return "ranging"
|
||||
|
||||
def _score_to_decision(self, score: float, *, market: str = "Crypto") -> str:
|
||||
"""
|
||||
根据客观评分转换为决策
|
||||
@@ -2072,7 +2492,11 @@ IMPORTANT:
|
||||
decision = fast_result.get("decision", "HOLD")
|
||||
confidence = fast_result.get("confidence", 50)
|
||||
scores = fast_result.get("scores", {})
|
||||
|
||||
to_sum = (fast_result.get("trend_outlook_summary") or "").strip()
|
||||
overview_report = fast_result.get("summary", "") or ""
|
||||
if to_sum:
|
||||
overview_report = f"{overview_report}\n\n【周期预判】{to_sum}" if overview_report.strip() else f"【周期预判】{to_sum}"
|
||||
|
||||
return {
|
||||
"overview": {
|
||||
"overallScore": scores.get("overall", 50),
|
||||
@@ -2085,7 +2509,7 @@ IMPORTANT:
|
||||
"sentiment": scores.get("sentiment", 50),
|
||||
"risk": 100 - confidence, # Inverse of confidence
|
||||
},
|
||||
"report": fast_result.get("summary", ""),
|
||||
"report": overview_report,
|
||||
},
|
||||
"fundamental": {
|
||||
"score": scores.get("fundamental", 50),
|
||||
@@ -2135,6 +2559,8 @@ IMPORTANT:
|
||||
"recommendation": "\n".join(fast_result.get("reasons", [])),
|
||||
},
|
||||
"fast_analysis": fast_result, # Include new format for gradual migration
|
||||
"trend_outlook": fast_result.get("trend_outlook"),
|
||||
"trend_outlook_summary": fast_result.get("trend_outlook_summary"),
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -82,11 +82,10 @@ class LLMService:
|
||||
|
||||
if provider_name:
|
||||
try:
|
||||
# Explicit selection should always be respected.
|
||||
# API key validation happens later in call path.
|
||||
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...")
|
||||
return selected
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -184,31 +183,36 @@ class LLMService:
|
||||
|
||||
response = requests.post(url, headers=headers, json=data, timeout=timeout)
|
||||
|
||||
# Handle errors with detailed messages
|
||||
if response.status_code == 403:
|
||||
error_msg = "OpenRouter API 403 Forbidden"
|
||||
# Handle non-2xx with provider/model-aware details
|
||||
if response.status_code >= 400:
|
||||
provider_name = "OpenRouter" if "openrouter" in (base_url or "").lower() else "LLM"
|
||||
error_msg = f"{provider_name} API {response.status_code}"
|
||||
err_text = ""
|
||||
try:
|
||||
error_data = response.json()
|
||||
if "error" in error_data:
|
||||
error_detail = error_data["error"]
|
||||
if isinstance(error_detail, dict):
|
||||
error_msg = f"OpenRouter API 403: {error_detail.get('message', 'Forbidden')}"
|
||||
elif isinstance(error_detail, str):
|
||||
error_msg = f"OpenRouter API 403: {error_detail}"
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check if API key is configured
|
||||
from app.config.api_keys import APIKeys
|
||||
if not APIKeys.OPENROUTER_API_KEY:
|
||||
error_msg += ". OPENROUTER_API_KEY 未配置,请在 backend_api_python/.env 中设置"
|
||||
else:
|
||||
error_msg += ". 可能的原因:1) API 密钥无效或过期 2) 账户余额不足 3) 没有权限访问该模型。请检查 https://openrouter.ai/keys"
|
||||
|
||||
error_data = response.json() or {}
|
||||
error_detail = error_data.get("error")
|
||||
if isinstance(error_detail, dict):
|
||||
err_text = str(error_detail.get("message") or "").strip()
|
||||
elif isinstance(error_detail, str):
|
||||
err_text = error_detail.strip()
|
||||
except Exception:
|
||||
err_text = (response.text or "").strip()[:300]
|
||||
|
||||
if err_text:
|
||||
error_msg = f"{error_msg}: {err_text}"
|
||||
|
||||
# OpenRouter targeted hints
|
||||
if "openrouter" in (base_url or "").lower():
|
||||
from app.config.api_keys import APIKeys
|
||||
if not APIKeys.OPENROUTER_API_KEY:
|
||||
error_msg += ". OPENROUTER_API_KEY 未配置,请在 backend_api_python/.env 中设置"
|
||||
elif response.status_code == 403:
|
||||
error_msg += ". 可能原因:API 密钥无效/过期、余额不足、或无模型权限。请检查 https://openrouter.ai/keys"
|
||||
elif response.status_code == 404:
|
||||
error_msg += ". 可能原因:模型不可用或账户隐私/数据策略限制。请检查 https://openrouter.ai/settings/privacy"
|
||||
|
||||
raise ValueError(error_msg)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
@@ -369,9 +373,23 @@ class LLMService:
|
||||
logger.debug(f"Auto-detected provider '{provider.value}' from model '{model}'")
|
||||
|
||||
p = provider or self.provider
|
||||
cfg = load_addon_config()
|
||||
explicit_provider_name = str(cfg.get('llm', {}).get('provider') or os.getenv('LLM_PROVIDER', '')).strip().lower()
|
||||
explicit_provider = None
|
||||
if explicit_provider_name:
|
||||
try:
|
||||
explicit_provider = LLMProvider(explicit_provider_name)
|
||||
except ValueError:
|
||||
explicit_provider = None
|
||||
api_key = self.get_api_key(p)
|
||||
|
||||
if not api_key:
|
||||
# If provider is explicitly configured by user, don't silently switch.
|
||||
if explicit_provider is not None and p == explicit_provider:
|
||||
raise ValueError(
|
||||
f"API key not configured for explicit provider: {p.value}. "
|
||||
f"Please set {p.value.upper()}_API_KEY in settings."
|
||||
)
|
||||
# If no API key for current provider, try to find any available provider
|
||||
if try_alternative_providers:
|
||||
for alt_provider in [LLMProvider.DEEPSEEK, LLMProvider.GROK, LLMProvider.OPENAI, LLMProvider.GOOGLE, LLMProvider.OPENROUTER]:
|
||||
|
||||
@@ -281,14 +281,12 @@ class MarketDataCollector:
|
||||
"""
|
||||
计算技术指标 (本地计算,无外部依赖)
|
||||
|
||||
返回格式符合前端 FastAnalysisReport.vue 的期望:
|
||||
{
|
||||
rsi: { value, signal },
|
||||
macd: { signal, trend },
|
||||
moving_averages: { ma5, ma10, ma20, trend },
|
||||
levels: { support, resistance },
|
||||
volatility: { level, pct }
|
||||
}
|
||||
返回格式符合前端 FastAnalysisReport.vue 的期望。
|
||||
口径说明(与常见行情终端对齐):
|
||||
- RSI(14):Wilder 平滑(首段均幅为前 14 期简单平均,其后递推)。
|
||||
- MACD:收盘 EMA12/EMA26(首值=前 N 日 SMA),信号线=MACD 的 EMA9(SMA 种子)。
|
||||
- MA:SMA。枢轴:上一根 K 的 H/L/C。摆动高低:近 20 根 H/L 窗口极值。
|
||||
- 布林:20 收盘 SMA ± 2×总体标准差。ATR(14):Wilder(首 ATR=前 14 期 TR 简单平均,其后递推)。
|
||||
"""
|
||||
if not klines or len(klines) < 5:
|
||||
return {}
|
||||
@@ -319,8 +317,8 @@ class MarketDataCollector:
|
||||
'signal': rsi_signal,
|
||||
}
|
||||
|
||||
# ========== MACD ==========
|
||||
if len(closes) >= 26:
|
||||
# ========== MACD(SMA 种子 EMA,与常见终端一致)==========
|
||||
if len(closes) >= 34:
|
||||
macd_raw = self._calc_macd(closes)
|
||||
macd_val = macd_raw.get('MACD', 0)
|
||||
macd_sig = macd_raw.get('MACD_signal', 0)
|
||||
@@ -366,6 +364,11 @@ class MarketDataCollector:
|
||||
'ma20': round(ma20, 6),
|
||||
'trend': ma_trend,
|
||||
}
|
||||
|
||||
# 先算布林带,供下方合成支撑/阻力使用(键名 BB_upper / BB_lower)
|
||||
bb_for_levels: Dict[str, Any] = {}
|
||||
if len(closes) >= 20:
|
||||
bb_for_levels = self._calc_bollinger(closes, 20, 2) or {}
|
||||
|
||||
# ========== 支撑/阻力位 (多种方法综合) ==========
|
||||
# 方法1: 枢轴点 (Pivot Points) - 使用前一日数据
|
||||
@@ -390,13 +393,13 @@ class MarketDataCollector:
|
||||
swing_high = max(recent_highs) if recent_highs else current_price * 1.05
|
||||
swing_low = min(recent_lows) if recent_lows else current_price * 0.95
|
||||
|
||||
# 方法3: 布林带中轨上下 (如果有)
|
||||
bb_upper = indicators.get('bollinger', {}).get('upper', swing_high)
|
||||
bb_lower = indicators.get('bollinger', {}).get('lower', swing_low)
|
||||
# 方法3: 布林上下轨(与 _calc_bollinger 返回字段一致)
|
||||
bb_upper = bb_for_levels.get('BB_upper', swing_high)
|
||||
bb_lower = bb_for_levels.get('BB_lower', swing_low)
|
||||
|
||||
# 综合取值: 取多种方法的平均/加权
|
||||
resistance = round((r1 + swing_high + bb_upper) / 3, 6) if bb_upper else round((r1 + swing_high) / 2, 6)
|
||||
support = round((s1 + swing_low + bb_lower) / 3, 6) if bb_lower else round((s1 + swing_low) / 2, 6)
|
||||
resistance = round((r1 + swing_high + bb_upper) / 3, 6)
|
||||
support = round((s1 + swing_low + bb_lower) / 3, 6)
|
||||
|
||||
indicators['levels'] = {
|
||||
'support': support,
|
||||
@@ -411,20 +414,10 @@ class MarketDataCollector:
|
||||
'method': 'pivot_swing_bb_avg' # 标注计算方法
|
||||
}
|
||||
|
||||
# ========== ATR 和波动率 ==========
|
||||
atr = 0
|
||||
# ========== ATR 和波动率(Wilder ATR,全序列递推至最新一根)==========
|
||||
atr = 0.0
|
||||
if len(klines) >= 14:
|
||||
# 真实波动幅度 ATR (True Range)
|
||||
true_ranges = []
|
||||
for i in range(-14, 0):
|
||||
h = float(klines[i].get('high', 0))
|
||||
l = float(klines[i].get('low', 0))
|
||||
prev_c = float(klines[i-1].get('close', 0)) if i > -14 else h
|
||||
if h > 0 and l > 0:
|
||||
tr = max(h - l, abs(h - prev_c), abs(l - prev_c))
|
||||
true_ranges.append(tr)
|
||||
|
||||
atr = sum(true_ranges) / len(true_ranges) if true_ranges else 0
|
||||
atr = float(self._calc_atr_wilder(klines, period=14))
|
||||
volatility_pct = (atr / current_price * 100) if current_price > 0 else 0
|
||||
|
||||
if volatility_pct > 5:
|
||||
@@ -468,10 +461,9 @@ class MarketDataCollector:
|
||||
'method': 'atr_support_resistance'
|
||||
}
|
||||
|
||||
# ========== 布林带 (附加) ==========
|
||||
if len(closes) >= 20:
|
||||
bb_data = self._calc_bollinger(closes, 20, 2)
|
||||
indicators['bollinger'] = bb_data
|
||||
# ========== 布林带 (附加,与 bb_for_levels 同一次计算) ==========
|
||||
if bb_for_levels:
|
||||
indicators['bollinger'] = bb_for_levels
|
||||
|
||||
# ========== 成交量 (附加) ==========
|
||||
if len(volumes) >= 20:
|
||||
@@ -498,48 +490,109 @@ class MarketDataCollector:
|
||||
return {}
|
||||
|
||||
def _calc_rsi(self, closes: List[float], period: int = 14) -> float:
|
||||
"""计算RSI"""
|
||||
"""Wilder RSI:首段均幅为前 period 期涨跌简单平均,之后按 Wilder 平滑递推。"""
|
||||
if len(closes) < period + 1:
|
||||
return 50.0
|
||||
|
||||
deltas = [closes[i] - closes[i-1] for i in range(1, len(closes))]
|
||||
gains = [d if d > 0 else 0 for d in deltas]
|
||||
losses = [-d if d < 0 else 0 for d in deltas]
|
||||
|
||||
avg_gain = sum(gains[-period:]) / period
|
||||
avg_loss = sum(losses[-period:]) / period
|
||||
|
||||
|
||||
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
|
||||
gains = [d if d > 0 else 0.0 for d in deltas]
|
||||
losses = [-d if d < 0 else 0.0 for d in deltas]
|
||||
|
||||
if len(gains) < period:
|
||||
return 50.0
|
||||
|
||||
avg_gain = sum(gains[:period]) / period
|
||||
avg_loss = sum(losses[:period]) / period
|
||||
|
||||
for i in range(period, len(gains)):
|
||||
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
|
||||
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
|
||||
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
|
||||
|
||||
rs = avg_gain / avg_loss
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
return round(rsi, 2)
|
||||
|
||||
return round(100.0 - (100.0 / (1.0 + rs)), 2)
|
||||
|
||||
def _ema_series_sma_seed(self, data: List[float], period: int) -> List[Optional[float]]:
|
||||
"""
|
||||
标准 EMA:首值 = 前 period 根简单平均(SMA),之后 EMA_t = (P_t - EMA_{t-1}) * k + EMA_{t-1},k=2/(period+1)。
|
||||
前 period-1 根无定义,返回 None。
|
||||
"""
|
||||
n = len(data)
|
||||
out: List[Optional[float]] = [None] * n
|
||||
if n < period:
|
||||
return out
|
||||
k = 2.0 / (period + 1)
|
||||
out[period - 1] = sum(data[:period]) / period
|
||||
for i in range(period, n):
|
||||
prev = out[i - 1]
|
||||
if prev is None:
|
||||
break
|
||||
out[i] = (data[i] - prev) * k + prev
|
||||
return out
|
||||
|
||||
def _calc_macd(self, closes: List[float]) -> Dict[str, float]:
|
||||
"""计算MACD"""
|
||||
def ema(data, period):
|
||||
multiplier = 2 / (period + 1)
|
||||
ema_values = [data[0]]
|
||||
for i in range(1, len(data)):
|
||||
ema_values.append((data[i] - ema_values[-1]) * multiplier + ema_values[-1])
|
||||
return ema_values
|
||||
|
||||
ema12 = ema(closes, 12)
|
||||
ema26 = ema(closes, 26)
|
||||
|
||||
macd_line = [ema12[i] - ema26[i] for i in range(len(closes))]
|
||||
signal_line = ema(macd_line, 9)
|
||||
histogram = [macd_line[i] - signal_line[i] for i in range(len(closes))]
|
||||
|
||||
"""
|
||||
MACD(12,26,9):DIF = EMA12(close) − EMA26(close),DEA = EMA9(DIF),柱 = DIF − DEA。
|
||||
各 EMA 均采用 SMA 种子;DIF 自第 26 根 K 起有定义,信号线对 DIF 子序列再算 EMA9。
|
||||
"""
|
||||
n = len(closes)
|
||||
ema12 = self._ema_series_sma_seed(closes, 12)
|
||||
ema26 = self._ema_series_sma_seed(closes, 26)
|
||||
if n < 26 or ema12[-1] is None or ema26[-1] is None:
|
||||
return {'MACD': 0.0, 'MACD_signal': 0.0, 'MACD_histogram': 0.0}
|
||||
|
||||
macd_sub: List[float] = []
|
||||
for i in range(25, n):
|
||||
v12 = ema12[i]
|
||||
v26 = ema26[i]
|
||||
if v12 is not None and v26 is not None:
|
||||
macd_sub.append(v12 - v26)
|
||||
|
||||
if not macd_sub:
|
||||
return {'MACD': 0.0, 'MACD_signal': 0.0, 'MACD_histogram': 0.0}
|
||||
|
||||
sig_series = self._ema_series_sma_seed(macd_sub, 9)
|
||||
last_macd = macd_sub[-1]
|
||||
last_sig = sig_series[-1]
|
||||
if last_sig is None:
|
||||
last_sig = last_macd
|
||||
|
||||
return {
|
||||
'MACD': round(macd_line[-1], 4),
|
||||
'MACD_signal': round(signal_line[-1], 4),
|
||||
'MACD_histogram': round(histogram[-1], 4)
|
||||
'MACD': round(last_macd, 6),
|
||||
'MACD_signal': round(last_sig, 6),
|
||||
'MACD_histogram': round(last_macd - last_sig, 6),
|
||||
}
|
||||
|
||||
def _true_ranges(self, klines: List[Dict[str, Any]]) -> List[float]:
|
||||
"""每根 K 的 True Range(首根仅 H−L)。"""
|
||||
trs: List[float] = []
|
||||
for i, k in enumerate(klines):
|
||||
h = float(k.get('high', 0))
|
||||
l = float(k.get('low', 0))
|
||||
if h <= 0 or l <= 0:
|
||||
trs.append(0.0)
|
||||
continue
|
||||
if i == 0:
|
||||
trs.append(h - l)
|
||||
else:
|
||||
pc = float(klines[i - 1].get('close', 0))
|
||||
trs.append(max(h - l, abs(h - pc), abs(l - pc)))
|
||||
return trs
|
||||
|
||||
def _calc_atr_wilder(self, klines: List[Dict[str, Any]], period: int = 14) -> float:
|
||||
"""Wilder ATR:首 ATR = 前 period 期 TR 简单平均,之后 ATR_t = (ATR_{t-1}*(period-1)+TR_t)/period。"""
|
||||
trs = self._true_ranges(klines)
|
||||
if len(trs) < period:
|
||||
return 0.0
|
||||
atr = sum(trs[:period]) / period
|
||||
for i in range(period, len(trs)):
|
||||
atr = (atr * (period - 1) + trs[i]) / period
|
||||
return atr
|
||||
|
||||
def _calc_bollinger(self, closes: List[float], period: int = 20, std_dev: int = 2) -> Dict[str, float]:
|
||||
"""计算布林带"""
|
||||
"""布林带:中轨为 period 收盘 SMA,σ 为总体标准差(方差/period),上下轨=中轨±std_dev×σ。"""
|
||||
if len(closes) < period:
|
||||
return {}
|
||||
|
||||
@@ -723,58 +776,86 @@ class MarketDataCollector:
|
||||
def _get_earnings_data(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取盈利报告数据(Earnings)
|
||||
|
||||
包括:历史盈利、盈利预测、盈利日期等
|
||||
|
||||
使用 quarterly_income_stmt 替代已弃用的 Ticker.earnings / quarterly_earnings,
|
||||
历史季度摘要从利润表推导;盈利日历仍用 ticker.calendar(若可用)。
|
||||
"""
|
||||
def _pick_float(stmt: pd.DataFrame, row_names: tuple, col) -> Optional[float]:
|
||||
for name in row_names:
|
||||
if name in stmt.index:
|
||||
raw = stmt.loc[name, col]
|
||||
if raw is None or (isinstance(raw, float) and pd.isna(raw)):
|
||||
continue
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
earnings_data = {}
|
||||
|
||||
# 历史盈利数据
|
||||
earnings_data: Dict[str, Any] = {}
|
||||
|
||||
# 季度利润表(yfinance 推荐路径,避免 fundamentals.Ticker.earnings 弃用告警)
|
||||
try:
|
||||
earnings_history = ticker.earnings_history
|
||||
if earnings_history is not None and not earnings_history.empty:
|
||||
# 获取最近4个季度
|
||||
recent_earnings = earnings_history.head(4)
|
||||
earnings_data['history'] = []
|
||||
for _, row in recent_earnings.iterrows():
|
||||
earnings_data['history'].append({
|
||||
'date': str(row.get('Date', '')),
|
||||
'eps_actual': float(row.get('EPS Actual', 0)) if row.get('EPS Actual') is not None else None,
|
||||
'eps_estimate': float(row.get('EPS Estimate', 0)) if row.get('EPS Estimate') is not None else None,
|
||||
'surprise': float(row.get('Surprise(%)', 0)) if row.get('Surprise(%)') is not None else None,
|
||||
q_inc = ticker.quarterly_income_stmt
|
||||
if q_inc is not None and not q_inc.empty and len(q_inc.columns) > 0:
|
||||
cols = list(q_inc.columns)[:4]
|
||||
latest_q = cols[0]
|
||||
|
||||
rev = _pick_float(
|
||||
q_inc,
|
||||
("Total Revenue", "Revenue", "Total Revenues", "Net Sales"),
|
||||
latest_q,
|
||||
)
|
||||
ni = _pick_float(
|
||||
q_inc,
|
||||
(
|
||||
"Net Income",
|
||||
"Net Income Common Stockholders",
|
||||
"Net Income Continuous Operations",
|
||||
"Net Income Including Noncontrolling Interests",
|
||||
),
|
||||
latest_q,
|
||||
)
|
||||
earnings_data["quarterly"] = {
|
||||
"latest_quarter": str(latest_q),
|
||||
"revenue": rev,
|
||||
"earnings": ni,
|
||||
}
|
||||
|
||||
# 最近若干季度 EPS(来自利润表行,非一致预期)
|
||||
earnings_data["history"] = []
|
||||
for col in cols:
|
||||
eps = _pick_float(q_inc, ("Diluted EPS", "Basic EPS"), col)
|
||||
earnings_data["history"].append({
|
||||
"date": str(col),
|
||||
"eps_actual": eps,
|
||||
"eps_estimate": None,
|
||||
"surprise": None,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Earnings history fetch failed for {symbol}: {e}")
|
||||
|
||||
# 盈利日历(未来盈利日期)
|
||||
logger.debug(f"Quarterly income statement (earnings) fetch failed for {symbol}: {e}")
|
||||
|
||||
# 盈利日历(未来盈利日期与一致预期)
|
||||
try:
|
||||
earnings_calendar = ticker.calendar
|
||||
if earnings_calendar is not None and not earnings_calendar.empty:
|
||||
earnings_data['upcoming'] = {
|
||||
'next_earnings_date': str(earnings_calendar.index[0]) if len(earnings_calendar.index) > 0 else None,
|
||||
'eps_estimate': float(earnings_calendar.loc[earnings_calendar.index[0], 'Earnings Estimate']) if len(earnings_calendar.index) > 0 and 'Earnings Estimate' in earnings_calendar.columns else None,
|
||||
'revenue_estimate': float(earnings_calendar.loc[earnings_calendar.index[0], 'Revenue Estimate']) if len(earnings_calendar.index) > 0 and 'Revenue Estimate' in earnings_calendar.columns else None,
|
||||
idx0 = earnings_calendar.index[0]
|
||||
earnings_data["upcoming"] = {
|
||||
"next_earnings_date": str(idx0),
|
||||
"eps_estimate": float(earnings_calendar.loc[idx0, "Earnings Estimate"])
|
||||
if "Earnings Estimate" in earnings_calendar.columns
|
||||
else None,
|
||||
"revenue_estimate": float(earnings_calendar.loc[idx0, "Revenue Estimate"])
|
||||
if "Revenue Estimate" in earnings_calendar.columns
|
||||
else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Earnings calendar fetch failed for {symbol}: {e}")
|
||||
|
||||
# 季度盈利数据
|
||||
try:
|
||||
quarterly_earnings = ticker.quarterly_earnings
|
||||
if quarterly_earnings is not None and not quarterly_earnings.empty:
|
||||
latest_q = quarterly_earnings.index[0] if len(quarterly_earnings.index) > 0 else None
|
||||
if latest_q:
|
||||
earnings_data['quarterly'] = {
|
||||
'latest_quarter': str(latest_q),
|
||||
'revenue': float(quarterly_earnings.loc[latest_q, 'Revenue']) if 'Revenue' in quarterly_earnings.columns else None,
|
||||
'earnings': float(quarterly_earnings.loc[latest_q, 'Earnings']) if 'Earnings' in quarterly_earnings.columns else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Quarterly earnings fetch failed for {symbol}: {e}")
|
||||
|
||||
|
||||
return earnings_data if earnings_data else None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Earnings data fetch failed for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
@@ -16,6 +17,7 @@ from app.utils.logger import get_logger
|
||||
from app.services.fast_analysis import get_fast_analysis_service
|
||||
from app.services.signal_notifier import SignalNotifier
|
||||
from app.services.kline import KlineService
|
||||
from app.services.billing_service import get_billing_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -154,98 +156,92 @@ def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = No
|
||||
return []
|
||||
|
||||
|
||||
MAX_PARALLEL_ANALYSIS = 5
|
||||
|
||||
|
||||
def _analyze_single_position(pos: Dict[str, Any], language: str) -> Dict[str, Any]:
|
||||
"""Analyze a single position (designed to run inside a thread pool)."""
|
||||
market = pos.get('market')
|
||||
symbol = pos.get('symbol')
|
||||
name = pos.get('name') or symbol
|
||||
group_name = pos.get('group_name')
|
||||
|
||||
if not market or not symbol:
|
||||
return {'market': market, 'symbol': symbol, 'name': name, 'error': 'missing market/symbol'}
|
||||
|
||||
try:
|
||||
logger.info(f"Running fast AI analysis for {market}:{symbol}")
|
||||
service = get_fast_analysis_service()
|
||||
analysis_result = service.analyze(
|
||||
market=market, symbol=symbol, language=language, timeframe='1D'
|
||||
)
|
||||
|
||||
detailed = analysis_result.get('detailed_analysis', {})
|
||||
trading_plan = analysis_result.get('trading_plan', {})
|
||||
scores = analysis_result.get('scores', {})
|
||||
risks = analysis_result.get('risks', [])
|
||||
risk_report = '\n'.join([f"• {r}" for r in risks]) if risks else ''
|
||||
|
||||
result = {
|
||||
'market': market, 'symbol': symbol, 'name': name, 'group_name': group_name,
|
||||
'entry_price': pos.get('entry_price'),
|
||||
'current_price': pos.get('current_price') or analysis_result.get('market_data', {}).get('current_price'),
|
||||
'pnl': pos.get('pnl'), 'pnl_percent': pos.get('pnl_percent'),
|
||||
'quantity': pos.get('quantity'), 'side': pos.get('side'),
|
||||
'final_decision': analysis_result.get('decision', 'HOLD'),
|
||||
'confidence': analysis_result.get('confidence', 50),
|
||||
'reasoning': analysis_result.get('summary', ''),
|
||||
'trader_decision': analysis_result.get('decision', 'HOLD'),
|
||||
'trader_reasoning': analysis_result.get('summary', ''),
|
||||
'overview_report': detailed.get('technical', ''),
|
||||
'fundamental_report': detailed.get('fundamental', ''),
|
||||
'sentiment_report': detailed.get('sentiment', ''),
|
||||
'risk_report': risk_report,
|
||||
'suggested_entry': trading_plan.get('entry_price'),
|
||||
'suggested_stop_loss': trading_plan.get('stop_loss'),
|
||||
'suggested_take_profit': trading_plan.get('take_profit'),
|
||||
'technical_score': scores.get('technical', 50),
|
||||
'fundamental_score': scores.get('fundamental', 50),
|
||||
'sentiment_score': scores.get('sentiment', 50),
|
||||
'key_reasons': analysis_result.get('reasons', []),
|
||||
'error': analysis_result.get('error')
|
||||
}
|
||||
logger.info(f"Fast analysis completed for {market}:{symbol}: {analysis_result.get('decision', 'N/A')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze {market}:{symbol}: {e}")
|
||||
return {'market': market, 'symbol': symbol, 'name': name, 'error': str(e)}
|
||||
|
||||
|
||||
def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Run fast AI analysis on positions.
|
||||
Uses the new FastAnalysisService (single LLM call, faster and more stable).
|
||||
Run fast AI analysis on positions **in parallel** using a thread pool.
|
||||
"""
|
||||
try:
|
||||
language = config.get('language', 'en-US')
|
||||
custom_prompt = config.get('prompt', '')
|
||||
|
||||
# Get the fast analysis service
|
||||
service = get_fast_analysis_service()
|
||||
|
||||
# Analyze each position
|
||||
position_analyses = []
|
||||
|
||||
for pos in positions:
|
||||
market = pos.get('market')
|
||||
symbol = pos.get('symbol')
|
||||
name = pos.get('name') or symbol
|
||||
group_name = pos.get('group_name')
|
||||
|
||||
if not market or not symbol:
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.info(f"Running fast AI analysis for {market}:{symbol}")
|
||||
|
||||
# Use the new FastAnalysisService (single LLM call)
|
||||
analysis_result = service.analyze(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
language=language,
|
||||
timeframe='1D'
|
||||
)
|
||||
|
||||
# Extract information from the new format
|
||||
detailed = analysis_result.get('detailed_analysis', {})
|
||||
trading_plan = analysis_result.get('trading_plan', {})
|
||||
scores = analysis_result.get('scores', {})
|
||||
|
||||
# Build risk report from risks list
|
||||
risks = analysis_result.get('risks', [])
|
||||
risk_report = '\n'.join([f"• {r}" for r in risks]) if risks else ''
|
||||
|
||||
position_analysis = {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'name': name,
|
||||
'group_name': group_name,
|
||||
'entry_price': pos.get('entry_price'),
|
||||
'current_price': pos.get('current_price') or analysis_result.get('market_data', {}).get('current_price'),
|
||||
'pnl': pos.get('pnl'),
|
||||
'pnl_percent': pos.get('pnl_percent'),
|
||||
'quantity': pos.get('quantity'),
|
||||
'side': pos.get('side'),
|
||||
# New fast analysis results
|
||||
'final_decision': analysis_result.get('decision', 'HOLD'),
|
||||
'confidence': analysis_result.get('confidence', 50),
|
||||
'reasoning': analysis_result.get('summary', ''),
|
||||
'trader_decision': analysis_result.get('decision', 'HOLD'), # Same as final for fast analysis
|
||||
'trader_reasoning': analysis_result.get('summary', ''),
|
||||
'overview_report': detailed.get('technical', ''),
|
||||
'fundamental_report': detailed.get('fundamental', ''),
|
||||
'sentiment_report': detailed.get('sentiment', ''),
|
||||
'risk_report': risk_report,
|
||||
# Trading plan
|
||||
'suggested_entry': trading_plan.get('entry_price'),
|
||||
'suggested_stop_loss': trading_plan.get('stop_loss'),
|
||||
'suggested_take_profit': trading_plan.get('take_profit'),
|
||||
# Scores
|
||||
'technical_score': scores.get('technical', 50),
|
||||
'fundamental_score': scores.get('fundamental', 50),
|
||||
'sentiment_score': scores.get('sentiment', 50),
|
||||
'key_reasons': analysis_result.get('reasons', []),
|
||||
'error': analysis_result.get('error')
|
||||
}
|
||||
|
||||
position_analyses.append(position_analysis)
|
||||
logger.info(f"Fast analysis completed for {market}:{symbol}: {analysis_result.get('decision', 'N/A')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze {market}:{symbol}: {e}")
|
||||
position_analyses.append({
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'name': name,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
# Build comprehensive report
|
||||
|
||||
workers = min(len(positions), MAX_PARALLEL_ANALYSIS)
|
||||
position_analyses: List[Dict[str, Any]] = [None] * len(positions)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
future_to_idx = {
|
||||
executor.submit(_analyze_single_position, pos, language): idx
|
||||
for idx, pos in enumerate(positions)
|
||||
}
|
||||
for future in as_completed(future_to_idx):
|
||||
idx = future_to_idx[future]
|
||||
try:
|
||||
position_analyses[idx] = future.result()
|
||||
except Exception as e:
|
||||
pos = positions[idx]
|
||||
position_analyses[idx] = {
|
||||
'market': pos.get('market'), 'symbol': pos.get('symbol'),
|
||||
'name': pos.get('name') or pos.get('symbol'), 'error': str(e)
|
||||
}
|
||||
|
||||
analysis_report = _build_comprehensive_report(positions, position_analyses, language, custom_prompt)
|
||||
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'analysis': analysis_report,
|
||||
@@ -254,15 +250,11 @@ def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) ->
|
||||
'analyzed_count': len([p for p in position_analyses if not p.get('error')]),
|
||||
'timestamp': _now_ts()
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"_run_ai_analysis failed: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'timestamp': _now_ts()
|
||||
}
|
||||
return {'success': False, 'error': str(e), 'timestamp': _now_ts()}
|
||||
|
||||
|
||||
def _build_comprehensive_report(
|
||||
@@ -914,22 +906,67 @@ def run_single_monitor(monitor_id: int, override_language: str = None, user_id:
|
||||
if override_language:
|
||||
config['language'] = override_language
|
||||
|
||||
# Get positions for this user
|
||||
# Resolve interval (frontend sends run_interval_minutes, legacy uses interval_minutes)
|
||||
interval_minutes = int(
|
||||
config.get('run_interval_minutes')
|
||||
or config.get('interval_minutes')
|
||||
or 60
|
||||
)
|
||||
|
||||
# Get positions (or build from config.symbol if no position_ids)
|
||||
positions = _get_positions_for_monitor(position_ids if position_ids else None, user_id=monitor_user_id)
|
||||
|
||||
|
||||
# If monitor was created without positions but has symbol in config, build a virtual position
|
||||
if not positions and config.get('symbol'):
|
||||
positions = [{
|
||||
'market': config.get('market', ''),
|
||||
'symbol': config.get('symbol', ''),
|
||||
'name': config.get('symbol', ''),
|
||||
'side': 'long',
|
||||
'quantity': 0,
|
||||
'entry_price': 0,
|
||||
'current_price': 0,
|
||||
'pnl': 0,
|
||||
'pnl_percent': 0,
|
||||
}]
|
||||
|
||||
if not positions:
|
||||
return {'success': False, 'error': 'No positions to analyze'}
|
||||
|
||||
# ── Billing: charge per symbol analyzed ──
|
||||
billing = get_billing_service()
|
||||
symbol_count = len(positions)
|
||||
per_symbol_cost = billing.get_feature_cost('ai_analysis')
|
||||
total_cost = per_symbol_cost * symbol_count
|
||||
|
||||
if total_cost > 0 and billing.is_billing_enabled():
|
||||
user_credits = billing.get_user_credits(monitor_user_id)
|
||||
if user_credits < total_cost:
|
||||
logger.warning(
|
||||
f"Monitor #{monitor_id} skipped: insufficient credits "
|
||||
f"({user_credits} < {total_cost} for {symbol_count} symbols)"
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Insufficient credits: need {total_cost}, have {user_credits}'
|
||||
}
|
||||
for i in range(symbol_count):
|
||||
pos = positions[i]
|
||||
ok, msg = billing.check_and_consume(
|
||||
user_id=monitor_user_id,
|
||||
feature='ai_analysis',
|
||||
reference_id=f"monitor_{monitor_id}_{pos.get('symbol', '')}"
|
||||
)
|
||||
if not ok:
|
||||
logger.warning(f"Monitor #{monitor_id} billing failed at symbol #{i+1}: {msg}")
|
||||
break
|
||||
|
||||
# Run analysis based on type
|
||||
if monitor_type == 'ai':
|
||||
result = _run_ai_analysis(positions, config)
|
||||
else:
|
||||
# For other types, we can add price_alert, pnl_alert logic later
|
||||
result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'}
|
||||
|
||||
# Update monitor record
|
||||
interval_minutes = int(config.get('interval_minutes') or 60)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Reflection Service - Post-trade validation and learning.
|
||||
|
||||
Validates historical AI decisions against actual price outcomes,
|
||||
updates qd_analysis_memory with was_correct/actual_return_pct,
|
||||
and optionally triggers AI calibration.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.services.analysis_memory import get_analysis_memory
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_reflection_thread: Optional[threading.Thread] = None
|
||||
_reflection_stop = threading.Event()
|
||||
|
||||
|
||||
class ReflectionService:
|
||||
"""
|
||||
Runs verification cycle: validate unvalidated decisions, optionally run calibration.
|
||||
"""
|
||||
|
||||
def run_verification_cycle(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Run one verification cycle:
|
||||
1. Validate unvalidated analysis records (older than min_age_days)
|
||||
2. Optionally run AI calibration for configured markets
|
||||
"""
|
||||
memory = get_analysis_memory()
|
||||
min_age_days = int(os.getenv("REFLECTION_MIN_AGE_DAYS", "7"))
|
||||
limit = int(os.getenv("REFLECTION_VALIDATE_LIMIT", "200"))
|
||||
|
||||
stats = memory.validate_unvalidated_older_than(
|
||||
min_age_days=min_age_days,
|
||||
limit=limit,
|
||||
)
|
||||
logger.info(f"Reflection validation: {stats}")
|
||||
|
||||
if stats.get("validated", 0) > 0:
|
||||
self._maybe_run_calibration()
|
||||
else:
|
||||
logger.debug("No new validations, skipping calibration")
|
||||
|
||||
return stats
|
||||
|
||||
def _maybe_run_calibration(self) -> None:
|
||||
"""Run AI calibration if enabled."""
|
||||
if os.getenv("ENABLE_OFFLINE_AI_CALIBRATION", "true").lower() != "true":
|
||||
return
|
||||
try:
|
||||
from app.services.ai_calibration import AICalibrationService
|
||||
svc = AICalibrationService()
|
||||
markets = (os.getenv("AI_CALIBRATION_MARKETS", "Crypto") or "Crypto").strip().split(",")
|
||||
for market in markets:
|
||||
market = market.strip()
|
||||
if not market:
|
||||
continue
|
||||
result = svc.calibrate_market(
|
||||
market=market,
|
||||
lookback_days=int(os.getenv("AI_CALIBRATION_LOOKBACK_DAYS", "30")),
|
||||
min_samples=int(os.getenv("AI_CALIBRATION_MIN_SAMPLES", "80")),
|
||||
validate_before=False,
|
||||
)
|
||||
if result:
|
||||
logger.info(
|
||||
f"[Reflection] Calibration updated for {market}: "
|
||||
f"accuracy={result.best_accuracy:.1f}% thr=±{result.buy_threshold:.1f}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Reflection calibration failed: {e}", exc_info=True)
|
||||
|
||||
|
||||
def start_reflection_worker() -> None:
|
||||
"""Start background reflection worker (validates + calibrates periodically)."""
|
||||
global _reflection_thread
|
||||
# Default to ON to reduce environment-specific configuration needs.
|
||||
if os.getenv("ENABLE_REFLECTION_WORKER", "true").lower() != "true":
|
||||
logger.info("Reflection worker disabled (ENABLE_REFLECTION_WORKER != true).")
|
||||
return
|
||||
interval_sec = int(os.getenv("REFLECTION_WORKER_INTERVAL_SEC", "86400"))
|
||||
if _reflection_thread and _reflection_thread.is_alive():
|
||||
return
|
||||
|
||||
def _run():
|
||||
_reflection_stop.clear()
|
||||
logger.info(f"Reflection worker started, interval={interval_sec}s")
|
||||
while not _reflection_stop.is_set():
|
||||
try:
|
||||
ReflectionService().run_verification_cycle()
|
||||
except Exception as e:
|
||||
logger.error(f"Reflection cycle failed: {e}", exc_info=True)
|
||||
_reflection_stop.wait(timeout=interval_sec)
|
||||
logger.info("Reflection worker stopped.")
|
||||
|
||||
_reflection_thread = threading.Thread(target=_run, daemon=True)
|
||||
_reflection_thread.start()
|
||||
@@ -2263,8 +2263,33 @@ class TradingExecutor:
|
||||
language = amc.get("language") or amc.get("lang") or tc.get("language") or "zh-CN"
|
||||
language = str(language or "zh-CN")
|
||||
|
||||
# ── Billing: AI filter uses the same cost as ai_analysis ──
|
||||
try:
|
||||
from app.services.billing_service import get_billing_service
|
||||
billing = get_billing_service()
|
||||
if billing.is_billing_enabled():
|
||||
user_id = 1
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
user_id = int((row or {}).get('user_id') or 1)
|
||||
except Exception:
|
||||
pass
|
||||
ok, msg = billing.check_and_consume(
|
||||
user_id=user_id,
|
||||
feature='ai_analysis',
|
||||
reference_id=f"ai_filter_{strategy_id}_{symbol}"
|
||||
)
|
||||
if not ok:
|
||||
logger.warning(f"AI filter billing failed for strategy {strategy_id}: {msg}")
|
||||
return False, {"ai_decision": "", "reason": f"billing_failed:{msg}"}
|
||||
except Exception as e:
|
||||
logger.warning(f"AI filter billing check error: {e}")
|
||||
|
||||
try:
|
||||
# 使用新的 FastAnalysisService (单次LLM调用,更快更稳定)
|
||||
from app.services.fast_analysis import get_fast_analysis_service
|
||||
|
||||
service = get_fast_analysis_service()
|
||||
|
||||
@@ -63,12 +63,8 @@ SMTP_USE_SSL=false
|
||||
# =========================
|
||||
# Proxy (optional)
|
||||
# =========================
|
||||
# Most users only need PROXY_URL.
|
||||
# Example local:
|
||||
# PROXY_URL=socks5h://127.0.0.1:10808
|
||||
#
|
||||
# Example Docker:
|
||||
# PROXY_URL=socks5h://host.docker.internal:10808
|
||||
# PROXY_URL=socks5h://127.0.0.1:10808 # local
|
||||
# PROXY_URL=socks5h://host.docker.internal:10808 # Docker
|
||||
PROXY_URL=
|
||||
|
||||
# =========================
|
||||
@@ -86,23 +82,47 @@ GITHUB_CLIENT_SECRET=
|
||||
GITHUB_REDIRECT_URI=http://localhost:5000/api/auth/oauth/github/callback
|
||||
|
||||
# =========================
|
||||
# Billing / payments (optional)
|
||||
# Billing / payments
|
||||
# =========================
|
||||
BILLING_ENABLED=False
|
||||
USDT_PAY_ENABLED=False
|
||||
BILLING_ENABLED=false
|
||||
|
||||
# 积分单价
|
||||
BILLING_COST_AI_ANALYSIS=10
|
||||
BILLING_COST_AI_CODE_GEN=30
|
||||
|
||||
CREDITS_REGISTER_BONUS=100
|
||||
CREDITS_REFERRAL_BONUS=50
|
||||
|
||||
# Membership plans
|
||||
MEMBERSHIP_MONTHLY_PRICE_USD=19.9
|
||||
MEMBERSHIP_YEARLY_PRICE_USD=199
|
||||
MEMBERSHIP_LIFETIME_PRICE_USD=499
|
||||
MEMBERSHIP_MONTHLY_CREDITS=500
|
||||
MEMBERSHIP_YEARLY_CREDITS=8000
|
||||
MEMBERSHIP_LIFETIME_MONTHLY_CREDITS=800
|
||||
|
||||
# USDT payment
|
||||
USDT_PAY_ENABLED=false
|
||||
USDT_PAY_CHAIN=TRC20
|
||||
USDT_TRC20_XPUB=
|
||||
USDT_TRC20_CONTRACT=TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj
|
||||
TRONGRID_BASE_URL=https://api.trongrid.io
|
||||
TRONGRID_API_KEY=
|
||||
USDT_PAY_CONFIRM_SECONDS=30
|
||||
USDT_PAY_EXPIRE_MINUTES=30
|
||||
USDT_WORKER_POLL_INTERVAL=30
|
||||
|
||||
# =========================
|
||||
# Advanced / rarely changed
|
||||
# =========================
|
||||
# The settings below are optional. Most users can leave them unchanged.
|
||||
|
||||
# Network / App tuning
|
||||
PYTHON_API_HOST=0.0.0.0
|
||||
PYTHON_API_PORT=5000
|
||||
PYTHON_API_DEBUG=False
|
||||
PYTHON_API_DEBUG=false
|
||||
RATE_LIMIT=100
|
||||
ENABLE_CACHE=False
|
||||
ENABLE_REQUEST_LOG=True
|
||||
ENABLE_CACHE=false
|
||||
ENABLE_REQUEST_LOG=true
|
||||
|
||||
# Strategy / execution tuning
|
||||
PENDING_ORDER_STALE_SEC=90
|
||||
@@ -112,6 +132,7 @@ MAKER_OFFSET_BPS=2
|
||||
STRATEGY_TICK_INTERVAL_SEC=10
|
||||
PRICE_CACHE_TTL_SEC=10
|
||||
K_LINE_HISTORY_GET_NUMBER=500
|
||||
SIGNAL_NOTIFY_TIMEOUT_SEC=6
|
||||
|
||||
# LLM advanced tuning
|
||||
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
|
||||
@@ -119,7 +140,6 @@ OPENROUTER_TEMPERATURE=0.7
|
||||
OPENROUTER_MAX_TOKENS=4000
|
||||
OPENROUTER_TIMEOUT=300
|
||||
OPENROUTER_CONNECT_TIMEOUT=30
|
||||
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
GROK_BASE_URL=https://api.x.ai/v1
|
||||
@@ -128,32 +148,26 @@ GROK_BASE_URL=https://api.x.ai/v1
|
||||
DATA_SOURCE_TIMEOUT=30
|
||||
DATA_SOURCE_RETRY=3
|
||||
DATA_SOURCE_RETRY_BACKOFF=0.5
|
||||
|
||||
FINNHUB_API_KEY=
|
||||
FINNHUB_TIMEOUT=10
|
||||
FINNHUB_RATE_LIMIT=60
|
||||
|
||||
CCXT_DEFAULT_EXCHANGE=coinbase
|
||||
CCXT_TIMEOUT=10000
|
||||
|
||||
AKSHARE_TIMEOUT=30
|
||||
YFINANCE_TIMEOUT=30
|
||||
|
||||
TIINGO_API_KEY=
|
||||
TIINGO_TIMEOUT=10
|
||||
|
||||
# AI search / news providers
|
||||
# AI search / news
|
||||
SEARCH_PROVIDER=google
|
||||
SEARCH_MAX_RESULTS=10
|
||||
|
||||
SEARCH_GOOGLE_API_KEY=
|
||||
SEARCH_GOOGLE_CX=
|
||||
SEARCH_BING_API_KEY=
|
||||
|
||||
TAVILY_API_KEYS=
|
||||
SERPAPI_KEYS=
|
||||
|
||||
# SMS / phone notifications
|
||||
# SMS / phone (optional)
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_FROM_NUMBER=
|
||||
@@ -162,42 +176,27 @@ TWILIO_FROM_NUMBER=
|
||||
SECURITY_IP_MAX_ATTEMPTS=10
|
||||
SECURITY_IP_WINDOW_MINUTES=5
|
||||
SECURITY_IP_BLOCK_MINUTES=15
|
||||
|
||||
SECURITY_ACCOUNT_MAX_ATTEMPTS=5
|
||||
SECURITY_ACCOUNT_WINDOW_MINUTES=60
|
||||
SECURITY_ACCOUNT_BLOCK_MINUTES=30
|
||||
|
||||
VERIFICATION_CODE_EXPIRE_MINUTES=10
|
||||
VERIFICATION_CODE_RATE_LIMIT=60
|
||||
VERIFICATION_CODE_IP_HOURLY_LIMIT=10
|
||||
VERIFICATION_CODE_MAX_ATTEMPTS=5
|
||||
VERIFICATION_CODE_LOCK_MINUTES=30
|
||||
|
||||
# Billing / credits
|
||||
BILLING_COST_AI_ANALYSIS=10
|
||||
BILLING_COST_STRATEGY_RUN=5
|
||||
BILLING_COST_BACKTEST=3
|
||||
BILLING_COST_PORTFOLIO_MONITOR=8
|
||||
BILLING_COST_POLYMARKET_DEEP_ANALYSIS=15
|
||||
# AI analysis tuning
|
||||
ENABLE_CONFIDENCE_CALIBRATION=false
|
||||
ENABLE_AI_ENSEMBLE=false
|
||||
AI_ENSEMBLE_MODELS=openai/gpt-4o,openai/gpt-4o-mini
|
||||
ENABLE_REFLECTION_WORKER=false
|
||||
REFLECTION_WORKER_INTERVAL_SEC=86400
|
||||
REFLECTION_MIN_AGE_DAYS=7
|
||||
REFLECTION_VALIDATE_LIMIT=200
|
||||
AI_CALIBRATION_MARKETS=Crypto
|
||||
AI_CALIBRATION_LOOKBACK_DAYS=30
|
||||
AI_CALIBRATION_MIN_SAMPLES=80
|
||||
AI_ANALYSIS_CONSENSUS_TIMEFRAMES=1D,4H
|
||||
|
||||
CREDITS_REGISTER_BONUS=100
|
||||
CREDITS_REFERRAL_BONUS=50
|
||||
|
||||
# Membership plans
|
||||
MEMBERSHIP_MONTHLY_PRICE_USD=19.9
|
||||
MEMBERSHIP_YEARLY_PRICE_USD=199
|
||||
MEMBERSHIP_LIFETIME_PRICE_USD=499
|
||||
|
||||
MEMBERSHIP_MONTHLY_CREDITS=500
|
||||
MEMBERSHIP_YEARLY_CREDITS=8000
|
||||
MEMBERSHIP_LIFETIME_MONTHLY_CREDITS=800
|
||||
|
||||
# USDT payment
|
||||
USDT_PAY_CHAIN=TRC20
|
||||
USDT_TRC20_XPUB=
|
||||
USDT_TRC20_CONTRACT=TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj
|
||||
TRONGRID_BASE_URL=https://api.trongrid.io
|
||||
TRONGRID_API_KEY=
|
||||
USDT_PAY_CONFIRM_SECONDS=30
|
||||
USDT_PAY_EXPIRE_MINUTES=30
|
||||
USDT_WORKER_POLL_INTERVAL=30
|
||||
# Internal
|
||||
INTERNAL_API_KEY=
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run AI calibration manually (e.g. via cron).
|
||||
|
||||
Usage:
|
||||
python scripts/run_calibration.py
|
||||
AI_CALIBRATION_MARKET=Crypto python scripts/run_calibration.py
|
||||
AI_CALIBRATION_MARKETS=Crypto,USStock python scripts/run_calibration.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from app.services.ai_calibration import AICalibrationService
|
||||
|
||||
|
||||
def main():
|
||||
markets = (os.getenv("AI_CALIBRATION_MARKETS") or os.getenv("AI_CALIBRATION_MARKET") or "Crypto").strip().split(",")
|
||||
for m in markets:
|
||||
m = m.strip()
|
||||
if not m:
|
||||
continue
|
||||
print(f"Calibrating market: {m}")
|
||||
svc = AICalibrationService()
|
||||
r = svc.calibrate_market(market=m, validate_before=True)
|
||||
if r:
|
||||
print(f" OK: accuracy={r.best_accuracy:.1f}% threshold=±{r.buy_threshold:.1f} samples={r.sample_count}")
|
||||
else:
|
||||
print(" SKIP: not enough validated samples")
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,19 +1,31 @@
|
||||
import sys
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
# 添加后端目录到 Python 路径(使得可以 import app.*)
|
||||
# 由于 app 包位于 backend_api_python/app 下,而脚本位于 backend_api_python/scripts
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from app.services.agents.reflection import ReflectionService
|
||||
from app.services.reflection import ReflectionService
|
||||
|
||||
def main():
|
||||
# Load backend envs for DATABASE_URL, reflection switches, etc.
|
||||
# This script may be run locally, so we must load .env explicitly.
|
||||
backend_env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
root_env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
|
||||
if os.path.exists(root_env_path):
|
||||
load_dotenv(root_env_path, override=False)
|
||||
if os.path.exists(backend_env_path):
|
||||
load_dotenv(backend_env_path, override=False)
|
||||
|
||||
"""
|
||||
运行自动反思验证任务
|
||||
建议通过 cron 或 定时任务调度器 每天运行一次
|
||||
"""
|
||||
print("Running Automated Reflection Verification Task...")
|
||||
service = ReflectionService()
|
||||
service.run_verification_cycle()
|
||||
stats = service.run_verification_cycle()
|
||||
print("Reflection stats:", stats)
|
||||
print("Task Completed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user