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:
@@ -294,6 +294,7 @@ Phase 3 (Decision): 🎯 TraderAgent → BUY / SELL / HOLD (with confidence %)
|
||||
- **🧠 Memory-Augmented** — Agents learn from past analyses (local RAG, not cloud)
|
||||
- **🔌 5+ LLM Providers**: OpenRouter (100+ models), OpenAI, Gemini, DeepSeek, Grok
|
||||
- **📊 Polymarket Prediction Markets** — On-demand AI analysis for prediction markets. Input a market link or title → AI analyzes probability divergence, opportunity score, and trading recommendations. Full history tracking and billing integration.
|
||||
- **📋 Virtual Position Tracking** — Create virtual positions directly from your watchlist with long/short direction, quantity, and entry price. Real-time PnL calculation without connecting to a real exchange.
|
||||
|
||||
### 📈 Full Trading Lifecycle
|
||||
|
||||
@@ -340,7 +341,7 @@ Phase 3 (Decision): 🎯 TraderAgent → BUY / SELL / HOLD (with confidence %)
|
||||
- **💳 Membership Plans** — Monthly / Yearly / Lifetime tiers with configurable pricing & credits
|
||||
- **₿ USDT On-Chain Payment** — TRC20 scan-to-pay, HD Wallet (xpub) per-order addresses, auto-reconciliation via TronGrid
|
||||
- **🏪 Indicator Marketplace** — Users publish & sell Python indicators, you take commission
|
||||
- **⚙️ Admin Dashboard** — Order management, AI usage stats, user analytics
|
||||
- **⚙️ Admin Dashboard** — Order management, AI usage stats, user analytics; settings hot-reload without server restart
|
||||
|
||||
### 🔐 Enterprise-Grade Security
|
||||
|
||||
@@ -350,49 +351,45 @@ Phase 3 (Decision): 🎯 TraderAgent → BUY / SELL / HOLD (with confidence %)
|
||||
- **Demo Mode** — Read-only mode for public showcases
|
||||
|
||||
<details>
|
||||
<summary><b>🧠 AI Agent Architecture Diagram (Click to expand)</b></summary>
|
||||
<summary><b>🧠 AI Analysis Architecture (Click to expand)</b></summary>
|
||||
|
||||
Uses **FastAnalysisService** single-LLM flow for speed and multi-factor decisions:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Entry["🌐 API Entry"]
|
||||
A["📡 POST /api/analysis/multi"]
|
||||
A2["🔄 POST /api/analysis/reflect"]
|
||||
A["📡 POST /api/fast-analysis/analyze"]
|
||||
A2["📜 GET /api/fast-analysis/history"]
|
||||
A3["📊 GET /api/fast-analysis/similar-patterns"]
|
||||
end
|
||||
subgraph Service["⚙️ Service Orchestration"]
|
||||
B[AnalysisService]
|
||||
C[AgentCoordinator]
|
||||
D["📊 Build Context<br/>price · kline · news · indicators"]
|
||||
subgraph Data["📊 Data Layer"]
|
||||
D1[MarketDataCollector]
|
||||
D2["Price · Kline · Macro · News · Fundamentals"]
|
||||
D3["Multi-TF Consensus 1D / 4H / 1H"]
|
||||
end
|
||||
subgraph Agents["🤖 7-Agent Workflow"]
|
||||
subgraph P1["📈 Phase 1 · Parallel Analysis"]
|
||||
E1["🔍 MarketAnalyst"]
|
||||
E2["📑 FundamentalAnalyst"]
|
||||
E3["📰 NewsAnalyst"]
|
||||
E4["💭 SentimentAnalyst"]
|
||||
E5["⚠️ RiskAnalyst"]
|
||||
end
|
||||
subgraph P2["🎯 Phase 2 · Bull vs Bear Debate"]
|
||||
F1["🐂 BullResearcher"]
|
||||
F2["🐻 BearResearcher"]
|
||||
end
|
||||
subgraph P3["💹 Phase 3 · Final Decision"]
|
||||
G["🎰 TraderAgent → BUY / SELL / HOLD"]
|
||||
end
|
||||
subgraph Analysis["⚙️ Analysis Layer"]
|
||||
B["FastAnalysisService"]
|
||||
C["Single LLM Call<br/>Constrained Prompt"]
|
||||
E["Objective Score + Multi-TF Consensus"]
|
||||
F["AICalibration Threshold Tuning"]
|
||||
G["BUY / SELL / HOLD"]
|
||||
end
|
||||
subgraph Memory["🧠 Local Memory Store"]
|
||||
M1[("Agent Memories (PostgreSQL)")]
|
||||
subgraph Memory["🧠 Memory Layer"]
|
||||
M1[("qd_analysis_memory<br/>PostgreSQL")]
|
||||
M2["RAG Similar-Pattern Retrieval<br/>(optional prompt injection)"]
|
||||
end
|
||||
subgraph Reflect["🔄 Reflection Loop"]
|
||||
R[ReflectionService]
|
||||
W["⏰ ReflectionWorker → verify + learn"]
|
||||
end
|
||||
A --> B --> C --> D
|
||||
D --> P1 --> P2 --> P3
|
||||
Agents <-.->|"RAG retrieval"| M1
|
||||
C --> R
|
||||
W -.->|"update memories"| M1
|
||||
A --> B
|
||||
B --> D1 --> D2 --> D3
|
||||
D3 --> C --> E --> F --> G
|
||||
B -.->|"store"| M1
|
||||
M1 -.->|"similar patterns"| M2
|
||||
M2 -.->|"optional context"| C
|
||||
A2 --> M1
|
||||
A3 --> M1
|
||||
```
|
||||
|
||||
**Flow:** Data collection → Multi-timeframe consensus → Single LLM call → Calibration override → Store in memory.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -499,7 +496,9 @@ The upper part is for first-time deployment, and the lower "Advanced / rarely ch
|
||||
| **Membership** | `MEMBERSHIP_MONTHLY_PRICE_USD`, `MEMBERSHIP_MONTHLY_CREDITS` |
|
||||
| **USDT Payment** | `USDT_PAY_ENABLED`, `USDT_TRC20_XPUB`, `TRONGRID_API_KEY` |
|
||||
| **Proxy** | `PROXY_URL` |
|
||||
| **Workers** | `ENABLE_PENDING_ORDER_WORKER`, `ENABLE_PORTFOLIO_MONITOR` |
|
||||
| **Billing** | `BILLING_ENABLED`, `BILLING_COST_AI_ANALYSIS`, `BILLING_COST_AI_CODE_GEN` |
|
||||
| **Workers** | `ENABLE_PENDING_ORDER_WORKER`, `ENABLE_PORTFOLIO_MONITOR`, `ENABLE_REFLECTION_WORKER` |
|
||||
| **AI Tuning** | `ENABLE_AI_ENSEMBLE`, `ENABLE_CONFIDENCE_CALIBRATION`, `AI_ENSEMBLE_MODELS` |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 快速分析(Fast Analysis)前端对接说明
|
||||
|
||||
本开源仓库中的 **`frontend/` 仅包含构建产物 `dist/`**,Vue 源码在**私有前端仓库**(见根目录 `.github/workflows/update-frontend.yml`)。因此在本仓库内**无法直接修改** `FastAnalysisReport.vue` 等组件,需要在私有仓库中按下列说明调整。
|
||||
|
||||
## 1. 止盈 / 止损显示反了(BUY/SELL)
|
||||
|
||||
### 后端约定(`/api/fast-analysis/analyze`)
|
||||
|
||||
- **`trading_plan.stop_loss` / `trading_plan.take_profit`** 已与 `decision` 对齐几何关系:
|
||||
- **BUY**:`stop_loss < 现价 < take_profit`
|
||||
- **SELL(空)**:`take_profit < 现价 < stop_loss`(止损在上方,止盈在下方)
|
||||
- **`indicators.trading_levels.suggested_stop_loss / suggested_take_profit`** 在采集器里是**多单(做多)参考价**,**不能**在 SELL 时直接拿来当界面上的「止损/止盈」两行,否则会和空单几何相反。
|
||||
|
||||
### 前端常见错误
|
||||
|
||||
- 第一行写死绑定 `trading_levels.suggested_stop_loss`、第二行绑定 `suggested_take_profit`。
|
||||
- 或把 `stop_loss` / `take_profit` **标签写反**(模板里「止损」绑了 `take_profit`)。
|
||||
|
||||
### 推荐写法(私有仓库中修改)
|
||||
|
||||
只使用接口返回的 **`data.trading_plan`**(或兼容字段 **`stopLoss` / `takeProfit`**):
|
||||
|
||||
```text
|
||||
止损(亏损离场价): trading_plan.stop_loss (或 stopLoss)
|
||||
止盈(盈利目标价): trading_plan.take_profit (或 takeProfit)
|
||||
```
|
||||
|
||||
可选:根据 `trading_plan.decision === 'SELL'` 在文案旁加一句「空单:止损在现价上方,止盈在现价下方」。
|
||||
|
||||
### API 兼容字段(后端已加)
|
||||
|
||||
`trading_plan` 内额外包含:
|
||||
|
||||
- `entryPrice`, `stopLoss`, `takeProfit`, `positionSizePct`
|
||||
- `loss_exit_price`, `profit_target_price`(与止损/止盈数值一致,语义更清晰)
|
||||
- `decision`:与主结果 `decision` 一致,便于组件内判断
|
||||
|
||||
根级另有驼峰:`trendOutlook`, `trendOutlookSummary`(与 `trend_outlook` 等相同内容)。
|
||||
|
||||
## 2. 「未来时间段预判」不显示
|
||||
|
||||
后端字段:
|
||||
|
||||
- **`trend_outlook`**:对象,含 `next_24h`, `next_3d`, `next_1w`, `next_1m`(每项含 `score`, `trend`, `strength`)。
|
||||
- **`trend_outlook_summary`**:一行可读摘要(中文/英文随 `language`)。
|
||||
- 若走 **`/api/fast-analysis/analyze-legacy`**:`fast_analysis` 内同样有上述字段;`overview.report` 会追加 **【周期预判】** 段落;顶层也有 `trend_outlook` / `trend_outlook_summary`。
|
||||
|
||||
### 前端需要做的
|
||||
|
||||
- 在快速分析结果页**单独渲染** `trend_outlook` 或 `trend_outlook_summary`(不要只读 `summary` 正文)。
|
||||
- 若请求的是 legacy 接口,请读 **`data.fast_analysis.trend_outlook`** 或顶层 **`data.trend_outlook`**,不要假设只在某一嵌套路径下。
|
||||
|
||||
## 3. 自检清单
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|--------|------|
|
||||
| 接口路径 | 确认用的是 `/analyze` 还是 `/analyze-legacy`,字段路径一致 |
|
||||
| 绑定来源 | 止损/止盈是否来自 `trading_plan`,而非 `trading_levels` |
|
||||
| SELL 几何 | 空单位:`take_profit < current < stop_loss` |
|
||||
| 周期预判 | 模板是否包含 `trend_outlook` 或 `trend_outlook_summary` |
|
||||
|
||||
更新私有前端仓库后,通过 CI 或手动打包替换本仓库的 `frontend/dist/`(参见 `update-frontend.yml`)。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 技术指标计算口径(与代码一致)
|
||||
|
||||
本文档与 `backend_api_python/app/services/market_data_collector.py` 中 `_calculate_indicators` 及子函数实现**严格对应**。
|
||||
|
||||
| 指标 | 实现要点 |
|
||||
|------|-----------|
|
||||
| **RSI(14)** | **Wilder RSI**:前 14 期涨跌分别取简单算术平均作为初始均幅;自第 15 期起 `avg = (avg_prev×13 + 当期) / 14`。RS = 均涨幅/均跌幅,RSI = 100 − 100/(1+RS)。 |
|
||||
| **MACD(12,26,9)** | 收盘 **EMA12、EMA26**(首值=各自前 N 根 **SMA**,再按 α=2/(N+1) 递推)。**DIF** 从第 26 根 K 起有定义;对 DIF 子序列再算 **EMA9** 得 **DEA**;柱 = DIF − DEA。至少需要 **34** 根收盘才能稳定给出信号线(子序列长度≥9)。 |
|
||||
| **MA5/10/20** | 最近 N 根收盘价的 **SMA**。 |
|
||||
| **枢轴 Pivot** | **上一根 K** 的高、低、收:P=(H+L+C)/3,R1/S1/R2/S2 标准式。 |
|
||||
| **摆动高/低** | 最近 **20 根 K** 的 `max(high)`、`min(low)`。 |
|
||||
| **布林(20,2)** | 中轨 = 最近 20 收盘 **SMA**;方差 = Σ(x−μ)²/**20**(总体方差);σ=√方差;上下轨 = 中轨 ± 2σ;带宽% = (上轨−下轨)/中轨×100。合成支撑/阻力使用 **`BB_upper` / `BB_lower`** 字段。 |
|
||||
| **ATR(14)** | **Wilder ATR**:TR 定义同经典;首 ATR = 前 14 个 TR 的简单平均;之后 `ATR_t = (ATR_{t-1}×13 + TR_t) / 14`,递推至**最后一根 K**(全序列,非仅尾窗)。 |
|
||||
| **量比** | 当前根成交量 / 近 **20** 根成交量算术平均。 |
|
||||
| **区间位置 %** | (当前收盘 − 近20低) / (近20高 − 近20低) × 100;高=低 时为 50。 |
|
||||
| **合成支撑/阻力** | `(R1 + swing_high + BB_upper) / 3` 与 `(S1 + swing_low + BB_lower) / 3`,见 `levels.method`。 |
|
||||
|
||||
前端说明文案键:`fastAnalysis.indicatorsProSubtitle`(应与上表同步维护)。
|
||||
+57
-36
@@ -264,6 +264,7 @@ IMAGE_PREFIX=docker.xuanyuan.me/library/
|
||||
- **🧠 记忆增强** — 智能体从过去的分析中学习(本地RAG,非云端)
|
||||
- **🔌 5+ LLM提供商**:OpenRouter(100+模型)、OpenAI、Gemini、DeepSeek、Grok
|
||||
- **📊 Polymarket预测市场** — 预测市场按需AI分析。输入市场链接或标题 → AI分析概率差异、机会评分和交易建议。完整历史跟踪和计费集成。
|
||||
- **📋 虚拟持仓管理** — 自选股中直接创建虚拟持仓,支持做多/做空方向、数量、买入单价,实时计算盈亏。无需连接真实交易所即可跟踪模拟投资组合。
|
||||
|
||||
### 📈 完整交易生命周期
|
||||
|
||||
@@ -310,7 +311,7 @@ IMAGE_PREFIX=docker.xuanyuan.me/library/
|
||||
- **💳 会员计划** — 月度/年度/终身层级,可配置定价和积分
|
||||
- **₿ USDT链上支付** — TRC20扫码支付,每订单地址的HD钱包(xpub),通过TronGrid自动对账
|
||||
- **🏪 指标市场** — 用户发布和销售Python指标,您收取佣金
|
||||
- **⚙️ 管理员仪表板** — 订单管理、AI使用统计、用户分析
|
||||
- **⚙️ 管理员仪表板** — 订单管理、AI使用统计、用户分析;系统设置保存后热重载,无需重启服务
|
||||
|
||||
### 🔐 企业级安全
|
||||
|
||||
@@ -320,49 +321,67 @@ IMAGE_PREFIX=docker.xuanyuan.me/library/
|
||||
- **演示模式** — 公共展示的只读模式
|
||||
|
||||
<details>
|
||||
<summary><b>🧠 AI智能体架构图(点击展开)</b></summary>
|
||||
<summary><b>🧠 AI 分析架构图(点击展开)</b></summary>
|
||||
|
||||
当前采用 **FastAnalysisService** 单次 LLM 流程,兼顾速度与多因子决策:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Entry["🌐 API入口"]
|
||||
A["📡 POST /api/analysis/multi"]
|
||||
A2["🔄 POST /api/analysis/reflect"]
|
||||
subgraph Entry["🌐 API 入口"]
|
||||
A["📡 POST /api/fast-analysis/analyze"]
|
||||
A2["📜 GET /api/fast-analysis/history"]
|
||||
A3["📊 GET /api/fast-analysis/similar-patterns"]
|
||||
end
|
||||
subgraph Service["⚙️ 服务编排"]
|
||||
B[AnalysisService]
|
||||
C[AgentCoordinator]
|
||||
D["📊 构建上下文<br/>价格 · K线 · 新闻 · 指标"]
|
||||
subgraph Data["📊 数据采集层"]
|
||||
D1[MarketDataCollector]
|
||||
D2["价格 · K线 · 宏观 · 新闻 · 基本面"]
|
||||
D3["多周期共识 1D / 4H / 1H"]
|
||||
end
|
||||
subgraph Agents["🤖 7智能体工作流"]
|
||||
subgraph P1["📈 阶段1 · 并行分析"]
|
||||
E1["🔍 MarketAnalyst"]
|
||||
E2["📑 FundamentalAnalyst"]
|
||||
E3["📰 NewsAnalyst"]
|
||||
E4["💭 SentimentAnalyst"]
|
||||
E5["⚠️ RiskAnalyst"]
|
||||
end
|
||||
subgraph P2["🎯 阶段2 · 多头vs空头辩论"]
|
||||
F1["🐂 BullResearcher"]
|
||||
F2["🐻 BearResearcher"]
|
||||
end
|
||||
subgraph P3["💹 阶段3 · 最终决策"]
|
||||
G["🎰 TraderAgent → 买入 / 卖出 / 持有"]
|
||||
end
|
||||
subgraph Analysis["⚙️ 分析层"]
|
||||
B["FastAnalysisService"]
|
||||
C["单次 LLM 调用<br/>强约束 Prompt"]
|
||||
E["客观评分 + 多周期共识"]
|
||||
F["AICalibration 阈值校准"]
|
||||
G["买入 / 卖出 / 持有"]
|
||||
end
|
||||
subgraph Memory["🧠 本地记忆存储"]
|
||||
M1[("智能体记忆(PostgreSQL)")]
|
||||
subgraph Memory["🧠 记忆层"]
|
||||
M1[("qd_analysis_memory<br/>PostgreSQL")]
|
||||
M2["RAG 相似模式检索<br/>(可选注入 Prompt)"]
|
||||
end
|
||||
subgraph Reflect["🔄 反思循环"]
|
||||
R[ReflectionService]
|
||||
W["⏰ ReflectionWorker → 验证 + 学习"]
|
||||
end
|
||||
A --> B --> C --> D
|
||||
D --> P1 --> P2 --> P3
|
||||
Agents <-.->|"RAG检索"| M1
|
||||
C --> R
|
||||
W -.->|"更新记忆"| M1
|
||||
A --> B
|
||||
B --> D1 --> D2 --> D3
|
||||
D3 --> C --> E --> F --> G
|
||||
B -.->|"存储"| M1
|
||||
M1 -.->|"相似模式"| M2
|
||||
M2 -.->|"可选上下文"| C
|
||||
A2 --> M1
|
||||
A3 --> M1
|
||||
```
|
||||
|
||||
**流程概要:**
|
||||
1. **数据采集** — 统一 `MarketDataCollector` 拉取价格、K 线、宏观、新闻、基本面
|
||||
2. **多周期共识** — 主周期 + 4H/1D 等技术周期,计算加权客观评分
|
||||
3. **单次 LLM** — 强约束 Prompt,综合技术 / 宏观 / 新闻 / 基本面
|
||||
4. **共识校准** — 当客观评分显著时,可覆盖 LLM 决策;数据质量差时倾向 HOLD
|
||||
5. **记忆存储** — 写入 `qd_analysis_memory`,支持历史查询与相似模式检索
|
||||
|
||||
<details>
|
||||
<summary><b>📈 AI 分析优化(已实现)</b></summary>
|
||||
|
||||
| 功能 | 说明 | 配置 |
|
||||
|------|------|------|
|
||||
| **RAG 记忆注入** | 相似历史模式注入 Prompt | 默认启用 |
|
||||
| **多指标相似度** | RSI、MACD、MA 趋势、波动率加权相似度 | 默认启用 |
|
||||
| **反思验证** | 验证历史决策,更新 `was_correct` | `ENABLE_REFLECTION_WORKER=true` |
|
||||
| **阈值自校准** | 定期校准 BUY/SELL 阈值 | `scripts/run_calibration.py` 或 `scripts/run_reflection_task.py` |
|
||||
| **置信度校准** | 按置信度分桶统计准确率并调整 | `ENABLE_CONFIDENCE_CALIBRATION=true` |
|
||||
| **市场状态感知** | 趋势/震荡市使用不同阈值(震荡市更保守) | 默认启用 |
|
||||
| **多模型投票** | 2–3 模型投票降低单点偏差 | `ENABLE_AI_ENSEMBLE=true` + `AI_ENSEMBLE_MODELS=model1,model2` |
|
||||
|
||||
详见 `backend_api_python/env.example` 中的 AI analysis optimizations 部分。
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -466,10 +485,12 @@ QuantDinger/
|
||||
| **AI / LLM** | `LLM_PROVIDER`、`OPENROUTER_API_KEY`、`OPENAI_API_KEY` |
|
||||
| **OAuth** | `GOOGLE_CLIENT_ID`、`GITHUB_CLIENT_ID` |
|
||||
| **安全** | `TURNSTILE_SITE_KEY`、`ENABLE_REGISTRATION` |
|
||||
| **计费** | `BILLING_ENABLED`、`BILLING_COST_AI_ANALYSIS`、`BILLING_COST_AI_CODE_GEN` |
|
||||
| **会员** | `MEMBERSHIP_MONTHLY_PRICE_USD`、`MEMBERSHIP_MONTHLY_CREDITS` |
|
||||
| **USDT支付** | `USDT_PAY_ENABLED`、`USDT_TRC20_XPUB`、`TRONGRID_API_KEY` |
|
||||
| **代理** | `PROXY_URL` |
|
||||
| **工作器** | `ENABLE_PENDING_ORDER_WORKER`、`ENABLE_PORTFOLIO_MONITOR` |
|
||||
| **工作器** | `ENABLE_PENDING_ORDER_WORKER`、`ENABLE_PORTFOLIO_MONITOR`、`ENABLE_REFLECTION_WORKER` |
|
||||
| **AI调优** | `ENABLE_AI_ENSEMBLE`、`ENABLE_CONFIDENCE_CALIBRATION`、`AI_ENSEMBLE_MODELS` |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.2.2
|
||||
3.0.4+indicators-copy-strict-20260323
|
||||
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
+131
-97
@@ -19,10 +19,12 @@
|
||||
:global .ant-pro-global-header-index-right .ant-dropdown-trigger:hover,:global .ant-pro-global-header-index-right .ant-pro-drop-down:hover{color:#1890ff!important}
|
||||
:global a:hover{color:#1890ff!important}
|
||||
:global .anticon:hover{color:#1890ff!important}
|
||||
:global(.ant-pro-layout).dark :global(.ant-menu-dark .ant-menu-item-selected),:global(.ant-pro-layout).realdark :global(.ant-menu-dark .ant-menu-item-selected){background:rgba(24,144,255,.25)!important}
|
||||
.ant-pro-global-header-index-right.ant-pro-global-header-index-dark .ant-pro-global-header-index-action:hover{background:#1890ff}
|
||||
.ant-pro-global-header-index-right.ant-pro-global-header-index-dark .ant-pro-drop-down:hover{color:#1890ff}
|
||||
.ant-pro-global-header-index-right .ant-pro-account-avatar .antd-pro-global-header-index-avatar{color:#1890ff}
|
||||
:global .ant-pro-layout.dark .ant-dropdown-menu .ant-dropdown-menu-item:hover,:global .ant-pro-layout.realdark .ant-dropdown-menu .ant-dropdown-menu-item:hover{color:#1890ff!important}
|
||||
:global body.dark .ant-menu-dark .ant-menu-item-selected,:global body.realdark .ant-menu-dark .ant-menu-item-selected{background:rgba(24,144,255,.25)!important}
|
||||
a{color:#1890ff}
|
||||
a:hover{color:#40a9ff}
|
||||
a:active{color:#096dd9}
|
||||
@@ -33,44 +35,76 @@ html{--antd-wave-shadow-color:#1890ff}
|
||||
#nprogress .bar{background:#1890ff}
|
||||
#nprogress .peg{-webkit-box-shadow:0 0 10px #1890ff,0 0 5px #1890ff;box-shadow:0 0 10px #1890ff,0 0 5px #1890ff}
|
||||
#nprogress .spinner-icon{border-top-color:#1890ff;border-left-color:#1890ff}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .loading-header .loading-icon-pro[data-v-693144f6]{color:#1890ff}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .progress-wrapper .progress-text[data-v-693144f6]{color:#1890ff}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .current-step[data-v-693144f6]{color:#1890ff}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .steps-list .step-item.active[data-v-693144f6]{background:#e6f7ff;color:#1890ff}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .steps-list .step-item.active .step-dot[data-v-693144f6]{background:#1890ff;-webkit-box-shadow:0 0 0 3px rgba(24,144,255,.2);box-shadow:0 0 0 3px rgba(24,144,255,.2)}
|
||||
.fast-analysis-report .result-container .decision-card[data-v-693144f6]{border-left:6px solid #1890ff}
|
||||
.fast-analysis-report .result-container .decision-card .consensus-strip[data-v-693144f6]{background:rgba(24,144,255,.06);border:1px solid rgba(24,144,255,.2)}
|
||||
.fast-analysis-report .result-container .decision-card .consensus-strip .consensus-strip-title[data-v-693144f6]{color:#1890ff}
|
||||
.fast-analysis-report .result-container .price-info-row .price-card.current[data-v-693144f6]{border-top:3px solid #1890ff}
|
||||
.fast-analysis-report .result-container .scores-row .score-item .score-header .anticon[data-v-693144f6]{color:#1890ff}
|
||||
.fast-analysis-report .result-container .scores-row .score-item.overall[data-v-693144f6]{background:linear-gradient(135deg,#e6f7ff,#fff);border:1px solid #91d5ff}
|
||||
.fast-analysis-report .result-container .detailed-analysis .analysis-card[data-v-693144f6]{border-left:4px solid #1890ff}
|
||||
.fast-analysis-report .result-container .detailed-analysis .analysis-card.technical[data-v-693144f6]{border-left-color:#1890ff}
|
||||
.fast-analysis-report .result-container .detailed-analysis .analysis-card .analysis-card-header.technical .anticon[data-v-693144f6]{color:#1890ff}
|
||||
.fast-analysis-report .result-container .analysis-details .detail-section .detail-list li[data-v-693144f6]:before{color:#1890ff}
|
||||
.fast-analysis-report .result-container .indicators-section .section-title .anticon[data-v-693144f6]{color:#1890ff}
|
||||
.fast-analysis-report.theme-dark .decision-card[data-v-693144f6]{border-left-color:#1890ff}
|
||||
.fast-analysis-report.theme-dark .decision-card .consensus-strip[data-v-693144f6]{background:rgba(24,144,255,.12);border-color:rgba(24,144,255,.35)}
|
||||
.fast-analysis-report.theme-dark .decision-card .consensus-strip .consensus-strip-title[data-v-693144f6]{color:#69c0ff}
|
||||
.fast-analysis-report.theme-dark .detailed-analysis .analysis-card.technical[data-v-693144f6]{border-left-color:#1890ff}
|
||||
.fast-analysis-report.theme-dark .score-item.overall[data-v-693144f6]{background:linear-gradient(135deg,rgba(24,144,255,.1),#2a2e39);border-color:#1890ff}
|
||||
.left-panel .heatmap-box .box-header[data-v-41eda137] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
|
||||
.left-panel .calendar-box .box-header .box-title .anticon[data-v-41eda137]{color:var(--primary-color,#1890ff)}
|
||||
.right-panel .analysis-toolbar .analyze-button[data-v-41eda137]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
|
||||
.right-panel .analysis-main .analysis-placeholder .placeholder-content .placeholder-icon[data-v-41eda137]{color:var(--primary-color,#1890ff)}
|
||||
.watchlist-panel .watchlist-list .watchlist-item.active[data-v-41eda137]{background:#e6f7ff;border:1px solid #91d5ff}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .watchlist-item.active[data-v-41eda137]{background:rgba(24,144,255,.1);border-color:#1890ff}
|
||||
.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip.active[data-v-41eda137],.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip[data-v-41eda137]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.1)}
|
||||
.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip.active[data-v-41eda137],.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip[data-v-41eda137]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.1)}
|
||||
body.dark .ant-pagination-item-active,body.realdark .ant-pagination-item-active{border-color:#1890ff!important}
|
||||
body.dark .ant-btn-default:hover,body.realdark .ant-btn-default:hover{border-color:#1890ff!important;color:#1890ff!important}
|
||||
body.dark .ant-tabs-tab-active,body.realdark .ant-tabs-tab-active{color:#1890ff!important}
|
||||
body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-selected,body.realdark .ant-select-dropdown .ant-select-dropdown-menu-item-selected{background:rgba(24,144,255,.1)!important;color:#1890ff!important}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .loading-header .loading-icon-pro[data-v-75da11ec]{color:#1890ff}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .progress-wrapper .progress-text[data-v-75da11ec]{color:#1890ff}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .current-step[data-v-75da11ec]{color:#1890ff}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .steps-list .step-item.active[data-v-75da11ec]{background:#e6f7ff;color:#1890ff}
|
||||
.fast-analysis-report .loading-container .loading-content-pro .steps-list .step-item.active .step-dot[data-v-75da11ec]{background:#1890ff;-webkit-box-shadow:0 0 0 3px rgba(24,144,255,.2);box-shadow:0 0 0 3px rgba(24,144,255,.2)}
|
||||
.fast-analysis-report .result-container .decision-card[data-v-75da11ec]{border-left:6px solid #1890ff}
|
||||
.fast-analysis-report .result-container .decision-card .consensus-strip[data-v-75da11ec]{background:rgba(24,144,255,.06);border:1px solid rgba(24,144,255,.2)}
|
||||
.fast-analysis-report .result-container .decision-card .consensus-strip .consensus-strip-title[data-v-75da11ec]{color:#1890ff}
|
||||
.fast-analysis-report .result-container .price-info-row .price-card.current[data-v-75da11ec]{border-top:3px solid #1890ff}
|
||||
.fast-analysis-report .result-container .scores-row .score-item .score-header .anticon[data-v-75da11ec]{color:#1890ff}
|
||||
.fast-analysis-report .result-container .scores-row .score-item.overall[data-v-75da11ec]{background:linear-gradient(135deg,#e6f7ff,#fff);border:1px solid #91d5ff}
|
||||
.fast-analysis-report .result-container .detailed-analysis .analysis-card[data-v-75da11ec]{border-left:4px solid #1890ff}
|
||||
.fast-analysis-report .result-container .detailed-analysis .analysis-card.technical[data-v-75da11ec]{border-left-color:#1890ff}
|
||||
.fast-analysis-report .result-container .detailed-analysis .analysis-card .analysis-card-header.technical .anticon[data-v-75da11ec]{color:#1890ff}
|
||||
.fast-analysis-report .result-container .analysis-details .detail-section .detail-list li[data-v-75da11ec]:before{color:#1890ff}
|
||||
.fast-analysis-report .result-container .indicators-section .section-title .anticon[data-v-75da11ec]{color:#1890ff}
|
||||
.fast-analysis-report.theme-dark .decision-card[data-v-75da11ec]{border-left-color:#1890ff}
|
||||
.fast-analysis-report.theme-dark .decision-card .consensus-strip[data-v-75da11ec]{background:rgba(24,144,255,.12);border-color:rgba(24,144,255,.35)}
|
||||
.fast-analysis-report.theme-dark .decision-card .consensus-strip .consensus-strip-title[data-v-75da11ec]{color:#69c0ff}
|
||||
.fast-analysis-report.theme-dark .detailed-analysis .analysis-card.technical[data-v-75da11ec]{border-left-color:#1890ff}
|
||||
.fast-analysis-report.theme-dark .detail-section .indicators-methodology .anticon[data-v-75da11ec],.fast-analysis-report.theme-dark .feedback-section .indicators-methodology .anticon[data-v-75da11ec],.fast-analysis-report.theme-dark .indicators-section .indicators-methodology .anticon[data-v-75da11ec],.fast-analysis-report.theme-dark .price-card .indicators-methodology .anticon[data-v-75da11ec],.fast-analysis-report.theme-dark .score-item .indicators-methodology .anticon[data-v-75da11ec]{color:#69c0ff}
|
||||
.left-panel .heatmap-box .box-header[data-v-5cbe52d9] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
|
||||
.left-panel .calendar-box .box-header .box-title .anticon[data-v-5cbe52d9]{color:var(--primary-color,#1890ff)}
|
||||
.right-panel .analysis-toolbar .analyze-button[data-v-5cbe52d9]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active[data-v-5cbe52d9]{background:rgba(24,144,255,.08);border-color:#1890ff}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card-hover-actions .wl-hover-btn[data-v-5cbe52d9]:hover{color:#1890ff;background:rgba(24,144,255,.1)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active .wl-card-hover-actions[data-v-5cbe52d9]{background:-webkit-gradient(linear,left top,right top,from(transparent),color-stop(30%,rgba(24,144,255,.06)));background:linear-gradient(90deg,transparent,rgba(24,144,255,.06) 30%)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hero-badge[data-v-5cbe52d9]{background:rgba(24,144,255,.1);border-color:rgba(24,144,255,.2)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hstat[data-v-5cbe52d9]:hover{border-color:#1890ff;-webkit-box-shadow:0 4px 16px rgba(24,144,255,.12);box-shadow:0 4px 16px rgba(24,144,255,.12)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hstat-icon[data-v-5cbe52d9]{background:linear-gradient(135deg,rgba(24,144,255,.12),rgba(114,46,209,.08))}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hero-cta .ant-btn-primary[data-v-5cbe52d9]{-webkit-box-shadow:0 4px 14px rgba(24,144,255,.35);box-shadow:0 4px 14px rgba(24,144,255,.35)}
|
||||
.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip.active[data-v-5cbe52d9],.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip[data-v-5cbe52d9]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
|
||||
.ai-analysis-container.theme-dark .hero-cta .ant-btn[data-v-5cbe52d9]:not(.ant-btn-primary):hover{border-color:#1890ff;color:#1890ff}
|
||||
.ai-analysis-container.theme-dark .panel-header-icon[data-v-5cbe52d9]:hover{color:#1890ff;background:rgba(24,144,255,.08)}
|
||||
.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip.active[data-v-5cbe52d9],.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip[data-v-5cbe52d9]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
|
||||
.ai-analysis-container.theme-dark[data-v-5cbe52d9] .ant-tabs-tab-active{color:#1890ff!important}
|
||||
.ai-analysis-container.theme-dark[data-v-5cbe52d9] .ant-tabs-ink-bar{background-color:#1890ff}
|
||||
.ai-analysis-container.theme-dark[data-v-5cbe52d9] .ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.ai-analysis-container.theme-dark[data-v-5cbe52d9] .ant-alert{background:rgba(24,144,255,.06)}
|
||||
.hero-bg-circle.c1[data-v-5cbe52d9]{background:radial-gradient(circle,rgba(24,144,255,.1) 0,transparent 70%)}
|
||||
.hero-bg-grid[data-v-5cbe52d9]{background-image:linear-gradient(rgba(24,144,255,.03) 1px,transparent 0),linear-gradient(90deg,rgba(24,144,255,.03) 1px,transparent 0)}
|
||||
.hero-badge[data-v-5cbe52d9]{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08);border:1px solid rgba(24,144,255,.2)}
|
||||
.hstat[data-v-5cbe52d9]:hover{border-color:var(--primary-color,#1890ff);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.1);box-shadow:0 4px 16px rgba(24,144,255,.1)}
|
||||
.hstat-icon[data-v-5cbe52d9]{background:linear-gradient(135deg,rgba(24,144,255,.1),rgba(114,46,209,.08));color:var(--primary-color,#1890ff)}
|
||||
.hero-cta .ant-btn-primary[data-v-5cbe52d9]{-webkit-box-shadow:0 4px 14px rgba(24,144,255,.3);box-shadow:0 4px 14px rgba(24,144,255,.3)}
|
||||
.panel-header-icon[data-v-5cbe52d9]:hover{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
|
||||
.wl-card.active[data-v-5cbe52d9]{background:linear-gradient(135deg,#e6f7ff,#f0f5ff);border-color:#91d5ff}
|
||||
.wl-card.active .wl-card-hover-actions[data-v-5cbe52d9]{background:-webkit-gradient(linear,left top,right top,from(transparent),color-stop(30%,#e6f7ff));background:linear-gradient(90deg,transparent,#e6f7ff 30%)}
|
||||
.wl-hover-btn[data-v-5cbe52d9]:hover{color:var(--primary-color,#1890ff);background:#e6f7ff}
|
||||
.qd-dark-modal .ant-modal-footer .ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.qd-dark-modal .ant-tabs-tab-active{color:#1890ff!important}
|
||||
.qd-dark-modal .ant-tag-blue{background:rgba(24,144,255,.1);border-color:rgba(24,144,255,.3);color:#1890ff}
|
||||
.qd-dark-modal .ant-alert{background:rgba(24,144,255,.06)}
|
||||
.qd-dark-modal .ant-alert-info .ant-alert-icon{color:#1890ff}
|
||||
.qd-dark-modal .batch-symbols-preview .ant-tag{background:rgba(24,144,255,.1);border-color:rgba(24,144,255,.3);color:#1890ff}
|
||||
.qd-dark-drawer .ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.qd-dark-modal .ant-select-dropdown .ant-select-dropdown-menu-item-selected,body.colorWeak .ant-select-dropdown .ant-select-dropdown-menu-item-selected{background:rgba(24,144,255,.1);color:#1890ff}
|
||||
.qt-header .qt-header-left .qt-icon[data-v-0d547552]{color:#1890ff}
|
||||
.theme-dark[data-v-0d547552] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:#1890ff;border-color:#1890ff}
|
||||
.theme-dark[data-v-0d547552] .ant-slider-track{background:#1890ff}
|
||||
.polymarket-analysis-modal .result-section .analysis-result .probability-comparison .prob-item .prob-value.ai-prob[data-v-41dfa054]{color:#1890ff}
|
||||
.polymarket-analysis-modal .result-section .analysis-result .recommendation-section .rec-card .rec-value[data-v-41dfa054]{color:#1890ff}
|
||||
.ai-asset-analysis-page .opp-section .opp-card .opp-action[data-v-20b827f5]{color:#1890ff}
|
||||
.ai-asset-analysis-page .opp-section .opp-card .opp-trade-btn[data-v-20b827f5]{background:linear-gradient(135deg,#1890ff,#722ed1)}
|
||||
.ai-asset-analysis-page .qt-floating-btn[data-v-20b827f5]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.4);box-shadow:0 4px 16px rgba(24,144,255,.4)}
|
||||
.ai-asset-analysis-page .workspace-card .tab-body .polymarket-tab-content .polymarket-placeholder .placeholder-icon[data-v-20b827f5]{color:#1890ff}
|
||||
.polymarket-analysis-modal .result-section .analysis-result .probability-comparison .prob-item .prob-value.ai-prob[data-v-67e891d2]{color:#1890ff}
|
||||
.polymarket-analysis-modal .result-section .analysis-result .recommendation-section .rec-card .rec-value[data-v-67e891d2]{color:#1890ff}
|
||||
.ai-asset-analysis-page .opp-section .opp-card .opp-action[data-v-443699fa]{color:#1890ff}
|
||||
.ai-asset-analysis-page .opp-section .opp-card .opp-trade-btn[data-v-443699fa]{background:linear-gradient(135deg,#1890ff,#722ed1)}
|
||||
.ai-asset-analysis-page .qt-floating-btn[data-v-443699fa]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.4);box-shadow:0 4px 16px rgba(24,144,255,.4)}
|
||||
.ai-asset-analysis-page .workspace-card .tab-body .polymarket-tab-content .polymarket-placeholder .placeholder-icon[data-v-443699fa]{color:#1890ff}
|
||||
.code-section .section-header .section-title[data-v-4fea1865]:before{background:#1890ff}
|
||||
.code-section .section-header .section-actions[data-v-4fea1865] .ant-btn-link{color:#1890ff}
|
||||
.code-section .section-header .section-actions[data-v-4fea1865] .ant-btn-link:hover{color:#40a9ff}
|
||||
@@ -81,47 +115,47 @@ html{--antd-wave-shadow-color:#1890ff}
|
||||
.editor-footer[data-v-4fea1865] .ant-btn:not(.ant-btn-primary):hover{border-color:#40a9ff;color:#40a9ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.2);box-shadow:0 2px 8px rgba(24,144,255,.2)}
|
||||
.editor-footer[data-v-4fea1865] .ant-btn.ant-btn-primary{-webkit-box-shadow:0 2px 4px rgba(24,144,255,.3);box-shadow:0 2px 4px rgba(24,144,255,.3)}
|
||||
.editor-footer[data-v-4fea1865] .ant-btn.ant-btn-primary:hover{-webkit-box-shadow:0 4px 12px rgba(24,144,255,.4);box-shadow:0 4px 12px rgba(24,144,255,.4)}
|
||||
.drawing-tool-btn[data-v-5f0b12e1]:hover{color:#1890ff}
|
||||
.drawing-tool-btn.active[data-v-5f0b12e1]{background:#e6f7ff;color:#1890ff;border:1px solid #1890ff}
|
||||
.indicator-btn[data-v-5f0b12e1]:hover{color:#1890ff;border-color:#1890ff}
|
||||
.indicator-btn.active[data-v-5f0b12e1]{color:#1890ff;border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.1);box-shadow:0 0 0 2px rgba(24,144,255,.1)}
|
||||
.drawing-tool-btn[data-v-58f93984]:hover{color:#1890ff}
|
||||
.drawing-tool-btn.active[data-v-58f93984]{background:#e6f7ff;color:#1890ff;border:1px solid #1890ff}
|
||||
.indicator-btn[data-v-58f93984]:hover{color:#1890ff;border-color:#1890ff}
|
||||
.indicator-btn.active[data-v-58f93984]{color:#1890ff;border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.1);box-shadow:0 0 0 2px rgba(24,144,255,.1)}
|
||||
.section-title .anticon[data-v-473e449c]{color:#1890ff}
|
||||
.loading-overlay .bar[data-v-473e449c]{background:-webkit-gradient(linear,left top,left bottom,from(#1890ff),to(#52c41a));background:linear-gradient(180deg,#1890ff,#52c41a)}
|
||||
.loading-overlay .loading-text[data-v-473e449c]{color:#1890ff}
|
||||
.symbol-select[data-v-b17e86c2] .ant-select-selection:hover{border-color:#1890ff}
|
||||
.symbol-select[data-v-b17e86c2] .ant-select-focused .ant-select-selection{border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.2);box-shadow:0 0 0 2px rgba(24,144,255,.2)}
|
||||
.timeframe-item[data-v-b17e86c2]:hover{color:#1890ff}
|
||||
.timeframe-item.active[data-v-b17e86c2]{color:#1890ff}
|
||||
.panel-header .realtime-toggle-btn[data-v-b17e86c2]:hover,.panel-header .theme-toggle-btn[data-v-b17e86c2]:hover{color:#1890ff}
|
||||
.panel-header .realtime-toggle-btn.active[data-v-b17e86c2],.panel-header .theme-toggle-btn.active[data-v-b17e86c2]{color:#1890ff;background:#e6f7ff}
|
||||
.indicator-card[data-v-b17e86c2]:hover{border-color:#1890ff}
|
||||
.indicator-card.active[data-v-b17e86c2]{background:#e6f7ff;border-color:#1890ff}
|
||||
.indicator-card.active .card-name[data-v-b17e86c2]{color:#1890ff}
|
||||
.card-action[data-v-b17e86c2]:hover{color:#1890ff}
|
||||
.action-icon.edit-icon[data-v-b17e86c2]{color:#1890ff}
|
||||
.action-icon.edit-icon[data-v-b17e86c2]:hover{color:#40a9ff}
|
||||
.action-icon[data-v-b17e86c2]:hover{color:#1890ff}
|
||||
.action-icon.publish-icon[data-v-b17e86c2]{color:#1890ff}
|
||||
.action-icon.publish-icon[data-v-b17e86c2]:hover{color:#40a9ff}
|
||||
.action-icon.expiry-icon[data-v-b17e86c2]{color:#1890ff}
|
||||
.symbol-select[data-v-211c2fea] .ant-select-selection:hover{border-color:#1890ff}
|
||||
.symbol-select[data-v-211c2fea] .ant-select-focused .ant-select-selection{border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.2);box-shadow:0 0 0 2px rgba(24,144,255,.2)}
|
||||
.timeframe-item[data-v-211c2fea]:hover{color:#1890ff}
|
||||
.timeframe-item.active[data-v-211c2fea]{color:#1890ff}
|
||||
.panel-header .realtime-toggle-btn[data-v-211c2fea]:hover,.panel-header .theme-toggle-btn[data-v-211c2fea]:hover{color:#1890ff}
|
||||
.panel-header .realtime-toggle-btn.active[data-v-211c2fea],.panel-header .theme-toggle-btn.active[data-v-211c2fea]{color:#1890ff;background:#e6f7ff}
|
||||
.indicator-card[data-v-211c2fea]:hover{border-color:#1890ff}
|
||||
.indicator-card.active[data-v-211c2fea]{background:#e6f7ff;border-color:#1890ff}
|
||||
.indicator-card.active .card-name[data-v-211c2fea]{color:#1890ff}
|
||||
.card-action[data-v-211c2fea]:hover{color:#1890ff}
|
||||
.action-icon.edit-icon[data-v-211c2fea]{color:#1890ff}
|
||||
.action-icon.edit-icon[data-v-211c2fea]:hover{color:#40a9ff}
|
||||
.action-icon[data-v-211c2fea]:hover{color:#1890ff}
|
||||
.action-icon.publish-icon[data-v-211c2fea]{color:#1890ff}
|
||||
.action-icon.publish-icon[data-v-211c2fea]:hover{color:#40a9ff}
|
||||
.action-icon.expiry-icon[data-v-211c2fea]{color:#1890ff}
|
||||
.dark-dropdown .ant-select-dropdown-menu-item-active{background-color:#e6f7ff;color:#1890ff}
|
||||
.qt-header-btn[data-v-b17e86c2]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 2px 8px rgba(24,144,255,.3);box-shadow:0 2px 8px rgba(24,144,255,.3)}
|
||||
.qt-header-btn[data-v-b17e86c2]:focus,.qt-header-btn[data-v-b17e86c2]:hover{background:linear-gradient(135deg,#40a9ff,#9254de);-webkit-box-shadow:0 4px 12px rgba(24,144,255,.45);box-shadow:0 4px 12px rgba(24,144,255,.45)}
|
||||
.qt-floating-btn[data-v-b17e86c2]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.4);box-shadow:0 4px 16px rgba(24,144,255,.4)}
|
||||
.qt-floating-btn[data-v-b17e86c2]:hover{-webkit-box-shadow:0 6px 24px rgba(24,144,255,.55);box-shadow:0 6px 24px rgba(24,144,255,.55)}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .symbol-select[data-v-b17e86c2] .ant-select-selection:hover,body.dark,body.realdark{border-color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .symbol-select[data-v-b17e86c2] .ant-select-focused .ant-select-selection,body.dark,body.realdark{border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.3);box-shadow:0 0 0 2px rgba(24,144,255,.3)}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .timeframe-group .timeframe-item[data-v-b17e86c2]:hover,body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .timeframe-group .timeframe-item.active[data-v-b17e86c2],body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .panel-header .realtime-toggle-btn.active[data-v-b17e86c2],.chart-container.theme-dark .chart-right .indicators-panel .panel-header .realtime-toggle-btn[data-v-b17e86c2]:hover,body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .section-label .buy-indicator-btn[data-v-b17e86c2],body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .section-label .buy-indicator-btn[data-v-b17e86c2]:hover,body.dark,body.realdark{color:#40a9ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card.active[data-v-b17e86c2],.chart-container.theme-dark .chart-right .indicators-panel .indicator-card[data-v-b17e86c2]:hover,body.dark,body.realdark{border-color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card.active .card-name[data-v-b17e86c2],body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.edit-icon[data-v-b17e86c2],.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon[data-v-b17e86c2]:hover,body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.edit-icon[data-v-b17e86c2]:hover,body.dark,body.realdark{color:#40a9ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-b17e86c2],body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-b17e86c2]:hover,body.dark,body.realdark{color:#40a9ff}
|
||||
.qt-header-btn[data-v-211c2fea]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 2px 8px rgba(24,144,255,.3);box-shadow:0 2px 8px rgba(24,144,255,.3)}
|
||||
.qt-header-btn[data-v-211c2fea]:focus,.qt-header-btn[data-v-211c2fea]:hover{background:linear-gradient(135deg,#40a9ff,#9254de);-webkit-box-shadow:0 4px 12px rgba(24,144,255,.45);box-shadow:0 4px 12px rgba(24,144,255,.45)}
|
||||
.qt-floating-btn[data-v-211c2fea]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.4);box-shadow:0 4px 16px rgba(24,144,255,.4)}
|
||||
.qt-floating-btn[data-v-211c2fea]:hover{-webkit-box-shadow:0 6px 24px rgba(24,144,255,.55);box-shadow:0 6px 24px rgba(24,144,255,.55)}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .symbol-select[data-v-211c2fea] .ant-select-selection:hover,body.dark,body.realdark{border-color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .symbol-select[data-v-211c2fea] .ant-select-focused .ant-select-selection,body.dark,body.realdark{border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.3);box-shadow:0 0 0 2px rgba(24,144,255,.3)}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .timeframe-group .timeframe-item[data-v-211c2fea]:hover,body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .timeframe-group .timeframe-item.active[data-v-211c2fea],body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .panel-header .realtime-toggle-btn.active[data-v-211c2fea],.chart-container.theme-dark .chart-right .indicators-panel .panel-header .realtime-toggle-btn[data-v-211c2fea]:hover,body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .section-label .buy-indicator-btn[data-v-211c2fea],body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .section-label .buy-indicator-btn[data-v-211c2fea]:hover,body.dark,body.realdark{color:#40a9ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card.active[data-v-211c2fea],.chart-container.theme-dark .chart-right .indicators-panel .indicator-card[data-v-211c2fea]:hover,body.dark,body.realdark{border-color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card.active .card-name[data-v-211c2fea],body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.edit-icon[data-v-211c2fea],.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon[data-v-211c2fea]:hover,body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.edit-icon[data-v-211c2fea]:hover,body.dark,body.realdark{color:#40a9ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-211c2fea],body.dark,body.realdark{color:#1890ff}
|
||||
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-211c2fea]:hover,body.dark,body.realdark{color:#40a9ff}
|
||||
.comment-list .comment-form .form-header .edit-label[data-v-4973cae6]{color:#1890ff}
|
||||
.comment-list .comments .comment-item.is-mine[data-v-4973cae6]{background:rgba(24,144,255,.02)}
|
||||
[data-theme=dark] .comment-list .comments .comment-item.is-mine[data-v-4973cae6]{background:rgba(24,144,255,.05)}
|
||||
@@ -185,32 +219,32 @@ body.dark .trading-records ::v-deep .ant-pagination .ant-pagination-next:hover .
|
||||
.theme-dark .position-checkbox-item[data-v-398d904c]:hover{background:rgba(24,144,255,.1)}
|
||||
.theme-dark .alert-symbol-info .current-price-info[data-v-398d904c]{background:linear-gradient(135deg,rgba(24,144,255,.15),rgba(114,46,209,.1))}
|
||||
.theme-dark .alert-symbol-info .current-price-info .price[data-v-398d904c]{color:#40a9ff}
|
||||
.user-manage-page .page-header .page-title .anticon[data-v-49c4f239]{color:#1890ff}
|
||||
.user-manage-page .address-text[data-v-49c4f239],.user-manage-page .hash-text[data-v-49c4f239]{color:#1890ff}
|
||||
.user-manage-page.theme-dark .manage-tabs[data-v-49c4f239] .ant-tabs-tab-active{color:#1890ff}
|
||||
.user-manage-page .current-credits-info .value[data-v-49c4f239],.user-manage-page .current-vip-info .value[data-v-49c4f239]{color:#1890ff}
|
||||
.profile-page .page-header .page-title .anticon[data-v-86a53e9c]{color:#1890ff}
|
||||
.profile-page .profile-card .profile-info .info-item .anticon[data-v-86a53e9c]{color:#1890ff}
|
||||
.profile-page.theme-dark .edit-card[data-v-86a53e9c] .ant-tabs-tab-active{color:#1890ff}
|
||||
.profile-page.theme-dark .edit-card[data-v-86a53e9c] .ant-input-password:focus,.profile-page.theme-dark .edit-card[data-v-86a53e9c] .ant-input-password:hover,.profile-page.theme-dark .edit-card[data-v-86a53e9c] .ant-input:focus,.profile-page.theme-dark .edit-card[data-v-86a53e9c] .ant-input:hover{border-color:#1890ff}
|
||||
.profile-page.theme-dark .notification-settings-form[data-v-86a53e9c] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .notification-settings-form[data-v-86a53e9c] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .password-form[data-v-86a53e9c] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .password-form[data-v-86a53e9c] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .profile-form[data-v-86a53e9c] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .profile-form[data-v-86a53e9c] .ant-radio-checked .ant-radio-inner{background-color:#1890ff;border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-86a53e9c] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-86a53e9c] .ant-pagination .ant-pagination-item-active{background:#1890ff;border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-86a53e9c] .ant-btn.ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.billing-page .plan-card.highlight[data-v-0cdd5f9c]{border:1px solid rgba(24,144,255,.35)}
|
||||
.user-manage-page .page-header .page-title .anticon[data-v-f0405968]{color:#1890ff}
|
||||
.user-manage-page .address-text[data-v-f0405968],.user-manage-page .hash-text[data-v-f0405968]{color:#1890ff}
|
||||
.user-manage-page.theme-dark .manage-tabs[data-v-f0405968] .ant-tabs-tab-active{color:#1890ff}
|
||||
.user-manage-page .current-credits-info .value[data-v-f0405968],.user-manage-page .current-vip-info .value[data-v-f0405968]{color:#1890ff}
|
||||
.profile-page .page-header .page-title .anticon[data-v-6dceb76a]{color:#1890ff}
|
||||
.profile-page .profile-card .profile-info .info-item .anticon[data-v-6dceb76a]{color:#1890ff}
|
||||
.profile-page.theme-dark .edit-card[data-v-6dceb76a] .ant-tabs-tab-active{color:#1890ff}
|
||||
.profile-page.theme-dark .edit-card[data-v-6dceb76a] .ant-input-password:focus,.profile-page.theme-dark .edit-card[data-v-6dceb76a] .ant-input-password:hover,.profile-page.theme-dark .edit-card[data-v-6dceb76a] .ant-input:focus,.profile-page.theme-dark .edit-card[data-v-6dceb76a] .ant-input:hover{border-color:#1890ff}
|
||||
.profile-page.theme-dark .notification-settings-form[data-v-6dceb76a] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .notification-settings-form[data-v-6dceb76a] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .password-form[data-v-6dceb76a] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .password-form[data-v-6dceb76a] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .profile-form[data-v-6dceb76a] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .profile-form[data-v-6dceb76a] .ant-radio-checked .ant-radio-inner{background-color:#1890ff;border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-6dceb76a] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-6dceb76a] .ant-pagination .ant-pagination-item-active{background:#1890ff;border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-6dceb76a] .ant-btn.ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.billing-page .plan-card.highlight[data-v-714c882a]{border:1px solid rgba(24,144,255,.35)}
|
||||
.usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,.usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover{background:rgba(24,144,255,.08);color:#1890ff}
|
||||
.theme-dark .usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,.theme-dark .usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover,body.realdark .usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,body.realdark .usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover{background:rgba(24,144,255,.15);color:#40a9ff}
|
||||
.settings-page .settings-header .page-title .anticon[data-v-20857e25]{color:#1890ff}
|
||||
.settings-page .openrouter-balance-card .ant-card[data-v-20857e25]{background:linear-gradient(135deg,#e6f7ff,#f0f5ff);border:1px solid #91d5ff}
|
||||
.settings-page .openrouter-balance-card .balance-header .balance-title[data-v-20857e25]{color:#1890ff}
|
||||
.settings-page .settings-collapse[data-v-20857e25] .ant-collapse-item .ant-collapse-header .ant-collapse-arrow{color:#1890ff}
|
||||
.settings-page .settings-collapse[data-v-20857e25] .ant-collapse-item .ant-collapse-header .panel-header .panel-icon-left{color:#1890ff}
|
||||
.settings-page .settings-form[data-v-20857e25] .ant-form-item-label .form-label-with-tooltip .help-icon:hover{color:#1890ff}
|
||||
.settings-page .settings-form[data-v-20857e25] .ant-form-item-label .form-label-with-tooltip .api-link{color:#1890ff;background:rgba(24,144,255,.08)}
|
||||
.settings-page .settings-form[data-v-20857e25] .ant-form-item-label .form-label-with-tooltip .api-link:hover{background:rgba(24,144,255,.15)}
|
||||
.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-form-item-label .form-label-with-tooltip .api-link{background:rgba(24,144,255,.15)}
|
||||
.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-form-item-label .form-label-with-tooltip .api-link:hover{background:rgba(24,144,255,.25)}
|
||||
.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-input-number:focus,.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-input-number:hover,.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-input-password:focus,.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-input-password:hover,.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-input:focus,.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-input:hover,.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-select-selection:focus,.settings-page.theme-dark .settings-form[data-v-20857e25] .ant-select-selection:hover{border-color:#1890ff}
|
||||
body.dark .usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,body.dark .usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover,body.realdark .usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,body.realdark .usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover{background:rgba(24,144,255,.15);color:#40a9ff}
|
||||
.settings-page .settings-header .page-title .anticon[data-v-f144964c]{color:#1890ff}
|
||||
.settings-page .openrouter-balance-card .ant-card[data-v-f144964c]{background:linear-gradient(135deg,#e6f7ff,#f0f5ff);border:1px solid #91d5ff}
|
||||
.settings-page .openrouter-balance-card .balance-header .balance-title[data-v-f144964c]{color:#1890ff}
|
||||
.settings-page .settings-collapse[data-v-f144964c] .ant-collapse-item .ant-collapse-header .ant-collapse-arrow{color:#1890ff}
|
||||
.settings-page .settings-collapse[data-v-f144964c] .ant-collapse-item .ant-collapse-header .panel-header .panel-icon-left{color:#1890ff}
|
||||
.settings-page .settings-form[data-v-f144964c] .ant-form-item-label .form-label-with-tooltip .help-icon:hover{color:#1890ff}
|
||||
.settings-page .settings-form[data-v-f144964c] .ant-form-item-label .form-label-with-tooltip .api-link{color:#1890ff;background:rgba(24,144,255,.08)}
|
||||
.settings-page .settings-form[data-v-f144964c] .ant-form-item-label .form-label-with-tooltip .api-link:hover{background:rgba(24,144,255,.15)}
|
||||
.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-form-item-label .form-label-with-tooltip .api-link{background:rgba(24,144,255,.15)}
|
||||
.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-form-item-label .form-label-with-tooltip .api-link:hover{background:rgba(24,144,255,.25)}
|
||||
.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-input-number:focus,.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-input-number:hover,.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-input-password:focus,.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-input-password:hover,.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-input:focus,.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-input:hover,.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-select-selection:focus,.settings-page.theme-dark .settings-form[data-v-f144964c] .ant-select-selection:hover{border-color:#1890ff}
|
||||
.turnstile-container .turnstile-error a[data-v-20437450]{color:#1890ff}
|
||||
.main .login-method-switch a[data-v-de9678f6]:hover{color:#1890ff}
|
||||
.main .login-method-switch a.active[data-v-de9678f6]{color:#1890ff;border-bottom-color:#1890ff}
|
||||
Vendored
+1
-1
@@ -421,4 +421,4 @@
|
||||
.brand-text {
|
||||
font-size: 20px;
|
||||
}
|
||||
}</style><script defer="defer" src="/js/chunk-vendors.47fd2294.js" type="module"></script><script defer="defer" src="/js/app.4799d28b.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.ee3dda40.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.74c62065.js" nomodule></script><script defer="defer" src="/js/app-legacy.73c5f6b0.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
|
||||
}</style><script defer="defer" src="/js/chunk-vendors.74240b6f.js" type="module"></script><script defer="defer" src="/js/app.09efca8e.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.23a9a8f8.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.a81203f4.js" nomodule></script><script defer="defer" src="/js/app-legacy.fca528f4.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
|
||||
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+18
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-18
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
-18
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user