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)
|
- **🧠 Memory-Augmented** — Agents learn from past analyses (local RAG, not cloud)
|
||||||
- **🔌 5+ LLM Providers**: OpenRouter (100+ models), OpenAI, Gemini, DeepSeek, Grok
|
- **🔌 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.
|
- **📊 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
|
### 📈 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
|
- **💳 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
|
- **₿ 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
|
- **🏪 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
|
### 🔐 Enterprise-Grade Security
|
||||||
|
|
||||||
@@ -350,49 +351,45 @@ Phase 3 (Decision): 🎯 TraderAgent → BUY / SELL / HOLD (with confidence %)
|
|||||||
- **Demo Mode** — Read-only mode for public showcases
|
- **Demo Mode** — Read-only mode for public showcases
|
||||||
|
|
||||||
<details>
|
<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
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph Entry["🌐 API Entry"]
|
subgraph Entry["🌐 API Entry"]
|
||||||
A["📡 POST /api/analysis/multi"]
|
A["📡 POST /api/fast-analysis/analyze"]
|
||||||
A2["🔄 POST /api/analysis/reflect"]
|
A2["📜 GET /api/fast-analysis/history"]
|
||||||
|
A3["📊 GET /api/fast-analysis/similar-patterns"]
|
||||||
end
|
end
|
||||||
subgraph Service["⚙️ Service Orchestration"]
|
subgraph Data["📊 Data Layer"]
|
||||||
B[AnalysisService]
|
D1[MarketDataCollector]
|
||||||
C[AgentCoordinator]
|
D2["Price · Kline · Macro · News · Fundamentals"]
|
||||||
D["📊 Build Context<br/>price · kline · news · indicators"]
|
D3["Multi-TF Consensus 1D / 4H / 1H"]
|
||||||
end
|
end
|
||||||
subgraph Agents["🤖 7-Agent Workflow"]
|
subgraph Analysis["⚙️ Analysis Layer"]
|
||||||
subgraph P1["📈 Phase 1 · Parallel Analysis"]
|
B["FastAnalysisService"]
|
||||||
E1["🔍 MarketAnalyst"]
|
C["Single LLM Call<br/>Constrained Prompt"]
|
||||||
E2["📑 FundamentalAnalyst"]
|
E["Objective Score + Multi-TF Consensus"]
|
||||||
E3["📰 NewsAnalyst"]
|
F["AICalibration Threshold Tuning"]
|
||||||
E4["💭 SentimentAnalyst"]
|
G["BUY / SELL / HOLD"]
|
||||||
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
|
|
||||||
end
|
end
|
||||||
subgraph Memory["🧠 Local Memory Store"]
|
subgraph Memory["🧠 Memory Layer"]
|
||||||
M1[("Agent Memories (PostgreSQL)")]
|
M1[("qd_analysis_memory<br/>PostgreSQL")]
|
||||||
|
M2["RAG Similar-Pattern Retrieval<br/>(optional prompt injection)"]
|
||||||
end
|
end
|
||||||
subgraph Reflect["🔄 Reflection Loop"]
|
A --> B
|
||||||
R[ReflectionService]
|
B --> D1 --> D2 --> D3
|
||||||
W["⏰ ReflectionWorker → verify + learn"]
|
D3 --> C --> E --> F --> G
|
||||||
end
|
B -.->|"store"| M1
|
||||||
A --> B --> C --> D
|
M1 -.->|"similar patterns"| M2
|
||||||
D --> P1 --> P2 --> P3
|
M2 -.->|"optional context"| C
|
||||||
Agents <-.->|"RAG retrieval"| M1
|
A2 --> M1
|
||||||
C --> R
|
A3 --> M1
|
||||||
W -.->|"update memories"| M1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Flow:** Data collection → Multi-timeframe consensus → Single LLM call → Calibration override → Store in memory.
|
||||||
|
|
||||||
</details>
|
</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` |
|
| **Membership** | `MEMBERSHIP_MONTHLY_PRICE_USD`, `MEMBERSHIP_MONTHLY_CREDITS` |
|
||||||
| **USDT Payment** | `USDT_PAY_ENABLED`, `USDT_TRC20_XPUB`, `TRONGRID_API_KEY` |
|
| **USDT Payment** | `USDT_PAY_ENABLED`, `USDT_TRC20_XPUB`, `TRONGRID_API_KEY` |
|
||||||
| **Proxy** | `PROXY_URL` |
|
| **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>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -177,20 +177,19 @@ POST /api/users/change-password - Change own password
|
|||||||
```text
|
```text
|
||||||
GET /api/health
|
GET /api/health
|
||||||
GET /api/indicator/kline
|
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
|
- Memory: `qd_analysis_memory` in PostgreSQL
|
||||||
- API hooks:
|
- API: `POST /api/fast-analysis/analyze` (main), `/history`, `/similar-patterns`, `/feedback`
|
||||||
- `POST /api/analysis/multi` (main entry)
|
- Calibration: `AICalibrationService` tunes BUY/SELL thresholds from validated outcomes
|
||||||
- `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`
|
|
||||||
|
|
||||||
## Frontend integration
|
## Frontend integration
|
||||||
|
|
||||||
|
|||||||
@@ -279,6 +279,12 @@ def create_app(config_name='default'):
|
|||||||
start_ai_calibration_worker()
|
start_ai_calibration_worker()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
restore_running_strategies()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"""
|
"""
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, List, Any, Optional
|
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
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
@@ -136,19 +136,35 @@ class BaseDataSource(ABC):
|
|||||||
klines: List[Dict[str, Any]],
|
klines: List[Dict[str, Any]],
|
||||||
timeframe: str
|
timeframe: str
|
||||||
):
|
):
|
||||||
"""记录获取结果日志"""
|
"""记录获取结果日志。
|
||||||
|
|
||||||
|
延迟判断:
|
||||||
|
- K 线 time 为 Unix 秒(UTC),与 datetime.now(UTC) 比较,避免本地时区误差。
|
||||||
|
- 日线/周线:最后一根通常是「上一交易日收盘」,周末/节假日可达 3~4 天,
|
||||||
|
原先用 2×86400s(48h)会在周一早盘误报;改为日线最多容忍约 5 个自然日,周线更宽。
|
||||||
|
"""
|
||||||
if klines:
|
if klines:
|
||||||
latest_time = datetime.fromtimestamp(klines[-1]['time'])
|
latest_ts = int(klines[-1]["time"])
|
||||||
time_diff = (datetime.now() - latest_time).total_seconds()
|
latest_utc = datetime.fromtimestamp(latest_ts, tz=timezone.utc)
|
||||||
# logger.info(
|
now_utc = datetime.now(timezone.utc)
|
||||||
# f"{self.name}: {symbol} 获取 {len(klines)} 条数据, "
|
time_diff = (now_utc - latest_utc).total_seconds()
|
||||||
# f"最新时间: {latest_time}, 延迟: {time_diff:.0f}秒"
|
|
||||||
# )
|
tf_sec = TIMEFRAME_SECONDS.get(timeframe, 3600)
|
||||||
|
if tf_sec < 86400:
|
||||||
# 检查数据是否过旧
|
# 分钟/小时级:超过约 2 根 K 未更新则告警
|
||||||
max_diff = TIMEFRAME_SECONDS.get(timeframe, 3600) * 2
|
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:
|
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:
|
else:
|
||||||
logger.warning(f"{self.name}: no data for {symbol}")
|
logger.warning(f"{self.name}: no data for {symbol}")
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,72 @@ _analysis_inflight_lock = threading.Lock()
|
|||||||
_analysis_inflight = {} # key -> expire_ts
|
_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:
|
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()}"
|
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')
|
language = data.get('language', 'en-US')
|
||||||
model = data.get('model')
|
model = data.get('model')
|
||||||
timeframe = data.get('timeframe', '1D')
|
timeframe = data.get('timeframe', '1D')
|
||||||
|
async_submit = bool(data.get('async_submit', False))
|
||||||
|
|
||||||
if not market or not symbol:
|
if not market or not symbol:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -134,6 +201,45 @@ def analyze():
|
|||||||
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
|
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
|
||||||
|
|
||||||
service = get_fast_analysis_service()
|
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(
|
result = service.analyze(
|
||||||
market=market,
|
market=market,
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
|
|||||||
@@ -424,12 +424,37 @@ def _fetch_commodities() -> List[Dict[str, Any]]:
|
|||||||
"category": "commodity"
|
"category": "commodity"
|
||||||
})
|
})
|
||||||
elif len(hist) == 1:
|
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({
|
result.append({
|
||||||
"symbol": commodity["symbol"],
|
"symbol": commodity["symbol"],
|
||||||
"name_cn": commodity["name_cn"],
|
"name_cn": commodity["name_cn"],
|
||||||
"name_en": commodity["name_en"],
|
"name_en": commodity["name_en"],
|
||||||
"price": round(hist["Close"].iloc[-1], 2),
|
"price": round(current, 2),
|
||||||
"change": 0,
|
"change": round(change, 2),
|
||||||
"unit": commodity["unit"],
|
"unit": commodity["unit"],
|
||||||
"category": "commodity"
|
"category": "commodity"
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -698,7 +698,18 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
|
|||||||
return content.strip() or _template_code()
|
return content.strip() or _template_code()
|
||||||
|
|
||||||
def stream():
|
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:
|
try:
|
||||||
code_text = _generate_code_via_llm()
|
code_text = _generate_code_via_llm()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -250,20 +250,17 @@ def add_position():
|
|||||||
|
|
||||||
with get_db_connection() as db:
|
with get_db_connection() as db:
|
||||||
cur = db.cursor()
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO qd_manual_positions
|
INSERT INTO qd_manual_positions
|
||||||
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at)
|
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
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)
|
(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'):
|
if monitor_type not in ('ai', 'price_alert', 'pnl_alert'):
|
||||||
monitor_type = 'ai'
|
monitor_type = 'ai'
|
||||||
|
|
||||||
# Calculate next_run_at based on interval
|
# Calculate next_run_at based on interval (frontend sends run_interval_minutes)
|
||||||
interval_minutes = int(config.get('interval_minutes') or 60)
|
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)
|
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)
|
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 = ?')
|
updates.append('config = ?')
|
||||||
params.append(json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False))
|
params.append(json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False))
|
||||||
|
|
||||||
# Recalculate next_run_at if interval changed (handled separately for PostgreSQL)
|
# Recalculate next_run_at if interval changed
|
||||||
next_run_interval = int(config.get('interval_minutes') or 60)
|
next_run_interval = int(config.get('run_interval_minutes') or config.get('interval_minutes') or 60)
|
||||||
|
|
||||||
if 'notification_config' in data:
|
if 'notification_config' in data:
|
||||||
notification_config = data.get('notification_config') or {}
|
notification_config = data.get('notification_config') or {}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ Admin-only endpoints for system configuration management.
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import importlib
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.config_loader import clear_config_cache
|
from app.utils.config_loader import clear_config_cache
|
||||||
from app.utils.auth import login_required, admin_required
|
from app.utils.auth import login_required, admin_required
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -17,6 +19,55 @@ settings_bp = Blueprint('settings', __name__)
|
|||||||
# .env 文件路径
|
# .env 文件路径
|
||||||
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.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',
|
'icon': 'experiment',
|
||||||
'order': 7,
|
'order': 7,
|
||||||
'items': [
|
'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',
|
'key': 'ENABLE_REFLECTION_WORKER',
|
||||||
'label': 'Enable Auto Reflection',
|
'label': 'Enable Auto Reflection',
|
||||||
'type': 'boolean',
|
'type': 'boolean',
|
||||||
'default': 'False',
|
'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',
|
'key': 'BILLING_COST_AI_ANALYSIS',
|
||||||
'label': 'AI Analysis Cost',
|
'label': 'AI Analysis Cost (per symbol)',
|
||||||
'type': 'number',
|
'type': 'number',
|
||||||
'default': '10',
|
'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',
|
'key': 'BILLING_COST_AI_CODE_GEN',
|
||||||
'label': 'Strategy Run Cost',
|
'label': 'AI Code Generation Cost',
|
||||||
'type': 'number',
|
'type': 'number',
|
||||||
'default': '5',
|
'default': '30',
|
||||||
'description': 'Credits per strategy start'
|
'description': 'Credits per AI strategy/indicator code generation (higher token usage)'
|
||||||
},
|
|
||||||
{
|
|
||||||
'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'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'key': 'CREDITS_REGISTER_BONUS',
|
'key': 'CREDITS_REGISTER_BONUS',
|
||||||
@@ -873,13 +966,19 @@ def save_settings():
|
|||||||
if write_env_file(current_env):
|
if write_env_file(current_env):
|
||||||
# 清除配置缓存
|
# 清除配置缓存
|
||||||
clear_config_cache()
|
clear_config_cache()
|
||||||
|
# 热重载运行时环境变量(无需重启进程)
|
||||||
|
_reload_runtime_env()
|
||||||
|
# 重置依赖配置的服务单例(下次请求自动按新配置重建)
|
||||||
|
_refresh_runtime_services()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'code': 1,
|
'code': 1,
|
||||||
'msg': 'Settings saved successfully',
|
'msg': 'Settings saved successfully',
|
||||||
'data': {
|
'data': {
|
||||||
'updated_keys': list(updates.keys()),
|
'updated_keys': list(updates.keys()),
|
||||||
'requires_restart': True # 标记需要重启
|
'requires_restart': False,
|
||||||
|
'hot_reloaded': True,
|
||||||
|
'services_refreshed': True
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class AnalysisMemory:
|
|||||||
consensus_abs DECIMAL(24, 8),
|
consensus_abs DECIMAL(24, 8),
|
||||||
agreement_ratio DECIMAL(10, 6),
|
agreement_ratio DECIMAL(10, 6),
|
||||||
quality_multiplier 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(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
validated_at TIMESTAMP,
|
validated_at TIMESTAMP,
|
||||||
actual_outcome VARCHAR(20),
|
actual_outcome VARCHAR(20),
|
||||||
@@ -124,6 +127,27 @@ class AnalysisMemory:
|
|||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE qd_analysis_memory ADD COLUMN quality_multiplier DECIMAL(10, 6);
|
ALTER TABLE qd_analysis_memory ADD COLUMN quality_multiplier DECIMAL(10, 6);
|
||||||
END IF;
|
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 $$;
|
END $$;
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -185,14 +209,16 @@ class AnalysisMemory:
|
|||||||
INSERT INTO qd_analysis_memory (
|
INSERT INTO qd_analysis_memory (
|
||||||
user_id, market, symbol, decision, confidence,
|
user_id, market, symbol, decision, confidence,
|
||||||
price_at_analysis, summary, reasons, scores, indicators_snapshot, raw_result,
|
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,
|
) 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
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
user_id, market, symbol, decision, confidence,
|
user_id, market, symbol, decision, confidence,
|
||||||
price, summary, reasons, scores, indicators, raw,
|
price, summary, reasons, scores, indicators, raw,
|
||||||
consensus_score, consensus_abs, agreement_ratio, quality_multiplier,
|
consensus_score, consensus_abs, agreement_ratio, quality_multiplier,
|
||||||
|
"completed", "",
|
||||||
))
|
))
|
||||||
|
|
||||||
# 使用 lastrowid 属性获取 ID(execute 内部已经处理了 RETURNING)
|
# 使用 lastrowid 属性获取 ID(execute 内部已经处理了 RETURNING)
|
||||||
@@ -227,7 +253,8 @@ class AnalysisMemory:
|
|||||||
SELECT
|
SELECT
|
||||||
id, decision, confidence, price_at_analysis,
|
id, decision, confidence, price_at_analysis,
|
||||||
summary, reasons, scores,
|
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
|
FROM qd_analysis_memory
|
||||||
WHERE market = %s AND symbol = %s
|
WHERE market = %s AND symbol = %s
|
||||||
AND created_at > NOW() - INTERVAL '{int(days)} days'
|
AND created_at > NOW() - INTERVAL '{int(days)} days'
|
||||||
@@ -248,7 +275,10 @@ class AnalysisMemory:
|
|||||||
"summary": row['summary'],
|
"summary": row['summary'],
|
||||||
"reasons": _safe_json_parse(row['reasons'], []),
|
"reasons": _safe_json_parse(row['reasons'], []),
|
||||||
"scores": _safe_json_parse(row['scores'], {}),
|
"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,
|
"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'],
|
"was_correct": row['was_correct'],
|
||||||
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
|
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
|
||||||
})
|
})
|
||||||
@@ -292,7 +322,8 @@ class AnalysisMemory:
|
|||||||
SELECT
|
SELECT
|
||||||
id, market, symbol, decision, confidence, price_at_analysis,
|
id, market, symbol, decision, confidence, price_at_analysis,
|
||||||
summary, reasons, scores, indicators_snapshot, raw_result,
|
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
|
FROM qd_analysis_memory
|
||||||
{where_clause}
|
{where_clause}
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -316,7 +347,10 @@ class AnalysisMemory:
|
|||||||
"scores": _safe_json_parse(row['scores'], {}),
|
"scores": _safe_json_parse(row['scores'], {}),
|
||||||
"indicators": _safe_json_parse(row['indicators_snapshot'], {}),
|
"indicators": _safe_json_parse(row['indicators_snapshot'], {}),
|
||||||
"full_result": _safe_json_parse(row['raw_result'], None),
|
"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,
|
"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'],
|
"was_correct": row['was_correct'],
|
||||||
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
|
"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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete memory {memory_id}: {e}")
|
logger.error(f"Failed to delete memory {memory_id}: {e}")
|
||||||
return False
|
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,
|
def get_similar_patterns(self, market: str, symbol: str,
|
||||||
current_indicators: Dict, limit: int = 3) -> List[Dict]:
|
current_indicators: Dict, limit: int = 3) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Find historical analyses with similar technical patterns.
|
Find historical analyses with similar technical patterns.
|
||||||
|
|
||||||
This is a simplified version - can be enhanced with vector similarity later.
|
Multi-indicator weighted similarity:
|
||||||
Currently matches based on:
|
- RSI: ±15 range, weighted 0.3
|
||||||
- Same symbol
|
- MACD signal: exact match, weighted 0.3
|
||||||
- Similar RSI range (±10)
|
- MA trend: exact match, weighted 0.25
|
||||||
- Same MACD signal direction
|
- Volatility level: similar band, weighted 0.15
|
||||||
- Validated outcomes preferred
|
- Time decay: prefer recent validated outcomes
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
rsi = current_indicators.get("rsi", {}).get("value", 50)
|
rsi = float(current_indicators.get("rsi", {}).get("value") or 50)
|
||||||
macd_signal = current_indicators.get("macd", {}).get("signal", "neutral")
|
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:
|
with get_db_connection() as db:
|
||||||
cur = db.cursor()
|
cur = db.cursor()
|
||||||
|
|
||||||
# Simple pattern matching query
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
id, decision, confidence, price_at_analysis,
|
id, decision, confidence, price_at_analysis,
|
||||||
@@ -388,49 +538,50 @@ class AnalysisMemory:
|
|||||||
WHERE market = %s AND symbol = %s
|
WHERE market = %s AND symbol = %s
|
||||||
AND validated_at IS NOT NULL
|
AND validated_at IS NOT NULL
|
||||||
AND was_correct IS NOT NULL
|
AND was_correct IS NOT NULL
|
||||||
ORDER BY
|
ORDER BY validated_at DESC NULLS LAST, created_at DESC
|
||||||
CASE WHEN was_correct = true THEN 0 ELSE 1 END,
|
|
||||||
created_at DESC
|
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""", (market, symbol, limit * 2)) # Get more for filtering
|
""", (market, symbol, limit * 5))
|
||||||
|
|
||||||
rows = cur.fetchall() or []
|
rows = cur.fetchall() or []
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
results = []
|
scored = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
indicators = _safe_json_parse(row['indicators_snapshot'], {})
|
ind = _safe_json_parse(row['indicators_snapshot'], {})
|
||||||
hist_rsi = indicators.get("rsi", {}).get("value", 50)
|
hist_rsi = float(ind.get("rsi", {}).get("value") or 50)
|
||||||
hist_macd = indicators.get("macd", {}).get("signal", "neutral")
|
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_diff = abs(hist_rsi - rsi)
|
||||||
rsi_similar = abs(hist_rsi - rsi) <= 15
|
rsi_score = max(0, 1 - rsi_diff / 30) * 0.3
|
||||||
macd_similar = hist_macd == macd_signal
|
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:
|
sim = rsi_score + macd_score + ma_score + vol_score
|
||||||
results.append({
|
if sim < 0.25:
|
||||||
"id": row['id'],
|
continue
|
||||||
"decision": row['decision'],
|
|
||||||
"confidence": row['confidence'],
|
bonus = 0.1 if row['was_correct'] else 0
|
||||||
"price": float(row['price_at_analysis']) if row['price_at_analysis'] else None,
|
scored.append((sim + bonus, {
|
||||||
"summary": row['summary'],
|
"id": row['id'],
|
||||||
"was_correct": row['was_correct'],
|
"decision": row['decision'],
|
||||||
"actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None,
|
"confidence": row['confidence'],
|
||||||
"similarity": {
|
"price": float(row['price_at_analysis']) if row['price_at_analysis'] else None,
|
||||||
"rsi_match": rsi_similar,
|
"summary": row['summary'],
|
||||||
"macd_match": macd_similar,
|
"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),
|
||||||
|
}))
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
return results
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
return [p[1] for p in scored[:limit]]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get similar patterns: {e}")
|
logger.error(f"Failed to get similar patterns: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def record_feedback(self, memory_id: int, feedback: str) -> bool:
|
def record_feedback(self, memory_id: int, feedback: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Record user feedback on an analysis.
|
Record user feedback on an analysis.
|
||||||
@@ -493,8 +644,8 @@ class AnalysisMemory:
|
|||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
# Get current price using MarketDataCollector
|
price_data = collector._get_price(row['market'], row['symbol'])
|
||||||
current_price = 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:
|
if not current_price or current_price <= 0:
|
||||||
continue
|
continue
|
||||||
analysis_price = float(row['price_at_analysis'])
|
analysis_price = float(row['price_at_analysis'])
|
||||||
@@ -580,7 +731,8 @@ class AnalysisMemory:
|
|||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
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:
|
if not current_price or current_price <= 0:
|
||||||
continue
|
continue
|
||||||
analysis_price = float(row.get("price_at_analysis") or 0.0)
|
analysis_price = float(row.get("price_at_analysis") or 0.0)
|
||||||
@@ -625,6 +777,74 @@ class AnalysisMemory:
|
|||||||
|
|
||||||
return stats
|
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,
|
def get_performance_stats(self, market: str = None, symbol: str = None,
|
||||||
days: int = 30) -> Dict[str, Any]:
|
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
|
# Singleton
|
||||||
_memory_instance = None
|
_memory_instance = None
|
||||||
|
|
||||||
|
|||||||
@@ -30,21 +30,16 @@ DEFAULT_BILLING_CONFIG = {
|
|||||||
'enabled': False, # 是否启用计费
|
'enabled': False, # 是否启用计费
|
||||||
|
|
||||||
# 各功能积分消耗(0表示免费)
|
# 各功能积分消耗(0表示免费)
|
||||||
'cost_ai_analysis': 10, # AI分析 每次消耗积分
|
# ai_analysis 统一单价:即时分析 / AI过滤 / 定时任务 均按此单价 × 标的数扣费
|
||||||
'cost_strategy_run': 5, # 策略运行 每次消耗积分(启动时)
|
'cost_ai_analysis': 10,
|
||||||
'cost_backtest': 3, # 回测 每次消耗积分
|
'cost_ai_code_gen': 30,
|
||||||
'cost_portfolio_monitor': 8, # Portfolio AI监控 每次消耗积分
|
'cost_polymarket_deep_analysis': 15,
|
||||||
'cost_indicator_create': 0, # 创建指标 免费
|
|
||||||
'cost_polymarket_deep_analysis': 15, # Polymarket深度分析 每次消耗积分
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Feature name mapping (for log recording)
|
# Feature name mapping (for log recording)
|
||||||
FEATURE_NAMES = {
|
FEATURE_NAMES = {
|
||||||
'ai_analysis': 'AI Analysis',
|
'ai_analysis': 'AI Analysis',
|
||||||
'strategy_run': 'Strategy Run',
|
'ai_code_gen': 'AI Code Generation',
|
||||||
'backtest': 'Backtest',
|
|
||||||
'portfolio_monitor': 'Portfolio Monitor',
|
|
||||||
'indicator_create': 'Indicator Create',
|
|
||||||
'polymarket_deep_analysis': 'Polymarket Deep Analysis',
|
'polymarket_deep_analysis': 'Polymarket Deep Analysis',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +453,7 @@ class BillingService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: 用户ID
|
user_id: 用户ID
|
||||||
feature: 功能名称(ai_analysis/strategy_run/backtest/portfolio_monitor等)
|
feature: 功能名称(ai_analysis / polymarket_deep_analysis)
|
||||||
reference_id: 关联ID(可选)
|
reference_id: 关联ID(可选)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -716,12 +711,10 @@ class BillingService:
|
|||||||
'is_vip': is_vip,
|
'is_vip': is_vip,
|
||||||
'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None,
|
'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None,
|
||||||
'billing_enabled': config.get('enabled', False),
|
'billing_enabled': config.get('enabled', False),
|
||||||
# 功能费用(供前端显示)
|
|
||||||
'feature_costs': {
|
'feature_costs': {
|
||||||
'ai_analysis': config.get('cost_ai_analysis', 0),
|
'ai_analysis': config.get('cost_ai_analysis', 0),
|
||||||
'strategy_run': config.get('cost_strategy_run', 0),
|
'ai_code_gen': config.get('cost_ai_code_gen', 0),
|
||||||
'backtest': config.get('cost_backtest', 0),
|
'polymarket_deep_analysis': config.get('cost_polymarket_deep_analysis', 0),
|
||||||
'portfolio_monitor': config.get('cost_portfolio_monitor', 0),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ Fast Analysis Service 3.0
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
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 decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
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__)
|
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:
|
class FastAnalysisService:
|
||||||
"""
|
"""
|
||||||
快速分析服务 3.0
|
快速分析服务 3.0
|
||||||
@@ -364,10 +526,12 @@ You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not spe
|
|||||||
|
|
||||||
⚠️ CRITICAL PRICE RULES:
|
⚠️ CRITICAL PRICE RULES:
|
||||||
1. Current price: ${current_price}
|
1. Current price: ${current_price}
|
||||||
2. Your stop_loss MUST be near ${suggested_stop_loss:.4f} (range: ${price_lower_bound:.4f} ~ ${current_price})
|
2. If decision=BUY: stop_loss should be below current price, take_profit above current price.
|
||||||
3. Your take_profit MUST be near ${suggested_take_profit:.4f} (range: ${current_price} ~ ${price_upper_bound:.4f})
|
3. If decision=SELL (short): stop_loss MUST be above current price; take_profit MUST be below current price.
|
||||||
4. Entry price: ${entry_range_low:.4f} ~ ${entry_range_high:.4f}
|
4. BUY stop_loss reference: near ${suggested_stop_loss:.4f} (range: ${price_lower_bound:.4f} ~ ${current_price})
|
||||||
5. These levels are based on ATR and support/resistance analysis - use them as reference!
|
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):
|
📊 YOUR ANALYSIS MUST INCLUDE (ALL factors are important):
|
||||||
1. **Technical Analysis**: Objectively interpret RSI, MACD, MA, support/resistance. Be honest about conflicting signals.
|
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:
|
📈 EARNINGS DATA:
|
||||||
{self._format_earnings_data(fundamental.get('earnings', {}))}
|
{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:
|
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.
|
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.
|
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_sum += overall_score * w
|
||||||
weighted_score_w_sum += 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_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_decision = self._score_to_decision(consensus_score, market=market)
|
||||||
consensus_abs = abs(consensus_score)
|
consensus_abs = abs(consensus_score)
|
||||||
@@ -892,32 +1108,52 @@ IMPORTANT:
|
|||||||
|
|
||||||
# Phase 2: Build prompt
|
# Phase 2: Build prompt
|
||||||
system_prompt, user_prompt = self._build_analysis_prompt(data, language)
|
system_prompt, user_prompt = self._build_analysis_prompt(data, language)
|
||||||
|
|
||||||
# Phase 3: Single LLM call
|
default_struct = {
|
||||||
logger.info(f"Calling LLM for analysis...")
|
"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()
|
llm_start = time.time()
|
||||||
|
ensemble_models = []
|
||||||
analysis = self.llm_service.safe_call_llm(
|
if os.getenv("ENABLE_AI_ENSEMBLE", "false").lower() == "true":
|
||||||
system_prompt,
|
env_models = (os.getenv("AI_ENSEMBLE_MODELS") or "").strip()
|
||||||
user_prompt,
|
if env_models:
|
||||||
default_structure={
|
ensemble_models = [m.strip() for m in env_models.split(",") if m.strip()]
|
||||||
"decision": "HOLD",
|
|
||||||
"confidence": 50,
|
if len(ensemble_models) >= 2:
|
||||||
"summary": "Analysis failed",
|
analyses_list = []
|
||||||
"entry_price": current_price,
|
for em in ensemble_models[:3]:
|
||||||
"stop_loss": current_price * 0.95,
|
a = self.llm_service.safe_call_llm(
|
||||||
"take_profit": current_price * 1.05,
|
system_prompt, user_prompt, default_structure=default_struct, model=em
|
||||||
"position_size_pct": 10,
|
)
|
||||||
"timeframe": "medium",
|
analyses_list.append(a)
|
||||||
"key_reasons": ["Unable to analyze"],
|
decisions = [str(a.get("decision", "HOLD") or "HOLD").upper() for a in analyses_list]
|
||||||
"risks": ["Analysis error"],
|
from collections import Counter
|
||||||
"technical_score": 50,
|
vote = Counter(decisions).most_common(1)[0][0]
|
||||||
"fundamental_score": 50,
|
idx = decisions.index(vote)
|
||||||
"sentiment_score": 50,
|
analysis = analyses_list[idx].copy()
|
||||||
},
|
analysis["decision"] = vote
|
||||||
model=model
|
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)
|
llm_time = int((time.time() - llm_start) * 1000)
|
||||||
logger.info(f"LLM call completed in {llm_time}ms")
|
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)
|
score_based_decision = self._score_to_decision(objective_score["overall_score"], market=market)
|
||||||
llm_decision = str(analysis.get("decision", "HOLD") or "HOLD").upper()
|
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 confidence:
|
||||||
consensus_conf = int(max(40, min(98, 50 + consensus_abs * 0.35)))
|
consensus_conf = int(max(40, min(98, 50 + consensus_abs * 0.35)))
|
||||||
# Agreement boosts, disagreement reduces
|
# Agreement boosts, disagreement reduces
|
||||||
@@ -942,6 +1222,9 @@ IMPORTANT:
|
|||||||
cfg = self._get_ai_calibration(market=market)
|
cfg = self._get_ai_calibration(market=market)
|
||||||
min_abs_override = float(cfg.get("min_consensus_abs_override") or 15.0)
|
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)
|
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:
|
if consensus_abs >= min_abs_override:
|
||||||
final_decision = consensus_decision
|
final_decision = consensus_decision
|
||||||
@@ -953,11 +1236,21 @@ IMPORTANT:
|
|||||||
analysis["decision"] = final_decision
|
analysis["decision"] = final_decision
|
||||||
analysis["confidence"] = consensus_conf
|
analysis["confidence"] = consensus_conf
|
||||||
original_summary = analysis.get("summary", "")
|
original_summary = analysis.get("summary", "")
|
||||||
level = "强烈" if consensus_abs >= 70 else "明显" if consensus_abs >= 40 else "轻微"
|
is_zh = str(language or "").lower().startswith("zh")
|
||||||
analysis["summary"] = (
|
if is_zh:
|
||||||
f"{original_summary} [多周期客观共识:综合评分{consensus_score:.1f}分("
|
level = "强烈" if consensus_abs >= 70 else "明显" if consensus_abs >= 40 else "轻微"
|
||||||
f"{level}{'利多' if consensus_score > 0 else '利空'}),建议{final_decision}]"
|
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:
|
else:
|
||||||
# Near-neutral: keep LLM but shrink confidence by quality and enforce HOLD if quality is poor
|
# 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)))
|
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,
|
"consensus_abs": consensus_abs,
|
||||||
"agreement_ratio": agreement_ratio,
|
"agreement_ratio": agreement_ratio,
|
||||||
"quality_multiplier": quality_multiplier,
|
"quality_multiplier": quality_multiplier,
|
||||||
|
"market_regime": regime,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Phase 5: Validate and constrain output (pass indicators for decision validation)
|
# Phase 5: Validate and constrain output (pass indicators for decision validation)
|
||||||
@@ -1014,6 +1308,17 @@ IMPORTANT:
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Keep model-provided position_size_pct
|
# Keep model-provided position_size_pct
|
||||||
pass
|
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
|
# Build final result
|
||||||
total_time = int((time.time() - start_time) * 1000)
|
total_time = int((time.time() - start_time) * 1000)
|
||||||
@@ -1041,6 +1346,15 @@ IMPORTANT:
|
|||||||
"take_profit": analysis.get("take_profit"),
|
"take_profit": analysis.get("take_profit"),
|
||||||
"position_size_pct": analysis.get("position_size_pct", 10),
|
"position_size_pct": analysis.get("position_size_pct", 10),
|
||||||
"timeframe": analysis.get("timeframe", "medium"),
|
"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", []),
|
"reasons": analysis.get("key_reasons", []),
|
||||||
"risks": analysis.get("risks", []),
|
"risks": analysis.get("risks", []),
|
||||||
@@ -1060,6 +1374,10 @@ IMPORTANT:
|
|||||||
},
|
},
|
||||||
"indicators": data.get("indicators", {}),
|
"indicators": data.get("indicators", {}),
|
||||||
"consensus": analysis.get("consensus", {}),
|
"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,
|
"analysis_time_ms": total_time,
|
||||||
"llm_time_ms": llm_time,
|
"llm_time_ms": llm_time,
|
||||||
"data_collection_time_ms": data.get("collection_time_ms", 0),
|
"data_collection_time_ms": data.get("collection_time_ms", 0),
|
||||||
@@ -1149,51 +1467,45 @@ IMPORTANT:
|
|||||||
"""
|
"""
|
||||||
检查是否有重大新闻事件。
|
检查是否有重大新闻事件。
|
||||||
重大新闻包括:监管变化、重大合作、丑闻、重大政策、地缘政治事件等。
|
重大新闻包括:监管变化、重大合作、丑闻、重大政策、地缘政治事件等。
|
||||||
|
地缘类使用词边界与分级,避免 toward/extension/us 等子串误判。
|
||||||
"""
|
"""
|
||||||
if not news_data:
|
if not news_data:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 检查新闻标题中的关键词(扩展了地缘政治相关关键词)
|
# 子串关键词(较长词或中文,避免过短英文误匹配)
|
||||||
major_keywords = [
|
major_keywords = [
|
||||||
# 监管和政策
|
"regulation", "regulatory", "approval", "policy", "government", "central bank",
|
||||||
"regulation", "regulatory", "ban", "approval", "policy", "government", "central bank",
|
|
||||||
"监管", "禁令", "批准", "政策", "政府", "央行",
|
"监管", "禁令", "批准", "政策", "政府", "央行",
|
||||||
# 商业事件
|
|
||||||
"partnership", "merger", "acquisition", "scandal", "lawsuit", "investigation",
|
"partnership", "merger", "acquisition", "scandal", "lawsuit", "investigation",
|
||||||
"合作", "合并", "收购", "丑闻", "诉讼", "调查",
|
"合作", "合并", "收购", "丑闻", "诉讼", "调查",
|
||||||
# 地缘政治事件(新增)
|
"sanctions", "embargo", "制裁", "中东", "海湾", "北约",
|
||||||
"war", "conflict", "military", "attack", "strike", "sanctions", "tension", "crisis",
|
"united states", "middle east",
|
||||||
"geopolitical", "iran", "israel", "russia", "ukraine", "china", "taiwan", "north korea",
|
|
||||||
"middle east", "gulf", "nato", "united states", "us", "usa", "america",
|
|
||||||
"战争", "冲突", "军事", "袭击", "打击", "制裁", "紧张", "危机",
|
|
||||||
"地缘政治", "伊朗", "以色列", "俄罗斯", "乌克兰", "中国", "台湾", "朝鲜",
|
|
||||||
"中东", "海湾", "北约", "美国"
|
|
||||||
]
|
]
|
||||||
|
# 短英文词用词边界匹配(不用裸子串)
|
||||||
for news in news_data[:10]: # 检查前10条最新新闻(增加检查范围)
|
major_short_patterns = [
|
||||||
title = (news.get("title") or news.get("headline") or "").lower()
|
re.compile(r"\b(?:ban|banned|banning)\b", re.I),
|
||||||
summary = (news.get("summary") or "").lower()
|
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")
|
sentiment = news.get("sentiment", "neutral")
|
||||||
|
|
||||||
# 检查标题和摘要中是否包含重大关键词
|
|
||||||
text_to_check = f"{title} {summary}"
|
text_to_check = f"{title} {summary}"
|
||||||
|
low = text_to_check.lower()
|
||||||
# 地缘政治事件通常很严重,即使情绪是中性也要识别
|
|
||||||
geopolitical_keywords = [
|
if _is_major_geopolitical_news_text(text_to_check):
|
||||||
"war", "conflict", "military", "attack", "strike", "geopolitical",
|
logger.info(f"Detected major geopolitical event in news: {low[:80]}")
|
||||||
"战争", "冲突", "军事", "袭击", "打击", "地缘政治"
|
|
||||||
]
|
|
||||||
|
|
||||||
# 如果是地缘政治相关,直接认为是重大新闻
|
|
||||||
if any(keyword in text_to_check for keyword in geopolitical_keywords):
|
|
||||||
logger.info(f"Detected major geopolitical event in news: {title[:60]}")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 其他重大关键词且情绪强烈(非中性),认为是重大新闻
|
if any(kw in low for kw in major_keywords) and sentiment != "neutral":
|
||||||
if any(keyword in text_to_check for keyword in major_keywords) and sentiment != "neutral":
|
logger.info(f"Detected major news event: {low[:80]}")
|
||||||
logger.info(f"Detected major news event: {title[:60]}")
|
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
def _has_macro_event(self, macro_data: Dict, market: str) -> bool:
|
def _has_macro_event(self, macro_data: Dict, market: str) -> bool:
|
||||||
@@ -1227,6 +1539,88 @@ IMPORTANT:
|
|||||||
|
|
||||||
return False
|
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,
|
def _validate_and_constrain(self, analysis: Dict, current_price: float, indicators: Dict = None,
|
||||||
has_major_news: bool = False, has_macro_event: bool = False) -> Dict:
|
has_major_news: bool = False, has_macro_event: bool = False) -> Dict:
|
||||||
"""
|
"""
|
||||||
@@ -1239,22 +1633,45 @@ IMPORTANT:
|
|||||||
# Price bounds
|
# Price bounds
|
||||||
min_price = current_price * 0.90
|
min_price = current_price * 0.90
|
||||||
max_price = current_price * 1.10
|
max_price = current_price * 1.10
|
||||||
|
decision = str(analysis.get("decision", "HOLD")).upper()
|
||||||
|
|
||||||
# Constrain entry price
|
# Constrain entry price
|
||||||
entry = analysis.get("entry_price", current_price)
|
entry = _safe_float_price(analysis.get("entry_price"), current_price)
|
||||||
if entry and (entry < min_price or entry > max_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}")
|
logger.warning(f"Entry price {entry} out of bounds, constraining to current price {current_price}")
|
||||||
analysis["entry_price"] = round(current_price, 6)
|
analysis["entry_price"] = round(current_price, 6)
|
||||||
|
elif entry is not None:
|
||||||
|
analysis["entry_price"] = round(entry, 6)
|
||||||
|
|
||||||
# Constrain stop loss
|
# Constrain stop loss / take profit by direction (numeric-safe).
|
||||||
stop_loss = analysis.get("stop_loss", current_price * 0.95)
|
# BUY: stop_loss < current < take_profit
|
||||||
if stop_loss and (stop_loss < min_price or stop_loss > current_price):
|
# SELL: take_profit < current < stop_loss
|
||||||
analysis["stop_loss"] = round(current_price * 0.95, 6)
|
if decision == "SELL":
|
||||||
|
stop_default = round(current_price * 1.05, 6)
|
||||||
# Constrain take profit
|
tp_default = round(current_price * 0.95, 6)
|
||||||
take_profit = analysis.get("take_profit", current_price * 1.05)
|
stop_loss = _safe_float_price(analysis.get("stop_loss"), stop_default)
|
||||||
if take_profit and (take_profit < current_price or take_profit > max_price):
|
take_profit = _safe_float_price(analysis.get("take_profit"), tp_default)
|
||||||
analysis["take_profit"] = round(current_price * 1.05, 6)
|
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
|
# Constrain confidence
|
||||||
confidence = analysis.get("confidence", 50)
|
confidence = analysis.get("confidence", 50)
|
||||||
@@ -1266,7 +1683,6 @@ IMPORTANT:
|
|||||||
analysis[score_key] = max(0, min(100, int(score)))
|
analysis[score_key] = max(0, min(100, int(score)))
|
||||||
|
|
||||||
# Validate decision
|
# Validate decision
|
||||||
decision = str(analysis.get("decision", "HOLD")).upper()
|
|
||||||
if decision not in ["BUY", "SELL", "HOLD"]:
|
if decision not in ["BUY", "SELL", "HOLD"]:
|
||||||
analysis["decision"] = "HOLD"
|
analysis["decision"] = "HOLD"
|
||||||
else:
|
else:
|
||||||
@@ -1279,6 +1695,9 @@ IMPORTANT:
|
|||||||
has_major_news=has_major_news,
|
has_major_news=has_major_news,
|
||||||
has_macro_event=has_macro_event
|
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
|
return analysis
|
||||||
|
|
||||||
@@ -1729,71 +2148,64 @@ IMPORTANT:
|
|||||||
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
|
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
|
||||||
"""
|
"""
|
||||||
计算新闻情绪评分 (-100 to +100)
|
计算新闻情绪评分 (-100 to +100)
|
||||||
包含地缘政治事件的特殊处理
|
地缘/冲突类:词边界 + 分级惩罚,单条封顶,避免 extension/toward 等误判叠加。
|
||||||
"""
|
"""
|
||||||
if not news:
|
if not news:
|
||||||
return 0.0 # 无新闻,中性
|
return 0.0 # 无新闻,中性
|
||||||
|
|
||||||
positive_count = 0
|
positive_count = 0
|
||||||
negative_count = 0
|
negative_count = 0
|
||||||
neutral_count = 0
|
neutral_count = 0
|
||||||
geopolitical_penalty = 0 # 地缘政治事件惩罚分数
|
geopolitical_penalty = 0
|
||||||
geopolitical_count = 0 # 地缘政治事件数量
|
max_geo_total = int(os.getenv("SENTIMENT_GEO_PENALTY_CAP", "-55"))
|
||||||
|
|
||||||
# 地缘政治关键词
|
for item in news[:15]:
|
||||||
geopolitical_keywords = [
|
title = item.get("headline") or item.get("title") or ""
|
||||||
"war", "conflict", "military", "attack", "strike", "sanctions",
|
summary = item.get("summary") or ""
|
||||||
"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()
|
|
||||||
text = f"{title} {summary}"
|
text = f"{title} {summary}"
|
||||||
sentiment = item.get("sentiment", "neutral")
|
sentiment = item.get("sentiment", "neutral")
|
||||||
is_global_event = item.get("is_global_event", False)
|
is_global_event = item.get("is_global_event", False)
|
||||||
|
|
||||||
# 检查是否是地缘政治事件
|
level, tag = _geopolitical_match_level(text)
|
||||||
is_geopolitical = is_global_event or any(keyword in text for keyword in geopolitical_keywords)
|
if is_global_event and level == "none":
|
||||||
|
level, tag = "moderate", "is_global_event"
|
||||||
if is_geopolitical:
|
|
||||||
geopolitical_count += 1
|
if level != "none":
|
||||||
# 地缘政治事件通常是利空的,给予严重惩罚
|
delta = _geopolitical_sentiment_penalty_delta(level)
|
||||||
if any(kw in text for kw in ["war", "conflict", "attack", "strike", "战争", "冲突", "袭击", "打击"]):
|
new_total = geopolitical_penalty + delta
|
||||||
geopolitical_penalty -= 50 # 战争/冲突事件严重利空
|
if new_total < max_geo_total:
|
||||||
elif any(kw in text for kw in ["sanctions", "crisis", "tension", "制裁", "危机", "紧张"]):
|
delta = max_geo_total - geopolitical_penalty
|
||||||
geopolitical_penalty -= 30 # 制裁/危机事件利空
|
geopolitical_penalty += delta
|
||||||
else:
|
preview = (title or summary or "")[:72]
|
||||||
geopolitical_penalty -= 20 # 其他地缘政治事件利空
|
logger.info(
|
||||||
logger.info(f"Detected geopolitical event in sentiment scoring: {title[:60]}, penalty: {geopolitical_penalty}")
|
f"Geopolitical sentiment ({level}, {tag}): {preview!r}, "
|
||||||
|
f"delta={delta}, cumulative={geopolitical_penalty}"
|
||||||
# 统计普通新闻情绪
|
)
|
||||||
|
|
||||||
if sentiment == "positive":
|
if sentiment == "positive":
|
||||||
positive_count += 1
|
positive_count += 1
|
||||||
elif sentiment == "negative":
|
elif sentiment == "negative":
|
||||||
negative_count += 1
|
negative_count += 1
|
||||||
else:
|
else:
|
||||||
neutral_count += 1
|
neutral_count += 1
|
||||||
|
|
||||||
total = positive_count + negative_count + neutral_count
|
total = positive_count + negative_count + neutral_count
|
||||||
|
|
||||||
# 计算净情绪(普通新闻)
|
|
||||||
if total > 0:
|
if total > 0:
|
||||||
net_sentiment = (positive_count - negative_count) / total
|
net_sentiment = (positive_count - negative_count) / total
|
||||||
base_score = net_sentiment * 60 # 基础情绪分数(-60到+60)
|
base_score = net_sentiment * 60
|
||||||
else:
|
else:
|
||||||
base_score = 0
|
base_score = 0
|
||||||
|
|
||||||
# 地缘政治事件惩罚(如果有地缘政治事件,直接应用惩罚)
|
if geopolitical_penalty != 0:
|
||||||
if geopolitical_count > 0:
|
|
||||||
# 地缘政治事件的影响权重很高,直接叠加惩罚
|
|
||||||
final_score = base_score + geopolitical_penalty
|
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:
|
else:
|
||||||
final_score = base_score
|
final_score = base_score
|
||||||
|
|
||||||
return max(-100, min(100, final_score))
|
return max(-100, min(100, final_score))
|
||||||
|
|
||||||
def _calculate_macro_score(self, macro: Dict, market: str) -> float:
|
def _calculate_macro_score(self, macro: Dict, market: str) -> float:
|
||||||
@@ -1908,6 +2320,14 @@ IMPORTANT:
|
|||||||
|
|
||||||
return max(-100, min(100, score))
|
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:
|
def _score_to_decision(self, score: float, *, market: str = "Crypto") -> str:
|
||||||
"""
|
"""
|
||||||
根据客观评分转换为决策
|
根据客观评分转换为决策
|
||||||
@@ -2072,7 +2492,11 @@ IMPORTANT:
|
|||||||
decision = fast_result.get("decision", "HOLD")
|
decision = fast_result.get("decision", "HOLD")
|
||||||
confidence = fast_result.get("confidence", 50)
|
confidence = fast_result.get("confidence", 50)
|
||||||
scores = fast_result.get("scores", {})
|
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 {
|
return {
|
||||||
"overview": {
|
"overview": {
|
||||||
"overallScore": scores.get("overall", 50),
|
"overallScore": scores.get("overall", 50),
|
||||||
@@ -2085,7 +2509,7 @@ IMPORTANT:
|
|||||||
"sentiment": scores.get("sentiment", 50),
|
"sentiment": scores.get("sentiment", 50),
|
||||||
"risk": 100 - confidence, # Inverse of confidence
|
"risk": 100 - confidence, # Inverse of confidence
|
||||||
},
|
},
|
||||||
"report": fast_result.get("summary", ""),
|
"report": overview_report,
|
||||||
},
|
},
|
||||||
"fundamental": {
|
"fundamental": {
|
||||||
"score": scores.get("fundamental", 50),
|
"score": scores.get("fundamental", 50),
|
||||||
@@ -2135,6 +2559,8 @@ IMPORTANT:
|
|||||||
"recommendation": "\n".join(fast_result.get("reasons", [])),
|
"recommendation": "\n".join(fast_result.get("reasons", [])),
|
||||||
},
|
},
|
||||||
"fast_analysis": fast_result, # Include new format for gradual migration
|
"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,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,11 +82,10 @@ class LLMService:
|
|||||||
|
|
||||||
if provider_name:
|
if provider_name:
|
||||||
try:
|
try:
|
||||||
|
# Explicit selection should always be respected.
|
||||||
|
# API key validation happens later in call path.
|
||||||
selected = LLMProvider(provider_name.lower())
|
selected = LLMProvider(provider_name.lower())
|
||||||
# Verify this provider has an API key configured
|
return selected
|
||||||
if self.get_api_key(selected):
|
|
||||||
return selected
|
|
||||||
logger.warning(f"LLM_PROVIDER={provider_name} but no API key configured, auto-detecting...")
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -184,31 +183,36 @@ class LLMService:
|
|||||||
|
|
||||||
response = requests.post(url, headers=headers, json=data, timeout=timeout)
|
response = requests.post(url, headers=headers, json=data, timeout=timeout)
|
||||||
|
|
||||||
# Handle errors with detailed messages
|
# Handle non-2xx with provider/model-aware details
|
||||||
if response.status_code == 403:
|
if response.status_code >= 400:
|
||||||
error_msg = "OpenRouter API 403 Forbidden"
|
provider_name = "OpenRouter" if "openrouter" in (base_url or "").lower() else "LLM"
|
||||||
|
error_msg = f"{provider_name} API {response.status_code}"
|
||||||
|
err_text = ""
|
||||||
try:
|
try:
|
||||||
error_data = response.json()
|
error_data = response.json() or {}
|
||||||
if "error" in error_data:
|
error_detail = error_data.get("error")
|
||||||
error_detail = error_data["error"]
|
if isinstance(error_detail, dict):
|
||||||
if isinstance(error_detail, dict):
|
err_text = str(error_detail.get("message") or "").strip()
|
||||||
error_msg = f"OpenRouter API 403: {error_detail.get('message', 'Forbidden')}"
|
elif isinstance(error_detail, str):
|
||||||
elif isinstance(error_detail, str):
|
err_text = error_detail.strip()
|
||||||
error_msg = f"OpenRouter API 403: {error_detail}"
|
except Exception:
|
||||||
except:
|
err_text = (response.text or "").strip()[:300]
|
||||||
pass
|
|
||||||
|
if err_text:
|
||||||
# Check if API key is configured
|
error_msg = f"{error_msg}: {err_text}"
|
||||||
from app.config.api_keys import APIKeys
|
|
||||||
if not APIKeys.OPENROUTER_API_KEY:
|
# OpenRouter targeted hints
|
||||||
error_msg += ". OPENROUTER_API_KEY 未配置,请在 backend_api_python/.env 中设置"
|
if "openrouter" in (base_url or "").lower():
|
||||||
else:
|
from app.config.api_keys import APIKeys
|
||||||
error_msg += ". 可能的原因:1) API 密钥无效或过期 2) 账户余额不足 3) 没有权限访问该模型。请检查 https://openrouter.ai/keys"
|
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)
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
if "choices" in result and len(result["choices"]) > 0:
|
if "choices" in result and len(result["choices"]) > 0:
|
||||||
content = result["choices"][0]["message"]["content"]
|
content = result["choices"][0]["message"]["content"]
|
||||||
@@ -369,9 +373,23 @@ class LLMService:
|
|||||||
logger.debug(f"Auto-detected provider '{provider.value}' from model '{model}'")
|
logger.debug(f"Auto-detected provider '{provider.value}' from model '{model}'")
|
||||||
|
|
||||||
p = provider or self.provider
|
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)
|
api_key = self.get_api_key(p)
|
||||||
|
|
||||||
if not api_key:
|
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 no API key for current provider, try to find any available provider
|
||||||
if try_alternative_providers:
|
if try_alternative_providers:
|
||||||
for alt_provider in [LLMProvider.DEEPSEEK, LLMProvider.GROK, LLMProvider.OPENAI, LLMProvider.GOOGLE, LLMProvider.OPENROUTER]:
|
for alt_provider in [LLMProvider.DEEPSEEK, LLMProvider.GROK, LLMProvider.OPENAI, LLMProvider.GOOGLE, LLMProvider.OPENROUTER]:
|
||||||
|
|||||||
@@ -281,14 +281,12 @@ class MarketDataCollector:
|
|||||||
"""
|
"""
|
||||||
计算技术指标 (本地计算,无外部依赖)
|
计算技术指标 (本地计算,无外部依赖)
|
||||||
|
|
||||||
返回格式符合前端 FastAnalysisReport.vue 的期望:
|
返回格式符合前端 FastAnalysisReport.vue 的期望。
|
||||||
{
|
口径说明(与常见行情终端对齐):
|
||||||
rsi: { value, signal },
|
- RSI(14):Wilder 平滑(首段均幅为前 14 期简单平均,其后递推)。
|
||||||
macd: { signal, trend },
|
- MACD:收盘 EMA12/EMA26(首值=前 N 日 SMA),信号线=MACD 的 EMA9(SMA 种子)。
|
||||||
moving_averages: { ma5, ma10, ma20, trend },
|
- MA:SMA。枢轴:上一根 K 的 H/L/C。摆动高低:近 20 根 H/L 窗口极值。
|
||||||
levels: { support, resistance },
|
- 布林:20 收盘 SMA ± 2×总体标准差。ATR(14):Wilder(首 ATR=前 14 期 TR 简单平均,其后递推)。
|
||||||
volatility: { level, pct }
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
if not klines or len(klines) < 5:
|
if not klines or len(klines) < 5:
|
||||||
return {}
|
return {}
|
||||||
@@ -319,8 +317,8 @@ class MarketDataCollector:
|
|||||||
'signal': rsi_signal,
|
'signal': rsi_signal,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ========== MACD ==========
|
# ========== MACD(SMA 种子 EMA,与常见终端一致)==========
|
||||||
if len(closes) >= 26:
|
if len(closes) >= 34:
|
||||||
macd_raw = self._calc_macd(closes)
|
macd_raw = self._calc_macd(closes)
|
||||||
macd_val = macd_raw.get('MACD', 0)
|
macd_val = macd_raw.get('MACD', 0)
|
||||||
macd_sig = macd_raw.get('MACD_signal', 0)
|
macd_sig = macd_raw.get('MACD_signal', 0)
|
||||||
@@ -366,6 +364,11 @@ class MarketDataCollector:
|
|||||||
'ma20': round(ma20, 6),
|
'ma20': round(ma20, 6),
|
||||||
'trend': ma_trend,
|
'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) - 使用前一日数据
|
# 方法1: 枢轴点 (Pivot Points) - 使用前一日数据
|
||||||
@@ -390,13 +393,13 @@ class MarketDataCollector:
|
|||||||
swing_high = max(recent_highs) if recent_highs else current_price * 1.05
|
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
|
swing_low = min(recent_lows) if recent_lows else current_price * 0.95
|
||||||
|
|
||||||
# 方法3: 布林带中轨上下 (如果有)
|
# 方法3: 布林上下轨(与 _calc_bollinger 返回字段一致)
|
||||||
bb_upper = indicators.get('bollinger', {}).get('upper', swing_high)
|
bb_upper = bb_for_levels.get('BB_upper', swing_high)
|
||||||
bb_lower = indicators.get('bollinger', {}).get('lower', swing_low)
|
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)
|
resistance = round((r1 + swing_high + bb_upper) / 3, 6)
|
||||||
support = round((s1 + swing_low + bb_lower) / 3, 6) if bb_lower else round((s1 + swing_low) / 2, 6)
|
support = round((s1 + swing_low + bb_lower) / 3, 6)
|
||||||
|
|
||||||
indicators['levels'] = {
|
indicators['levels'] = {
|
||||||
'support': support,
|
'support': support,
|
||||||
@@ -411,20 +414,10 @@ class MarketDataCollector:
|
|||||||
'method': 'pivot_swing_bb_avg' # 标注计算方法
|
'method': 'pivot_swing_bb_avg' # 标注计算方法
|
||||||
}
|
}
|
||||||
|
|
||||||
# ========== ATR 和波动率 ==========
|
# ========== ATR 和波动率(Wilder ATR,全序列递推至最新一根)==========
|
||||||
atr = 0
|
atr = 0.0
|
||||||
if len(klines) >= 14:
|
if len(klines) >= 14:
|
||||||
# 真实波动幅度 ATR (True Range)
|
atr = float(self._calc_atr_wilder(klines, period=14))
|
||||||
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
|
|
||||||
volatility_pct = (atr / current_price * 100) if current_price > 0 else 0
|
volatility_pct = (atr / current_price * 100) if current_price > 0 else 0
|
||||||
|
|
||||||
if volatility_pct > 5:
|
if volatility_pct > 5:
|
||||||
@@ -468,10 +461,9 @@ class MarketDataCollector:
|
|||||||
'method': 'atr_support_resistance'
|
'method': 'atr_support_resistance'
|
||||||
}
|
}
|
||||||
|
|
||||||
# ========== 布林带 (附加) ==========
|
# ========== 布林带 (附加,与 bb_for_levels 同一次计算) ==========
|
||||||
if len(closes) >= 20:
|
if bb_for_levels:
|
||||||
bb_data = self._calc_bollinger(closes, 20, 2)
|
indicators['bollinger'] = bb_for_levels
|
||||||
indicators['bollinger'] = bb_data
|
|
||||||
|
|
||||||
# ========== 成交量 (附加) ==========
|
# ========== 成交量 (附加) ==========
|
||||||
if len(volumes) >= 20:
|
if len(volumes) >= 20:
|
||||||
@@ -498,48 +490,109 @@ class MarketDataCollector:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _calc_rsi(self, closes: List[float], period: int = 14) -> float:
|
def _calc_rsi(self, closes: List[float], period: int = 14) -> float:
|
||||||
"""计算RSI"""
|
"""Wilder RSI:首段均幅为前 period 期涨跌简单平均,之后按 Wilder 平滑递推。"""
|
||||||
if len(closes) < period + 1:
|
if len(closes) < period + 1:
|
||||||
return 50.0
|
return 50.0
|
||||||
|
|
||||||
deltas = [closes[i] - closes[i-1] for i in range(1, len(closes))]
|
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
|
||||||
gains = [d if d > 0 else 0 for d in deltas]
|
gains = [d if d > 0 else 0.0 for d in deltas]
|
||||||
losses = [-d if d < 0 else 0 for d in deltas]
|
losses = [-d if d < 0 else 0.0 for d in deltas]
|
||||||
|
|
||||||
avg_gain = sum(gains[-period:]) / period
|
if len(gains) < period:
|
||||||
avg_loss = sum(losses[-period:]) / 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:
|
if avg_loss == 0:
|
||||||
return 100.0
|
return 100.0
|
||||||
|
|
||||||
rs = avg_gain / avg_loss
|
rs = avg_gain / avg_loss
|
||||||
rsi = 100 - (100 / (1 + rs))
|
return round(100.0 - (100.0 / (1.0 + rs)), 2)
|
||||||
return round(rsi, 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]:
|
def _calc_macd(self, closes: List[float]) -> Dict[str, float]:
|
||||||
"""计算MACD"""
|
"""
|
||||||
def ema(data, period):
|
MACD(12,26,9):DIF = EMA12(close) − EMA26(close),DEA = EMA9(DIF),柱 = DIF − DEA。
|
||||||
multiplier = 2 / (period + 1)
|
各 EMA 均采用 SMA 种子;DIF 自第 26 根 K 起有定义,信号线对 DIF 子序列再算 EMA9。
|
||||||
ema_values = [data[0]]
|
"""
|
||||||
for i in range(1, len(data)):
|
n = len(closes)
|
||||||
ema_values.append((data[i] - ema_values[-1]) * multiplier + ema_values[-1])
|
ema12 = self._ema_series_sma_seed(closes, 12)
|
||||||
return ema_values
|
ema26 = self._ema_series_sma_seed(closes, 26)
|
||||||
|
if n < 26 or ema12[-1] is None or ema26[-1] is None:
|
||||||
ema12 = ema(closes, 12)
|
return {'MACD': 0.0, 'MACD_signal': 0.0, 'MACD_histogram': 0.0}
|
||||||
ema26 = ema(closes, 26)
|
|
||||||
|
macd_sub: List[float] = []
|
||||||
macd_line = [ema12[i] - ema26[i] for i in range(len(closes))]
|
for i in range(25, n):
|
||||||
signal_line = ema(macd_line, 9)
|
v12 = ema12[i]
|
||||||
histogram = [macd_line[i] - signal_line[i] for i in range(len(closes))]
|
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 {
|
return {
|
||||||
'MACD': round(macd_line[-1], 4),
|
'MACD': round(last_macd, 6),
|
||||||
'MACD_signal': round(signal_line[-1], 4),
|
'MACD_signal': round(last_sig, 6),
|
||||||
'MACD_histogram': round(histogram[-1], 4)
|
'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]:
|
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:
|
if len(closes) < period:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -723,58 +776,86 @@ class MarketDataCollector:
|
|||||||
def _get_earnings_data(self, symbol: str) -> Optional[Dict[str, Any]]:
|
def _get_earnings_data(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取盈利报告数据(Earnings)
|
获取盈利报告数据(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:
|
try:
|
||||||
ticker = yf.Ticker(symbol)
|
ticker = yf.Ticker(symbol)
|
||||||
earnings_data = {}
|
earnings_data: Dict[str, Any] = {}
|
||||||
|
|
||||||
# 历史盈利数据
|
# 季度利润表(yfinance 推荐路径,避免 fundamentals.Ticker.earnings 弃用告警)
|
||||||
try:
|
try:
|
||||||
earnings_history = ticker.earnings_history
|
q_inc = ticker.quarterly_income_stmt
|
||||||
if earnings_history is not None and not earnings_history.empty:
|
if q_inc is not None and not q_inc.empty and len(q_inc.columns) > 0:
|
||||||
# 获取最近4个季度
|
cols = list(q_inc.columns)[:4]
|
||||||
recent_earnings = earnings_history.head(4)
|
latest_q = cols[0]
|
||||||
earnings_data['history'] = []
|
|
||||||
for _, row in recent_earnings.iterrows():
|
rev = _pick_float(
|
||||||
earnings_data['history'].append({
|
q_inc,
|
||||||
'date': str(row.get('Date', '')),
|
("Total Revenue", "Revenue", "Total Revenues", "Net Sales"),
|
||||||
'eps_actual': float(row.get('EPS Actual', 0)) if row.get('EPS Actual') is not None else None,
|
latest_q,
|
||||||
'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,
|
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:
|
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:
|
try:
|
||||||
earnings_calendar = ticker.calendar
|
earnings_calendar = ticker.calendar
|
||||||
if earnings_calendar is not None and not earnings_calendar.empty:
|
if earnings_calendar is not None and not earnings_calendar.empty:
|
||||||
earnings_data['upcoming'] = {
|
idx0 = earnings_calendar.index[0]
|
||||||
'next_earnings_date': str(earnings_calendar.index[0]) if len(earnings_calendar.index) > 0 else None,
|
earnings_data["upcoming"] = {
|
||||||
'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,
|
"next_earnings_date": str(idx0),
|
||||||
'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,
|
"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:
|
except Exception as e:
|
||||||
logger.debug(f"Earnings calendar fetch failed for {symbol}: {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
|
return earnings_data if earnings_data else None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Earnings data fetch failed for {symbol}: {e}")
|
logger.debug(f"Earnings data fetch failed for {symbol}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from app.utils.db import get_db_connection
|
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.fast_analysis import get_fast_analysis_service
|
||||||
from app.services.signal_notifier import SignalNotifier
|
from app.services.signal_notifier import SignalNotifier
|
||||||
from app.services.kline import KlineService
|
from app.services.kline import KlineService
|
||||||
|
from app.services.billing_service import get_billing_service
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -154,98 +156,92 @@ def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = No
|
|||||||
return []
|
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]:
|
def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Run fast AI analysis on positions.
|
Run fast AI analysis on positions **in parallel** using a thread pool.
|
||||||
Uses the new FastAnalysisService (single LLM call, faster and more stable).
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
language = config.get('language', 'en-US')
|
language = config.get('language', 'en-US')
|
||||||
custom_prompt = config.get('prompt', '')
|
custom_prompt = config.get('prompt', '')
|
||||||
|
|
||||||
# Get the fast analysis service
|
workers = min(len(positions), MAX_PARALLEL_ANALYSIS)
|
||||||
service = get_fast_analysis_service()
|
position_analyses: List[Dict[str, Any]] = [None] * len(positions)
|
||||||
|
|
||||||
# Analyze each position
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||||
position_analyses = []
|
future_to_idx = {
|
||||||
|
executor.submit(_analyze_single_position, pos, language): idx
|
||||||
for pos in positions:
|
for idx, pos in enumerate(positions)
|
||||||
market = pos.get('market')
|
}
|
||||||
symbol = pos.get('symbol')
|
for future in as_completed(future_to_idx):
|
||||||
name = pos.get('name') or symbol
|
idx = future_to_idx[future]
|
||||||
group_name = pos.get('group_name')
|
try:
|
||||||
|
position_analyses[idx] = future.result()
|
||||||
if not market or not symbol:
|
except Exception as e:
|
||||||
continue
|
pos = positions[idx]
|
||||||
|
position_analyses[idx] = {
|
||||||
try:
|
'market': pos.get('market'), 'symbol': pos.get('symbol'),
|
||||||
logger.info(f"Running fast AI analysis for {market}:{symbol}")
|
'name': pos.get('name') or pos.get('symbol'), 'error': str(e)
|
||||||
|
}
|
||||||
# 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
|
|
||||||
analysis_report = _build_comprehensive_report(positions, position_analyses, language, custom_prompt)
|
analysis_report = _build_comprehensive_report(positions, position_analyses, language, custom_prompt)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'analysis': analysis_report,
|
'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')]),
|
'analyzed_count': len([p for p in position_analyses if not p.get('error')]),
|
||||||
'timestamp': _now_ts()
|
'timestamp': _now_ts()
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"_run_ai_analysis failed: {e}")
|
logger.error(f"_run_ai_analysis failed: {e}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return {
|
return {'success': False, 'error': str(e), 'timestamp': _now_ts()}
|
||||||
'success': False,
|
|
||||||
'error': str(e),
|
|
||||||
'timestamp': _now_ts()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_comprehensive_report(
|
def _build_comprehensive_report(
|
||||||
@@ -914,22 +906,67 @@ def run_single_monitor(monitor_id: int, override_language: str = None, user_id:
|
|||||||
if override_language:
|
if override_language:
|
||||||
config['language'] = 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)
|
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:
|
if not positions:
|
||||||
return {'success': False, 'error': 'No positions to analyze'}
|
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
|
# Run analysis based on type
|
||||||
if monitor_type == 'ai':
|
if monitor_type == 'ai':
|
||||||
result = _run_ai_analysis(positions, config)
|
result = _run_ai_analysis(positions, config)
|
||||||
else:
|
else:
|
||||||
# For other types, we can add price_alert, pnl_alert logic later
|
|
||||||
result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'}
|
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:
|
with get_db_connection() as db:
|
||||||
cur = db.cursor()
|
cur = db.cursor()
|
||||||
cur.execute(
|
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 = amc.get("language") or amc.get("lang") or tc.get("language") or "zh-CN"
|
||||||
language = str(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:
|
try:
|
||||||
# 使用新的 FastAnalysisService (单次LLM调用,更快更稳定)
|
|
||||||
from app.services.fast_analysis import get_fast_analysis_service
|
from app.services.fast_analysis import get_fast_analysis_service
|
||||||
|
|
||||||
service = get_fast_analysis_service()
|
service = get_fast_analysis_service()
|
||||||
|
|||||||
@@ -63,12 +63,8 @@ SMTP_USE_SSL=false
|
|||||||
# =========================
|
# =========================
|
||||||
# Proxy (optional)
|
# Proxy (optional)
|
||||||
# =========================
|
# =========================
|
||||||
# Most users only need PROXY_URL.
|
# PROXY_URL=socks5h://127.0.0.1:10808 # local
|
||||||
# Example local:
|
# PROXY_URL=socks5h://host.docker.internal:10808 # Docker
|
||||||
# PROXY_URL=socks5h://127.0.0.1:10808
|
|
||||||
#
|
|
||||||
# Example Docker:
|
|
||||||
# PROXY_URL=socks5h://host.docker.internal:10808
|
|
||||||
PROXY_URL=
|
PROXY_URL=
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
@@ -86,23 +82,47 @@ GITHUB_CLIENT_SECRET=
|
|||||||
GITHUB_REDIRECT_URI=http://localhost:5000/api/auth/oauth/github/callback
|
GITHUB_REDIRECT_URI=http://localhost:5000/api/auth/oauth/github/callback
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# Billing / payments (optional)
|
# Billing / payments
|
||||||
# =========================
|
# =========================
|
||||||
BILLING_ENABLED=False
|
BILLING_ENABLED=false
|
||||||
USDT_PAY_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
|
# Advanced / rarely changed
|
||||||
# =========================
|
# =========================
|
||||||
# The settings below are optional. Most users can leave them unchanged.
|
|
||||||
|
|
||||||
# Network / App tuning
|
# Network / App tuning
|
||||||
PYTHON_API_HOST=0.0.0.0
|
PYTHON_API_HOST=0.0.0.0
|
||||||
PYTHON_API_PORT=5000
|
PYTHON_API_PORT=5000
|
||||||
PYTHON_API_DEBUG=False
|
PYTHON_API_DEBUG=false
|
||||||
RATE_LIMIT=100
|
RATE_LIMIT=100
|
||||||
ENABLE_CACHE=False
|
ENABLE_CACHE=false
|
||||||
ENABLE_REQUEST_LOG=True
|
ENABLE_REQUEST_LOG=true
|
||||||
|
|
||||||
# Strategy / execution tuning
|
# Strategy / execution tuning
|
||||||
PENDING_ORDER_STALE_SEC=90
|
PENDING_ORDER_STALE_SEC=90
|
||||||
@@ -112,6 +132,7 @@ MAKER_OFFSET_BPS=2
|
|||||||
STRATEGY_TICK_INTERVAL_SEC=10
|
STRATEGY_TICK_INTERVAL_SEC=10
|
||||||
PRICE_CACHE_TTL_SEC=10
|
PRICE_CACHE_TTL_SEC=10
|
||||||
K_LINE_HISTORY_GET_NUMBER=500
|
K_LINE_HISTORY_GET_NUMBER=500
|
||||||
|
SIGNAL_NOTIFY_TIMEOUT_SEC=6
|
||||||
|
|
||||||
# LLM advanced tuning
|
# LLM advanced tuning
|
||||||
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
|
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
|
||||||
@@ -119,7 +140,6 @@ OPENROUTER_TEMPERATURE=0.7
|
|||||||
OPENROUTER_MAX_TOKENS=4000
|
OPENROUTER_MAX_TOKENS=4000
|
||||||
OPENROUTER_TIMEOUT=300
|
OPENROUTER_TIMEOUT=300
|
||||||
OPENROUTER_CONNECT_TIMEOUT=30
|
OPENROUTER_CONNECT_TIMEOUT=30
|
||||||
|
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||||
GROK_BASE_URL=https://api.x.ai/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_TIMEOUT=30
|
||||||
DATA_SOURCE_RETRY=3
|
DATA_SOURCE_RETRY=3
|
||||||
DATA_SOURCE_RETRY_BACKOFF=0.5
|
DATA_SOURCE_RETRY_BACKOFF=0.5
|
||||||
|
|
||||||
FINNHUB_API_KEY=
|
FINNHUB_API_KEY=
|
||||||
FINNHUB_TIMEOUT=10
|
FINNHUB_TIMEOUT=10
|
||||||
FINNHUB_RATE_LIMIT=60
|
FINNHUB_RATE_LIMIT=60
|
||||||
|
|
||||||
CCXT_DEFAULT_EXCHANGE=coinbase
|
CCXT_DEFAULT_EXCHANGE=coinbase
|
||||||
CCXT_TIMEOUT=10000
|
CCXT_TIMEOUT=10000
|
||||||
|
|
||||||
AKSHARE_TIMEOUT=30
|
AKSHARE_TIMEOUT=30
|
||||||
YFINANCE_TIMEOUT=30
|
YFINANCE_TIMEOUT=30
|
||||||
|
|
||||||
TIINGO_API_KEY=
|
TIINGO_API_KEY=
|
||||||
TIINGO_TIMEOUT=10
|
TIINGO_TIMEOUT=10
|
||||||
|
|
||||||
# AI search / news providers
|
# AI search / news
|
||||||
SEARCH_PROVIDER=google
|
SEARCH_PROVIDER=google
|
||||||
SEARCH_MAX_RESULTS=10
|
SEARCH_MAX_RESULTS=10
|
||||||
|
|
||||||
SEARCH_GOOGLE_API_KEY=
|
SEARCH_GOOGLE_API_KEY=
|
||||||
SEARCH_GOOGLE_CX=
|
SEARCH_GOOGLE_CX=
|
||||||
SEARCH_BING_API_KEY=
|
SEARCH_BING_API_KEY=
|
||||||
|
|
||||||
TAVILY_API_KEYS=
|
TAVILY_API_KEYS=
|
||||||
SERPAPI_KEYS=
|
SERPAPI_KEYS=
|
||||||
|
|
||||||
# SMS / phone notifications
|
# SMS / phone (optional)
|
||||||
TWILIO_ACCOUNT_SID=
|
TWILIO_ACCOUNT_SID=
|
||||||
TWILIO_AUTH_TOKEN=
|
TWILIO_AUTH_TOKEN=
|
||||||
TWILIO_FROM_NUMBER=
|
TWILIO_FROM_NUMBER=
|
||||||
@@ -162,42 +176,27 @@ TWILIO_FROM_NUMBER=
|
|||||||
SECURITY_IP_MAX_ATTEMPTS=10
|
SECURITY_IP_MAX_ATTEMPTS=10
|
||||||
SECURITY_IP_WINDOW_MINUTES=5
|
SECURITY_IP_WINDOW_MINUTES=5
|
||||||
SECURITY_IP_BLOCK_MINUTES=15
|
SECURITY_IP_BLOCK_MINUTES=15
|
||||||
|
|
||||||
SECURITY_ACCOUNT_MAX_ATTEMPTS=5
|
SECURITY_ACCOUNT_MAX_ATTEMPTS=5
|
||||||
SECURITY_ACCOUNT_WINDOW_MINUTES=60
|
SECURITY_ACCOUNT_WINDOW_MINUTES=60
|
||||||
SECURITY_ACCOUNT_BLOCK_MINUTES=30
|
SECURITY_ACCOUNT_BLOCK_MINUTES=30
|
||||||
|
|
||||||
VERIFICATION_CODE_EXPIRE_MINUTES=10
|
VERIFICATION_CODE_EXPIRE_MINUTES=10
|
||||||
VERIFICATION_CODE_RATE_LIMIT=60
|
VERIFICATION_CODE_RATE_LIMIT=60
|
||||||
VERIFICATION_CODE_IP_HOURLY_LIMIT=10
|
VERIFICATION_CODE_IP_HOURLY_LIMIT=10
|
||||||
VERIFICATION_CODE_MAX_ATTEMPTS=5
|
VERIFICATION_CODE_MAX_ATTEMPTS=5
|
||||||
VERIFICATION_CODE_LOCK_MINUTES=30
|
VERIFICATION_CODE_LOCK_MINUTES=30
|
||||||
|
|
||||||
# Billing / credits
|
# AI analysis tuning
|
||||||
BILLING_COST_AI_ANALYSIS=10
|
ENABLE_CONFIDENCE_CALIBRATION=false
|
||||||
BILLING_COST_STRATEGY_RUN=5
|
ENABLE_AI_ENSEMBLE=false
|
||||||
BILLING_COST_BACKTEST=3
|
AI_ENSEMBLE_MODELS=openai/gpt-4o,openai/gpt-4o-mini
|
||||||
BILLING_COST_PORTFOLIO_MONITOR=8
|
ENABLE_REFLECTION_WORKER=false
|
||||||
BILLING_COST_POLYMARKET_DEEP_ANALYSIS=15
|
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
|
# Internal
|
||||||
CREDITS_REFERRAL_BONUS=50
|
INTERNAL_API_KEY=
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -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 sys
|
||||||
import os
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# 添加项目根目录到 Python 路径
|
# 添加后端目录到 Python 路径(使得可以 import app.*)
|
||||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..'))
|
# 由于 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():
|
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 或 定时任务调度器 每天运行一次
|
建议通过 cron 或 定时任务调度器 每天运行一次
|
||||||
"""
|
"""
|
||||||
print("Running Automated Reflection Verification Task...")
|
print("Running Automated Reflection Verification Task...")
|
||||||
service = ReflectionService()
|
service = ReflectionService()
|
||||||
service.run_verification_cycle()
|
stats = service.run_verification_cycle()
|
||||||
|
print("Reflection stats:", stats)
|
||||||
print("Task Completed.")
|
print("Task Completed.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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,非云端)
|
- **🧠 记忆增强** — 智能体从过去的分析中学习(本地RAG,非云端)
|
||||||
- **🔌 5+ LLM提供商**:OpenRouter(100+模型)、OpenAI、Gemini、DeepSeek、Grok
|
- **🔌 5+ LLM提供商**:OpenRouter(100+模型)、OpenAI、Gemini、DeepSeek、Grok
|
||||||
- **📊 Polymarket预测市场** — 预测市场按需AI分析。输入市场链接或标题 → AI分析概率差异、机会评分和交易建议。完整历史跟踪和计费集成。
|
- **📊 Polymarket预测市场** — 预测市场按需AI分析。输入市场链接或标题 → AI分析概率差异、机会评分和交易建议。完整历史跟踪和计费集成。
|
||||||
|
- **📋 虚拟持仓管理** — 自选股中直接创建虚拟持仓,支持做多/做空方向、数量、买入单价,实时计算盈亏。无需连接真实交易所即可跟踪模拟投资组合。
|
||||||
|
|
||||||
### 📈 完整交易生命周期
|
### 📈 完整交易生命周期
|
||||||
|
|
||||||
@@ -310,7 +311,7 @@ IMAGE_PREFIX=docker.xuanyuan.me/library/
|
|||||||
- **💳 会员计划** — 月度/年度/终身层级,可配置定价和积分
|
- **💳 会员计划** — 月度/年度/终身层级,可配置定价和积分
|
||||||
- **₿ USDT链上支付** — TRC20扫码支付,每订单地址的HD钱包(xpub),通过TronGrid自动对账
|
- **₿ USDT链上支付** — TRC20扫码支付,每订单地址的HD钱包(xpub),通过TronGrid自动对账
|
||||||
- **🏪 指标市场** — 用户发布和销售Python指标,您收取佣金
|
- **🏪 指标市场** — 用户发布和销售Python指标,您收取佣金
|
||||||
- **⚙️ 管理员仪表板** — 订单管理、AI使用统计、用户分析
|
- **⚙️ 管理员仪表板** — 订单管理、AI使用统计、用户分析;系统设置保存后热重载,无需重启服务
|
||||||
|
|
||||||
### 🔐 企业级安全
|
### 🔐 企业级安全
|
||||||
|
|
||||||
@@ -320,49 +321,67 @@ IMAGE_PREFIX=docker.xuanyuan.me/library/
|
|||||||
- **演示模式** — 公共展示的只读模式
|
- **演示模式** — 公共展示的只读模式
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>🧠 AI智能体架构图(点击展开)</b></summary>
|
<summary><b>🧠 AI 分析架构图(点击展开)</b></summary>
|
||||||
|
|
||||||
|
当前采用 **FastAnalysisService** 单次 LLM 流程,兼顾速度与多因子决策:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph Entry["🌐 API入口"]
|
subgraph Entry["🌐 API 入口"]
|
||||||
A["📡 POST /api/analysis/multi"]
|
A["📡 POST /api/fast-analysis/analyze"]
|
||||||
A2["🔄 POST /api/analysis/reflect"]
|
A2["📜 GET /api/fast-analysis/history"]
|
||||||
|
A3["📊 GET /api/fast-analysis/similar-patterns"]
|
||||||
end
|
end
|
||||||
subgraph Service["⚙️ 服务编排"]
|
subgraph Data["📊 数据采集层"]
|
||||||
B[AnalysisService]
|
D1[MarketDataCollector]
|
||||||
C[AgentCoordinator]
|
D2["价格 · K线 · 宏观 · 新闻 · 基本面"]
|
||||||
D["📊 构建上下文<br/>价格 · K线 · 新闻 · 指标"]
|
D3["多周期共识 1D / 4H / 1H"]
|
||||||
end
|
end
|
||||||
subgraph Agents["🤖 7智能体工作流"]
|
subgraph Analysis["⚙️ 分析层"]
|
||||||
subgraph P1["📈 阶段1 · 并行分析"]
|
B["FastAnalysisService"]
|
||||||
E1["🔍 MarketAnalyst"]
|
C["单次 LLM 调用<br/>强约束 Prompt"]
|
||||||
E2["📑 FundamentalAnalyst"]
|
E["客观评分 + 多周期共识"]
|
||||||
E3["📰 NewsAnalyst"]
|
F["AICalibration 阈值校准"]
|
||||||
E4["💭 SentimentAnalyst"]
|
G["买入 / 卖出 / 持有"]
|
||||||
E5["⚠️ RiskAnalyst"]
|
|
||||||
end
|
|
||||||
subgraph P2["🎯 阶段2 · 多头vs空头辩论"]
|
|
||||||
F1["🐂 BullResearcher"]
|
|
||||||
F2["🐻 BearResearcher"]
|
|
||||||
end
|
|
||||||
subgraph P3["💹 阶段3 · 最终决策"]
|
|
||||||
G["🎰 TraderAgent → 买入 / 卖出 / 持有"]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
subgraph Memory["🧠 本地记忆存储"]
|
subgraph Memory["🧠 记忆层"]
|
||||||
M1[("智能体记忆(PostgreSQL)")]
|
M1[("qd_analysis_memory<br/>PostgreSQL")]
|
||||||
|
M2["RAG 相似模式检索<br/>(可选注入 Prompt)"]
|
||||||
end
|
end
|
||||||
subgraph Reflect["🔄 反思循环"]
|
A --> B
|
||||||
R[ReflectionService]
|
B --> D1 --> D2 --> D3
|
||||||
W["⏰ ReflectionWorker → 验证 + 学习"]
|
D3 --> C --> E --> F --> G
|
||||||
end
|
B -.->|"存储"| M1
|
||||||
A --> B --> C --> D
|
M1 -.->|"相似模式"| M2
|
||||||
D --> P1 --> P2 --> P3
|
M2 -.->|"可选上下文"| C
|
||||||
Agents <-.->|"RAG检索"| M1
|
A2 --> M1
|
||||||
C --> R
|
A3 --> M1
|
||||||
W -.->|"更新记忆"| 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>
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -466,10 +485,12 @@ QuantDinger/
|
|||||||
| **AI / LLM** | `LLM_PROVIDER`、`OPENROUTER_API_KEY`、`OPENAI_API_KEY` |
|
| **AI / LLM** | `LLM_PROVIDER`、`OPENROUTER_API_KEY`、`OPENAI_API_KEY` |
|
||||||
| **OAuth** | `GOOGLE_CLIENT_ID`、`GITHUB_CLIENT_ID` |
|
| **OAuth** | `GOOGLE_CLIENT_ID`、`GITHUB_CLIENT_ID` |
|
||||||
| **安全** | `TURNSTILE_SITE_KEY`、`ENABLE_REGISTRATION` |
|
| **安全** | `TURNSTILE_SITE_KEY`、`ENABLE_REGISTRATION` |
|
||||||
|
| **计费** | `BILLING_ENABLED`、`BILLING_COST_AI_ANALYSIS`、`BILLING_COST_AI_CODE_GEN` |
|
||||||
| **会员** | `MEMBERSHIP_MONTHLY_PRICE_USD`、`MEMBERSHIP_MONTHLY_CREDITS` |
|
| **会员** | `MEMBERSHIP_MONTHLY_PRICE_USD`、`MEMBERSHIP_MONTHLY_CREDITS` |
|
||||||
| **USDT支付** | `USDT_PAY_ENABLED`、`USDT_TRC20_XPUB`、`TRONGRID_API_KEY` |
|
| **USDT支付** | `USDT_PAY_ENABLED`、`USDT_TRC20_XPUB`、`TRONGRID_API_KEY` |
|
||||||
| **代理** | `PROXY_URL` |
|
| **代理** | `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>
|
</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 .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 a:hover{color:#1890ff!important}
|
||||||
:global .anticon: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-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-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}
|
.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 .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{color:#1890ff}
|
||||||
a:hover{color:#40a9ff}
|
a:hover{color:#40a9ff}
|
||||||
a:active{color:#096dd9}
|
a:active{color:#096dd9}
|
||||||
@@ -33,44 +35,76 @@ html{--antd-wave-shadow-color:#1890ff}
|
|||||||
#nprogress .bar{background:#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 .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}
|
#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}
|
body.dark .ant-pagination-item-active,body.realdark .ant-pagination-item-active{border-color:#1890ff!important}
|
||||||
.fast-analysis-report .loading-container .loading-content-pro .progress-wrapper .progress-text[data-v-693144f6]{color:#1890ff}
|
body.dark .ant-btn-default:hover,body.realdark .ant-btn-default:hover{border-color:#1890ff!important;color:#1890ff!important}
|
||||||
.fast-analysis-report .loading-container .loading-content-pro .current-step[data-v-693144f6]{color:#1890ff}
|
body.dark .ant-tabs-tab-active,body.realdark .ant-tabs-tab-active{color:#1890ff!important}
|
||||||
.fast-analysis-report .loading-container .loading-content-pro .steps-list .step-item.active[data-v-693144f6]{background:#e6f7ff;color:#1890ff}
|
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 .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 .loading-container .loading-content-pro .loading-header .loading-icon-pro[data-v-75da11ec]{color:#1890ff}
|
||||||
.fast-analysis-report .result-container .decision-card[data-v-693144f6]{border-left:6px solid #1890ff}
|
.fast-analysis-report .loading-container .loading-content-pro .progress-wrapper .progress-text[data-v-75da11ec]{color:#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 .loading-container .loading-content-pro .current-step[data-v-75da11ec]{color:#1890ff}
|
||||||
.fast-analysis-report .result-container .decision-card .consensus-strip .consensus-strip-title[data-v-693144f6]{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 .result-container .price-info-row .price-card.current[data-v-693144f6]{border-top:3px solid #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 .scores-row .score-item .score-header .anticon[data-v-693144f6]{color:#1890ff}
|
.fast-analysis-report .result-container .decision-card[data-v-75da11ec]{border-left:6px solid #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 .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 .detailed-analysis .analysis-card[data-v-693144f6]{border-left:4px solid #1890ff}
|
.fast-analysis-report .result-container .decision-card .consensus-strip .consensus-strip-title[data-v-75da11ec]{color:#1890ff}
|
||||||
.fast-analysis-report .result-container .detailed-analysis .analysis-card.technical[data-v-693144f6]{border-left-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 .detailed-analysis .analysis-card .analysis-card-header.technical .anticon[data-v-693144f6]{color:#1890ff}
|
.fast-analysis-report .result-container .scores-row .score-item .score-header .anticon[data-v-75da11ec]{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 .scores-row .score-item.overall[data-v-75da11ec]{background:linear-gradient(135deg,#e6f7ff,#fff);border:1px solid #91d5ff}
|
||||||
.fast-analysis-report .result-container .indicators-section .section-title .anticon[data-v-693144f6]{color:#1890ff}
|
.fast-analysis-report .result-container .detailed-analysis .analysis-card[data-v-75da11ec]{border-left:4px solid #1890ff}
|
||||||
.fast-analysis-report.theme-dark .decision-card[data-v-693144f6]{border-left-color:#1890ff}
|
.fast-analysis-report .result-container .detailed-analysis .analysis-card.technical[data-v-75da11ec]{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 .result-container .detailed-analysis .analysis-card .analysis-card-header.technical .anticon[data-v-75da11ec]{color:#1890ff}
|
||||||
.fast-analysis-report.theme-dark .decision-card .consensus-strip .consensus-strip-title[data-v-693144f6]{color:#69c0ff}
|
.fast-analysis-report .result-container .analysis-details .detail-section .detail-list li[data-v-75da11ec]:before{color:#1890ff}
|
||||||
.fast-analysis-report.theme-dark .detailed-analysis .analysis-card.technical[data-v-693144f6]{border-left-color:#1890ff}
|
.fast-analysis-report .result-container .indicators-section .section-title .anticon[data-v-75da11ec]{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}
|
.fast-analysis-report.theme-dark .decision-card[data-v-75da11ec]{border-left-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)}
|
.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)}
|
||||||
.left-panel .calendar-box .box-header .box-title .anticon[data-v-41eda137]{color:var(--primary-color,#1890ff)}
|
.fast-analysis-report.theme-dark .decision-card .consensus-strip .consensus-strip-title[data-v-75da11ec]{color:#69c0ff}
|
||||||
.right-panel .analysis-toolbar .analyze-button[data-v-41eda137]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
|
.fast-analysis-report.theme-dark .detailed-analysis .analysis-card.technical[data-v-75da11ec]{border-left-color:#1890ff}
|
||||||
.right-panel .analysis-main .analysis-placeholder .placeholder-content .placeholder-icon[data-v-41eda137]{color:var(--primary-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}
|
||||||
.watchlist-panel .watchlist-list .watchlist-item.active[data-v-41eda137]{background:#e6f7ff;border:1px solid #91d5ff}
|
.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)}
|
||||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .watchlist-item.active[data-v-41eda137]{background:rgba(24,144,255,.1);border-color:#1890ff}
|
.left-panel .calendar-box .box-header .box-title .anticon[data-v-5cbe52d9]{color:var(--primary-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)}
|
.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-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)}
|
.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}
|
.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-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}
|
.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 .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-41dfa054]{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-20b827f5]{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-20b827f5]{background:linear-gradient(135deg,#1890ff,#722ed1)}
|
.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-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 .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-20b827f5]{color:#1890ff}
|
.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-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{color:#1890ff}
|
||||||
.code-section .section-header .section-actions[data-v-4fea1865] .ant-btn-link:hover{color:#40a9ff}
|
.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: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{-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)}
|
.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[data-v-58f93984]:hover{color:#1890ff}
|
||||||
.drawing-tool-btn.active[data-v-5f0b12e1]{background:#e6f7ff;color:#1890ff;border:1px solid #1890ff}
|
.drawing-tool-btn.active[data-v-58f93984]{background:#e6f7ff;color:#1890ff;border:1px solid #1890ff}
|
||||||
.indicator-btn[data-v-5f0b12e1]:hover{color:#1890ff;border-color:#1890ff}
|
.indicator-btn[data-v-58f93984]: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)}
|
.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}
|
.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 .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}
|
.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-211c2fea] .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)}
|
.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-b17e86c2]:hover{color:#1890ff}
|
.timeframe-item[data-v-211c2fea]:hover{color:#1890ff}
|
||||||
.timeframe-item.active[data-v-b17e86c2]{color:#1890ff}
|
.timeframe-item.active[data-v-211c2fea]{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[data-v-211c2fea]:hover,.panel-header .theme-toggle-btn[data-v-211c2fea]: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}
|
.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-b17e86c2]:hover{border-color:#1890ff}
|
.indicator-card[data-v-211c2fea]:hover{border-color:#1890ff}
|
||||||
.indicator-card.active[data-v-b17e86c2]{background:#e6f7ff;border-color:#1890ff}
|
.indicator-card.active[data-v-211c2fea]{background:#e6f7ff;border-color:#1890ff}
|
||||||
.indicator-card.active .card-name[data-v-b17e86c2]{color:#1890ff}
|
.indicator-card.active .card-name[data-v-211c2fea]{color:#1890ff}
|
||||||
.card-action[data-v-b17e86c2]:hover{color:#1890ff}
|
.card-action[data-v-211c2fea]:hover{color:#1890ff}
|
||||||
.action-icon.edit-icon[data-v-b17e86c2]{color:#1890ff}
|
.action-icon.edit-icon[data-v-211c2fea]{color:#1890ff}
|
||||||
.action-icon.edit-icon[data-v-b17e86c2]:hover{color:#40a9ff}
|
.action-icon.edit-icon[data-v-211c2fea]:hover{color:#40a9ff}
|
||||||
.action-icon[data-v-b17e86c2]:hover{color:#1890ff}
|
.action-icon[data-v-211c2fea]:hover{color:#1890ff}
|
||||||
.action-icon.publish-icon[data-v-b17e86c2]{color:#1890ff}
|
.action-icon.publish-icon[data-v-211c2fea]{color:#1890ff}
|
||||||
.action-icon.publish-icon[data-v-b17e86c2]:hover{color:#40a9ff}
|
.action-icon.publish-icon[data-v-211c2fea]:hover{color:#40a9ff}
|
||||||
.action-icon.expiry-icon[data-v-b17e86c2]{color:#1890ff}
|
.action-icon.expiry-icon[data-v-211c2fea]{color:#1890ff}
|
||||||
.dark-dropdown .ant-select-dropdown-menu-item-active{background-color:#e6f7ff;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-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-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-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-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-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-b17e86c2]:hover{-webkit-box-shadow:0 6px 24px rgba(24,144,255,.55);box-shadow:0 6px 24px rgba(24,144,255,.55)}
|
.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-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-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-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 .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-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[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-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 .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-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 .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-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-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-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 .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-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[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-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.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-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-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-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.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-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-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-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-211c2fea]:hover,body.dark,body.realdark{color:#40a9ff}
|
||||||
.comment-list .comment-form .form-header .edit-label[data-v-4973cae6]{color:#1890ff}
|
.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)}
|
.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)}
|
[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 .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[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}
|
.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 .page-header .page-title .anticon[data-v-f0405968]{color:#1890ff}
|
||||||
.user-manage-page .address-text[data-v-49c4f239],.user-manage-page .hash-text[data-v-49c4f239]{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-49c4f239] .ant-tabs-tab-active{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-49c4f239],.user-manage-page .current-vip-info .value[data-v-49c4f239]{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-86a53e9c]{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-86a53e9c]{color:#1890ff}
|
.profile-page .profile-card .profile-info .info-item .anticon[data-v-6dceb76a]{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-6dceb76a] .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 .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-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 .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-86a53e9c] .ant-pagination .ant-pagination-item:hover{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-86a53e9c] .ant-pagination .ant-pagination-item-active{background:#1890ff;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-86a53e9c] .ant-btn.ant-btn-default:hover{border-color:#1890ff;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-0cdd5f9c]{border:1px solid rgba(24,144,255,.35)}
|
.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}
|
.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}
|
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-20857e25]{color:#1890ff}
|
.settings-page .settings-header .page-title .anticon[data-v-f144964c]{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 .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-20857e25]{color:#1890ff}
|
.settings-page .openrouter-balance-card .balance-header .balance-title[data-v-f144964c]{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-f144964c] .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-collapse[data-v-f144964c] .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-f144964c] .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-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-20857e25] .ant-form-item-label .form-label-with-tooltip .api-link:hover{background:rgba(24,144,255,.15)}
|
.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-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-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-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-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-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}
|
.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}
|
.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[data-v-de9678f6]:hover{color:#1890ff}
|
||||||
.main .login-method-switch a.active[data-v-de9678f6]{color:#1890ff;border-bottom-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 {
|
.brand-text {
|
||||||
font-size: 20px;
|
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