@@ -1,65 +1,169 @@
|
||||
<div align="center">
|
||||
<img src="https://ai.quantdinger.com/img/logo.e0f510a8.png" alt="QuantDinger" width="160" />
|
||||
<h2>QuantDinger</h2>
|
||||
<p>
|
||||
A local-first quant research & trading workspace: market data, indicators, AI analysis, backtesting, and strategy execution in one place.
|
||||
<a href="https://github.com/brokermr810/QuantDinger">
|
||||
<img src="https://ai.quantdinger.com/img/logo.e0f510a8.png" alt="QuantDinger Logo" width="160" height="160">
|
||||
</a>
|
||||
|
||||
<h1 align="center">QuantDinger</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🤖 AI-Native · 🔒 Privacy-First · 🚀 All-in-One Quant Workspace</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://www.quantdinger.com">Website</a>
|
||||
·
|
||||
<a href="https://ai.quantdinger.com">Live Demo</a>
|
||||
<p align="center">
|
||||
<i>The Next-Gen Local Quant Platform: Multi-Market Data, AI Research, Visual Backtesting, and Automated Trading.</i>
|
||||
</p>
|
||||
<p>
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" /></a>
|
||||
<img alt="Backend" src="https://img.shields.io/badge/Backend-Flask%20%2B%20SQLite-black" />
|
||||
<img alt="Frontend" src="https://img.shields.io/badge/Frontend-Vue%202%20%2B%20Ant%20Design%20Vue-2c7be5" />
|
||||
<a href="https://ai.quantdinger.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-ai.quantdinger.com-00b894" /></a>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.quantdinger.com"><strong>Website</strong></a> ·
|
||||
<a href="https://ai.quantdinger.com"><strong>Live Demo</strong></a> ·
|
||||
<a href="https://github.com/brokermr810/QuantDinger/issues"><strong>Report Bug</strong></a> ·
|
||||
<a href="https://github.com/brokermr810/QuantDinger/discussions"><strong>Discussions</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square&logo=apache" alt="License"></a>
|
||||
<img src="https://img.shields.io/badge/Python-3.10+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python">
|
||||
<img src="https://img.shields.io/badge/Vue.js-2.x-4FC08D?style=flat-square&logo=vue.js&logoColor=white" alt="Vue">
|
||||
<img src="https://img.shields.io/badge/Docker-Ready-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker">
|
||||
<img src="https://img.shields.io/github/stars/brokermr810/QuantDinger?style=flat-square&logo=github" alt="Stars">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/worldinbroker"><img src="https://img.shields.io/badge/Telegram-Join%20Chat-26A5E4?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
<a href="https://discord.gg/cn6HVE2KC"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?style=for-the-badge&logo=discord" alt="Discord"></a>
|
||||
<a href="https://x.com/HenryCryption"><img src="https://img.shields.io/badge/X-Follow%20Us-000000?style=for-the-badge&logo=x" alt="X"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
This repository is intentionally simple (no PHP gateway): it contains **one Python backend** and **one web UI**.
|
||||
## 📖 Introduction
|
||||
|
||||
- **`backend_api_python/`**: Flask API + strategy runtime + AI agents
|
||||
- **`quantdinger_vue/`**: Vue 2 UI (Ant Design Vue based) with charts, backtests, and strategy management
|
||||
**QuantDinger** is a **Local-First** quantitative trading workspace designed for traders, researchers, and geeks.
|
||||
|
||||
> This repo does not include real secrets. Configure API keys and credentials via `.env` / environment variables.
|
||||
Unlike expensive SaaS platforms, QuantDinger returns **data ownership** to you. It features a built-in **LLM-based Multi-Agent Research Team** that autonomously gathers financial intelligence from the web, combines it with local market data, generates professional analysis reports, and seamlessly integrates with your strategy development, backtesting, and live trading workflows.
|
||||
|
||||
### Core Value
|
||||
- **🛡️ Privacy First**: All strategies, trading logs, and API keys are stored locally in your SQLite database.
|
||||
- **🧠 AI Empowered**: Not just code completion, but a real AI Research Analyst (powered by OpenRouter/LLM).
|
||||
- **⚡ Multi-Market**: Native support for **Crypto**, **US Stocks**, **CN/HK Stocks**, **Forex**, and **Futures**.
|
||||
- **🔌 Out-of-the-Box**: One-click deployment via Docker. No complex environment setup required.
|
||||
|
||||
---
|
||||
|
||||
### Why QuantDinger
|
||||
## 📸 Visual Tour
|
||||
|
||||
- **Local-first (SQLite) out of the box**: no external database required to run a full workflow locally.
|
||||
- **AI research team in code**: multi-agent analysis produces structured reports (with optional web search + LLMs).
|
||||
- **Multi-market data layer**: a factory-based data source abstraction for crypto, US stocks, CN/HK stocks, forex, and futures.
|
||||
- **From signals to execution**: strategy runtime + a pending-order worker to dispatch queued actions reliably.
|
||||
<div align="center">
|
||||
<h3>📊 Professional Quant Dashboard</h3>
|
||||
<p>Real-time monitoring of market dynamics, assets, and strategy status.</p>
|
||||
<img src="docs/screenshots/dashboard.png" alt="QuantDinger Dashboard" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<table align="center" width="100%">
|
||||
<tr>
|
||||
<td width="50%" align="center" valign="top">
|
||||
<h3>🤖 AI Deep Research</h3>
|
||||
<p>Multi-agent collaboration for market sentiment & technical analysis.</p>
|
||||
<img src="docs/screenshots/ai_analysis1.png" alt="AI Market Analysis" style="border-radius: 6px;">
|
||||
</td>
|
||||
<td width="50%" align="center" valign="top">
|
||||
<h3>💬 Smart Trading Assistant</h3>
|
||||
<p>Natural language interface for instant market insights.</p>
|
||||
<img src="docs/screenshots/trading_assistant.png" alt="Trading Assistant" style="border-radius: 6px;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center" valign="top">
|
||||
<h3>📈 Interactive Indicator Analysis</h3>
|
||||
<p>Rich library of technical indicators with drag-and-drop analysis.</p>
|
||||
<img src="docs/screenshots/indicator_analysis.png" alt="Indicator Analysis" style="border-radius: 6px;">
|
||||
</td>
|
||||
<td width="50%" align="center" valign="top">
|
||||
<h3>🐍 Python Strategy Gen</h3>
|
||||
<p>Built-in editor with AI-assisted strategy coding.</p>
|
||||
<img src="docs/screenshots/indicator_creat_python_code.png" alt="Code Generation" style="border-radius: 6px;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
### Highlights (What You Get)
|
||||
## ✨ Key Features
|
||||
|
||||
- **Multi-market market data**
|
||||
- Data source factory in `backend_api_python/app/data_sources/`.
|
||||
- Optional proxy support for restricted networks (see `.env`).
|
||||
### 1. Universal Data Engine
|
||||
Stop worrying about data APIs. QuantDinger features a powerful Data Source Factory pattern:
|
||||
- **Crypto**: Seamless integration with **CCXT**, supporting Binance, OKX, Bybit, and 100+ exchanges.
|
||||
- **Stocks**: Integrates Yahoo Finance, Finnhub, Tiingo (US), and AkShare (CN/HK).
|
||||
- **Futures/Forex**: Supports OANDA and major futures data sources.
|
||||
- **Proxy Support**: Built-in proxy configuration for restricted network environments.
|
||||
|
||||
- **AI multi-agent analysis**
|
||||
- Coordinator + role agents in `backend_api_python/app/services/agents/`.
|
||||
- Optional web search (Google/Bing) and LLM access (OpenRouter).
|
||||
### 2. AI Multi-Agent Research
|
||||
Your tireless team of analysts:
|
||||
- **Coordinator Agent**: Decomposes tasks and manages workflows.
|
||||
- **Research Agent**: Performs full-web searches (Google/Bing) for macro news.
|
||||
- **Crypto/Stock Agent**: Specializes in technical and capital flow analysis for specific markets.
|
||||
- **Report Generation**: Automatically produces structured Daily/Weekly research reports.
|
||||
|
||||
- **Indicator engine + backtesting**
|
||||
- Indicator code storage with safe execution utilities.
|
||||
- Backtest endpoints + persisted run history (SQLite).
|
||||
### 3. Robust Strategy Runtime
|
||||
- **Thread-Based Executor**: Independent thread pool management for strategy execution.
|
||||
- **Auto-Restore**: Automatically resumes running strategies after system restarts.
|
||||
- **Pending Order Worker**: Reliable background queue ensures precise signal execution and prevents slippage.
|
||||
|
||||
- **Strategy runtime**
|
||||
- Thread-based executor with startup auto-restore (configurable).
|
||||
- Background pending-order worker that polls queued orders and dispatches signals (can be disabled).
|
||||
### 4. Modern Tech Stack
|
||||
- **Backend**: Python (Flask) + SQLite + Redis (Optional) — Simple, powerful, extensible.
|
||||
- **Frontend**: Vue 2 + Ant Design Vue + KlineCharts/ECharts — Responsive and interactive.
|
||||
- **Deployment**: Docker Compose orchestration.
|
||||
|
||||
- **Live trading integrations**
|
||||
- Exchange execution adapters in `backend_api_python/app/services/live_trading/` (CCXT-based where applicable).
|
||||
---
|
||||
|
||||
- **Local auth (single-user)**
|
||||
- Simple login (`/login`) with env-configured admin credentials (JWT token).
|
||||
## 🏦 Supported Exchanges
|
||||
|
||||
Powered by the robust CCXT library, we support nearly all major cryptocurrency exchanges:
|
||||
|
||||
| Binance | OKX | Bybit | Bitget | Gate.io | KuCoin |
|
||||
|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| <img src="https://user-images.githubusercontent.com/1294454/29604020-d5483cdc-87ee-11e7-94c7-d1a8d9169293.jpg" width="50"> | <img src="https://user-images.githubusercontent.com/1294454/29604052-fc7770a6-87ee-11e7-91f4-34adcb17cb83.jpg" width="50"> | <img src="https://user-images.githubusercontent.com/1294454/59143324-00445d80-89b9-11e9-9807-742261596e00.jpg" width="50"> | <img src="https://user-images.githubusercontent.com/1294454/152643503-4554366b-4e60-466d-97e3-057d29219808.jpg" width="50"> | <img src="https://user-images.githubusercontent.com/1294454/31784034-0d5b4a04-b514-11e7-9d78-2d886895b6c3.jpg" width="50"> | <img src="https://user-images.githubusercontent.com/1294454/32065879-e58f0004-ba91-11e7-9092-22589e472624.jpg" width="50"> |
|
||||
|
||||
> Plus Kraken, Huobi, Coinbase Pro, and 100+ others.
|
||||
|
||||
---
|
||||
|
||||
### Multi-Language Support
|
||||
|
||||
<details>
|
||||
<summary><b>Show supported languages</b></summary>
|
||||
|
||||
QuantDinger is built for a global audience with comprehensive internationalization:
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/🇺🇸_English-Supported-2563EB?style=flat-square" alt="English" />
|
||||
<img src="https://img.shields.io/badge/🇨🇳_简体中文-Supported-2563EB?style=flat-square" alt="Simplified Chinese" />
|
||||
<img src="https://img.shields.io/badge/🇹🇼_繁體中文-Supported-2563EB?style=flat-square" alt="Traditional Chinese" />
|
||||
<img src="https://img.shields.io/badge/🇯🇵_日本語-Supported-2563EB?style=flat-square" alt="Japanese" />
|
||||
<img src="https://img.shields.io/badge/🇰🇷_한국어-Supported-2563EB?style=flat-square" alt="Korean" />
|
||||
<img src="https://img.shields.io/badge/🇩🇪_Deutsch-Supported-2563EB?style=flat-square" alt="German" />
|
||||
<img src="https://img.shields.io/badge/🇫🇷_Français-Supported-2563EB?style=flat-square" alt="French" />
|
||||
<img src="https://img.shields.io/badge/🇹🇭_ไทย-Supported-2563EB?style=flat-square" alt="Thai" />
|
||||
<img src="https://img.shields.io/badge/🇻🇳_Tiếng_Việt-Supported-2563EB?style=flat-square" alt="Vietnamese" />
|
||||
<img src="https://img.shields.io/badge/🇸🇦_العربية-Supported-2563EB?style=flat-square" alt="Arabic" />
|
||||
</p>
|
||||
|
||||
All UI elements, error messages, and documentation are fully translated. Language is auto-detected based on browser settings or can be manually switched in the app.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Supported Markets
|
||||
|
||||
| Market Type | Data Sources | Trading |
|
||||
|-------------|--------------|---------|
|
||||
| **Cryptocurrency** | Binance, OKX, Bitget, + 100 exchanges | ✅ Full support |
|
||||
| **US Stocks** | Yahoo Finance, Finnhub, Tiingo | ✅ Via broker API |
|
||||
| **CN/HK Stocks** | AkShare, East Money | ⚡ Data only |
|
||||
| **Forex** | Finnhub, OANDA | ✅ Via broker API |
|
||||
| **Futures** | Exchange APIs, AkShare | ⚡ Data only |
|
||||
|
||||
---
|
||||
|
||||
@@ -98,25 +202,247 @@ This repository is intentionally simple (no PHP gateway): it contains **one Pyth
|
||||
|
||||
---
|
||||
|
||||
### Quick Start (Local Development)
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker Deployment (Recommended)
|
||||
|
||||
The fastest way to get QuantDinger running.
|
||||
|
||||
#### 1. Prepare Configuration
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
cp docker.env.example backend_api_python/.env
|
||||
nano backend_api_python/.env
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item docker.env.example backend_api_python/.env
|
||||
notepad backend_api_python/.env
|
||||
```
|
||||
|
||||
**Required settings:**
|
||||
- `SECRET_KEY` - Application secret, use a random string
|
||||
- `ADMIN_USER` / `ADMIN_PASSWORD` - Login credentials
|
||||
- `OPENROUTER_API_KEY` - OpenRouter API key (required for AI analysis)
|
||||
|
||||
#### 2. Build and Start
|
||||
|
||||
```bash
|
||||
# Build images and start (first run)
|
||||
docker-compose up -d --build
|
||||
|
||||
# Subsequent starts (no rebuild needed)
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### 3. Access the Application
|
||||
|
||||
- **Frontend UI**: http://localhost
|
||||
- **Backend API**: http://localhost:5000
|
||||
|
||||
#### Docker Commands Reference
|
||||
|
||||
```bash
|
||||
# View running status
|
||||
docker-compose ps
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# View backend logs only
|
||||
docker-compose logs -f backend
|
||||
|
||||
# View frontend logs only
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
|
||||
# Stop and remove volumes (WARNING: deletes database!)
|
||||
docker-compose down -v
|
||||
|
||||
# Restart services
|
||||
docker-compose restart
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose up -d --build
|
||||
|
||||
# Enter backend container
|
||||
docker exec -it quantdinger-backend /bin/bash
|
||||
|
||||
# Enter frontend container
|
||||
docker exec -it quantdinger-frontend /bin/sh
|
||||
```
|
||||
|
||||
#### Docker Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │
|
||||
│ (Nginx) │────▶│ (Python) │
|
||||
│ Port: 80 │ │ Port: 5000 │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
└───────────────────────┘
|
||||
Docker Network
|
||||
```
|
||||
|
||||
- **Frontend**: Vue.js app served by Nginx, proxies API requests to backend
|
||||
- **Backend**: Python Flask API service
|
||||
|
||||
#### Data Persistence
|
||||
|
||||
The following data is mounted to the host and persists across container restarts:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./backend_api_python/quantdinger.db:/app/quantdinger.db # Database
|
||||
- ./backend_api_python/logs:/app/logs # Logs
|
||||
- ./backend_api_python/data:/app/data # Data directory
|
||||
- ./backend_api_python/.env:/app/.env # Configuration
|
||||
```
|
||||
|
||||
#### Customization
|
||||
|
||||
**Change ports** - Edit `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
ports:
|
||||
- "8080:80" # Change to port 8080
|
||||
|
||||
backend:
|
||||
ports:
|
||||
- "5001:5000" # Change to port 5001
|
||||
```
|
||||
|
||||
**Configure HTTPS** - Use a reverse proxy (like Caddy/Nginx):
|
||||
|
||||
```bash
|
||||
# Using Caddy (automatic HTTPS)
|
||||
caddy reverse-proxy --from yourdomain.com --to localhost:80
|
||||
```
|
||||
|
||||
#### Production Recommendations
|
||||
|
||||
**Security:**
|
||||
|
||||
```bash
|
||||
# Generate strong SECRET_KEY
|
||||
openssl rand -hex 32
|
||||
|
||||
# Set secure admin password
|
||||
ADMIN_PASSWORD=your-very-secure-password
|
||||
```
|
||||
|
||||
**Resource limits** - Add to `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
**Log management:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
#### Docker Troubleshooting
|
||||
|
||||
**Frontend can't connect to backend:**
|
||||
|
||||
```bash
|
||||
docker-compose logs backend
|
||||
curl http://localhost:5000/api/health
|
||||
```
|
||||
|
||||
**Database permission issues:**
|
||||
|
||||
```bash
|
||||
chmod 666 backend_api_python/quantdinger.db
|
||||
```
|
||||
|
||||
**Build failures:**
|
||||
|
||||
```bash
|
||||
# Clear Docker cache and rebuild
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
**Out of memory:**
|
||||
|
||||
```bash
|
||||
# Check memory usage
|
||||
docker stats
|
||||
|
||||
# Add swap space (Linux)
|
||||
sudo fallocate -l 2G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
#### Updating
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
#### Backup
|
||||
|
||||
```bash
|
||||
# Backup database
|
||||
cp backend_api_python/quantdinger.db backup/quantdinger_$(date +%Y%m%d).db
|
||||
|
||||
# Backup configuration
|
||||
cp backend_api_python/.env backup/.env_$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option 2: Local Development
|
||||
|
||||
**Prerequisites**
|
||||
|
||||
- Python 3.10+ recommended
|
||||
- Node.js 16+ recommended
|
||||
|
||||
#### 1) Start the backend (Flask API)
|
||||
#### 1. Start the backend (Flask API)
|
||||
|
||||
```bash
|
||||
cd backend_api_python
|
||||
pip install -r requirements.txt
|
||||
copy env.example .env # Windows PowerShell users can use: Copy-Item env.example .env
|
||||
cp env.example .env # Windows: copy env.example .env
|
||||
python run.py
|
||||
```
|
||||
|
||||
Backend will be available at `http://localhost:5000`.
|
||||
|
||||
#### 2) Start the frontend (Vue UI)
|
||||
#### 2. Start the frontend (Vue UI)
|
||||
|
||||
```bash
|
||||
cd quantdinger_vue
|
||||
@@ -146,8 +472,8 @@ Use `backend_api_python/env.example` as a template. Common settings include:
|
||||
|
||||
The backend provides REST endpoints for login, market data, indicators, backtesting, strategies, and AI analysis.
|
||||
|
||||
- Health: `GET /health`
|
||||
- Auth: `POST /login`, `POST /logout`, `GET /info`
|
||||
- Health: `GET /health` (also supports `GET /api/health` for deployment probes)
|
||||
- Auth (frontend-compatible): `POST /api/user/login`, `POST /api/user/logout`, `GET /api/user/info`
|
||||
|
||||
For the full route list, see `backend_api_python/app/routes/`.
|
||||
|
||||
@@ -159,17 +485,74 @@ Licensed under the **Apache License 2.0**. See `LICENSE`.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Community & Support
|
||||
|
||||
Join our global community for strategy sharing and technical support:
|
||||
|
||||
- **Telegram (Official)**: [t.me/worldinbroker](https://t.me/worldinbroker)
|
||||
- **Discord**: [Join Server](https://discord.gg/cn6HVE2KC)
|
||||
- **YouTube**: [@quantdinger](https://youtube.com/@quantdinger)
|
||||
- **Email**: [brokermr810@gmail.com](mailto:brokermr810@gmail.com)
|
||||
- **GitHub Issues**: [Report bugs / Request features](https://github.com/brokermr810/QuantDinger/issues)
|
||||
|
||||
---
|
||||
|
||||
## ☕ Support the Project
|
||||
|
||||
If QuantDinger helps you profit, consider buying the developers a coffee. Your support keeps the project alive!
|
||||
|
||||
<details>
|
||||
<summary><b>View Crypto Donation Addresses</b></summary>
|
||||
|
||||
**ERC-20 / BEP-20 / Polygon / Arbitrum**
|
||||
```
|
||||
0x96fa4962181bea077f8c7240efe46afbe73641a7
|
||||
```
|
||||
<img src="https://img.shields.io/badge/USDT-Accepted-26A17B?style=flat-square&logo=tether" alt="USDT">
|
||||
<img src="https://img.shields.io/badge/ETH-Accepted-3C3C3D?style=flat-square&logo=ethereum" alt="ETH">
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Commercial Services
|
||||
|
||||
<details>
|
||||
<summary><b>Show services</b></summary>
|
||||
|
||||
We offer professional services to help you get the most out of QuantDinger:
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| **Deployment & Setup** | One-on-one assistance with server deployment, configuration, and optimization |
|
||||
| **Custom Strategy Development** | Tailored trading strategies designed for your specific needs and markets |
|
||||
| **Enterprise Upgrade** | Commercial license, priority support, and advanced features for businesses |
|
||||
| **Training & Consulting** | Hands-on training sessions and strategic consulting for your trading team |
|
||||
|
||||
**Interested?** Contact us via:
|
||||
- 📧 Email: [brokermr810@gmail.com](mailto:brokermr810@gmail.com)
|
||||
- 💬 Telegram: [@worldinbroker](https://t.me/worldinbroker)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
QuantDinger stands on the shoulders of great open-source projects, including:
|
||||
|
||||
- **Flask** / **flask-cors**
|
||||
- **Pandas**
|
||||
- **CCXT**
|
||||
- **yfinance**, **akshare**, **requests**
|
||||
- **Vue 2** and **Ant Design Vue**
|
||||
- Charting libraries used in the UI (e.g., **KlineCharts**)
|
||||
|
||||
Thanks to maintainers and contributors across these ecosystems.
|
||||
QuantDinger stands on the shoulders of great open-source projects:
|
||||
|
||||
| Project | Description | Link |
|
||||
|---------|-------------|------|
|
||||
| **Flask** | Lightweight WSGI web framework | [flask.palletsprojects.com](https://flask.palletsprojects.com/) |
|
||||
| **flask-cors** | Cross-Origin Resource Sharing extension | [GitHub](https://github.com/corydolphin/flask-cors) |
|
||||
| **Pandas** | Data analysis and manipulation library | [pandas.pydata.org](https://pandas.pydata.org/) |
|
||||
| **CCXT** | Cryptocurrency exchange trading library | [github.com/ccxt/ccxt](https://github.com/ccxt/ccxt) |
|
||||
| **yfinance** | Yahoo Finance market data downloader | [github.com/ranaroussi/yfinance](https://github.com/ranaroussi/yfinance) |
|
||||
| **akshare** | China financial data interface | [github.com/akfamily/akshare](https://github.com/akfamily/akshare) |
|
||||
| **requests** | HTTP library for Python | [requests.readthedocs.io](https://requests.readthedocs.io/) |
|
||||
| **Vue.js** | Progressive JavaScript framework | [vuejs.org](https://vuejs.org/) |
|
||||
| **Ant Design Vue** | Enterprise-class UI components | [antdv.com](https://antdv.com/) |
|
||||
| **KlineCharts** | Lightweight financial charting library | [github.com/klinecharts/KLineChart](https://github.com/klinecharts/KLineChart) |
|
||||
| **Lightweight Charts** | TradingView charting library | [github.com/nicepkg/lightweight-charts](https://github.com/nicepkg/lightweight-charts) |
|
||||
| **ECharts** | Apache data visualization library | [echarts.apache.org](https://echarts.apache.org/) |
|
||||
|
||||
Thanks to all maintainers and contributors across these ecosystems! ❤️
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Database (在 docker-compose 中挂载)
|
||||
*.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Data (在 docker-compose 中挂载)
|
||||
data/memory/*.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Tests
|
||||
tests/
|
||||
pytest_cache/
|
||||
.coverage
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# QuantDinger Backend API Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libffi-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy dependency file
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create log and data directories
|
||||
RUN mkdir -p logs data/memory
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHON_API_HOST=0.0.0.0
|
||||
ENV PYTHON_API_PORT=5000
|
||||
|
||||
# Start command
|
||||
CMD ["python", "run.py"]
|
||||
@@ -17,6 +17,7 @@ def register_routes(app: Flask):
|
||||
from app.routes.ai_chat import ai_chat_bp
|
||||
from app.routes.indicator import indicator_bp
|
||||
from app.routes.dashboard import dashboard_bp
|
||||
from app.routes.settings import settings_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login
|
||||
@@ -29,4 +30,5 @@ def register_routes(app: Flask):
|
||||
app.register_blueprint(strategy_bp, url_prefix='/api')
|
||||
app.register_blueprint(credentials_bp, url_prefix='/api/credentials')
|
||||
app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard')
|
||||
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ def _safe_int(v: Any, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _safe_float(v: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _safe_json_loads(value: Any, default: Any) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
@@ -96,6 +103,157 @@ def _calc_pnl_percent(entry_price: float, size: float, pnl: float, leverage: flo
|
||||
return 0.0
|
||||
|
||||
|
||||
def _compute_performance_stats(trades: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Compute performance statistics from trade history.
|
||||
Returns: {
|
||||
total_trades, winning_trades, losing_trades, win_rate,
|
||||
total_profit, total_loss, profit_factor,
|
||||
avg_win, avg_loss, avg_trade,
|
||||
max_win, max_loss, max_drawdown, max_drawdown_pct
|
||||
}
|
||||
"""
|
||||
total_trades = len(trades)
|
||||
if total_trades == 0:
|
||||
return {
|
||||
"total_trades": 0,
|
||||
"winning_trades": 0,
|
||||
"losing_trades": 0,
|
||||
"win_rate": 0.0,
|
||||
"total_profit": 0.0,
|
||||
"total_loss": 0.0,
|
||||
"profit_factor": 0.0,
|
||||
"avg_win": 0.0,
|
||||
"avg_loss": 0.0,
|
||||
"avg_trade": 0.0,
|
||||
"max_win": 0.0,
|
||||
"max_loss": 0.0,
|
||||
"max_drawdown": 0.0,
|
||||
"max_drawdown_pct": 0.0,
|
||||
"best_day": 0.0,
|
||||
"worst_day": 0.0,
|
||||
}
|
||||
|
||||
profits = [_safe_float(t.get("profit"), 0.0) for t in trades]
|
||||
wins = [p for p in profits if p > 0]
|
||||
losses = [p for p in profits if p < 0]
|
||||
|
||||
winning_trades = len(wins)
|
||||
losing_trades = len(losses)
|
||||
win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0.0
|
||||
|
||||
total_profit = sum(wins) if wins else 0.0
|
||||
total_loss = abs(sum(losses)) if losses else 0.0
|
||||
profit_factor = (total_profit / total_loss) if total_loss > 0 else (total_profit if total_profit > 0 else 0.0)
|
||||
|
||||
avg_win = (total_profit / winning_trades) if winning_trades > 0 else 0.0
|
||||
avg_loss = (total_loss / losing_trades) if losing_trades > 0 else 0.0
|
||||
avg_trade = sum(profits) / total_trades if total_trades > 0 else 0.0
|
||||
|
||||
max_win = max(profits) if profits else 0.0
|
||||
max_loss = min(profits) if profits else 0.0
|
||||
|
||||
# Calculate max drawdown from cumulative equity
|
||||
cumulative = []
|
||||
acc = 0.0
|
||||
for p in profits:
|
||||
acc += p
|
||||
cumulative.append(acc)
|
||||
|
||||
peak = 0.0
|
||||
max_drawdown = 0.0
|
||||
for val in cumulative:
|
||||
if val > peak:
|
||||
peak = val
|
||||
dd = peak - val
|
||||
if dd > max_drawdown:
|
||||
max_drawdown = dd
|
||||
|
||||
max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0.0
|
||||
|
||||
# Best/worst day
|
||||
day_profits: Dict[str, float] = {}
|
||||
for t in trades:
|
||||
ts = _safe_int(t.get("created_at"), 0)
|
||||
if ts <= 0:
|
||||
continue
|
||||
day = time.strftime("%Y-%m-%d", time.localtime(ts))
|
||||
profit = _safe_float(t.get("profit"), 0.0)
|
||||
day_profits[day] = day_profits.get(day, 0.0) + profit
|
||||
|
||||
best_day = max(day_profits.values()) if day_profits else 0.0
|
||||
worst_day = min(day_profits.values()) if day_profits else 0.0
|
||||
|
||||
return {
|
||||
"total_trades": total_trades,
|
||||
"winning_trades": winning_trades,
|
||||
"losing_trades": losing_trades,
|
||||
"win_rate": round(win_rate, 2),
|
||||
"total_profit": round(total_profit, 2),
|
||||
"total_loss": round(total_loss, 2),
|
||||
"profit_factor": round(profit_factor, 2),
|
||||
"avg_win": round(avg_win, 2),
|
||||
"avg_loss": round(avg_loss, 2),
|
||||
"avg_trade": round(avg_trade, 2),
|
||||
"max_win": round(max_win, 2),
|
||||
"max_loss": round(max_loss, 2),
|
||||
"max_drawdown": round(max_drawdown, 2),
|
||||
"max_drawdown_pct": round(max_drawdown_pct, 2),
|
||||
"best_day": round(best_day, 2),
|
||||
"worst_day": round(worst_day, 2),
|
||||
}
|
||||
|
||||
|
||||
def _compute_strategy_stats(trades: List[Dict[str, Any]], strategies: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Compute per-strategy statistics.
|
||||
Only includes strategies that still exist (not deleted).
|
||||
"""
|
||||
# Build set of existing strategy IDs
|
||||
existing_strategy_ids: set = set()
|
||||
sid_to_name: Dict[int, str] = {}
|
||||
sid_to_capital: Dict[int, float] = {}
|
||||
for s in strategies:
|
||||
sid = _safe_int(s.get("id"), 0)
|
||||
if sid > 0:
|
||||
existing_strategy_ids.add(sid)
|
||||
sid_to_name[sid] = str(s.get("strategy_name") or f"Strategy_{sid}")
|
||||
sid_to_capital[sid] = _safe_float(s.get("initial_capital"), 0.0)
|
||||
|
||||
# Group trades by strategy (only for existing strategies)
|
||||
sid_to_trades: Dict[int, List[Dict[str, Any]]] = {}
|
||||
for t in trades:
|
||||
sid = _safe_int(t.get("strategy_id"), 0)
|
||||
# Skip trades from deleted strategies
|
||||
if sid not in existing_strategy_ids:
|
||||
continue
|
||||
if sid not in sid_to_trades:
|
||||
sid_to_trades[sid] = []
|
||||
sid_to_trades[sid].append(t)
|
||||
|
||||
result = []
|
||||
for sid, strades in sid_to_trades.items():
|
||||
stats = _compute_performance_stats(strades)
|
||||
total_pnl = sum(_safe_float(t.get("profit"), 0.0) for t in strades)
|
||||
capital = sid_to_capital.get(sid, 0.0)
|
||||
roi = (total_pnl / capital * 100) if capital > 0 else 0.0
|
||||
|
||||
result.append({
|
||||
"strategy_id": sid,
|
||||
"strategy_name": sid_to_name.get(sid, f"Strategy_{sid}"),
|
||||
"total_trades": stats["total_trades"],
|
||||
"win_rate": stats["win_rate"],
|
||||
"profit_factor": stats["profit_factor"],
|
||||
"total_pnl": round(total_pnl, 2),
|
||||
"roi": round(roi, 2),
|
||||
"max_drawdown": stats["max_drawdown"],
|
||||
})
|
||||
|
||||
# Sort by total PnL descending
|
||||
result.sort(key=lambda x: x.get("total_pnl", 0), reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
@dashboard_bp.route("/summary", methods=["GET"])
|
||||
def summary():
|
||||
"""
|
||||
@@ -183,12 +341,18 @@ def summary():
|
||||
FROM qd_strategy_trades t
|
||||
LEFT JOIN qd_strategies_trading s ON s.id = t.strategy_id
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 200
|
||||
LIMIT 500
|
||||
"""
|
||||
)
|
||||
recent_trades = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
# Compute performance statistics
|
||||
perf_stats = _compute_performance_stats(recent_trades)
|
||||
|
||||
# Compute per-strategy statistics
|
||||
strategy_stats = _compute_strategy_stats(recent_trades, strategies)
|
||||
|
||||
# Total equity/pnl (best-effort)
|
||||
total_initial_capital = 0.0
|
||||
for s in strategies:
|
||||
@@ -196,7 +360,10 @@ def summary():
|
||||
total_initial_capital += float(s.get("initial_capital") or 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
total_pnl = float(total_unrealized_pnl)
|
||||
|
||||
# Include realized PnL from trades
|
||||
total_realized_pnl = sum(_safe_float(t.get("profit"), 0.0) for t in recent_trades)
|
||||
total_pnl = float(total_unrealized_pnl + total_realized_pnl)
|
||||
total_equity = float(total_initial_capital + total_pnl)
|
||||
|
||||
# Daily PnL chart (uses realized profit field if present, otherwise 0)
|
||||
@@ -223,6 +390,80 @@ def summary():
|
||||
sid_to_unreal[sid] = float(sid_to_unreal.get(sid, 0.0) + float(p.get("unrealized_pnl") or 0.0))
|
||||
strategy_pnl_chart = [{"name": sid_to_name[sid], "value": float(val)} for sid, val in sid_to_unreal.items()]
|
||||
|
||||
# Monthly returns for heatmap
|
||||
month_to_profit: Dict[str, float] = {}
|
||||
for trow in recent_trades:
|
||||
ts = _safe_int(trow.get("created_at"), 0)
|
||||
if ts <= 0:
|
||||
continue
|
||||
month = time.strftime("%Y-%m", time.localtime(ts))
|
||||
try:
|
||||
p = float(trow.get("profit") or 0.0)
|
||||
except Exception:
|
||||
p = 0.0
|
||||
month_to_profit[month] = month_to_profit.get(month, 0.0) + p
|
||||
monthly_returns = [{"month": m, "profit": round(v, 2)} for m, v in sorted(month_to_profit.items())]
|
||||
|
||||
# Hourly distribution
|
||||
hour_to_count: Dict[int, int] = {}
|
||||
hour_to_profit: Dict[int, float] = {}
|
||||
for trow in recent_trades:
|
||||
ts = _safe_int(trow.get("created_at"), 0)
|
||||
if ts <= 0:
|
||||
continue
|
||||
hour = int(time.strftime("%H", time.localtime(ts)))
|
||||
hour_to_count[hour] = hour_to_count.get(hour, 0) + 1
|
||||
hour_to_profit[hour] = hour_to_profit.get(hour, 0.0) + _safe_float(trow.get("profit"), 0.0)
|
||||
hourly_distribution = [
|
||||
{"hour": h, "count": hour_to_count.get(h, 0), "profit": round(hour_to_profit.get(h, 0.0), 2)}
|
||||
for h in range(24)
|
||||
]
|
||||
|
||||
# Calendar data: organized by month for monthly calendar view
|
||||
# Format: { "2024-01": { "days": { "01": 123.45, "02": -50.0, ... }, "total": 500.0 }, ... }
|
||||
import calendar as cal_module
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
calendar_data: Dict[str, Dict[str, Any]] = {}
|
||||
for d, p in day_to_profit.items():
|
||||
try:
|
||||
dt = datetime.strptime(d, "%Y-%m-%d")
|
||||
month_key = dt.strftime("%Y-%m")
|
||||
day_num = dt.strftime("%d")
|
||||
if month_key not in calendar_data:
|
||||
# Get number of days in month
|
||||
year, month = int(dt.strftime("%Y")), int(dt.strftime("%m"))
|
||||
_, days_in_month = cal_module.monthrange(year, month)
|
||||
# Get first day of month (0=Monday, 6=Sunday)
|
||||
first_weekday = cal_module.monthrange(year, month)[0]
|
||||
calendar_data[month_key] = {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"days_in_month": days_in_month,
|
||||
"first_weekday": first_weekday, # 0=Mon, 6=Sun
|
||||
"days": {},
|
||||
"total": 0.0,
|
||||
"win_days": 0,
|
||||
"lose_days": 0,
|
||||
}
|
||||
calendar_data[month_key]["days"][day_num] = round(p, 2)
|
||||
calendar_data[month_key]["total"] = round(calendar_data[month_key]["total"] + p, 2)
|
||||
if p > 0:
|
||||
calendar_data[month_key]["win_days"] += 1
|
||||
elif p < 0:
|
||||
calendar_data[month_key]["lose_days"] += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Convert to sorted list for frontend
|
||||
calendar_months = []
|
||||
for month_key in sorted(calendar_data.keys(), reverse=True):
|
||||
data = calendar_data[month_key]
|
||||
calendar_months.append({
|
||||
"month_key": month_key,
|
||||
**data
|
||||
})
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"code": 1,
|
||||
@@ -230,11 +471,22 @@ def summary():
|
||||
"data": {
|
||||
"ai_strategy_count": int(ai_enabled_strategy_count),
|
||||
"indicator_strategy_count": int(indicator_strategy_count),
|
||||
"total_equity": float(total_equity),
|
||||
"total_pnl": float(total_pnl),
|
||||
"total_equity": round(total_equity, 2),
|
||||
"total_pnl": round(total_pnl, 2),
|
||||
"total_realized_pnl": round(total_realized_pnl, 2),
|
||||
"total_unrealized_pnl": round(total_unrealized_pnl, 2),
|
||||
# Performance KPIs
|
||||
"performance": perf_stats,
|
||||
# Strategy-level stats
|
||||
"strategy_stats": strategy_stats,
|
||||
# Chart data
|
||||
"daily_pnl_chart": daily_pnl_chart,
|
||||
"strategy_pnl_chart": strategy_pnl_chart,
|
||||
"recent_trades": recent_trades,
|
||||
"monthly_returns": monthly_returns,
|
||||
"hourly_distribution": hourly_distribution,
|
||||
"calendar_months": calendar_months, # Monthly calendar data
|
||||
# Lists
|
||||
"recent_trades": recent_trades[:100], # Limit for frontend
|
||||
"current_positions": current_positions,
|
||||
},
|
||||
}
|
||||
@@ -383,4 +635,3 @@ def delete_pending_order(order_id: int):
|
||||
except Exception as e:
|
||||
logger.error(f"dashboard delete pendingOrders failed: {e}", exc_info=True)
|
||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
||||
|
||||
|
||||
@@ -26,3 +26,8 @@ def health_check():
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
|
||||
@health_bp.route('/api/health', methods=['GET'])
|
||||
def api_health_check():
|
||||
"""兼容路径:用于容器健康检查/反代探针等场景。"""
|
||||
return health_check()
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Settings API - 读取和保存 .env 配置
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.config_loader import clear_config_cache
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
settings_bp = Blueprint('settings', __name__)
|
||||
|
||||
# .env 文件路径
|
||||
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
|
||||
|
||||
# 配置项定义(分组)- 完整对照 env.example
|
||||
CONFIG_SCHEMA = {
|
||||
'auth': {
|
||||
'title': '认证配置',
|
||||
'items': [
|
||||
{'key': 'SECRET_KEY', 'label': 'Secret Key', 'type': 'password', 'default': 'quantdinger-secret-key-change-me'},
|
||||
{'key': 'ADMIN_USER', 'label': '管理员用户名', 'type': 'text', 'default': 'quantdinger'},
|
||||
{'key': 'ADMIN_PASSWORD', 'label': '管理员密码', 'type': 'password', 'default': '123456'},
|
||||
]
|
||||
},
|
||||
'server': {
|
||||
'title': '服务器配置',
|
||||
'items': [
|
||||
{'key': 'PYTHON_API_HOST', 'label': '监听地址', 'type': 'text', 'default': '0.0.0.0'},
|
||||
{'key': 'PYTHON_API_PORT', 'label': '端口', 'type': 'number', 'default': '5000'},
|
||||
{'key': 'PYTHON_API_DEBUG', 'label': '调试模式', 'type': 'boolean', 'default': 'False'},
|
||||
]
|
||||
},
|
||||
'worker': {
|
||||
'title': '订单处理配置',
|
||||
'items': [
|
||||
{'key': 'ENABLE_PENDING_ORDER_WORKER', 'label': '启用订单处理Worker', 'type': 'boolean', 'default': 'True'},
|
||||
{'key': 'PENDING_ORDER_STALE_SEC', 'label': '订单超时时间(秒)', 'type': 'number', 'default': '90'},
|
||||
]
|
||||
},
|
||||
'notification': {
|
||||
'title': '信号通知配置',
|
||||
'items': [
|
||||
{'key': 'SIGNAL_WEBHOOK_URL', 'label': 'Webhook URL', 'type': 'text', 'required': False},
|
||||
{'key': 'SIGNAL_WEBHOOK_TOKEN', 'label': 'Webhook Token', 'type': 'password', 'required': False},
|
||||
{'key': 'SIGNAL_NOTIFY_TIMEOUT_SEC', 'label': '通知超时(秒)', 'type': 'number', 'default': '6'},
|
||||
{'key': 'TELEGRAM_BOT_TOKEN', 'label': 'Telegram Bot Token', 'type': 'password', 'required': False, 'link': 'https://t.me/BotFather', 'link_text': 'settings.link.createBot'},
|
||||
]
|
||||
},
|
||||
'smtp': {
|
||||
'title': '邮件SMTP配置',
|
||||
'items': [
|
||||
{'key': 'SMTP_HOST', 'label': 'SMTP服务器', 'type': 'text', 'required': False},
|
||||
{'key': 'SMTP_PORT', 'label': 'SMTP端口', 'type': 'number', 'default': '587'},
|
||||
{'key': 'SMTP_USER', 'label': 'SMTP用户名', 'type': 'text', 'required': False},
|
||||
{'key': 'SMTP_PASSWORD', 'label': 'SMTP密码', 'type': 'password', 'required': False},
|
||||
{'key': 'SMTP_FROM', 'label': '发件人地址', 'type': 'text', 'required': False},
|
||||
{'key': 'SMTP_USE_TLS', 'label': '使用TLS', 'type': 'boolean', 'default': 'True'},
|
||||
{'key': 'SMTP_USE_SSL', 'label': '使用SSL', 'type': 'boolean', 'default': 'False'},
|
||||
]
|
||||
},
|
||||
'twilio': {
|
||||
'title': 'Twilio短信配置',
|
||||
'items': [
|
||||
{'key': 'TWILIO_ACCOUNT_SID', 'label': 'Account SID', 'type': 'password', 'required': False, 'link': 'https://console.twilio.com/', 'link_text': 'settings.link.getApi'},
|
||||
{'key': 'TWILIO_AUTH_TOKEN', 'label': 'Auth Token', 'type': 'password', 'required': False},
|
||||
{'key': 'TWILIO_FROM_NUMBER', 'label': '发送号码', 'type': 'text', 'required': False},
|
||||
]
|
||||
},
|
||||
'strategy': {
|
||||
'title': '策略执行配置',
|
||||
'items': [
|
||||
{'key': 'DISABLE_RESTORE_RUNNING_STRATEGIES', 'label': '禁用自动恢复策略', 'type': 'boolean', 'default': 'False'},
|
||||
{'key': 'STRATEGY_TICK_INTERVAL_SEC', 'label': '策略Tick间隔(秒)', 'type': 'number', 'default': '10'},
|
||||
{'key': 'PRICE_CACHE_TTL_SEC', 'label': '价格缓存TTL(秒)', 'type': 'number', 'default': '10'},
|
||||
]
|
||||
},
|
||||
'proxy': {
|
||||
'title': '代理配置',
|
||||
'items': [
|
||||
{'key': 'PROXY_PORT', 'label': '代理端口', 'type': 'text', 'required': False},
|
||||
{'key': 'PROXY_HOST', 'label': '代理主机', 'type': 'text', 'default': '127.0.0.1'},
|
||||
{'key': 'PROXY_SCHEME', 'label': '代理协议', 'type': 'select', 'options': ['socks5h', 'socks5', 'http', 'https'], 'default': 'socks5h'},
|
||||
{'key': 'PROXY_URL', 'label': '完整代理URL', 'type': 'text', 'required': False},
|
||||
]
|
||||
},
|
||||
'app': {
|
||||
'title': '应用配置',
|
||||
'items': [
|
||||
{'key': 'CORS_ORIGINS', 'label': 'CORS来源', 'type': 'text', 'default': '*'},
|
||||
{'key': 'RATE_LIMIT', 'label': '速率限制(每分钟)', 'type': 'number', 'default': '100'},
|
||||
{'key': 'ENABLE_CACHE', 'label': '启用缓存', 'type': 'boolean', 'default': 'False'},
|
||||
{'key': 'ENABLE_REQUEST_LOG', 'label': '启用请求日志', 'type': 'boolean', 'default': 'True'},
|
||||
{'key': 'ENABLE_AI_ANALYSIS', 'label': '启用AI分析', 'type': 'boolean', 'default': 'True'},
|
||||
]
|
||||
},
|
||||
'ai': {
|
||||
'title': 'AI/LLM配置',
|
||||
'items': [
|
||||
{'key': 'OPENROUTER_API_KEY', 'label': 'OpenRouter API Key', 'type': 'password', 'required': False, 'link': 'https://openrouter.ai/keys', 'link_text': 'settings.link.getApiKey'},
|
||||
{'key': 'OPENROUTER_API_URL', 'label': 'OpenRouter API URL', 'type': 'text', 'default': 'https://openrouter.ai/api/v1/chat/completions'},
|
||||
{'key': 'OPENROUTER_MODEL', 'label': '默认模型', 'type': 'text', 'default': 'openai/gpt-4o', 'link': 'https://openrouter.ai/models', 'link_text': 'settings.link.viewModels'},
|
||||
{'key': 'OPENROUTER_TEMPERATURE', 'label': 'Temperature', 'type': 'number', 'default': '0.7'},
|
||||
{'key': 'OPENROUTER_MAX_TOKENS', 'label': 'Max Tokens', 'type': 'number', 'default': '4000'},
|
||||
{'key': 'OPENROUTER_TIMEOUT', 'label': '超时时间(秒)', 'type': 'number', 'default': '300'},
|
||||
{'key': 'OPENROUTER_CONNECT_TIMEOUT', 'label': '连接超时(秒)', 'type': 'number', 'default': '30'},
|
||||
{'key': 'AI_MODELS_JSON', 'label': '模型列表(JSON)', 'type': 'text', 'default': '{}', 'required': False},
|
||||
]
|
||||
},
|
||||
'market': {
|
||||
'title': '市场预设',
|
||||
'items': [
|
||||
{'key': 'MARKET_TYPES_JSON', 'label': '市场类型(JSON)', 'type': 'text', 'default': '[]', 'required': False},
|
||||
{'key': 'TRADING_SUPPORTED_SYMBOLS_JSON', 'label': '支持的交易对(JSON)', 'type': 'text', 'default': '[]', 'required': False},
|
||||
]
|
||||
},
|
||||
'data_source': {
|
||||
'title': '数据源配置',
|
||||
'items': [
|
||||
{'key': 'DATA_SOURCE_TIMEOUT', 'label': '数据源超时(秒)', 'type': 'number', 'default': '30'},
|
||||
{'key': 'DATA_SOURCE_RETRY', 'label': '重试次数', 'type': 'number', 'default': '3'},
|
||||
{'key': 'DATA_SOURCE_RETRY_BACKOFF', 'label': '重试退避(秒)', 'type': 'number', 'default': '0.5'},
|
||||
{'key': 'FINNHUB_API_KEY', 'label': 'Finnhub API Key', 'type': 'password', 'required': False, 'link': 'https://finnhub.io/register', 'link_text': 'settings.link.freeRegister'},
|
||||
{'key': 'FINNHUB_TIMEOUT', 'label': 'Finnhub超时(秒)', 'type': 'number', 'default': '10'},
|
||||
{'key': 'FINNHUB_RATE_LIMIT', 'label': 'Finnhub速率限制', 'type': 'number', 'default': '60'},
|
||||
{'key': 'CCXT_DEFAULT_EXCHANGE', 'label': 'CCXT默认交易所', 'type': 'text', 'default': 'binance', 'link': 'https://github.com/ccxt/ccxt#supported-cryptocurrency-exchange-markets', 'link_text': 'settings.link.supportedExchanges'},
|
||||
{'key': 'CCXT_TIMEOUT', 'label': 'CCXT超时(ms)', 'type': 'number', 'default': '10000'},
|
||||
{'key': 'CCXT_PROXY', 'label': 'CCXT代理', 'type': 'text', 'required': False},
|
||||
{'key': 'AKSHARE_TIMEOUT', 'label': 'Akshare超时(秒)', 'type': 'number', 'default': '30'},
|
||||
{'key': 'YFINANCE_TIMEOUT', 'label': 'YFinance超时(秒)', 'type': 'number', 'default': '30'},
|
||||
{'key': 'TIINGO_API_KEY', 'label': 'Tiingo API Key', 'type': 'password', 'required': False, 'link': 'https://www.tiingo.com/account/api/token', 'link_text': 'settings.link.getToken'},
|
||||
{'key': 'TIINGO_TIMEOUT', 'label': 'Tiingo超时(秒)', 'type': 'number', 'default': '10'},
|
||||
]
|
||||
},
|
||||
'search': {
|
||||
'title': '搜索配置',
|
||||
'items': [
|
||||
{'key': 'SEARCH_PROVIDER', 'label': '搜索提供商', 'type': 'select', 'options': ['google', 'bing', 'none'], 'default': 'google'},
|
||||
{'key': 'SEARCH_MAX_RESULTS', 'label': '最大结果数', 'type': 'number', 'default': '10'},
|
||||
{'key': 'SEARCH_GOOGLE_API_KEY', 'label': 'Google API Key', 'type': 'password', 'required': False, 'link': 'https://developers.google.com/custom-search/v1/introduction', 'link_text': 'settings.link.applyApi'},
|
||||
{'key': 'SEARCH_GOOGLE_CX', 'label': 'Google CX', 'type': 'text', 'required': False, 'link': 'https://programmablesearchengine.google.com/controlpanel/all', 'link_text': 'settings.link.createSearchEngine'},
|
||||
{'key': 'SEARCH_BING_API_KEY', 'label': 'Bing API Key', 'type': 'password', 'required': False, 'link': 'https://www.microsoft.com/en-us/bing/apis/bing-web-search-api', 'link_text': 'settings.link.applyApi'},
|
||||
{'key': 'INTERNAL_API_KEY', 'label': '内部API Key', 'type': 'password', 'required': False},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def read_env_file():
|
||||
"""读取 .env 文件"""
|
||||
env_values = {}
|
||||
|
||||
if not os.path.exists(ENV_FILE_PATH):
|
||||
logger.warning(f".env file not found at {ENV_FILE_PATH}")
|
||||
return env_values
|
||||
|
||||
try:
|
||||
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# 跳过空行和注释
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
# 解析 KEY=VALUE
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
# 移除引号
|
||||
if (value.startswith('"') and value.endswith('"')) or \
|
||||
(value.startswith("'") and value.endswith("'")):
|
||||
value = value[1:-1]
|
||||
env_values[key] = value
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read .env file: {e}")
|
||||
|
||||
return env_values
|
||||
|
||||
|
||||
def write_env_file(env_values):
|
||||
"""写入 .env 文件,保留注释和格式"""
|
||||
lines = []
|
||||
existing_keys = set()
|
||||
|
||||
# 读取原文件保留格式
|
||||
if os.path.exists(ENV_FILE_PATH):
|
||||
try:
|
||||
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
original_line = line
|
||||
stripped = line.strip()
|
||||
|
||||
# 保留空行和注释
|
||||
if not stripped or stripped.startswith('#'):
|
||||
lines.append(original_line)
|
||||
continue
|
||||
|
||||
# 更新已存在的键
|
||||
if '=' in stripped:
|
||||
key = stripped.split('=', 1)[0].strip()
|
||||
if key in env_values:
|
||||
existing_keys.add(key)
|
||||
value = env_values[key]
|
||||
# 如果值包含特殊字符,用引号包裹
|
||||
if ' ' in str(value) or '"' in str(value) or "'" in str(value):
|
||||
lines.append(f'{key}="{value}"\n')
|
||||
else:
|
||||
lines.append(f'{key}={value}\n')
|
||||
else:
|
||||
lines.append(original_line)
|
||||
else:
|
||||
lines.append(original_line)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read .env file for update: {e}")
|
||||
|
||||
# 添加新的键
|
||||
new_keys = set(env_values.keys()) - existing_keys
|
||||
if new_keys:
|
||||
if lines and not lines[-1].endswith('\n'):
|
||||
lines.append('\n')
|
||||
lines.append('\n# Added by Settings UI\n')
|
||||
for key in sorted(new_keys):
|
||||
value = env_values[key]
|
||||
if ' ' in str(value) or '"' in str(value) or "'" in str(value):
|
||||
lines.append(f'{key}="{value}"\n')
|
||||
else:
|
||||
lines.append(f'{key}={value}\n')
|
||||
|
||||
# 写入文件
|
||||
try:
|
||||
with open(ENV_FILE_PATH, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write .env file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@settings_bp.route('/schema', methods=['GET'])
|
||||
def get_settings_schema():
|
||||
"""获取配置项定义"""
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': CONFIG_SCHEMA
|
||||
})
|
||||
|
||||
|
||||
@settings_bp.route('/values', methods=['GET'])
|
||||
def get_settings_values():
|
||||
"""获取当前配置值 - 包括敏感信息(真实值)"""
|
||||
env_values = read_env_file()
|
||||
|
||||
# 构建返回数据,返回真实值
|
||||
result = {}
|
||||
for group_key, group in CONFIG_SCHEMA.items():
|
||||
result[group_key] = {}
|
||||
for item in group['items']:
|
||||
key = item['key']
|
||||
value = env_values.get(key, item.get('default', ''))
|
||||
result[group_key][key] = value
|
||||
# 标记密码类型是否已配置
|
||||
if item['type'] == 'password':
|
||||
result[group_key][f'{key}_configured'] = bool(value)
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': result
|
||||
})
|
||||
|
||||
|
||||
@settings_bp.route('/save', methods=['POST'])
|
||||
def save_settings():
|
||||
"""保存配置"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 0, 'msg': '无效的请求数据'})
|
||||
|
||||
# 读取当前配置
|
||||
current_env = read_env_file()
|
||||
|
||||
# 更新配置
|
||||
updates = {}
|
||||
for group_key, group_values in data.items():
|
||||
if group_key not in CONFIG_SCHEMA:
|
||||
continue
|
||||
|
||||
for item in CONFIG_SCHEMA[group_key]['items']:
|
||||
key = item['key']
|
||||
if key in group_values:
|
||||
new_value = group_values[key]
|
||||
|
||||
# 空值处理
|
||||
if new_value is None or new_value == '':
|
||||
if not item.get('required', True):
|
||||
updates[key] = ''
|
||||
else:
|
||||
updates[key] = str(new_value)
|
||||
|
||||
# 合并更新
|
||||
current_env.update(updates)
|
||||
|
||||
# 写入文件
|
||||
if write_env_file(current_env):
|
||||
# 清除配置缓存
|
||||
clear_config_cache()
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': '配置保存成功',
|
||||
'data': {
|
||||
'updated_keys': list(updates.keys()),
|
||||
'requires_restart': True # 标记需要重启
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': '保存配置失败'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save settings: {e}")
|
||||
return jsonify({'code': 0, 'msg': f'保存失败: {str(e)}'})
|
||||
|
||||
|
||||
@settings_bp.route('/test-connection', methods=['POST'])
|
||||
def test_connection():
|
||||
"""测试API连接"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
service = data.get('service')
|
||||
|
||||
if service == 'openrouter':
|
||||
# 测试 OpenRouter 连接
|
||||
from app.services.llm import LLMService
|
||||
llm = LLMService()
|
||||
result = llm.test_connection()
|
||||
if result:
|
||||
return jsonify({'code': 1, 'msg': 'OpenRouter连接成功'})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'OpenRouter连接失败'})
|
||||
|
||||
elif service == 'finnhub':
|
||||
# 测试 Finnhub 连接
|
||||
import requests
|
||||
api_key = data.get('api_key') or os.getenv('FINNHUB_API_KEY')
|
||||
if not api_key:
|
||||
return jsonify({'code': 0, 'msg': 'API Key未配置'})
|
||||
resp = requests.get(
|
||||
f'https://finnhub.io/api/v1/quote?symbol=AAPL&token={api_key}',
|
||||
timeout=10
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return jsonify({'code': 1, 'msg': 'Finnhub连接成功'})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': f'Finnhub连接失败: {resp.status_code}'})
|
||||
|
||||
return jsonify({'code': 0, 'msg': '未知服务'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Connection test failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': f'测试失败: {str(e)}'})
|
||||
@@ -593,6 +593,7 @@ class StrategyService:
|
||||
indicator_config = payload.get('indicator_config') if payload.get('indicator_config') is not None else (existing.get('indicator_config') or {})
|
||||
trading_config = payload.get('trading_config') if payload.get('trading_config') is not None else (existing.get('trading_config') or {})
|
||||
exchange_config = payload.get('exchange_config') if payload.get('exchange_config') is not None else (existing.get('exchange_config') or {})
|
||||
ai_model_config = payload.get('ai_model_config') if payload.get('ai_model_config') is not None else (existing.get('ai_model_config') or {})
|
||||
|
||||
symbol = (trading_config or {}).get('symbol')
|
||||
timeframe = (trading_config or {}).get('timeframe')
|
||||
@@ -617,6 +618,7 @@ class StrategyService:
|
||||
exchange_config = ?,
|
||||
indicator_config = ?,
|
||||
trading_config = ?,
|
||||
ai_model_config = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
@@ -633,6 +635,7 @@ class StrategyService:
|
||||
self._dump_json_or_encrypt(exchange_config, encrypt=False) if exchange_config else '',
|
||||
self._dump_json_or_encrypt(indicator_config, encrypt=False),
|
||||
self._dump_json_or_encrypt(trading_config, encrypt=False),
|
||||
self._dump_json_or_encrypt(ai_model_config, encrypt=False),
|
||||
now,
|
||||
strategy_id
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ try:
|
||||
import resource # Linux/Unix only
|
||||
except Exception:
|
||||
resource = None
|
||||
from typing import Dict, List, Any, Optional
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from datetime import datetime
|
||||
import json
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||
@@ -486,10 +486,12 @@ class TradingExecutor:
|
||||
# 初始化策略状态
|
||||
trading_config = strategy['trading_config']
|
||||
indicator_config = strategy['indicator_config']
|
||||
ai_model_config = strategy.get('ai_model_config') or {}
|
||||
execution_mode = (strategy.get('execution_mode') or 'signal').strip().lower()
|
||||
if execution_mode not in ['signal', 'live']:
|
||||
execution_mode = 'signal'
|
||||
notification_config = strategy.get('notification_config') or {}
|
||||
strategy_name = strategy.get('strategy_name') or f"strategy_{int(strategy_id)}"
|
||||
symbol = trading_config.get('symbol', '')
|
||||
timeframe = trading_config.get('timeframe', '1H')
|
||||
|
||||
@@ -943,6 +945,7 @@ class TradingExecutor:
|
||||
|
||||
ok = self._execute_signal(
|
||||
strategy_id=strategy_id,
|
||||
strategy_name=strategy_name,
|
||||
exchange=exchange,
|
||||
symbol=symbol,
|
||||
current_price=execute_price,
|
||||
@@ -957,6 +960,7 @@ class TradingExecutor:
|
||||
execution_mode=execution_mode,
|
||||
notification_config=notification_config,
|
||||
trading_config=trading_config,
|
||||
ai_model_config=ai_model_config,
|
||||
)
|
||||
if ok:
|
||||
logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}")
|
||||
@@ -1005,7 +1009,7 @@ class TradingExecutor:
|
||||
id, strategy_name, strategy_type, status,
|
||||
initial_capital, leverage, decide_interval,
|
||||
execution_mode, notification_config,
|
||||
indicator_config, exchange_config, trading_config
|
||||
indicator_config, exchange_config, trading_config, ai_model_config
|
||||
FROM qd_strategies_trading
|
||||
WHERE id = %s
|
||||
"""
|
||||
@@ -1015,7 +1019,7 @@ class TradingExecutor:
|
||||
|
||||
if strategy:
|
||||
# 解析JSON字段
|
||||
for field in ['indicator_config', 'trading_config', 'notification_config']:
|
||||
for field in ['indicator_config', 'trading_config', 'notification_config', 'ai_model_config']:
|
||||
if isinstance(strategy.get(field), str):
|
||||
try:
|
||||
strategy[field] = json.loads(strategy[field])
|
||||
@@ -1839,6 +1843,7 @@ class TradingExecutor:
|
||||
def _execute_signal(
|
||||
self,
|
||||
strategy_id: int,
|
||||
strategy_name: str,
|
||||
exchange: Any,
|
||||
symbol: str,
|
||||
current_price: float,
|
||||
@@ -1855,6 +1860,7 @@ class TradingExecutor:
|
||||
execution_mode: str = 'signal',
|
||||
notification_config: Optional[Dict[str, Any]] = None,
|
||||
trading_config: Optional[Dict[str, Any]] = None,
|
||||
ai_model_config: Optional[Dict[str, Any]] = None,
|
||||
signal_ts: int = 0,
|
||||
):
|
||||
"""执行具体的交易信号"""
|
||||
@@ -1868,11 +1874,49 @@ class TradingExecutor:
|
||||
if market_type == 'spot' and 'short' in signal_type:
|
||||
return False
|
||||
|
||||
sig = (signal_type or "").strip().lower()
|
||||
|
||||
# 1.1 开仓 AI 过滤(仅 open_*)
|
||||
if sig in ("open_long", "open_short") and self._is_entry_ai_filter_enabled(ai_model_config=ai_model_config, trading_config=trading_config):
|
||||
ok_ai, ai_info = self._entry_ai_filter_allows(
|
||||
strategy_id=strategy_id,
|
||||
symbol=symbol,
|
||||
signal_type=sig,
|
||||
ai_model_config=ai_model_config,
|
||||
trading_config=trading_config,
|
||||
)
|
||||
if not ok_ai:
|
||||
# Best-effort persist a browser notification so UI can show "HOLD due to AI filter".
|
||||
reason = (ai_info or {}).get("reason") or "ai_filter_rejected"
|
||||
ai_decision = (ai_info or {}).get("ai_decision") or ""
|
||||
title = f"AI过滤拦截开仓 | {symbol}"
|
||||
msg = f"策略信号={sig},AI决策={ai_decision or 'UNKNOWN'},原因={reason};已HOLD(不下单)"
|
||||
self._persist_browser_notification(
|
||||
strategy_id=strategy_id,
|
||||
symbol=symbol,
|
||||
signal_type="ai_filter_hold",
|
||||
title=title,
|
||||
message=msg,
|
||||
payload={
|
||||
"event": "qd.ai_filter",
|
||||
"strategy_id": int(strategy_id),
|
||||
"strategy_name": str(strategy_name or ""),
|
||||
"symbol": str(symbol or ""),
|
||||
"signal_type": str(sig),
|
||||
"ai_decision": str(ai_decision),
|
||||
"reason": str(reason),
|
||||
"signal_ts": int(signal_ts or 0),
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
f"AI entry filter rejected: strategy_id={strategy_id} symbol={symbol} signal={sig} ai={ai_decision} reason={reason}"
|
||||
)
|
||||
return False
|
||||
|
||||
# 2. 计算下单数量
|
||||
available_capital = self._get_available_capital(strategy_id, initial_capital)
|
||||
|
||||
amount = 0.0
|
||||
sig = (signal_type or "").strip().lower()
|
||||
|
||||
# Frontend position sizing alignment:
|
||||
# - open_* uses entry_pct from trading_config if provided (0~1 or 0~100 are both accepted)
|
||||
@@ -2006,6 +2050,164 @@ class TradingExecutor:
|
||||
logger.error(f"Failed to execute signal: {e}")
|
||||
return False
|
||||
|
||||
def _is_entry_ai_filter_enabled(self, *, ai_model_config: Optional[Dict[str, Any]], trading_config: Optional[Dict[str, Any]]) -> bool:
|
||||
"""Detect whether the strategy enabled 'AI filter on entry (open positions only)'."""
|
||||
amc = ai_model_config if isinstance(ai_model_config, dict) else {}
|
||||
tc = trading_config if isinstance(trading_config, dict) else {}
|
||||
|
||||
# Accept multiple key names for forward/backward compatibility.
|
||||
candidates = [
|
||||
amc.get("entry_ai_filter_enabled"),
|
||||
amc.get("entryAiFilterEnabled"),
|
||||
amc.get("ai_filter_enabled"),
|
||||
amc.get("aiFilterEnabled"),
|
||||
amc.get("enable_ai_filter"),
|
||||
amc.get("enableAiFilter"),
|
||||
tc.get("entry_ai_filter_enabled"),
|
||||
tc.get("ai_filter_enabled"),
|
||||
tc.get("enable_ai_filter"),
|
||||
tc.get("enableAiFilter"),
|
||||
]
|
||||
for v in candidates:
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, bool):
|
||||
return bool(v)
|
||||
s = str(v).strip().lower()
|
||||
if s in ("1", "true", "yes", "y", "on", "enabled"):
|
||||
return True
|
||||
if s in ("0", "false", "no", "n", "off", "disabled"):
|
||||
return False
|
||||
return False
|
||||
|
||||
def _entry_ai_filter_allows(
|
||||
self,
|
||||
*,
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
signal_type: str,
|
||||
ai_model_config: Optional[Dict[str, Any]],
|
||||
trading_config: Optional[Dict[str, Any]],
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
Run internal AI analysis and decide whether an entry signal is allowed.
|
||||
|
||||
Returns:
|
||||
(allowed, info)
|
||||
- allowed: True -> proceed; False -> hold (reject open)
|
||||
- info: {ai_decision, reason, analysis_error?}
|
||||
"""
|
||||
amc = ai_model_config if isinstance(ai_model_config, dict) else {}
|
||||
tc = trading_config if isinstance(trading_config, dict) else {}
|
||||
|
||||
# Market for AnalysisService. Live trading executor is Crypto-focused.
|
||||
market = str(amc.get("market") or amc.get("analysis_market") or "Crypto").strip() or "Crypto"
|
||||
|
||||
# Optional model override (OpenRouter model id)
|
||||
model = amc.get("model") or amc.get("openrouter_model") or amc.get("openrouterModel") or None
|
||||
model = str(model).strip() if model else None
|
||||
|
||||
# Prefer zh-CN for local UI; can be overridden.
|
||||
language = amc.get("language") or amc.get("lang") or tc.get("language") or "zh-CN"
|
||||
language = str(language or "zh-CN")
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular deps + heavy init unless the filter is enabled and entry signal happens.
|
||||
from app.services.analysis import AnalysisService
|
||||
|
||||
service = AnalysisService()
|
||||
result = service.analyze(market, symbol, language, model=model)
|
||||
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
return False, {"ai_decision": "", "reason": "analysis_error", "analysis_error": str(result.get("error") or "")}
|
||||
|
||||
ai_dec = self._extract_ai_trade_decision(result)
|
||||
if not ai_dec:
|
||||
return False, {"ai_decision": "", "reason": "missing_ai_decision"}
|
||||
|
||||
expected = "BUY" if signal_type == "open_long" else "SELL"
|
||||
if ai_dec == expected:
|
||||
return True, {"ai_decision": ai_dec, "reason": "match"}
|
||||
if ai_dec == "HOLD":
|
||||
return False, {"ai_decision": ai_dec, "reason": "ai_hold"}
|
||||
return False, {"ai_decision": ai_dec, "reason": "direction_mismatch"}
|
||||
except Exception as e:
|
||||
return False, {"ai_decision": "", "reason": "analysis_exception", "analysis_error": str(e)}
|
||||
|
||||
def _extract_ai_trade_decision(self, analysis_result: Any) -> str:
|
||||
"""
|
||||
Normalize AI analysis output into one of: BUY / SELL / HOLD / "".
|
||||
We primarily look at final_decision.decision, with fallbacks.
|
||||
"""
|
||||
if not isinstance(analysis_result, dict):
|
||||
return ""
|
||||
|
||||
def _pick(*paths: str) -> str:
|
||||
for p in paths:
|
||||
cur: Any = analysis_result
|
||||
ok = True
|
||||
for k in p.split("."):
|
||||
if not isinstance(cur, dict):
|
||||
ok = False
|
||||
break
|
||||
cur = cur.get(k)
|
||||
if ok and cur is not None:
|
||||
s = str(cur).strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
raw = _pick("final_decision.decision", "trader_decision.decision", "decision", "final.decision")
|
||||
s = raw.strip().upper()
|
||||
if not s:
|
||||
return ""
|
||||
|
||||
# Common variants / synonyms
|
||||
if "BUY" in s or s == "LONG" or "LONG" in s:
|
||||
return "BUY"
|
||||
if "SELL" in s or s == "SHORT" or "SHORT" in s:
|
||||
return "SELL"
|
||||
if "HOLD" in s or "WAIT" in s or "NEUTRAL" in s:
|
||||
return "HOLD"
|
||||
return s if s in ("BUY", "SELL", "HOLD") else ""
|
||||
|
||||
def _persist_browser_notification(
|
||||
self,
|
||||
*,
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
signal_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Best-effort persist notification row for the frontend '通知' panel (browser channel)."""
|
||||
try:
|
||||
now = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(strategy_id),
|
||||
str(symbol or ""),
|
||||
str(signal_type or ""),
|
||||
"browser",
|
||||
str(title or ""),
|
||||
str(message or ""),
|
||||
json.dumps(payload or {}, ensure_ascii=False),
|
||||
int(now),
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"persist_browser_notification failed: {e}")
|
||||
|
||||
def _execute_exchange_order(
|
||||
self,
|
||||
exchange: Any,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# QuantDinger Docker Compose
|
||||
# Quick start: docker-compose up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Backend API Service
|
||||
backend:
|
||||
build:
|
||||
context: ./backend_api_python
|
||||
dockerfile: Dockerfile
|
||||
container_name: quantdinger-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
# Persistent database and logs
|
||||
- ./backend_api_python/quantdinger.db:/app/quantdinger.db
|
||||
- ./backend_api_python/logs:/app/logs
|
||||
- ./backend_api_python/data:/app/data
|
||||
# Configuration file (optional, for development)
|
||||
- ./backend_api_python/.env:/app/.env:ro
|
||||
environment:
|
||||
- PYTHON_API_HOST=0.0.0.0
|
||||
- PYTHON_API_PORT=5000
|
||||
- TZ=Asia/Shanghai
|
||||
networks:
|
||||
- quantdinger-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Frontend Web Service
|
||||
frontend:
|
||||
build:
|
||||
context: ./quantdinger_vue
|
||||
dockerfile: Dockerfile
|
||||
container_name: quantdinger-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- quantdinger-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
quantdinger-network:
|
||||
driver: bridge
|
||||
|
||||
# Optional: Add data volumes (for more complex persistence)
|
||||
# volumes:
|
||||
# backend-data:
|
||||
# backend-logs:
|
||||
@@ -0,0 +1,79 @@
|
||||
# QuantDinger Docker Environment Configuration
|
||||
# Copy this file to .env and modify the settings
|
||||
# cp docker.env.example backend_api_python/.env
|
||||
|
||||
# =========================
|
||||
# Authentication
|
||||
# =========================
|
||||
SECRET_KEY=your-secure-secret-key-change-me
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# =========================
|
||||
# Network Configuration
|
||||
# =========================
|
||||
PYTHON_API_HOST=0.0.0.0
|
||||
PYTHON_API_PORT=5000
|
||||
PYTHON_API_DEBUG=False
|
||||
CORS_ORIGINS=*
|
||||
|
||||
# =========================
|
||||
# OpenRouter / LLM (Required for AI analysis)
|
||||
# =========================
|
||||
OPENROUTER_API_KEY=your-openrouter-api-key
|
||||
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
|
||||
OPENROUTER_MODEL=openai/gpt-4o
|
||||
OPENROUTER_TEMPERATURE=0.7
|
||||
OPENROUTER_MAX_TOKENS=4000
|
||||
OPENROUTER_TIMEOUT=300
|
||||
|
||||
# =========================
|
||||
# Data Source API Keys (fill as needed)
|
||||
# =========================
|
||||
# Finnhub (US stocks / forex)
|
||||
FINNHUB_API_KEY=
|
||||
|
||||
# Tiingo (optional backup data source)
|
||||
TIINGO_API_KEY=
|
||||
|
||||
# =========================
|
||||
# Notification Settings (optional)
|
||||
# =========================
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Email / SMTP
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
# Twilio SMS
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_FROM_NUMBER=
|
||||
|
||||
# Webhook
|
||||
SIGNAL_WEBHOOK_URL=
|
||||
SIGNAL_WEBHOOK_TOKEN=
|
||||
|
||||
# =========================
|
||||
# Strategy Execution Settings
|
||||
# =========================
|
||||
ENABLE_PENDING_ORDER_WORKER=true
|
||||
STRATEGY_TICK_INTERVAL_SEC=10
|
||||
|
||||
# =========================
|
||||
# Search Engine (optional, for AI analysis)
|
||||
# =========================
|
||||
SEARCH_PROVIDER=google
|
||||
SEARCH_GOOGLE_API_KEY=
|
||||
SEARCH_GOOGLE_CX=
|
||||
|
||||
# =========================
|
||||
# Proxy Configuration (if network requires)
|
||||
# =========================
|
||||
PROXY_PORT=
|
||||
PROXY_HOST=127.0.0.1
|
||||
PROXY_SCHEME=socks5h
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 730 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 563 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 362 KiB |
@@ -0,0 +1,41 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output (会在容器内构建)
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Tests
|
||||
tests/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# QuantDinger Frontend Dockerfile
|
||||
# Stage 1: Build
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY pnpm-lock.yaml* ./
|
||||
COPY yarn.lock* ./
|
||||
|
||||
# Install dependencies (prefer npm)
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build production version
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production image (using nginx)
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy build artifacts
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY deploy/nginx-docker.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,43 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_min_length 1000;
|
||||
|
||||
# Static asset caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:5000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
# SPA routing support
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
return 200 'OK';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取配置项定义
|
||||
*/
|
||||
export function getSettingsSchema () {
|
||||
return request({
|
||||
url: '/api/settings/schema',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置值
|
||||
*/
|
||||
export function getSettingsValues () {
|
||||
return request({
|
||||
url: '/api/settings/values',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置
|
||||
* @param {Object} data - 配置数据
|
||||
*/
|
||||
export function saveSettings (data) {
|
||||
return request({
|
||||
url: '/api/settings/save',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试API连接
|
||||
* @param {string} service - 服务名称 (openrouter, finnhub, etc.)
|
||||
* @param {Object} params - 额外参数
|
||||
*/
|
||||
export function testConnection (service, params = {}) {
|
||||
return request({
|
||||
url: '/api/settings/test-connection',
|
||||
method: 'post',
|
||||
data: { service, ...params }
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,13 @@ export const asyncRouterMap = [
|
||||
name: 'IndicatorCommunity',
|
||||
component: () => import('@/views/indicator-community'),
|
||||
meta: { title: 'menu.dashboard.community', keepAlive: false, icon: 'shop', permission: ['dashboard'] }
|
||||
},
|
||||
// 系统设置
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/settings'),
|
||||
meta: { title: 'menu.settings', keepAlive: false, icon: 'setting', permission: ['dashboard'] }
|
||||
}
|
||||
|
||||
// other
|
||||
|
||||
@@ -200,17 +200,17 @@ export default {
|
||||
// Static footer config (local OSS build)
|
||||
menuFooterConfig: {
|
||||
contact: {
|
||||
support_url: 'https://github.com/',
|
||||
feature_request_url: 'https://github.com/',
|
||||
email: 'support@example.com',
|
||||
live_chat_url: 'https://github.com/'
|
||||
support_url: 'https://t.me/worldinbroker',
|
||||
feature_request_url: 'https://github.com/brokermr810/QuantDinger/issues',
|
||||
email: 'brokermr810@gmail.com',
|
||||
live_chat_url: 'https://t.me/worldinbroker'
|
||||
},
|
||||
social_accounts: [
|
||||
{ name: 'GitHub', icon: 'github', url: 'https://github.com/' },
|
||||
{ name: 'X', icon: 'x', url: 'https://x.com/' },
|
||||
{ name: 'Discord', icon: 'discord', url: 'https://discord.com/' },
|
||||
{ name: 'Telegram', icon: 'telegram', url: 'https://t.me/' },
|
||||
{ name: 'YouTube', icon: 'youtube', url: 'https://www.youtube.com/' }
|
||||
{ name: 'GitHub', icon: 'github', url: 'https://github.com/brokermr810/QuantDinger' },
|
||||
{ name: 'X', icon: 'x', url: 'https://x.com/HenryCryption' },
|
||||
{ name: 'Discord', icon: 'discord', url: 'https://discord.gg/cn6HVE2KC' },
|
||||
{ name: 'Telegram', icon: 'telegram', url: 'https://t.me/worldinbroker' },
|
||||
{ name: 'YouTube', icon: 'youtube', url: 'https://youtube.com/@quantdinger' }
|
||||
],
|
||||
legal: {
|
||||
user_agreement: '',
|
||||
|
||||
@@ -19,6 +19,7 @@ const locale = {
|
||||
'menu.dashboard.indicator': 'Indicator Analysis',
|
||||
'menu.dashboard.community': 'Indicator Community',
|
||||
'menu.dashboard.tradingAssistant': 'Trading Assistant',
|
||||
'menu.settings': 'Settings',
|
||||
'menu.dashboard.aiTradingAssistant': 'AI Trading Assistant',
|
||||
'menu.dashboard.signalRobot': 'Signal Robot',
|
||||
'menu.dashboard.monitor': 'Monitor',
|
||||
@@ -937,6 +938,41 @@ const locale = {
|
||||
'dashboard.enabled': 'Enabled',
|
||||
'dashboard.pnlHistory': 'P&L History',
|
||||
'dashboard.strategyPerformance': 'Strategy Performance',
|
||||
'dashboard.strategyRanking': 'Strategy Ranking',
|
||||
// New KPI labels
|
||||
'dashboard.winRate': 'Win Rate',
|
||||
'dashboard.profitFactor': 'Profit Factor',
|
||||
'dashboard.maxDrawdown': 'Max Drawdown',
|
||||
'dashboard.totalTrades': 'Total Trades',
|
||||
'dashboard.runningStrategies': 'Running Strategies',
|
||||
'dashboard.equityCurve': 'Equity Curve',
|
||||
'dashboard.strategyAllocation': 'Strategy Allocation',
|
||||
'dashboard.drawdownCurve': 'Drawdown Curve',
|
||||
'dashboard.drawdown': 'Drawdown',
|
||||
'dashboard.hourlyDistribution': 'Hourly Distribution',
|
||||
// Chart labels
|
||||
'dashboard.dailyPnl': 'Daily P&L',
|
||||
'dashboard.cumulativePnl': 'Cumulative P&L',
|
||||
'dashboard.tradeCount': 'Trade Count',
|
||||
'dashboard.profit': 'Profit',
|
||||
'dashboard.noData': 'No Data',
|
||||
'dashboard.noStrategyData': 'No strategy data',
|
||||
// Ranking labels
|
||||
'dashboard.ranking.totalProfit': 'Total Profit',
|
||||
'dashboard.ranking.roi': 'ROI',
|
||||
'dashboard.ranking.trades': 'Trades',
|
||||
// Units and labels
|
||||
'dashboard.unit.trades': '',
|
||||
'dashboard.unit.strategies': '',
|
||||
'dashboard.label.avgDaily': 'Avg Daily',
|
||||
'dashboard.label.avgProfit': 'Avg Profit',
|
||||
'dashboard.label.win': 'W',
|
||||
'dashboard.label.lose': 'L',
|
||||
'dashboard.label.trade': 'Trades',
|
||||
'dashboard.label.indicator': 'Indicator',
|
||||
'dashboard.label.totalPnl': 'Total P&L',
|
||||
'dashboard.label.maxDrawdownPoint': 'Max DD',
|
||||
'dashboard.profitCalendar': 'Profit Calendar',
|
||||
'dashboard.recentTrades': 'Recent Trades',
|
||||
'dashboard.currentPositions': 'Current Positions',
|
||||
'dashboard.table.time': 'Time',
|
||||
@@ -969,6 +1005,12 @@ const locale = {
|
||||
'dashboard.orderTable.deleteConfirm': 'Delete this record?',
|
||||
'dashboard.orderTable.deleteSuccess': 'Deleted',
|
||||
'dashboard.orderTable.deleteFailed': 'Delete failed',
|
||||
'dashboard.newOrderNotify': 'New Order Alert',
|
||||
'dashboard.newOrderDesc': 'New order execution record',
|
||||
'dashboard.soundEnabled': 'Sound notification enabled',
|
||||
'dashboard.soundDisabled': 'Sound notification disabled',
|
||||
'dashboard.clickToMute': 'Click to mute',
|
||||
'dashboard.clickToUnmute': 'Click to unmute',
|
||||
'dashboard.signalType.openLong': 'Open Long',
|
||||
'dashboard.signalType.openShort': 'Open Short',
|
||||
'dashboard.signalType.closeLong': 'Close Long',
|
||||
@@ -1817,7 +1859,124 @@ const locale = {
|
||||
'signal-robot.indicator.volume': 'Volume',
|
||||
'signal-robot.indicator.changePct1h': '1H Change %',
|
||||
'signal-robot.indicator.changePct24h': '24H Change %',
|
||||
'signal-robot.indicator.signalLine': 'Signal Line'
|
||||
'signal-robot.indicator.signalLine': 'Signal Line',
|
||||
|
||||
// Settings
|
||||
'settings.title': 'System Settings',
|
||||
'settings.description': 'Configure application settings, API keys, and system preferences',
|
||||
'settings.save': 'Save Settings',
|
||||
'settings.reset': 'Reset',
|
||||
'settings.saveSuccess': 'Settings saved successfully',
|
||||
'settings.saveFailed': 'Failed to save settings',
|
||||
'settings.loadFailed': 'Failed to load settings',
|
||||
'settings.default': 'Default',
|
||||
'settings.pleaseSelect': 'Please select',
|
||||
'settings.inputApiKey': 'Enter key',
|
||||
'settings.getApi': 'Get API',
|
||||
// Settings link texts
|
||||
'settings.link.getApi': 'Get API',
|
||||
'settings.link.getApiKey': 'Get API Key',
|
||||
'settings.link.getToken': 'Get Token',
|
||||
'settings.link.createBot': 'Create Bot',
|
||||
'settings.link.viewModels': 'View Models',
|
||||
'settings.link.freeRegister': 'Free Register',
|
||||
'settings.link.supportedExchanges': 'Supported Exchanges',
|
||||
'settings.link.applyApi': 'Apply API',
|
||||
'settings.link.createSearchEngine': 'Create Search Engine',
|
||||
'settings.restartRequired': 'Settings saved. Some changes require Python service restart to take effect.',
|
||||
'settings.copyRestartCmd': 'Copy restart command',
|
||||
'settings.copySuccess': 'Copied',
|
||||
'settings.copyFailed': 'Copy failed',
|
||||
// Settings groups
|
||||
'settings.group.auth': 'Authentication',
|
||||
'settings.group.server': 'Server Configuration',
|
||||
'settings.group.worker': 'Order Worker',
|
||||
'settings.group.notification': 'Signal Notification',
|
||||
'settings.group.smtp': 'Email SMTP',
|
||||
'settings.group.twilio': 'Twilio SMS',
|
||||
'settings.group.strategy': 'Strategy Execution',
|
||||
'settings.group.proxy': 'Proxy Configuration',
|
||||
'settings.group.app': 'Application',
|
||||
'settings.group.ai': 'AI/LLM Configuration',
|
||||
'settings.group.market': 'Market Presets',
|
||||
'settings.group.data_source': 'Data Sources',
|
||||
'settings.group.search': 'Search Configuration',
|
||||
// Settings fields - Auth
|
||||
'settings.field.SECRET_KEY': 'Secret Key',
|
||||
'settings.field.ADMIN_USER': 'Admin Username',
|
||||
'settings.field.ADMIN_PASSWORD': 'Admin Password',
|
||||
// Settings fields - Server
|
||||
'settings.field.PYTHON_API_HOST': 'Listen Address',
|
||||
'settings.field.PYTHON_API_PORT': 'Port',
|
||||
'settings.field.PYTHON_API_DEBUG': 'Debug Mode',
|
||||
// Settings fields - Worker
|
||||
'settings.field.ENABLE_PENDING_ORDER_WORKER': 'Enable Order Worker',
|
||||
'settings.field.PENDING_ORDER_STALE_SEC': 'Order Stale Timeout (sec)',
|
||||
// Settings fields - Notification
|
||||
'settings.field.SIGNAL_WEBHOOK_URL': 'Webhook URL',
|
||||
'settings.field.SIGNAL_WEBHOOK_TOKEN': 'Webhook Token',
|
||||
'settings.field.SIGNAL_NOTIFY_TIMEOUT_SEC': 'Notify Timeout (sec)',
|
||||
'settings.field.TELEGRAM_BOT_TOKEN': 'Telegram Bot Token',
|
||||
// Settings fields - SMTP
|
||||
'settings.field.SMTP_HOST': 'SMTP Host',
|
||||
'settings.field.SMTP_PORT': 'SMTP Port',
|
||||
'settings.field.SMTP_USER': 'SMTP Username',
|
||||
'settings.field.SMTP_PASSWORD': 'SMTP Password',
|
||||
'settings.field.SMTP_FROM': 'From Address',
|
||||
'settings.field.SMTP_USE_TLS': 'Use TLS',
|
||||
'settings.field.SMTP_USE_SSL': 'Use SSL',
|
||||
// Settings fields - Twilio
|
||||
'settings.field.TWILIO_ACCOUNT_SID': 'Account SID',
|
||||
'settings.field.TWILIO_AUTH_TOKEN': 'Auth Token',
|
||||
'settings.field.TWILIO_FROM_NUMBER': 'From Number',
|
||||
// Settings fields - Strategy
|
||||
'settings.field.DISABLE_RESTORE_RUNNING_STRATEGIES': 'Disable Auto Restore',
|
||||
'settings.field.STRATEGY_TICK_INTERVAL_SEC': 'Tick Interval (sec)',
|
||||
'settings.field.PRICE_CACHE_TTL_SEC': 'Price Cache TTL (sec)',
|
||||
// Settings fields - Proxy
|
||||
'settings.field.PROXY_PORT': 'Proxy Port',
|
||||
'settings.field.PROXY_HOST': 'Proxy Host',
|
||||
'settings.field.PROXY_SCHEME': 'Proxy Scheme',
|
||||
'settings.field.PROXY_URL': 'Full Proxy URL',
|
||||
// Settings fields - App
|
||||
'settings.field.CORS_ORIGINS': 'CORS Origins',
|
||||
'settings.field.RATE_LIMIT': 'Rate Limit (per min)',
|
||||
'settings.field.ENABLE_CACHE': 'Enable Cache',
|
||||
'settings.field.ENABLE_REQUEST_LOG': 'Enable Request Log',
|
||||
'settings.field.ENABLE_AI_ANALYSIS': 'Enable AI Analysis',
|
||||
// Settings fields - AI
|
||||
'settings.field.OPENROUTER_API_KEY': 'OpenRouter API Key',
|
||||
'settings.field.OPENROUTER_API_URL': 'OpenRouter API URL',
|
||||
'settings.field.OPENROUTER_MODEL': 'Default Model',
|
||||
'settings.field.OPENROUTER_TEMPERATURE': 'Temperature',
|
||||
'settings.field.OPENROUTER_MAX_TOKENS': 'Max Tokens',
|
||||
'settings.field.OPENROUTER_TIMEOUT': 'Timeout (sec)',
|
||||
'settings.field.OPENROUTER_CONNECT_TIMEOUT': 'Connect Timeout (sec)',
|
||||
'settings.field.AI_MODELS_JSON': 'Models JSON',
|
||||
// Settings fields - Market
|
||||
'settings.field.MARKET_TYPES_JSON': 'Market Types JSON',
|
||||
'settings.field.TRADING_SUPPORTED_SYMBOLS_JSON': 'Supported Symbols JSON',
|
||||
// Settings fields - Data Source
|
||||
'settings.field.DATA_SOURCE_TIMEOUT': 'Timeout (sec)',
|
||||
'settings.field.DATA_SOURCE_RETRY': 'Retry Count',
|
||||
'settings.field.DATA_SOURCE_RETRY_BACKOFF': 'Retry Backoff (sec)',
|
||||
'settings.field.FINNHUB_API_KEY': 'Finnhub API Key',
|
||||
'settings.field.FINNHUB_TIMEOUT': 'Finnhub Timeout (sec)',
|
||||
'settings.field.FINNHUB_RATE_LIMIT': 'Finnhub Rate Limit',
|
||||
'settings.field.CCXT_DEFAULT_EXCHANGE': 'CCXT Default Exchange',
|
||||
'settings.field.CCXT_TIMEOUT': 'CCXT Timeout (ms)',
|
||||
'settings.field.CCXT_PROXY': 'CCXT Proxy',
|
||||
'settings.field.AKSHARE_TIMEOUT': 'Akshare Timeout (sec)',
|
||||
'settings.field.YFINANCE_TIMEOUT': 'YFinance Timeout (sec)',
|
||||
'settings.field.TIINGO_API_KEY': 'Tiingo API Key',
|
||||
'settings.field.TIINGO_TIMEOUT': 'Tiingo Timeout (sec)',
|
||||
// Settings fields - Search
|
||||
'settings.field.SEARCH_PROVIDER': 'Search Provider',
|
||||
'settings.field.SEARCH_MAX_RESULTS': 'Max Results',
|
||||
'settings.field.SEARCH_GOOGLE_API_KEY': 'Google API Key',
|
||||
'settings.field.SEARCH_GOOGLE_CX': 'Google CX',
|
||||
'settings.field.SEARCH_BING_API_KEY': 'Bing API Key',
|
||||
'settings.field.INTERNAL_API_KEY': 'Internal API Key'
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ const locale = {
|
||||
'menu.dashboard.community': '指標社區',
|
||||
'menu.dashboard.analysis': 'AI 分析',
|
||||
'menu.dashboard.tradingAssistant': '交易助手',
|
||||
'menu.settings': '系统设置',
|
||||
'menu.dashboard.aiTradingAssistant': 'AI交易助手',
|
||||
'menu.dashboard.signalRobot': '信號機器人',
|
||||
'menu.dashboard.monitor': '監控頁',
|
||||
@@ -846,6 +847,41 @@ const locale = {
|
||||
'dashboard.enabled': '已啟用',
|
||||
'dashboard.pnlHistory': '歷史盈虧',
|
||||
'dashboard.strategyPerformance': '策略盈虧佔比',
|
||||
'dashboard.drawdown': '回撤曲線',
|
||||
'dashboard.strategyRanking': '策略排行榜',
|
||||
// New KPI labels
|
||||
'dashboard.winRate': '勝率',
|
||||
'dashboard.profitFactor': '盈虧比',
|
||||
'dashboard.maxDrawdown': '最大回撤',
|
||||
'dashboard.totalTrades': '總交易',
|
||||
'dashboard.runningStrategies': '運行中策略',
|
||||
'dashboard.equityCurve': '權益曲線',
|
||||
'dashboard.strategyAllocation': '策略分佈',
|
||||
'dashboard.drawdownCurve': '回撤曲線',
|
||||
'dashboard.hourlyDistribution': '交易時段',
|
||||
// Chart labels
|
||||
'dashboard.dailyPnl': '日盈虧',
|
||||
'dashboard.cumulativePnl': '累計盈虧',
|
||||
'dashboard.tradeCount': '交易次數',
|
||||
'dashboard.profit': '盈虧',
|
||||
'dashboard.noData': '暫無數據',
|
||||
'dashboard.noStrategyData': '暫無策略數據',
|
||||
// Ranking labels
|
||||
'dashboard.ranking.totalProfit': '總收益',
|
||||
'dashboard.ranking.roi': '收益率',
|
||||
'dashboard.ranking.trades': '交易數',
|
||||
// Units and labels
|
||||
'dashboard.unit.trades': '筆',
|
||||
'dashboard.unit.strategies': '個',
|
||||
'dashboard.label.avgDaily': '日均',
|
||||
'dashboard.label.avgProfit': '平均盈利',
|
||||
'dashboard.label.win': '勝',
|
||||
'dashboard.label.lose': '負',
|
||||
'dashboard.label.trade': '交易',
|
||||
'dashboard.label.indicator': '指標',
|
||||
'dashboard.label.totalPnl': '總盈虧',
|
||||
'dashboard.label.maxDrawdownPoint': '最大回撤',
|
||||
'dashboard.profitCalendar': '收益日曆',
|
||||
'dashboard.recentTrades': '最近交易',
|
||||
'dashboard.currentPositions': '當前持倉',
|
||||
'dashboard.table.time': '時間',
|
||||
@@ -878,6 +914,12 @@ const locale = {
|
||||
'dashboard.orderTable.deleteConfirm': '確認刪除這條記錄?',
|
||||
'dashboard.orderTable.deleteSuccess': '刪除成功',
|
||||
'dashboard.orderTable.deleteFailed': '刪除失敗',
|
||||
'dashboard.newOrderNotify': '新訂單提醒',
|
||||
'dashboard.newOrderDesc': '有新的訂單執行記錄',
|
||||
'dashboard.soundEnabled': '聲音提醒已開啟',
|
||||
'dashboard.soundDisabled': '聲音提醒已關閉',
|
||||
'dashboard.clickToMute': '點擊關閉聲音提醒',
|
||||
'dashboard.clickToUnmute': '點擊開啟聲音提醒',
|
||||
'dashboard.signalType.openLong': '開多',
|
||||
'dashboard.signalType.openShort': '開空',
|
||||
'dashboard.signalType.closeLong': '平多',
|
||||
@@ -1521,7 +1563,124 @@ const locale = {
|
||||
'message': '-',
|
||||
'layouts.usermenu.dialog.title': '信息',
|
||||
'layouts.usermenu.dialog.content': '您確定要注銷嗎?',
|
||||
'layouts.userLayout.title': '於不確定中,尋見真理'
|
||||
'layouts.userLayout.title': '於不確定中,尋見真理',
|
||||
|
||||
// Settings
|
||||
'settings.title': '系統設置',
|
||||
'settings.description': '配置應用設置、API密鑰和系統偏好',
|
||||
'settings.save': '保存設置',
|
||||
'settings.reset': '重置',
|
||||
'settings.saveSuccess': '設置保存成功',
|
||||
'settings.saveFailed': '保存設置失敗',
|
||||
'settings.loadFailed': '加載配置失敗',
|
||||
'settings.default': '默認',
|
||||
'settings.pleaseSelect': '請選擇',
|
||||
'settings.inputApiKey': '請輸入密鑰',
|
||||
'settings.getApi': '獲取API',
|
||||
// Settings link texts
|
||||
'settings.link.getApi': '獲取API',
|
||||
'settings.link.getApiKey': '獲取API Key',
|
||||
'settings.link.getToken': '獲取Token',
|
||||
'settings.link.createBot': '創建Bot',
|
||||
'settings.link.viewModels': '查看模型列表',
|
||||
'settings.link.freeRegister': '免費註冊',
|
||||
'settings.link.supportedExchanges': '支持的交易所',
|
||||
'settings.link.applyApi': '申請API',
|
||||
'settings.link.createSearchEngine': '創建搜索引擎',
|
||||
'settings.restartRequired': '配置已保存,部分配置需要重啟Python服務才能生效',
|
||||
'settings.copyRestartCmd': '複製重啟命令',
|
||||
'settings.copySuccess': '複製成功',
|
||||
'settings.copyFailed': '複製失敗',
|
||||
// Settings groups
|
||||
'settings.group.auth': '認證配置',
|
||||
'settings.group.server': '服務器配置',
|
||||
'settings.group.worker': '訂單處理配置',
|
||||
'settings.group.notification': '信號通知配置',
|
||||
'settings.group.smtp': '郵件SMTP配置',
|
||||
'settings.group.twilio': 'Twilio短信配置',
|
||||
'settings.group.strategy': '策略執行配置',
|
||||
'settings.group.proxy': '代理配置',
|
||||
'settings.group.app': '應用配置',
|
||||
'settings.group.ai': 'AI/LLM配置',
|
||||
'settings.group.market': '市場預設',
|
||||
'settings.group.data_source': '數據源配置',
|
||||
'settings.group.search': '搜索配置',
|
||||
// Settings fields - Auth
|
||||
'settings.field.SECRET_KEY': 'Secret Key',
|
||||
'settings.field.ADMIN_USER': '管理員用戶名',
|
||||
'settings.field.ADMIN_PASSWORD': '管理員密碼',
|
||||
// Settings fields - Server
|
||||
'settings.field.PYTHON_API_HOST': '監聽地址',
|
||||
'settings.field.PYTHON_API_PORT': '端口',
|
||||
'settings.field.PYTHON_API_DEBUG': '調試模式',
|
||||
// Settings fields - Worker
|
||||
'settings.field.ENABLE_PENDING_ORDER_WORKER': '啟用訂單處理Worker',
|
||||
'settings.field.PENDING_ORDER_STALE_SEC': '訂單超時時間(秒)',
|
||||
// Settings fields - Notification
|
||||
'settings.field.SIGNAL_WEBHOOK_URL': 'Webhook URL',
|
||||
'settings.field.SIGNAL_WEBHOOK_TOKEN': 'Webhook Token',
|
||||
'settings.field.SIGNAL_NOTIFY_TIMEOUT_SEC': '通知超時(秒)',
|
||||
'settings.field.TELEGRAM_BOT_TOKEN': 'Telegram Bot Token',
|
||||
// Settings fields - SMTP
|
||||
'settings.field.SMTP_HOST': 'SMTP服務器',
|
||||
'settings.field.SMTP_PORT': 'SMTP端口',
|
||||
'settings.field.SMTP_USER': 'SMTP用戶名',
|
||||
'settings.field.SMTP_PASSWORD': 'SMTP密碼',
|
||||
'settings.field.SMTP_FROM': '發件人地址',
|
||||
'settings.field.SMTP_USE_TLS': '使用TLS',
|
||||
'settings.field.SMTP_USE_SSL': '使用SSL',
|
||||
// Settings fields - Twilio
|
||||
'settings.field.TWILIO_ACCOUNT_SID': 'Account SID',
|
||||
'settings.field.TWILIO_AUTH_TOKEN': 'Auth Token',
|
||||
'settings.field.TWILIO_FROM_NUMBER': '發送號碼',
|
||||
// Settings fields - Strategy
|
||||
'settings.field.DISABLE_RESTORE_RUNNING_STRATEGIES': '禁用自動恢復策略',
|
||||
'settings.field.STRATEGY_TICK_INTERVAL_SEC': '策略Tick間隔(秒)',
|
||||
'settings.field.PRICE_CACHE_TTL_SEC': '價格緩存TTL(秒)',
|
||||
// Settings fields - Proxy
|
||||
'settings.field.PROXY_PORT': '代理端口',
|
||||
'settings.field.PROXY_HOST': '代理主機',
|
||||
'settings.field.PROXY_SCHEME': '代理協議',
|
||||
'settings.field.PROXY_URL': '完整代理URL',
|
||||
// Settings fields - App
|
||||
'settings.field.CORS_ORIGINS': 'CORS來源',
|
||||
'settings.field.RATE_LIMIT': '速率限制(每分鐘)',
|
||||
'settings.field.ENABLE_CACHE': '啟用緩存',
|
||||
'settings.field.ENABLE_REQUEST_LOG': '啟用請求日誌',
|
||||
'settings.field.ENABLE_AI_ANALYSIS': '啟用AI分析',
|
||||
// Settings fields - AI
|
||||
'settings.field.OPENROUTER_API_KEY': 'OpenRouter API Key',
|
||||
'settings.field.OPENROUTER_API_URL': 'OpenRouter API URL',
|
||||
'settings.field.OPENROUTER_MODEL': '默認模型',
|
||||
'settings.field.OPENROUTER_TEMPERATURE': 'Temperature',
|
||||
'settings.field.OPENROUTER_MAX_TOKENS': 'Max Tokens',
|
||||
'settings.field.OPENROUTER_TIMEOUT': '超時時間(秒)',
|
||||
'settings.field.OPENROUTER_CONNECT_TIMEOUT': '連接超時(秒)',
|
||||
'settings.field.AI_MODELS_JSON': '模型列表(JSON)',
|
||||
// Settings fields - Market
|
||||
'settings.field.MARKET_TYPES_JSON': '市場類型(JSON)',
|
||||
'settings.field.TRADING_SUPPORTED_SYMBOLS_JSON': '支持的交易對(JSON)',
|
||||
// Settings fields - Data Source
|
||||
'settings.field.DATA_SOURCE_TIMEOUT': '數據源超時(秒)',
|
||||
'settings.field.DATA_SOURCE_RETRY': '重試次數',
|
||||
'settings.field.DATA_SOURCE_RETRY_BACKOFF': '重試退避(秒)',
|
||||
'settings.field.FINNHUB_API_KEY': 'Finnhub API Key',
|
||||
'settings.field.FINNHUB_TIMEOUT': 'Finnhub超時(秒)',
|
||||
'settings.field.FINNHUB_RATE_LIMIT': 'Finnhub速率限制',
|
||||
'settings.field.CCXT_DEFAULT_EXCHANGE': 'CCXT默認交易所',
|
||||
'settings.field.CCXT_TIMEOUT': 'CCXT超時(ms)',
|
||||
'settings.field.CCXT_PROXY': 'CCXT代理',
|
||||
'settings.field.AKSHARE_TIMEOUT': 'Akshare超時(秒)',
|
||||
'settings.field.YFINANCE_TIMEOUT': 'YFinance超時(秒)',
|
||||
'settings.field.TIINGO_API_KEY': 'Tiingo API Key',
|
||||
'settings.field.TIINGO_TIMEOUT': 'Tiingo超時(秒)',
|
||||
// Settings fields - Search
|
||||
'settings.field.SEARCH_PROVIDER': '搜索提供商',
|
||||
'settings.field.SEARCH_MAX_RESULTS': '最大結果數',
|
||||
'settings.field.SEARCH_GOOGLE_API_KEY': 'Google API Key',
|
||||
'settings.field.SEARCH_GOOGLE_CX': 'Google CX',
|
||||
'settings.field.SEARCH_BING_API_KEY': 'Bing API Key',
|
||||
'settings.field.INTERNAL_API_KEY': '內部API Key'
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,621 @@
|
||||
<template>
|
||||
<div class="settings-page" :class="{ 'theme-dark': isDarkTheme }">
|
||||
<!-- 重启提示 -->
|
||||
<a-alert
|
||||
v-if="showRestartTip"
|
||||
class="restart-alert"
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
@close="showRestartTip = false"
|
||||
>
|
||||
<template slot="message">
|
||||
<span>{{ $t('settings.restartRequired') }}</span>
|
||||
<a-button size="small" type="link" @click="copyRestartCommand">
|
||||
{{ $t('settings.copyRestartCmd') }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="settings-header">
|
||||
<h2 class="page-title">
|
||||
<a-icon type="setting" />
|
||||
<span>{{ $t('settings.title') }}</span>
|
||||
</h2>
|
||||
<p class="page-desc">{{ $t('settings.description') }}</p>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="settings-content">
|
||||
<a-collapse v-model="activeKeys" :bordered="false" class="settings-collapse">
|
||||
<a-collapse-panel v-for="(group, groupKey) in schema" :key="groupKey">
|
||||
<template slot="header">
|
||||
<span class="panel-header">
|
||||
<span class="panel-title">{{ getGroupTitle(groupKey, group.title) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template slot="extra">
|
||||
<a-icon :type="getGroupIcon(groupKey)" class="panel-icon" />
|
||||
</template>
|
||||
|
||||
<a-form :form="form" layout="vertical" class="settings-form">
|
||||
<a-row :gutter="24">
|
||||
<a-col
|
||||
:xs="24"
|
||||
:sm="24"
|
||||
:md="12"
|
||||
:lg="12"
|
||||
v-for="item in group.items"
|
||||
:key="item.key">
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<span class="form-label-with-link">
|
||||
<span>{{ getItemLabel(groupKey, item) }}</span>
|
||||
<a
|
||||
v-if="item.link"
|
||||
:href="item.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="api-link"
|
||||
@click.stop
|
||||
>
|
||||
<a-icon type="link" />
|
||||
{{ getLinkText(item.link_text) }}
|
||||
</a>
|
||||
</span>
|
||||
</template>
|
||||
<!-- 文本输入 -->
|
||||
<template v-if="item.type === 'text'">
|
||||
<a-input
|
||||
v-decorator="[item.key, { initialValue: getFieldValue(groupKey, item.key) }]"
|
||||
:placeholder="item.default ? `${$t('settings.default')}: ${item.default}` : ''"
|
||||
allowClear
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 密码输入 -->
|
||||
<template v-else-if="item.type === 'password'">
|
||||
<div class="password-field">
|
||||
<a-input
|
||||
v-decorator="[item.key, { initialValue: getFieldValue(groupKey, item.key) }]"
|
||||
:type="passwordVisible[item.key] ? 'text' : 'password'"
|
||||
:placeholder="$t('settings.inputApiKey')"
|
||||
allowClear
|
||||
>
|
||||
<a-icon
|
||||
slot="suffix"
|
||||
:type="passwordVisible[item.key] ? 'eye' : 'eye-invisible'"
|
||||
@click="togglePasswordVisible(item.key)"
|
||||
style="cursor: pointer"
|
||||
/>
|
||||
</a-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<template v-else-if="item.type === 'number'">
|
||||
<a-input-number
|
||||
v-decorator="[item.key, { initialValue: getNumberValue(groupKey, item.key, item.default) }]"
|
||||
:placeholder="item.default ? `${$t('settings.default')}: ${item.default}` : ''"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 布尔开关 -->
|
||||
<template v-else-if="item.type === 'boolean'">
|
||||
<a-switch
|
||||
v-decorator="[item.key, { valuePropName: 'checked', initialValue: getBoolValue(groupKey, item.key, item.default) }]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<template v-else-if="item.type === 'select'">
|
||||
<a-select
|
||||
v-decorator="[item.key, { initialValue: getFieldValue(groupKey, item.key) || item.default }]"
|
||||
:placeholder="item.default ? `${$t('settings.default')}: ${item.default}` : $t('settings.pleaseSelect')"
|
||||
>
|
||||
<a-select-option v-for="opt in item.options" :key="opt" :value="opt">
|
||||
{{ opt }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
|
||||
<div class="field-default" v-if="item.default && item.type !== 'boolean' && item.type !== 'password'">
|
||||
{{ $t('settings.default') }}: {{ item.default }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<div class="settings-footer">
|
||||
<a-button @click="handleReset" :disabled="saving">
|
||||
<a-icon type="undo" />
|
||||
{{ $t('settings.reset') }}
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleSave" :loading="saving">
|
||||
<a-icon type="save" />
|
||||
{{ $t('settings.save') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getSettingsSchema, getSettingsValues, saveSettings } from '@/api/settings'
|
||||
import { baseMixin } from '@/store/app-mixin'
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
mixins: [baseMixin],
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
schema: {},
|
||||
values: {},
|
||||
activeKeys: ['ai', 'data_source', 'app', 'auth'],
|
||||
passwordVisible: {},
|
||||
showRestartTip: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isDarkTheme () {
|
||||
return this.navTheme === 'dark' || this.navTheme === 'realdark'
|
||||
}
|
||||
},
|
||||
beforeCreate () {
|
||||
this.form = this.$form.createForm(this)
|
||||
},
|
||||
mounted () {
|
||||
this.loadSettings()
|
||||
},
|
||||
methods: {
|
||||
async loadSettings () {
|
||||
this.loading = true
|
||||
try {
|
||||
const [schemaRes, valuesRes] = await Promise.all([
|
||||
getSettingsSchema(),
|
||||
getSettingsValues()
|
||||
])
|
||||
|
||||
if (schemaRes.code === 1) {
|
||||
this.schema = schemaRes.data
|
||||
}
|
||||
|
||||
if (valuesRes.code === 1) {
|
||||
this.values = valuesRes.data
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(this.$t('settings.loadFailed'))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
getGroupIcon (groupKey) {
|
||||
const icons = {
|
||||
auth: 'lock',
|
||||
server: 'cloud-server',
|
||||
worker: 'schedule',
|
||||
notification: 'notification',
|
||||
smtp: 'mail',
|
||||
twilio: 'phone',
|
||||
strategy: 'fund',
|
||||
proxy: 'global',
|
||||
app: 'appstore',
|
||||
ai: 'robot',
|
||||
market: 'stock',
|
||||
data_source: 'database',
|
||||
search: 'search'
|
||||
}
|
||||
return icons[groupKey] || 'setting'
|
||||
},
|
||||
|
||||
getGroupTitle (groupKey, defaultTitle) {
|
||||
const key = `settings.group.${groupKey}`
|
||||
const translated = this.$t(key)
|
||||
return translated !== key ? translated : defaultTitle
|
||||
},
|
||||
|
||||
getItemLabel (groupKey, item) {
|
||||
const key = `settings.field.${item.key}`
|
||||
const translated = this.$t(key)
|
||||
return translated !== key ? translated : item.label
|
||||
},
|
||||
|
||||
getLinkText (linkText) {
|
||||
if (!linkText) return this.$t('settings.getApi')
|
||||
// 如果是翻译键(以 settings.link. 开头),则翻译
|
||||
if (linkText.startsWith('settings.link.')) {
|
||||
const translated = this.$t(linkText)
|
||||
return translated !== linkText ? translated : linkText
|
||||
}
|
||||
return linkText
|
||||
},
|
||||
|
||||
getFieldValue (groupKey, key) {
|
||||
const groupValues = this.values[groupKey] || {}
|
||||
return groupValues[key] || ''
|
||||
},
|
||||
|
||||
togglePasswordVisible (key) {
|
||||
this.$set(this.passwordVisible, key, !this.passwordVisible[key])
|
||||
},
|
||||
|
||||
getNumberValue (groupKey, key, defaultVal) {
|
||||
const val = this.getFieldValue(groupKey, key)
|
||||
if (val === '' || val === null || val === undefined) {
|
||||
return defaultVal ? parseFloat(defaultVal) : null
|
||||
}
|
||||
return parseFloat(val)
|
||||
},
|
||||
|
||||
getBoolValue (groupKey, key, defaultVal) {
|
||||
const val = this.getFieldValue(groupKey, key)
|
||||
if (val === '' || val === null || val === undefined) {
|
||||
return defaultVal === 'True' || defaultVal === 'true' || defaultVal === true
|
||||
}
|
||||
return val === 'True' || val === 'true' || val === true
|
||||
},
|
||||
|
||||
handleReset () {
|
||||
this.form.resetFields()
|
||||
this.loadSettings()
|
||||
},
|
||||
|
||||
copyRestartCommand () {
|
||||
const cmd = 'cd backend_api_python && py run.py'
|
||||
navigator.clipboard.writeText(cmd).then(() => {
|
||||
this.$message.success(this.$t('settings.copySuccess'))
|
||||
}).catch(() => {
|
||||
this.$message.error(this.$t('settings.copyFailed'))
|
||||
})
|
||||
},
|
||||
|
||||
async handleSave () {
|
||||
this.form.validateFields(async (err, formValues) => {
|
||||
if (err) {
|
||||
return
|
||||
}
|
||||
|
||||
this.saving = true
|
||||
try {
|
||||
// 按组整理数据
|
||||
const data = {}
|
||||
for (const groupKey of Object.keys(this.schema)) {
|
||||
data[groupKey] = {}
|
||||
const group = this.schema[groupKey]
|
||||
for (const item of group.items) {
|
||||
if (item.key in formValues) {
|
||||
let value = formValues[item.key]
|
||||
// 布尔值转字符串
|
||||
if (item.type === 'boolean') {
|
||||
value = value ? 'True' : 'False'
|
||||
}
|
||||
data[groupKey][item.key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await saveSettings(data)
|
||||
if (res.code === 1) {
|
||||
this.$message.success(res.msg || this.$t('settings.saveSuccess'))
|
||||
// 显示重启提示
|
||||
if (res.data && res.data.requires_restart) {
|
||||
this.showRestartTip = true
|
||||
}
|
||||
// 重新加载配置
|
||||
this.loadSettings()
|
||||
} else {
|
||||
this.$message.error(res.msg || this.$t('settings.saveFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(this.$t('settings.saveFailed') + ': ' + error.message)
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@primary-color: #1890ff;
|
||||
@success-color: #52c41a;
|
||||
@border-radius: 12px;
|
||||
@card-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.settings-page {
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 120px);
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
|
||||
.restart-alert {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
color: #1e3a5f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.anticon {
|
||||
font-size: 28px;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.settings-collapse {
|
||||
background: transparent;
|
||||
|
||||
/deep/ .ant-collapse-item {
|
||||
margin-bottom: 16px;
|
||||
border: none;
|
||||
border-radius: @border-radius;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: @card-shadow;
|
||||
|
||||
.ant-collapse-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e3a5f;
|
||||
padding: 16px 24px;
|
||||
padding-left: 48px;
|
||||
background: linear-gradient(135deg, #fff 0%, #f8fafc 100%);
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ant-collapse-arrow {
|
||||
color: @primary-color;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-icon {
|
||||
font-size: 18px;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none;
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
/deep/ .ant-form-item-label {
|
||||
padding-bottom: 4px;
|
||||
|
||||
label {
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-label-with-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.api-link {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: @primary-color;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(24, 144, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(24, 144, 255, 0.15);
|
||||
color: darken(@primary-color, 10%);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/deep/ .ant-input,
|
||||
/deep/ .ant-input-number,
|
||||
/deep/ .ant-select-selection {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/deep/ .ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.password-field {
|
||||
.field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: @success-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-default {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 208px;
|
||||
right: 0;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
z-index: 100;
|
||||
|
||||
.ant-btn {
|
||||
min-width: 100px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗黑主题
|
||||
&.theme-dark {
|
||||
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
|
||||
|
||||
.restart-alert {
|
||||
background: #2d333b;
|
||||
border-color: #b08800;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
.page-title {
|
||||
color: #e0e6ed;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
color: #8b949e;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-collapse {
|
||||
/deep/ .ant-collapse-item {
|
||||
background: #1e222d;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
|
||||
|
||||
.ant-collapse-header {
|
||||
background: linear-gradient(135deg, #252a36 0%, #1e222d 100%);
|
||||
color: #e0e6ed;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
|
||||
.panel-header .panel-title {
|
||||
color: #e0e6ed;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
background: #1e222d;
|
||||
|
||||
.ant-collapse-content-box {
|
||||
background: #1e222d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
/deep/ .ant-form-item-label {
|
||||
label {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.form-label-with-link .api-link {
|
||||
background: rgba(24, 144, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
|
||||
&:hover {
|
||||
background: rgba(24, 144, 255, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/deep/ .ant-input,
|
||||
/deep/ .ant-input-password,
|
||||
/deep/ .ant-input-number,
|
||||
/deep/ .ant-select-selection {
|
||||
background: #0d1117;
|
||||
border-color: #30363d;
|
||||
color: #c9d1d9;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
/deep/ .ant-input-number-input {
|
||||
background: transparent;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
/deep/ .ant-select-arrow {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.field-default {
|
||||
color: #6e7681;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
background: #1e222d;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.settings-page {
|
||||
padding: 16px;
|
||||
|
||||
.settings-footer {
|
||||
left: 0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -211,6 +211,11 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// 颜色变量
|
||||
@primary-color: #1890ff;
|
||||
@success-color: #0ecb81;
|
||||
@danger-color: #f6465d;
|
||||
|
||||
.position-records {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
@@ -222,6 +227,9 @@ export default {
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 40px 0;
|
||||
background: linear-gradient(135deg, rgba(248, 250, 252, 0.5) 0%, rgba(241, 245, 249, 0.5) 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px dashed #e0e6ed;
|
||||
}
|
||||
|
||||
::v-deep .ant-table {
|
||||
@@ -232,21 +240,21 @@ export default {
|
||||
// 自定义细滚动条
|
||||
::v-deep .ant-table-body {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin; // Firefox - 细滚动条
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; // Firefox - 滑块颜色和轨道颜色
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px; // 横向滚动条高度
|
||||
width: 6px; // 纵向滚动条宽度
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent; // 轨道背景透明
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2); // 滑块颜色
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3); // 悬停时颜色
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,16 +303,51 @@ export default {
|
||||
}
|
||||
|
||||
::v-deep .ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
color: #475569;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr > td {
|
||||
padding: 12px 16px;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
strong {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr {
|
||||
&:hover > td {
|
||||
background: #f0f7ff !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 方向标签美化
|
||||
::v-deep .ant-tag {
|
||||
border-radius: 6px;
|
||||
padding: 2px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
border: none;
|
||||
|
||||
&[color="green"] {
|
||||
background: linear-gradient(135deg, rgba(14, 203, 129, 0.15) 0%, rgba(14, 203, 129, 0.08) 100%);
|
||||
color: @success-color;
|
||||
border: 1px solid rgba(14, 203, 129, 0.3);
|
||||
}
|
||||
|
||||
&[color="red"] {
|
||||
background: linear-gradient(135deg, rgba(246, 70, 93, 0.15) 0%, rgba(246, 70, 93, 0.08) 100%);
|
||||
color: @danger-color;
|
||||
border: 1px solid rgba(246, 70, 93, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// 暗黑主题适配
|
||||
@@ -350,13 +393,35 @@ export default {
|
||||
}
|
||||
|
||||
.profit {
|
||||
color: #52c41a;
|
||||
font-weight: 600;
|
||||
color: @success-color;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: linear-gradient(135deg, rgba(14, 203, 129, 0.12) 0%, rgba(14, 203, 129, 0.06) 100%);
|
||||
border-radius: 6px;
|
||||
|
||||
&::before {
|
||||
content: '▲';
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loss {
|
||||
color: #ff4d4f;
|
||||
font-weight: 600;
|
||||
color: @danger-color;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: linear-gradient(135deg, rgba(246, 70, 93, 0.12) 0%, rgba(246, 70, 93, 0.06) 100%);
|
||||
border-radius: 6px;
|
||||
|
||||
&::before {
|
||||
content: '▼';
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
|
||||
@@ -241,19 +241,29 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// 颜色变量
|
||||
@primary-color: #1890ff;
|
||||
@success-color: #0ecb81;
|
||||
@danger-color: #f6465d;
|
||||
|
||||
.trading-records {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
padding: 0;
|
||||
overflow-x: visible;
|
||||
overflow-y: visible;
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 40px 0;
|
||||
background: linear-gradient(135deg, rgba(248, 250, 252, 0.5) 0%, rgba(241, 245, 249, 0.5) 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px dashed #e0e6ed;
|
||||
}
|
||||
|
||||
::v-deep .ant-spin-nested-loading {
|
||||
overflow-x: visible;
|
||||
}
|
||||
@@ -264,7 +274,6 @@ export default {
|
||||
|
||||
::v-deep .ant-table-wrapper {
|
||||
overflow-x: visible;
|
||||
// 自定义细滚动条
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
@@ -296,27 +305,25 @@ export default {
|
||||
::v-deep .ant-table-body {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
// 自定义细滚动条
|
||||
scrollbar-width: thin; // Firefox - 细滚动条
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; // Firefox - 滑块颜色和轨道颜色
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px; // 横向滚动条高度
|
||||
width: 6px; // 纵向滚动条宽度
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent; // 轨道背景透明
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2); // 滑块颜色
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3); // 悬停时颜色
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表格容器的滚动条样式
|
||||
::v-deep .ant-table-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
@@ -337,7 +344,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 所有可能的表格滚动容器的滚动条样式
|
||||
::v-deep .ant-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
@@ -358,23 +364,109 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 防止单元格折行,触发横向滚动
|
||||
::v-deep .ant-table-thead > tr > th,
|
||||
::v-deep .ant-table-tbody > tr > td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
color: #475569;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr > td {
|
||||
padding: 12px 16px;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr {
|
||||
&:hover > td {
|
||||
background: #f0f7ff !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 交易类型标签美化
|
||||
::v-deep .ant-tag {
|
||||
border-radius: 6px;
|
||||
padding: 3px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&[color="green"], &[color="cyan"], &[color="lime"] {
|
||||
background: linear-gradient(135deg, rgba(14, 203, 129, 0.15) 0%, rgba(14, 203, 129, 0.08) 100%);
|
||||
color: @success-color;
|
||||
border: 1px solid rgba(14, 203, 129, 0.3);
|
||||
}
|
||||
|
||||
&[color="red"], &[color="magenta"] {
|
||||
background: linear-gradient(135deg, rgba(246, 70, 93, 0.15) 0%, rgba(246, 70, 93, 0.08) 100%);
|
||||
color: @danger-color;
|
||||
border: 1px solid rgba(246, 70, 93, 0.3);
|
||||
}
|
||||
|
||||
&[color="orange"] {
|
||||
background: linear-gradient(135deg, rgba(250, 173, 20, 0.15) 0%, rgba(250, 173, 20, 0.08) 100%);
|
||||
color: #d48806;
|
||||
border: 1px solid rgba(250, 173, 20, 0.3);
|
||||
}
|
||||
|
||||
&[color="blue"] {
|
||||
background: linear-gradient(135deg, rgba(24, 144, 255, 0.15) 0%, rgba(24, 144, 255, 0.08) 100%);
|
||||
color: @primary-color;
|
||||
border: 1px solid rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// 分页器美化
|
||||
::v-deep .ant-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.ant-pagination-item {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
|
||||
a {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-pagination-item-active {
|
||||
background: linear-gradient(135deg, @primary-color 0%, #40a9ff 100%);
|
||||
border-color: @primary-color;
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-prev,
|
||||
.ant-pagination-next {
|
||||
.ant-pagination-item-link {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗黑主题适配
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user