feat: Multi-user system with PostgreSQL - WIP temporary save
This commit is contained in:
+137
-56
@@ -1,17 +1,16 @@
|
||||
# QuantDinger Python API (backend)
|
||||
|
||||
Flask-based local-first backend for QuantDinger: market data, indicators, AI analysis, backtesting, and a strategy runtime (with an optional pending-order worker).
|
||||
|
||||
This repository is intentionally simple: **no external database is required by default**. Data is stored in a local SQLite file (`quantdinger.db`) created/updated automatically on startup.
|
||||
Flask-based backend for QuantDinger: market data, indicators, AI analysis, backtesting, and a strategy runtime with multi-user support.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Multi-market data layer**: factory-based providers (crypto / US stocks / CN&HK stocks / futures, etc.)
|
||||
- **Indicators + backtesting**: persisted runs/history in SQLite
|
||||
- **Indicators + backtesting**: persisted runs/history in PostgreSQL
|
||||
- **AI multi-agent analysis**: optional web search + OpenRouter LLM integration
|
||||
- **Strategy runtime**: thread-based executor, with optional auto-restore on startup
|
||||
- **Pending orders worker (optional)**: polls queued orders and dispatches signals (webhook/notifications)
|
||||
- **Local auth (single-user)**: `/login` with env-configured admin credentials (JWT)
|
||||
- **Multi-user authentication**: role-based access control (admin/manager/user/viewer)
|
||||
- **User management**: admin can create/edit/delete users and reset passwords
|
||||
|
||||
## Project layout
|
||||
|
||||
@@ -22,8 +21,10 @@ backend_api_python/
|
||||
│ ├─ config/ # Settings (env-driven)
|
||||
│ ├─ data_sources/ # Data sources + factory
|
||||
│ ├─ routes/ # REST endpoints
|
||||
│ ├─ services/ # Analysis, agents, strategies, search, ...
|
||||
│ └─ utils/ # SQLite helpers, config loader, logging, HTTP utils
|
||||
│ ├─ services/ # Analysis, agents, strategies, search, user_service
|
||||
│ └─ utils/ # PostgreSQL helpers, config loader, logging, HTTP utils
|
||||
├─ migrations/
|
||||
│ └─ init.sql # PostgreSQL schema initialization
|
||||
├─ env.example # Copy to .env for local config
|
||||
├─ requirements.txt
|
||||
├─ run.py # Entrypoint (loads .env, applies proxy env, starts Flask)
|
||||
@@ -31,46 +32,99 @@ backend_api_python/
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
## Quick start (local development)
|
||||
## Quick start (Docker - Recommended)
|
||||
|
||||
### 1) Configure environment
|
||||
|
||||
Create `.env` file in project root:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
POSTGRES_USER=quantdinger
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
POSTGRES_DB=quantdinger
|
||||
|
||||
# Admin account (created on first startup)
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
|
||||
# Optional
|
||||
OPENROUTER_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
### 2) Start services
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This will:
|
||||
- Start PostgreSQL database (port 5432)
|
||||
- Initialize database schema automatically
|
||||
- Start backend API (port 5000)
|
||||
- Start frontend (port 8888)
|
||||
- Create admin user from `ADMIN_USER`/`ADMIN_PASSWORD`
|
||||
|
||||
### 3) Access the system
|
||||
|
||||
- Frontend: `http://localhost:8888`
|
||||
- Backend API: `http://localhost:5000`
|
||||
- Login with your configured admin credentials
|
||||
|
||||
## Quick start (Local Development)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10+ recommended
|
||||
- PostgreSQL 14+ installed and running
|
||||
|
||||
### 1) Install dependencies
|
||||
### 1) Setup PostgreSQL
|
||||
|
||||
```bash
|
||||
# Create database and user
|
||||
sudo -u postgres psql
|
||||
CREATE DATABASE quantdinger;
|
||||
CREATE USER quantdinger WITH ENCRYPTED PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE quantdinger TO quantdinger;
|
||||
\q
|
||||
|
||||
# Initialize schema
|
||||
psql -U quantdinger -d quantdinger -f migrations/init.sql
|
||||
```
|
||||
|
||||
### 2) Install dependencies
|
||||
|
||||
```bash
|
||||
cd backend_api_python
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2) Create your local `.env`
|
||||
### 3) Create your local `.env`
|
||||
|
||||
Windows (CMD):
|
||||
|
||||
```bash
|
||||
copy env.example .env
|
||||
```
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```bash
|
||||
Copy-Item env.example .env
|
||||
```
|
||||
|
||||
Then edit `.env` and set at least:
|
||||
Then edit `.env` and set:
|
||||
|
||||
- `SECRET_KEY`
|
||||
- `ADMIN_USER`
|
||||
- `ADMIN_PASSWORD`
|
||||
```bash
|
||||
# Required
|
||||
DATABASE_URL=postgresql://quantdinger:your_password@localhost:5432/quantdinger
|
||||
SECRET_KEY=your-secret-key-change-me
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
|
||||
Optional but common:
|
||||
# Optional but recommended
|
||||
OPENROUTER_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
- `OPENROUTER_API_KEY` (for AI analysis)
|
||||
- `FINNHUB_API_KEY` / `SEARCH_GOOGLE_*` / `SEARCH_BING_API_KEY` (for richer data/search)
|
||||
- `PROXY_PORT` or `PROXY_URL` (if your network blocks some providers)
|
||||
|
||||
### 3) Start the API server
|
||||
### 4) Start the API server
|
||||
|
||||
```bash
|
||||
python run.py
|
||||
@@ -78,47 +132,74 @@ python run.py
|
||||
|
||||
Default address: `http://localhost:5000`
|
||||
|
||||
## Database (SQLite)
|
||||
## Database (PostgreSQL)
|
||||
|
||||
- Default file: `backend_api_python/data/quantdinger.db` (override via `SQLITE_DATABASE_FILE`)
|
||||
- Tables are created/updated automatically on startup (see `app/utils/db.py`)
|
||||
- `qd_addon_config` exists for backward compatibility, but **this backend reads secrets from `.env` / OS env**, not from the database (see `app/utils/config_loader.py`)
|
||||
- Connection: configured via `DATABASE_URL` environment variable
|
||||
- Schema: initialized via `migrations/init.sql`
|
||||
- Tables are managed with foreign key constraints and indexes for performance
|
||||
- User data isolation via `user_id` column in relevant tables
|
||||
|
||||
## AI memory augmentation (local-only)
|
||||
## User Roles & Permissions
|
||||
|
||||
This backend includes a lightweight, privacy-first **memory-augmented multi-agent** system:
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| admin | Full access + user management |
|
||||
| manager | Strategy, backtest, portfolio, settings |
|
||||
| user | Strategy, backtest, portfolio (own data) |
|
||||
| viewer | Dashboard view only |
|
||||
|
||||
- Memory DBs (per role): `backend_api_python/data/memory/*_memory.db`
|
||||
- Reflection DB (optional auto-verify loop): `backend_api_python/data/memory/reflection_records.db`
|
||||
- API hooks:
|
||||
- `POST /api/analysis/multi` (main entry)
|
||||
- `POST /api/analysis/reflect` (manual learn from post-trade outcomes)
|
||||
- Controls: see `.env` / `env.example`:
|
||||
- `ENABLE_AGENT_MEMORY`, `AGENT_MEMORY_*`
|
||||
- `ENABLE_REFLECTION_WORKER`, `REFLECTION_WORKER_INTERVAL_SEC`
|
||||
|
||||
## Frontend integration (Vue dev server)
|
||||
|
||||
The Vue dev server proxies `/api/*` to this backend by default:
|
||||
|
||||
- Frontend: `http://localhost:8000`
|
||||
- Backend: `http://localhost:5000`
|
||||
|
||||
Proxy config: `quantdinger_vue/vue.config.js`
|
||||
|
||||
## Useful endpoints
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
```text
|
||||
GET /health
|
||||
POST /login
|
||||
GET /info
|
||||
POST /api/user/login - User login
|
||||
POST /api/user/logout - User logout
|
||||
GET /api/user/info - Get current user info
|
||||
```
|
||||
|
||||
### User Management (Admin only)
|
||||
```text
|
||||
GET /api/users/list - List all users
|
||||
POST /api/users/create - Create user
|
||||
PUT /api/users/update?id= - Update user
|
||||
DELETE /api/users/delete?id= - Delete user
|
||||
POST /api/users/reset-password - Reset password
|
||||
```
|
||||
|
||||
### Self-Service
|
||||
```text
|
||||
GET /api/users/profile - Get own profile
|
||||
PUT /api/users/profile/update - Update own profile
|
||||
POST /api/users/change-password - Change own password
|
||||
```
|
||||
|
||||
### Other Endpoints
|
||||
```text
|
||||
GET /api/health
|
||||
GET /api/indicator/kline
|
||||
POST /api/analysis/multi
|
||||
```
|
||||
|
||||
## Production (optional)
|
||||
## AI memory augmentation
|
||||
|
||||
Gunicorn example:
|
||||
This backend includes a lightweight, privacy-first **memory-augmented multi-agent** system:
|
||||
|
||||
- Memory DBs stored in PostgreSQL
|
||||
- API hooks:
|
||||
- `POST /api/analysis/multi` (main entry)
|
||||
- `POST /api/analysis/reflect` (manual learn from post-trade outcomes)
|
||||
- Controls in `.env`:
|
||||
- `ENABLE_AGENT_MEMORY`, `AGENT_MEMORY_*`
|
||||
- `ENABLE_REFLECTION_WORKER`, `REFLECTION_WORKER_INTERVAL_SEC`
|
||||
|
||||
## Frontend integration
|
||||
|
||||
For Vue dev server:
|
||||
- Frontend: `http://localhost:8000`
|
||||
- Backend: `http://localhost:5000`
|
||||
- Proxy config: `quantdinger_vue/vue.config.js`
|
||||
|
||||
## Production (Gunicorn)
|
||||
|
||||
```bash
|
||||
gunicorn -c gunicorn_config.py "run:app"
|
||||
@@ -126,11 +207,11 @@ gunicorn -c gunicorn_config.py "run:app"
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If outbound data/search requests fail, configure `PROXY_PORT` (or `PROXY_URL`) in `.env`.
|
||||
- If you don’t want strategies to auto-restore on startup, set `DISABLE_RESTORE_RUNNING_STRATEGIES=true`.
|
||||
- If you don’t want the pending-order worker, set `ENABLE_PENDING_ORDER_WORKER=false`.
|
||||
- **Database connection failed**: Check `DATABASE_URL` format and PostgreSQL service status
|
||||
- **Outbound requests fail**: Configure `PROXY_PORT` or `PROXY_URL` in `.env`
|
||||
- **Disable auto-restore**: Set `DISABLE_RESTORE_RUNNING_STRATEGIES=true`
|
||||
- **Disable pending-order worker**: Set `ENABLE_PENDING_ORDER_WORKER=false`
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0. See repository root `LICENSE`.
|
||||
|
||||
|
||||
@@ -180,6 +180,85 @@ def create_app(config_name='default'):
|
||||
|
||||
setup_logger()
|
||||
|
||||
# Initialize database and ensure admin user exists
|
||||
try:
|
||||
from app.utils.db import init_database, get_db_type
|
||||
logger.info(f"Database type: {get_db_type()}")
|
||||
init_database()
|
||||
|
||||
# Ensure admin user exists (multi-user mode)
|
||||
from app.services.user_service import get_user_service
|
||||
get_user_service().ensure_admin_exists()
|
||||
except Exception as e:
|
||||
logger.warning(f"Database initialization note: {e}")
|
||||
|
||||
# =====================================================
|
||||
# Demo Mode Middleware (Read-Only Mode)
|
||||
# =====================================================
|
||||
import os
|
||||
from flask import request, jsonify
|
||||
|
||||
# Check environment variable IS_DEMO_MODE
|
||||
is_demo_mode = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
|
||||
|
||||
if is_demo_mode:
|
||||
logger.info("!!! SYSTEM STARTING IN DEMO MODE (READ-ONLY) !!!")
|
||||
|
||||
@app.before_request
|
||||
def global_demo_mode_check():
|
||||
"""
|
||||
Global interceptor for demo mode.
|
||||
Blocks all state-changing methods AND access to sensitive GET endpoints.
|
||||
"""
|
||||
path = request.path
|
||||
|
||||
# 1. Block access to sensitive settings/config APIs (even if GET)
|
||||
# These endpoints reveal internal config or allow settings changes
|
||||
sensitive_endpoints = [
|
||||
'/api/settings', # All settings routes
|
||||
'/api/credentials', # Credentials management
|
||||
'/api/market/watchlist/add', # Modifying watchlist (POST, already blocked but good to be explicit)
|
||||
'/api/market/watchlist/remove'
|
||||
]
|
||||
|
||||
# Check if path starts with any sensitive prefix
|
||||
if any(path.startswith(endpoint) for endpoint in sensitive_endpoints):
|
||||
return jsonify({
|
||||
'code': 403,
|
||||
'msg': 'Demo mode: Access to settings and credentials is forbidden.',
|
||||
'data': None
|
||||
}), 403
|
||||
|
||||
# 2. Allow safe methods (GET, HEAD, OPTIONS)
|
||||
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
||||
return None
|
||||
|
||||
# 2. Allow Authentication (Login/Logout)
|
||||
# The auth routes are mounted at /api/user (see app/routes/__init__.py)
|
||||
if request.path.endswith('/login') or request.path.endswith('/logout'):
|
||||
return None
|
||||
|
||||
# 3. Allow specific read-only POST endpoints (Whitelist)
|
||||
# Some search/query endpoints use POST for complex payloads but don't modify state.
|
||||
whitelist_post_endpoints = [
|
||||
'/api/indicator/getIndicators', # Search indicators
|
||||
'/api/market/klines', # Fetch K-lines (sometimes POST)
|
||||
'/api/ai/chat', # AI Chat (generates response, doesn't mutate system state)
|
||||
'/api/analysis/indicator', # Analysis request
|
||||
'/api/analysis/ai_analysis' # AI Analysis request
|
||||
]
|
||||
|
||||
# Check if current path ends with any whitelist item
|
||||
if any(request.path.endswith(endpoint) for endpoint in whitelist_post_endpoints):
|
||||
return None
|
||||
|
||||
# 4. Block everything else
|
||||
return jsonify({
|
||||
'code': 403,
|
||||
'msg': 'Demo mode: Read-only access. Forbidden to modify data.',
|
||||
'data': None
|
||||
}), 403
|
||||
|
||||
from app.routes import register_routes
|
||||
register_routes(app)
|
||||
|
||||
|
||||
@@ -1,120 +1,106 @@
|
||||
"""
|
||||
Seed symbol list used by Market APIs in local-only mode.
|
||||
Market symbols seed data and lookup functions.
|
||||
|
||||
This file is derived from the legacy MySQL `qd_market_symbols` table (non-secret data).
|
||||
It provides a deterministic offline list for:
|
||||
- hot symbols per market
|
||||
- lightweight symbol search
|
||||
Data is stored in PostgreSQL table `qd_market_symbols` (initialized via migrations/init.sql).
|
||||
This module provides helper functions to query hot symbols, search, and get symbol names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
SEED_MARKET_SYMBOLS: List[Dict] = [
|
||||
# AShare
|
||||
{'market': 'AShare', 'symbol': '000001', 'name': '平安银行', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 100},
|
||||
{'market': 'AShare', 'symbol': '000002', 'name': '万科A', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 99},
|
||||
{'market': 'AShare', 'symbol': '600000', 'name': '浦发银行', 'exchange': 'SSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 98},
|
||||
{'market': 'AShare', 'symbol': '600036', 'name': '招商银行', 'exchange': 'SSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 97},
|
||||
{'market': 'AShare', 'symbol': '600519', 'name': '贵州茅台', 'exchange': 'SSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 96},
|
||||
{'market': 'AShare', 'symbol': '000858', 'name': '五粮液', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 95},
|
||||
{'market': 'AShare', 'symbol': '002415', 'name': '海康威视', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 94},
|
||||
{'market': 'AShare', 'symbol': '300059', 'name': '东方财富', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 93},
|
||||
{'market': 'AShare', 'symbol': '000725', 'name': '京东方A', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 92},
|
||||
{'market': 'AShare', 'symbol': '002594', 'name': '比亚迪', 'exchange': 'SZSE', 'currency': 'CNY', 'is_active': 1, 'is_hot': 1, 'sort_order': 91},
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# USStock
|
||||
{'market': 'USStock', 'symbol': 'AAPL', 'name': 'Apple Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 100},
|
||||
{'market': 'USStock', 'symbol': 'MSFT', 'name': 'Microsoft Corporation', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 99},
|
||||
{'market': 'USStock', 'symbol': 'GOOGL', 'name': 'Alphabet Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 98},
|
||||
{'market': 'USStock', 'symbol': 'AMZN', 'name': 'Amazon.com Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 97},
|
||||
{'market': 'USStock', 'symbol': 'TSLA', 'name': 'Tesla, Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 96},
|
||||
{'market': 'USStock', 'symbol': 'META', 'name': 'Meta Platforms Inc.', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 95},
|
||||
{'market': 'USStock', 'symbol': 'NVDA', 'name': 'NVIDIA Corporation', 'exchange': 'NASDAQ', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 94},
|
||||
{'market': 'USStock', 'symbol': 'JPM', 'name': 'JPMorgan Chase & Co.', 'exchange': 'NYSE', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 93},
|
||||
{'market': 'USStock', 'symbol': 'V', 'name': 'Visa Inc.', 'exchange': 'NYSE', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 92},
|
||||
{'market': 'USStock', 'symbol': 'JNJ', 'name': 'Johnson & Johnson', 'exchange': 'NYSE', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 91},
|
||||
|
||||
# HShare
|
||||
{'market': 'HShare', 'symbol': '00700', 'name': 'Tencent Holdings', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 100},
|
||||
{'market': 'HShare', 'symbol': '09988', 'name': 'Alibaba Group', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 99},
|
||||
{'market': 'HShare', 'symbol': '03690', 'name': 'Meituan', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 98},
|
||||
{'market': 'HShare', 'symbol': '01810', 'name': 'Xiaomi Corporation', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 97},
|
||||
{'market': 'HShare', 'symbol': '02318', 'name': 'Ping An Insurance', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 96},
|
||||
{'market': 'HShare', 'symbol': '01398', 'name': 'ICBC', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 95},
|
||||
{'market': 'HShare', 'symbol': '00939', 'name': 'CCB', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 94},
|
||||
{'market': 'HShare', 'symbol': '01299', 'name': 'AIA Group', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 93},
|
||||
{'market': 'HShare', 'symbol': '02020', 'name': 'Anta Sports', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 92},
|
||||
{'market': 'HShare', 'symbol': '01024', 'name': 'Kuaishou Technology', 'exchange': 'HKEX', 'currency': 'HKD', 'is_active': 1, 'is_hot': 1, 'sort_order': 91},
|
||||
|
||||
# Crypto
|
||||
{'market': 'Crypto', 'symbol': 'BTC/USDT', 'name': 'Bitcoin', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 100},
|
||||
{'market': 'Crypto', 'symbol': 'ETH/USDT', 'name': 'Ethereum', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 99},
|
||||
{'market': 'Crypto', 'symbol': 'BNB/USDT', 'name': 'BNB', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 98},
|
||||
{'market': 'Crypto', 'symbol': 'SOL/USDT', 'name': 'Solana', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 97},
|
||||
{'market': 'Crypto', 'symbol': 'XRP/USDT', 'name': 'Ripple', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 96},
|
||||
{'market': 'Crypto', 'symbol': 'ADA/USDT', 'name': 'Cardano', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 95},
|
||||
{'market': 'Crypto', 'symbol': 'DOGE/USDT', 'name': 'Dogecoin', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 94},
|
||||
{'market': 'Crypto', 'symbol': 'DOT/USDT', 'name': 'Polkadot', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 93},
|
||||
{'market': 'Crypto', 'symbol': 'MATIC/USDT', 'name': 'Polygon', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 92},
|
||||
{'market': 'Crypto', 'symbol': 'AVAX/USDT', 'name': 'Avalanche', 'exchange': 'Binance', 'currency': 'USDT', 'is_active': 1, 'is_hot': 1, 'sort_order': 91},
|
||||
|
||||
# Forex (names are kept as-is from legacy dump)
|
||||
{'market': 'Forex', 'symbol': 'XAUUSD', 'name': 'Euro/US Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 100},
|
||||
{'market': 'Forex', 'symbol': 'XAGUSD', 'name': 'British Pound/US Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 99},
|
||||
{'market': 'Forex', 'symbol': 'EURUSD', 'name': 'US Dollar/Japanese Yen', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 98},
|
||||
{'market': 'Forex', 'symbol': 'GBPUSD', 'name': 'Australian Dollar/US Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 97},
|
||||
{'market': 'Forex', 'symbol': 'USDJPY', 'name': 'US Dollar/Canadian Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 96},
|
||||
{'market': 'Forex', 'symbol': 'AUDUSD', 'name': 'US Dollar/Swiss Franc', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 95},
|
||||
{'market': 'Forex', 'symbol': 'USDCAD', 'name': 'New Zealand Dollar/US Dollar', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 94},
|
||||
{'market': 'Forex', 'symbol': 'NZDUSD', 'name': 'US Dollar/Offshore Chinese Yuan', 'exchange': 'Forex', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 93},
|
||||
{'market': 'Forex', 'symbol': 'USDCHF', 'name': 'Euro/British Pound', 'exchange': 'Forex', 'currency': 'EUR', 'is_active': 1, 'is_hot': 1, 'sort_order': 92},
|
||||
{'market': 'Forex', 'symbol': 'EURJPY', 'name': 'Euro/Japanese Yen', 'exchange': 'Forex', 'currency': 'EUR', 'is_active': 1, 'is_hot': 1, 'sort_order': 91},
|
||||
|
||||
# Futures
|
||||
{'market': 'Futures', 'symbol': 'CL', 'name': 'WTI Crude Oil', 'exchange': 'NYMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 100},
|
||||
{'market': 'Futures', 'symbol': 'GC', 'name': 'Gold', 'exchange': 'COMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 99},
|
||||
{'market': 'Futures', 'symbol': 'SI', 'name': 'Silver', 'exchange': 'COMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 98},
|
||||
{'market': 'Futures', 'symbol': 'NG', 'name': 'Natural Gas', 'exchange': 'NYMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 97},
|
||||
{'market': 'Futures', 'symbol': 'HG', 'name': 'Copper', 'exchange': 'COMEX', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 96},
|
||||
{'market': 'Futures', 'symbol': 'ZC', 'name': 'Corn', 'exchange': 'CBOT', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 95},
|
||||
{'market': 'Futures', 'symbol': 'ZS', 'name': 'Soybeans', 'exchange': 'CBOT', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 94},
|
||||
{'market': 'Futures', 'symbol': 'ZW', 'name': 'Wheat', 'exchange': 'CBOT', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 93},
|
||||
{'market': 'Futures', 'symbol': 'ES', 'name': 'S&P 500 E-mini', 'exchange': 'CME', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 92},
|
||||
{'market': 'Futures', 'symbol': 'NQ', 'name': 'NASDAQ 100 E-mini', 'exchange': 'CME', 'currency': 'USD', 'is_active': 1, 'is_hot': 1, 'sort_order': 91},
|
||||
]
|
||||
def _get_db_connection():
|
||||
"""Get database connection, returns None if not available."""
|
||||
try:
|
||||
from app.utils.db import get_db_connection
|
||||
return get_db_connection()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_hot_symbols(market: str, limit: int = 10) -> List[Dict]:
|
||||
"""
|
||||
Get hot symbols for a market.
|
||||
|
||||
Args:
|
||||
market: Market name (e.g., 'Crypto', 'USStock', 'AShare')
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of {market, symbol, name} dicts
|
||||
"""
|
||||
market = (market or '').strip()
|
||||
items = [x for x in SEED_MARKET_SYMBOLS if x.get('market') == market and int(x.get('is_active', 1)) == 1 and int(x.get('is_hot', 0)) == 1]
|
||||
items.sort(key=lambda x: int(x.get('sort_order', 0)), reverse=True)
|
||||
return [{'market': x['market'], 'symbol': x['symbol'], 'name': x.get('name') or ''} for x in items[: max(limit, 0)]]
|
||||
if not market:
|
||||
return []
|
||||
|
||||
try:
|
||||
with _get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT market, symbol, name FROM qd_market_symbols
|
||||
WHERE market = ? AND is_active = 1 AND is_hot = 1
|
||||
ORDER BY sort_order DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(market, max(limit, 0))
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
return [{'market': r['market'], 'symbol': r['symbol'], 'name': r.get('name') or ''} for r in rows]
|
||||
except Exception as e:
|
||||
logger.debug(f"get_hot_symbols from DB failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def search_symbols(market: str, keyword: str, limit: int = 20) -> List[Dict]:
|
||||
"""
|
||||
Search symbols by keyword.
|
||||
|
||||
Args:
|
||||
market: Market name
|
||||
keyword: Search keyword (matches symbol or name)
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of {market, symbol, name} dicts
|
||||
"""
|
||||
market = (market or '').strip()
|
||||
kw = (keyword or '').strip().upper()
|
||||
kw = (keyword or '').strip()
|
||||
if not market or not kw:
|
||||
return []
|
||||
|
||||
items = [x for x in SEED_MARKET_SYMBOLS if x.get('market') == market and int(x.get('is_active', 1)) == 1]
|
||||
out: List[Dict] = []
|
||||
|
||||
for x in sorted(items, key=lambda i: int(i.get('sort_order', 0)), reverse=True):
|
||||
sym = str(x.get('symbol') or '').upper()
|
||||
name = str(x.get('name') or '').upper()
|
||||
if kw in sym or kw in name:
|
||||
out.append({'market': x['market'], 'symbol': x['symbol'], 'name': x.get('name') or ''})
|
||||
if len(out) >= max(limit, 0):
|
||||
break
|
||||
|
||||
return out
|
||||
|
||||
# Use ILIKE for case-insensitive search in PostgreSQL
|
||||
pattern = f'%{kw}%'
|
||||
|
||||
try:
|
||||
with _get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT market, symbol, name FROM qd_market_symbols
|
||||
WHERE market = ? AND is_active = 1
|
||||
AND (UPPER(symbol) LIKE UPPER(?) OR UPPER(name) LIKE UPPER(?))
|
||||
ORDER BY sort_order DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(market, pattern, pattern, max(limit, 0))
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
return [{'market': r['market'], 'symbol': r['symbol'], 'name': r.get('name') or ''} for r in rows]
|
||||
except Exception as e:
|
||||
logger.debug(f"search_symbols from DB failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_for_match(market: str, symbol: str) -> str:
|
||||
"""Normalize symbol for matching (padding digits for A-Share/H-Share)."""
|
||||
m = (market or '').strip()
|
||||
s = (symbol or '').strip().upper()
|
||||
if not m or not s:
|
||||
@@ -123,7 +109,7 @@ def _normalize_for_match(market: str, symbol: str) -> str:
|
||||
# A-Share codes are usually 6 digits
|
||||
if m == 'AShare' and s.isdigit() and len(s) < 6:
|
||||
s = s.zfill(6)
|
||||
# H-Share codes are often 5 digits in the dump
|
||||
# H-Share codes are often 5 digits
|
||||
if m == 'HShare' and s.isdigit() and len(s) < 5:
|
||||
s = s.zfill(5)
|
||||
return s
|
||||
@@ -131,8 +117,14 @@ def _normalize_for_match(market: str, symbol: str) -> str:
|
||||
|
||||
def get_symbol_name(market: str, symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Best-effort exact lookup for display name.
|
||||
Returns None if not found.
|
||||
Get display name for a symbol.
|
||||
|
||||
Args:
|
||||
market: Market name
|
||||
symbol: Symbol (e.g., 'AAPL', 'BTC/USDT', '600519')
|
||||
|
||||
Returns:
|
||||
Symbol name or None if not found
|
||||
"""
|
||||
m = (market or '').strip()
|
||||
if not m:
|
||||
@@ -142,20 +134,65 @@ def get_symbol_name(market: str, symbol: str) -> Optional[str]:
|
||||
if not s:
|
||||
return None
|
||||
|
||||
# Crypto: allow user to pass BTC (try BTC/USDT) or full pair.
|
||||
# Crypto: allow user to pass BTC (try BTC/USDT) or full pair
|
||||
candidate_symbols = [s]
|
||||
if m == 'Crypto' and '/' not in s:
|
||||
candidate_symbols.append(f"{s}/USDT")
|
||||
|
||||
for item in SEED_MARKET_SYMBOLS:
|
||||
if item.get('market') != m:
|
||||
continue
|
||||
item_sym = _normalize_for_match(m, str(item.get('symbol') or ''))
|
||||
for cand in candidate_symbols:
|
||||
if item_sym == cand:
|
||||
name = item.get('name')
|
||||
return str(name) if name else None
|
||||
|
||||
try:
|
||||
with _get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
for cand in candidate_symbols:
|
||||
cur.execute(
|
||||
"SELECT name FROM qd_market_symbols WHERE market = ? AND UPPER(symbol) = ?",
|
||||
(m, cand.upper())
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row.get('name'):
|
||||
cur.close()
|
||||
return str(row['name'])
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"get_symbol_name from DB failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_all_symbols(market: str = None) -> List[Dict]:
|
||||
"""
|
||||
Get all active symbols, optionally filtered by market.
|
||||
|
||||
Args:
|
||||
market: Optional market filter
|
||||
|
||||
Returns:
|
||||
List of symbol records
|
||||
"""
|
||||
try:
|
||||
with _get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
if market:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT market, symbol, name, exchange, currency, is_hot, sort_order
|
||||
FROM qd_market_symbols
|
||||
WHERE market = ? AND is_active = 1
|
||||
ORDER BY sort_order DESC
|
||||
""",
|
||||
(market.strip(),)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT market, symbol, name, exchange, currency, is_hot, sort_order
|
||||
FROM qd_market_symbols
|
||||
WHERE is_active = 1
|
||||
ORDER BY market, sort_order DESC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
logger.debug(f"get_all_symbols from DB failed: {e}")
|
||||
return []
|
||||
|
||||
@@ -21,9 +21,12 @@ def register_routes(app: Flask):
|
||||
from app.routes.portfolio import portfolio_bp
|
||||
from app.routes.ibkr import ibkr_bp
|
||||
from app.routes.mt5 import mt5_bp
|
||||
from app.routes.user import user_bp
|
||||
from app.routes.strategy_code import strategy_code_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/user')
|
||||
app.register_blueprint(user_bp, url_prefix='/api/users') # User management
|
||||
app.register_blueprint(kline_bp, url_prefix='/api/indicator')
|
||||
app.register_blueprint(analysis_bp, url_prefix='/api/analysis')
|
||||
app.register_blueprint(backtest_bp, url_prefix='/api/indicator')
|
||||
@@ -37,4 +40,4 @@ def register_routes(app: Flask):
|
||||
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
|
||||
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
|
||||
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
|
||||
|
||||
app.register_blueprint(strategy_code_bp, url_prefix='/api/strategy-code')
|
||||
|
||||
@@ -32,7 +32,7 @@ def chat_message():
|
||||
})
|
||||
|
||||
|
||||
@ai_chat_bp.route('/chat/history', methods=['POST'])
|
||||
@ai_chat_bp.route('/chat/history', methods=['GET'])
|
||||
def get_chat_history():
|
||||
"""Return empty history (compatibility stub)."""
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': []})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Analysis API routes (local-only).
|
||||
Implements multi-dimensional analysis plus lightweight task/history APIs for the frontend.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
from flask import Blueprint, request, jsonify, Response, g
|
||||
import json
|
||||
import traceback
|
||||
import time
|
||||
@@ -11,11 +11,11 @@ from app.services.analysis import AnalysisService, reflect_analysis
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.language import detect_request_language
|
||||
from app.utils.auth import login_required
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
analysis_bp = Blueprint('analysis', __name__)
|
||||
DEFAULT_USER_ID = 1
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
@@ -23,27 +23,58 @@ def _now_ts() -> int:
|
||||
def _normalize_symbol(symbol: str) -> str:
|
||||
return (symbol or '').strip().upper()
|
||||
|
||||
def _store_task(market: str, symbol: str, model: str, language: str, status: str, result: dict = None, error_message: str = "") -> int:
|
||||
now = _now_ts()
|
||||
def _store_task(user_id: int, market: str, symbol: str, model: str, language: str, status: str, result: dict = None, error_message: str = "") -> int:
|
||||
"""Create a new task record. For pending tasks, completed_at is NULL."""
|
||||
result_json = json.dumps(result or {}, ensure_ascii=False)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(DEFAULT_USER_ID, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '', now, now if status in ['completed', 'failed'] else None)
|
||||
)
|
||||
if status in ['completed', 'failed']:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(user_id, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '')
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(user_id, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '')
|
||||
)
|
||||
task_id = cur.lastrowid
|
||||
db.commit()
|
||||
cur.close()
|
||||
return int(task_id)
|
||||
|
||||
def _get_task(task_id: int) -> dict:
|
||||
|
||||
def _update_task(task_id: int, status: str, result: dict = None, error_message: str = "") -> bool:
|
||||
"""Update an existing task with result and status."""
|
||||
try:
|
||||
result_json = json.dumps(result or {}, ensure_ascii=False)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_analysis_tasks
|
||||
SET status = ?, result_json = ?, error_message = ?, completed_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, result_json, error_message or '', task_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"_update_task failed: {e}")
|
||||
return False
|
||||
|
||||
def _get_task(task_id: int, user_id: int) -> dict:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT * FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, DEFAULT_USER_ID))
|
||||
cur.execute("SELECT * FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, user_id))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row or None
|
||||
@@ -60,16 +91,25 @@ def _parse_result_json(row: dict) -> dict:
|
||||
|
||||
@analysis_bp.route('/multi', methods=['POST'])
|
||||
@analysis_bp.route('/multiAnalysis', methods=['POST']) # compatibility with legacy naming
|
||||
@login_required
|
||||
def multi_analysis():
|
||||
"""
|
||||
Multi-dimensional analysis.
|
||||
Multi-dimensional analysis for the current user.
|
||||
|
||||
Request body:
|
||||
market: Market (AShare, USStock, HShare, Crypto, Forex, Futures)
|
||||
symbol: Symbol
|
||||
language: Optional; if omitted we will detect from request headers (X-App-Lang / Accept-Language)
|
||||
"""
|
||||
task_id = None
|
||||
user_id = None
|
||||
market = ''
|
||||
symbol = ''
|
||||
model = None
|
||||
language = 'en-US'
|
||||
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({
|
||||
@@ -100,49 +140,64 @@ def multi_analysis():
|
||||
|
||||
logger.info(f"Analyze request: {market}:{symbol}, use_multi_agent={use_multi_agent}, model={model}")
|
||||
|
||||
# Create analysis service instance (local-only; no paid credits)
|
||||
service = AnalysisService(use_multi_agent=use_multi_agent)
|
||||
result = service.analyze(market, symbol, language, model=model, timeframe=timeframe)
|
||||
# Step 1: Create a "pending" task record first (so user can see progress in history)
|
||||
task_id = _store_task(user_id, market, symbol, model or '', language, 'pending', result={}, error_message='')
|
||||
|
||||
# Step 2: Run analysis in background thread (so user can navigate away)
|
||||
import threading
|
||||
|
||||
def run_analysis_background(task_id_inner, market_inner, symbol_inner, language_inner, model_inner, timeframe_inner, use_multi_agent_inner):
|
||||
"""Execute analysis in background and update task when done."""
|
||||
try:
|
||||
service = AnalysisService(use_multi_agent=use_multi_agent_inner)
|
||||
result = service.analyze(market_inner, symbol_inner, language_inner, model=model_inner, timeframe=timeframe_inner)
|
||||
_update_task(task_id_inner, 'completed', result=result, error_message='')
|
||||
logger.info(f"Background analysis completed for task {task_id_inner}")
|
||||
except Exception as e:
|
||||
logger.error(f"Background analysis failed for task {task_id_inner}: {e}")
|
||||
_update_task(task_id_inner, 'failed', result={}, error_message=str(e))
|
||||
|
||||
analysis_thread = threading.Thread(
|
||||
target=run_analysis_background,
|
||||
args=(task_id, market, symbol, language, model, timeframe, use_multi_agent),
|
||||
daemon=False # Keep running even if main request thread ends
|
||||
)
|
||||
analysis_thread.start()
|
||||
|
||||
# Persist as "completed" history (no paid credits in local mode).
|
||||
task_id = _store_task(market, symbol, model or '', language, 'completed', result=result, error_message='')
|
||||
|
||||
# Keep frontend compatible: if it expects task polling, it can still use the id.
|
||||
result_payload = dict(result or {})
|
||||
result_payload['task_id'] = task_id
|
||||
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': result_payload})
|
||||
# Step 3: Return immediately with task_id (frontend will poll for results)
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'task_id': task_id, 'status': 'pending'}})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Analysis failed: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
# Update existing task as "failed", or create a new failed record if task_id doesn't exist
|
||||
try:
|
||||
market = (data or {}).get('market', '') if 'data' in locals() else ''
|
||||
symbol = (data or {}).get('symbol', '') if 'data' in locals() else ''
|
||||
language = detect_request_language(request, body=(data or {}), default='en-US')
|
||||
model = (data or {}).get('model', '') if 'data' in locals() else ''
|
||||
market = str(market).strip()
|
||||
symbol = _normalize_symbol(symbol)
|
||||
_store_task(market, symbol, model, language, 'failed', result={}, error_message=str(e))
|
||||
if task_id:
|
||||
_update_task(task_id, 'failed', result={}, error_message=str(e))
|
||||
elif user_id:
|
||||
_store_task(user_id, market, symbol, model or '', language, 'failed', result={}, error_message=str(e))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': f'Analysis failed: {str(e)}',
|
||||
'data': None
|
||||
'data': {'task_id': task_id} if task_id else None
|
||||
}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/getTaskStatus', methods=['POST'])
|
||||
@analysis_bp.route('/getTaskStatus', methods=['GET'])
|
||||
@login_required
|
||||
def get_task_status():
|
||||
"""Frontend compatibility: return task status + result by task_id."""
|
||||
"""Frontend compatibility: return task status + result by task_id for the current user."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
task_id = int(data.get('task_id') or 0)
|
||||
user_id = g.user_id
|
||||
task_id = int(request.args.get('task_id') or 0)
|
||||
if not task_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing task_id', 'data': None}), 400
|
||||
|
||||
row = _get_task(task_id)
|
||||
row = _get_task(task_id, user_id)
|
||||
if not row:
|
||||
return jsonify({'code': 0, 'msg': 'Task not found', 'data': None}), 404
|
||||
|
||||
@@ -161,20 +216,21 @@ def get_task_status():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/getHistoryList', methods=['POST'])
|
||||
@analysis_bp.route('/getHistoryList', methods=['GET'])
|
||||
@login_required
|
||||
def get_history_list():
|
||||
"""Frontend compatibility: paginated analysis history for the single user."""
|
||||
"""Frontend compatibility: paginated analysis history for the current user."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
page = int(data.get('page') or 1)
|
||||
pagesize = int(data.get('pagesize') or 20)
|
||||
user_id = g.user_id
|
||||
page = int(request.args.get('page') or 1)
|
||||
pagesize = int(request.args.get('pagesize') or 20)
|
||||
page = max(page, 1)
|
||||
pagesize = min(max(pagesize, 1), 100)
|
||||
offset = (page - 1) * pagesize
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT COUNT(1) as cnt FROM qd_analysis_tasks WHERE user_id = ?", (DEFAULT_USER_ID,))
|
||||
cur.execute("SELECT COUNT(1) as cnt FROM qd_analysis_tasks WHERE user_id = ?", (user_id,))
|
||||
total = int((cur.fetchone() or {}).get('cnt') or 0)
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -184,7 +240,7 @@ def get_history_list():
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(DEFAULT_USER_ID, pagesize, offset)
|
||||
(user_id, pagesize, offset)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -192,6 +248,11 @@ def get_history_list():
|
||||
out = []
|
||||
for r in rows:
|
||||
has_result = bool((r.get('result_json') or '').strip())
|
||||
# Convert datetime to Unix timestamp for frontend compatibility
|
||||
created_at = r.get('created_at')
|
||||
completed_at = r.get('completed_at')
|
||||
createtime = int(created_at.timestamp()) if created_at else 0
|
||||
completetime = int(completed_at.timestamp()) if completed_at else None
|
||||
out.append({
|
||||
'id': r.get('id'),
|
||||
'market': r.get('market'),
|
||||
@@ -200,8 +261,8 @@ def get_history_list():
|
||||
'status': r.get('status'),
|
||||
'has_result': has_result,
|
||||
'error_message': r.get('error_message') or '',
|
||||
'createtime': int(r.get('created_at') or 0),
|
||||
'completetime': int(r.get('completed_at') or 0) if r.get('completed_at') else None
|
||||
'createtime': createtime,
|
||||
'completetime': completetime
|
||||
})
|
||||
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'list': out, 'total': total}})
|
||||
@@ -211,13 +272,46 @@ def get_history_list():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': {'list': [], 'total': 0}}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/deleteTask', methods=['POST'])
|
||||
@login_required
|
||||
def delete_task():
|
||||
"""Delete an analysis task by task_id for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
task_id = int(data.get('task_id') or 0)
|
||||
|
||||
if not task_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing task_id', 'data': None}), 400
|
||||
|
||||
# Verify task belongs to user
|
||||
row = _get_task(task_id, user_id)
|
||||
if not row:
|
||||
return jsonify({'code': 0, 'msg': 'Task not found', 'data': None}), 404
|
||||
|
||||
# Delete the task
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("DELETE FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, user_id))
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'deleted_id': task_id}})
|
||||
except Exception as e:
|
||||
logger.error(f"delete_task failed: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/createTask', methods=['POST'])
|
||||
@login_required
|
||||
def create_task():
|
||||
"""
|
||||
Compatibility endpoint for legacy frontend.
|
||||
In local-only mode we do not run a separate async worker; we create a completed task record immediately.
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
market = str((data.get('market') or '')).strip()
|
||||
symbol = _normalize_symbol(data.get('symbol'))
|
||||
@@ -228,7 +322,7 @@ def create_task():
|
||||
return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400
|
||||
|
||||
# Create a placeholder "pending" task so frontend can show task_id if it needs it.
|
||||
task_id = _store_task(market, symbol, str(model), language, 'pending', result={}, error_message='')
|
||||
task_id = _store_task(user_id, market, symbol, str(model), language, 'pending', result={}, error_message='')
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'task_id': task_id, 'status': 'pending'}})
|
||||
except Exception as e:
|
||||
logger.error(f"create_task failed: {str(e)}")
|
||||
@@ -236,47 +330,8 @@ def create_task():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/stream', methods=['POST'])
|
||||
def stream_analysis():
|
||||
"""Streaming analysis (SSE)."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 0, 'msg': 'Request body is required'}), 400
|
||||
|
||||
market = data.get('market', '')
|
||||
symbol = data.get('symbol', '')
|
||||
language = detect_request_language(request, body=data, default='en-US')
|
||||
use_multi_agent = data.get('use_multi_agent', None)
|
||||
timeframe = data.get('timeframe', '1D')
|
||||
|
||||
def generate():
|
||||
try:
|
||||
yield f"data: {json.dumps({'status': 'started', 'message': 'Analysis started'})}\n\n"
|
||||
|
||||
service = AnalysisService(use_multi_agent=use_multi_agent)
|
||||
result = service.analyze(market, symbol, language, timeframe=timeframe)
|
||||
|
||||
yield f"data: {json.dumps({'status': 'completed', 'data': result})}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'status': 'error', 'message': str(e)})}\n\n"
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Streaming analysis failed: {str(e)}")
|
||||
return jsonify({'code': 0, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
@analysis_bp.route('/reflect', methods=['POST'])
|
||||
@login_required
|
||||
def reflect():
|
||||
"""
|
||||
Reflection API.
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
"""
|
||||
Authentication API Routes
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
Handles login, logout, and user info retrieval.
|
||||
Supports both multi-user (database) and single-user (legacy) modes.
|
||||
"""
|
||||
import os
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app.config.settings import Config
|
||||
from app.utils.auth import generate_token
|
||||
from app.utils.auth import generate_token, login_required, authenticate_legacy
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
def _is_single_user_mode() -> bool:
|
||||
"""Check if system is in single-user (legacy) mode"""
|
||||
return os.getenv('SINGLE_USER_MODE', 'false').lower() == 'true'
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""Login (single-user, env-configured credentials)."""
|
||||
"""
|
||||
User login endpoint.
|
||||
|
||||
Request body:
|
||||
username: str
|
||||
password: str
|
||||
|
||||
Returns:
|
||||
token: JWT token
|
||||
userinfo: User information
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
@@ -20,49 +43,135 @@ def login():
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'code': 400, 'msg': 'Missing username or password', 'data': None}), 400
|
||||
|
||||
# Validate credentials from environment / settings
|
||||
if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD:
|
||||
token = generate_token(username)
|
||||
if token:
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Login successful',
|
||||
'data': {
|
||||
'token': token,
|
||||
'userinfo': {
|
||||
'username': username,
|
||||
'nickname': 'Admin',
|
||||
'avatar': ''
|
||||
}
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500
|
||||
else:
|
||||
|
||||
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
|
||||
user = None
|
||||
|
||||
# Try multi-user authentication first
|
||||
if not _is_single_user_mode():
|
||||
try:
|
||||
from app.services.user_service import get_user_service
|
||||
user = get_user_service().authenticate(username, password)
|
||||
except Exception as e:
|
||||
logger.warning(f"Multi-user auth failed, trying legacy: {e}")
|
||||
|
||||
# Fallback to legacy single-user mode
|
||||
if not user:
|
||||
user = authenticate_legacy(username, password)
|
||||
|
||||
if not user:
|
||||
return jsonify({'code': 0, 'msg': 'Invalid credentials', 'data': None}), 401
|
||||
|
||||
# Generate token
|
||||
token = generate_token(
|
||||
user_id=user.get('id') or user.get('user_id', 1),
|
||||
username=user.get('username', username),
|
||||
role=user.get('role', 'admin')
|
||||
)
|
||||
|
||||
if not token:
|
||||
return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500
|
||||
|
||||
# Build user info for frontend
|
||||
userinfo = {
|
||||
'id': user.get('id') or user.get('user_id', 1),
|
||||
'username': user.get('username', username),
|
||||
'nickname': user.get('nickname', 'User') + (' (Demo)' if is_demo else ''),
|
||||
'avatar': user.get('avatar', '/avatar2.jpg'),
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': user.get('role', 'admin'),
|
||||
'permissions': _get_permissions(user.get('role', 'admin'))
|
||||
}
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Login successful',
|
||||
'data': {
|
||||
'token': token,
|
||||
'userinfo': userinfo
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {e}")
|
||||
return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
"""Logout (client removes token; server is stateless)."""
|
||||
return jsonify({'code': 1, 'msg': 'Logout successful', 'data': None})
|
||||
|
||||
@auth_bp.route('/info', methods=['GET'])
|
||||
def get_user_info():
|
||||
"""Get user info (single-user mock)."""
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Success',
|
||||
'data': {
|
||||
'id': 1,
|
||||
'username': Config.ADMIN_USER,
|
||||
'nickname': 'Admin',
|
||||
'avatar': '/avatar2.jpg',
|
||||
'role': {'id': 'admin', 'permissions': ['dashboard', 'exception', 'account']}
|
||||
}
|
||||
})
|
||||
|
||||
@auth_bp.route('/info', methods=['GET'])
|
||||
@login_required
|
||||
def get_user_info():
|
||||
"""Get current user info."""
|
||||
try:
|
||||
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
|
||||
|
||||
user_id = getattr(g, 'user_id', 1)
|
||||
username = getattr(g, 'user', Config.ADMIN_USER)
|
||||
role = getattr(g, 'user_role', 'admin')
|
||||
|
||||
# Try to get full user info from database
|
||||
user_data = None
|
||||
if not _is_single_user_mode():
|
||||
try:
|
||||
from app.services.user_service import get_user_service
|
||||
user_data = get_user_service().get_user_by_id(user_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get user from database: {e}")
|
||||
|
||||
if user_data:
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Success',
|
||||
'data': {
|
||||
'id': user_data.get('id'),
|
||||
'username': user_data.get('username'),
|
||||
'nickname': user_data.get('nickname', 'User') + (' (Demo)' if is_demo else ''),
|
||||
'email': user_data.get('email'),
|
||||
'avatar': user_data.get('avatar', '/avatar2.jpg'),
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': user_data.get('role', 'user'),
|
||||
'permissions': _get_permissions(user_data.get('role', 'user'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Fallback for legacy mode
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Success',
|
||||
'data': {
|
||||
'id': user_id,
|
||||
'username': username,
|
||||
'nickname': 'Admin' + (' (Demo)' if is_demo else ''),
|
||||
'avatar': '/avatar2.jpg',
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': role,
|
||||
'permissions': _get_permissions(role)
|
||||
}
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_info error: {e}")
|
||||
return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
def _get_permissions(role: str) -> list:
|
||||
"""Get permissions list for a role"""
|
||||
try:
|
||||
from app.services.user_service import get_user_service
|
||||
return get_user_service().get_user_permissions(role)
|
||||
except Exception:
|
||||
# Default permissions for admin
|
||||
if role == 'admin':
|
||||
return ['dashboard', 'view', 'indicator', 'backtest', 'strategy',
|
||||
'portfolio', 'settings', 'user_manage', 'credentials']
|
||||
return ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Backtest API routes
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from datetime import datetime
|
||||
import traceback
|
||||
import json
|
||||
@@ -11,6 +11,7 @@ import os
|
||||
from app.services.backtest import BacktestService
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.auth import login_required
|
||||
import requests
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -82,12 +83,12 @@ def _normalize_lang(lang: str | None) -> str:
|
||||
return l2 if l2 in supported else "zh-CN"
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest/precision-info', methods=['POST'])
|
||||
@backtest_bp.route('/backtest/precision-info', methods=['GET'])
|
||||
def get_precision_info():
|
||||
"""
|
||||
获取回测精度信息(用于前端提示)
|
||||
|
||||
Params:
|
||||
Params (Query String):
|
||||
market: 市场类型
|
||||
startDate: 开始日期 (YYYY-MM-DD)
|
||||
endDate: 结束日期 (YYYY-MM-DD)
|
||||
@@ -96,13 +97,10 @@ def get_precision_info():
|
||||
精度信息,包含推荐的执行时间框架和预估K线数量
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 0, 'msg': 'Request body is required'}), 400
|
||||
|
||||
market = data.get('market', 'crypto')
|
||||
start_date_str = data.get('startDate', '')
|
||||
end_date_str = data.get('endDate', '')
|
||||
# Use request.args for GET params
|
||||
market = request.args.get('market', 'crypto')
|
||||
start_date_str = request.args.get('startDate', '')
|
||||
end_date_str = request.args.get('endDate', '')
|
||||
|
||||
if not start_date_str or not end_date_str:
|
||||
return jsonify({'code': 0, 'msg': 'startDate and endDate are required'}), 400
|
||||
@@ -123,9 +121,10 @@ def get_precision_info():
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest', methods=['POST'])
|
||||
@login_required
|
||||
def run_backtest():
|
||||
"""
|
||||
Run indicator backtest
|
||||
Run indicator backtest for the current user.
|
||||
|
||||
Params:
|
||||
indicatorId: Indicator ID (optional)
|
||||
@@ -148,8 +147,8 @@ def run_backtest():
|
||||
'data': None
|
||||
}), 400
|
||||
|
||||
# Extract params
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
# Extract params - use current user's ID
|
||||
user_id = g.user_id
|
||||
indicator_code = data.get('indicatorCode', '')
|
||||
indicator_id = data.get('indicatorId')
|
||||
symbol = data.get('symbol', '')
|
||||
@@ -267,7 +266,6 @@ def run_backtest():
|
||||
# Persist backtest run for AI optimization / history
|
||||
run_id = None
|
||||
try:
|
||||
now_ts = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -276,7 +274,7 @@ def run_backtest():
|
||||
(user_id, indicator_id, market, symbol, timeframe, start_date, end_date,
|
||||
initial_capital, commission, slippage, leverage, trade_direction,
|
||||
strategy_config, status, error_message, result_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
@@ -294,8 +292,7 @@ def run_backtest():
|
||||
json.dumps(strategy_config or {}, ensure_ascii=False),
|
||||
'success',
|
||||
'',
|
||||
json.dumps(result or {}, ensure_ascii=False),
|
||||
now_ts
|
||||
json.dumps(result or {}, ensure_ascii=False)
|
||||
)
|
||||
)
|
||||
run_id = cur.lastrowid
|
||||
@@ -327,9 +324,8 @@ def run_backtest():
|
||||
# Best-effort persist failed run (if we have enough context)
|
||||
try:
|
||||
data = data if isinstance(data, dict) else {}
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
user_id = g.user_id
|
||||
indicator_id = data.get('indicatorId')
|
||||
now_ts = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -338,7 +334,7 @@ def run_backtest():
|
||||
(user_id, indicator_id, market, symbol, timeframe, start_date, end_date,
|
||||
initial_capital, commission, slippage, leverage, trade_direction,
|
||||
strategy_config, status, error_message, result_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
@@ -356,8 +352,7 @@ def run_backtest():
|
||||
json.dumps(data.get('strategyConfig') or {}, ensure_ascii=False),
|
||||
'failed',
|
||||
str(e),
|
||||
'',
|
||||
now_ts
|
||||
''
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
@@ -371,13 +366,13 @@ def run_backtest():
|
||||
}), 500
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest/history', methods=['POST'])
|
||||
@backtest_bp.route('/backtest/history', methods=['GET'])
|
||||
@login_required
|
||||
def get_backtest_history():
|
||||
"""
|
||||
Get backtest run history (saved in SQLite).
|
||||
Get backtest run history for the current user.
|
||||
|
||||
Params:
|
||||
userid: User ID (default 1)
|
||||
Params (Query String):
|
||||
limit: Page size (default 50, max 200)
|
||||
offset: Offset (default 0)
|
||||
indicatorId: Optional indicator id filter
|
||||
@@ -386,17 +381,17 @@ def get_backtest_history():
|
||||
timeframe: Optional timeframe filter
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
limit = int(data.get('limit') or 50)
|
||||
offset = int(data.get('offset') or 0)
|
||||
# Use current user's ID
|
||||
user_id = g.user_id
|
||||
limit = int(request.args.get('limit') or 50)
|
||||
offset = int(request.args.get('offset') or 0)
|
||||
limit = max(1, min(limit, 200))
|
||||
offset = max(0, offset)
|
||||
|
||||
indicator_id = data.get('indicatorId')
|
||||
symbol = (data.get('symbol') or '').strip()
|
||||
market = (data.get('market') or '').strip()
|
||||
timeframe = (data.get('timeframe') or '').strip()
|
||||
indicator_id = request.args.get('indicatorId')
|
||||
symbol = (request.args.get('symbol') or '').strip()
|
||||
market = (request.args.get('market') or '').strip()
|
||||
timeframe = (request.args.get('timeframe') or '').strip()
|
||||
|
||||
where = ["user_id = ?"]
|
||||
params = [user_id]
|
||||
@@ -449,19 +444,18 @@ def get_backtest_history():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest/get', methods=['POST'])
|
||||
@backtest_bp.route('/backtest/get', methods=['GET'])
|
||||
@login_required
|
||||
def get_backtest_run():
|
||||
"""
|
||||
Get a backtest run detail by run id (includes result_json).
|
||||
Get a backtest run detail by run id for the current user.
|
||||
|
||||
Params:
|
||||
userid: User ID (default 1)
|
||||
Params (Query String):
|
||||
runId: Backtest run id (required)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
run_id = int(data.get('runId') or 0)
|
||||
user_id = g.user_id
|
||||
run_id = int(request.args.get('runId') or 0)
|
||||
if not run_id:
|
||||
return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400
|
||||
|
||||
@@ -716,17 +710,18 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
|
||||
|
||||
|
||||
@backtest_bp.route('/backtest/aiAnalyze', methods=['POST'])
|
||||
@login_required
|
||||
def ai_analyze_backtest_runs():
|
||||
"""
|
||||
AI analyze selected backtest runs and provide strategy_config tuning suggestions.
|
||||
AI analyze selected backtest runs and provide strategy_config tuning suggestions
|
||||
for the current user.
|
||||
|
||||
Params:
|
||||
userid: User ID (default 1)
|
||||
runIds: list[int] (required)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get('userid') or data.get('userId') or 1)
|
||||
user_id = g.user_id
|
||||
lang = _normalize_lang(data.get('lang'))
|
||||
run_ids = data.get('runIds') or []
|
||||
if not isinstance(run_ids, list) or not run_ids:
|
||||
|
||||
@@ -9,17 +9,16 @@ Local deployment notes:
|
||||
import time
|
||||
import traceback
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.auth import login_required
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
credentials_bp = Blueprint('credentials', __name__)
|
||||
|
||||
DEFAULT_USER_ID = 1
|
||||
|
||||
|
||||
def _api_key_hint(api_key: str) -> str:
|
||||
if not api_key:
|
||||
@@ -31,9 +30,11 @@ def _api_key_hint(api_key: str) -> str:
|
||||
|
||||
|
||||
@credentials_bp.route('/list', methods=['GET'])
|
||||
@login_required
|
||||
def list_credentials():
|
||||
"""List all credentials for the current user."""
|
||||
try:
|
||||
user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID
|
||||
user_id = g.user_id
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -57,10 +58,12 @@ def list_credentials():
|
||||
|
||||
|
||||
@credentials_bp.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_credential():
|
||||
"""Create a new credential for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get('user_id') or DEFAULT_USER_ID)
|
||||
name = (data.get('name') or '').strip()
|
||||
exchange_id = (data.get('exchange_id') or '').strip()
|
||||
api_key = (data.get('api_key') or '').strip()
|
||||
@@ -78,16 +81,15 @@ def create_credential():
|
||||
'secret_key': secret_key,
|
||||
'passphrase': passphrase
|
||||
}, ensure_ascii=False)
|
||||
now = int(time.time())
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_exchange_credentials (user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config, now, now)
|
||||
(user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config)
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
db.commit()
|
||||
@@ -101,10 +103,12 @@ def create_credential():
|
||||
|
||||
|
||||
@credentials_bp.route('/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_credential():
|
||||
"""Delete a credential for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
cred_id = request.args.get('id', type=int)
|
||||
user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID
|
||||
if not cred_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
|
||||
|
||||
@@ -125,14 +129,14 @@ def delete_credential():
|
||||
|
||||
|
||||
@credentials_bp.route('/get', methods=['GET'])
|
||||
@login_required
|
||||
def get_credential():
|
||||
"""
|
||||
Return decrypted credential for form auto-fill.
|
||||
NOTE: In a production system, this must be protected by strong authentication/authorization.
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
cred_id = request.args.get('id', type=int)
|
||||
user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID
|
||||
if not cred_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
|
||||
|
||||
|
||||
@@ -15,10 +15,11 @@ import json
|
||||
import time
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.auth import login_required
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -255,19 +256,24 @@ def _compute_strategy_stats(trades: List[Dict[str, Any]], strategies: List[Dict[
|
||||
|
||||
|
||||
@dashboard_bp.route("/summary", methods=["GET"])
|
||||
@login_required
|
||||
def summary():
|
||||
"""
|
||||
Return dashboard summary used by `quantdinger_vue/src/views/dashboard/index.vue`.
|
||||
"""
|
||||
try:
|
||||
# Strategy counts
|
||||
user_id = g.user_id
|
||||
|
||||
# Strategy counts (filtered by user_id)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, strategy_name, strategy_type, status, initial_capital, trading_config
|
||||
FROM qd_strategies_trading
|
||||
"""
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
strategies = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -292,7 +298,7 @@ def summary():
|
||||
if isinstance(tc, dict) and _truthy(tc.get("enable_ai_filter")):
|
||||
ai_enabled_strategy_count += 1
|
||||
|
||||
# Positions (best-effort)
|
||||
# Positions (best-effort, filtered by user_id)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -300,8 +306,10 @@ def summary():
|
||||
SELECT p.*, s.strategy_name, s.initial_capital, s.leverage, s.market_type
|
||||
FROM qd_strategy_positions p
|
||||
LEFT JOIN qd_strategies_trading s ON s.id = p.strategy_id
|
||||
WHERE p.user_id = ?
|
||||
ORDER BY p.updated_at DESC
|
||||
"""
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -332,7 +340,7 @@ def summary():
|
||||
}
|
||||
)
|
||||
|
||||
# Recent trades (best-effort)
|
||||
# Recent trades (best-effort, filtered by user_id)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -340,9 +348,11 @@ def summary():
|
||||
SELECT t.*, s.strategy_name
|
||||
FROM qd_strategy_trades t
|
||||
LEFT JOIN qd_strategies_trading s ON s.id = t.strategy_id
|
||||
WHERE t.user_id = ?
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 500
|
||||
"""
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
recent_trades = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -497,18 +507,20 @@ def summary():
|
||||
|
||||
|
||||
@dashboard_bp.route("/pendingOrders", methods=["GET"])
|
||||
@login_required
|
||||
def pending_orders():
|
||||
"""
|
||||
Return pending orders list for dashboard page.
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
page_size = max(1, min(200, _safe_int(request.args.get("pageSize"), 20)))
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT COUNT(1) AS cnt FROM pending_orders")
|
||||
cur.execute("SELECT COUNT(1) AS cnt FROM pending_orders WHERE user_id = ?", (user_id,))
|
||||
total = int((cur.fetchone() or {}).get("cnt") or 0)
|
||||
cur.close()
|
||||
|
||||
@@ -525,10 +537,11 @@ def pending_orders():
|
||||
s.execution_mode AS strategy_execution_mode
|
||||
FROM pending_orders o
|
||||
LEFT JOIN qd_strategies_trading s ON s.id = o.strategy_id
|
||||
WHERE o.user_id = ?
|
||||
ORDER BY o.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(int(page_size), int(offset)),
|
||||
(user_id, int(page_size), int(offset)),
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -607,18 +620,21 @@ def pending_orders():
|
||||
|
||||
|
||||
@dashboard_bp.route("/pendingOrders/<int:order_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_pending_order(order_id: int):
|
||||
"""
|
||||
Delete a pending order record (dashboard operation).
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
oid = int(order_id or 0)
|
||||
if oid <= 0:
|
||||
return jsonify({"code": 0, "msg": "invalid_id", "data": None}), 400
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT id, status FROM pending_orders WHERE id = %s", (oid,))
|
||||
# Verify the order belongs to current user
|
||||
cur.execute("SELECT id, status FROM pending_orders WHERE id = ? AND user_id = ?", (oid, user_id))
|
||||
row = cur.fetchone() or {}
|
||||
if not row:
|
||||
cur.close()
|
||||
@@ -627,7 +643,7 @@ def delete_pending_order(order_id: int):
|
||||
if st == "processing":
|
||||
cur.close()
|
||||
return jsonify({"code": 0, "msg": "cannot_delete_processing", "data": None}), 400
|
||||
cur.execute("DELETE FROM pending_orders WHERE id = %s", (oid,))
|
||||
cur.execute("DELETE FROM pending_orders WHERE id = ? AND user_id = ?", (oid, user_id))
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
@@ -17,12 +17,13 @@ import time
|
||||
import traceback
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
from flask import Blueprint, Response, jsonify, request, g
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.auth import login_required
|
||||
import requests
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -115,24 +116,21 @@ def _generate_mock_df(length=200):
|
||||
return df
|
||||
|
||||
|
||||
@indicator_bp.route("/getIndicators", methods=["POST"])
|
||||
@indicator_bp.route("/getIndicators", methods=["GET"])
|
||||
@login_required
|
||||
def get_indicators():
|
||||
"""
|
||||
Get indicator list for a user.
|
||||
|
||||
Request:
|
||||
{ userid: number }
|
||||
Get indicator list for the current user.
|
||||
|
||||
Response:
|
||||
{ code: 1, data: [ ... ] }
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get("userid") or 1)
|
||||
user_id = g.user_id
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# Local mode: "我的指标" should include both purchased and custom indicators.
|
||||
# Get user's own indicators (both purchased and custom).
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -156,13 +154,13 @@ def get_indicators():
|
||||
|
||||
|
||||
@indicator_bp.route("/saveIndicator", methods=["POST"])
|
||||
@login_required
|
||||
def save_indicator():
|
||||
"""
|
||||
Create or update an indicator.
|
||||
Create or update an indicator for the current user.
|
||||
|
||||
Request (frontend sends many extra fields; we store only the essentials):
|
||||
{
|
||||
userid: number,
|
||||
id: number (0 for create),
|
||||
name: string,
|
||||
code: string,
|
||||
@@ -172,7 +170,7 @@ def save_indicator():
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get("userid") or 1)
|
||||
user_id = g.user_id
|
||||
indicator_id = int(data.get("id") or 0)
|
||||
code = data.get("code") or ""
|
||||
name = (data.get("name") or "").strip()
|
||||
@@ -199,7 +197,7 @@ def save_indicator():
|
||||
if not name:
|
||||
name = "Custom Indicator"
|
||||
|
||||
now = _now_ts()
|
||||
now = _now_ts() # For BIGINT fields (createtime, updatetime)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -209,10 +207,10 @@ def save_indicator():
|
||||
UPDATE qd_indicator_codes
|
||||
SET name = ?, code = ?, description = ?,
|
||||
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
|
||||
updatetime = ?, updated_at = ?
|
||||
updatetime = ?, updated_at = NOW()
|
||||
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
|
||||
""",
|
||||
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, now, indicator_id, user_id),
|
||||
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
@@ -221,9 +219,9 @@ def save_indicator():
|
||||
(user_id, is_buy, end_time, name, code, description,
|
||||
publish_to_community, pricing_type, price, preview_image,
|
||||
createtime, updatetime, created_at, updated_at)
|
||||
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, now, now, now, now),
|
||||
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, now, now),
|
||||
)
|
||||
indicator_id = int(cur.lastrowid or 0)
|
||||
db.commit()
|
||||
@@ -236,11 +234,12 @@ def save_indicator():
|
||||
|
||||
|
||||
@indicator_bp.route("/deleteIndicator", methods=["POST"])
|
||||
@login_required
|
||||
def delete_indicator():
|
||||
"""Delete an indicator by id."""
|
||||
"""Delete an indicator by id for the current user."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get("userid") or 1)
|
||||
user_id = g.user_id
|
||||
indicator_id = int(data.get("id") or 0)
|
||||
if not indicator_id:
|
||||
return jsonify({"code": 0, "msg": "id is required", "data": None}), 400
|
||||
@@ -261,6 +260,7 @@ def delete_indicator():
|
||||
|
||||
|
||||
@indicator_bp.route("/verifyCode", methods=["POST"])
|
||||
@login_required
|
||||
def verify_code():
|
||||
"""
|
||||
Verify/Dry-run indicator code with mock data.
|
||||
@@ -372,6 +372,7 @@ def verify_code():
|
||||
|
||||
|
||||
@indicator_bp.route("/aiGenerate", methods=["POST"])
|
||||
@login_required
|
||||
def ai_generate():
|
||||
"""
|
||||
SSE endpoint to generate indicator code.
|
||||
|
||||
@@ -14,7 +14,7 @@ kline_bp = Blueprint('kline', __name__)
|
||||
kline_service = KlineService()
|
||||
|
||||
|
||||
@kline_bp.route('/kline', methods=['GET', 'POST'])
|
||||
@kline_bp.route('/kline', methods=['GET'])
|
||||
def get_kline():
|
||||
"""
|
||||
获取K线数据
|
||||
@@ -27,17 +27,12 @@ def get_kline():
|
||||
before_time: 获取此时间之前的数据 (可选,Unix时间戳)
|
||||
"""
|
||||
try:
|
||||
# 支持 GET 和 POST
|
||||
if request.method == 'POST':
|
||||
data = request.get_json() or {}
|
||||
else:
|
||||
data = request.args
|
||||
|
||||
market = data.get('market', 'USStock')
|
||||
symbol = data.get('symbol', '')
|
||||
timeframe = data.get('timeframe', '1D')
|
||||
limit = int(data.get('limit', 300))
|
||||
before_time = data.get('before_time') or data.get('beforeTime')
|
||||
# 强制 GET, 使用 request.args
|
||||
market = request.args.get('market', 'USStock')
|
||||
symbol = request.args.get('symbol', '')
|
||||
timeframe = request.args.get('timeframe', '1D')
|
||||
limit = int(request.args.get('limit', 300))
|
||||
before_time = request.args.get('before_time') or request.args.get('beforeTime')
|
||||
|
||||
if before_time:
|
||||
before_time = int(before_time)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Market API routes (local-only).
|
||||
Provides watchlist, market metadata, symbol search, and pricing helpers for the frontend.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
import traceback
|
||||
import json
|
||||
import time
|
||||
@@ -13,6 +13,7 @@ from app.utils.logger import get_logger
|
||||
from app.utils.cache import CacheManager
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.config_loader import load_addon_config
|
||||
from app.utils.auth import login_required
|
||||
from app.data.market_symbols_seed import (
|
||||
get_hot_symbols as seed_get_hot_symbols,
|
||||
search_symbols as seed_search_symbols,
|
||||
@@ -26,11 +27,9 @@ market_bp = Blueprint('market', __name__)
|
||||
kline_service = KlineService()
|
||||
cache = CacheManager()
|
||||
|
||||
# 线程池用于并行获取价格
|
||||
# Thread pool for parallel price fetching
|
||||
executor = ThreadPoolExecutor(max_workers=10)
|
||||
|
||||
DEFAULT_USER_ID = 1
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
@@ -125,7 +124,7 @@ def get_market_types():
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': data})
|
||||
|
||||
|
||||
@market_bp.route('/menuFooterConfig', methods=['POST'])
|
||||
@market_bp.route('/menuFooterConfig', methods=['GET'])
|
||||
def get_menu_footer_config():
|
||||
"""
|
||||
Compatibility stub for old PHP `getMenuFooterConfig`.
|
||||
@@ -150,17 +149,16 @@ def get_menu_footer_config():
|
||||
}
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': data})
|
||||
|
||||
@market_bp.route('/symbols/search', methods=['POST'])
|
||||
@market_bp.route('/symbols/search', methods=['GET'])
|
||||
def search_symbols():
|
||||
"""
|
||||
Lightweight symbol search.
|
||||
In local-only mode we keep this simple; frontend allows manual input when no results.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
market = (data.get('market') or '').strip()
|
||||
keyword = (data.get('keyword') or '').strip().upper()
|
||||
limit = int(data.get('limit') or 20)
|
||||
market = (request.args.get('market') or '').strip()
|
||||
keyword = (request.args.get('keyword') or '').strip().upper()
|
||||
limit = int(request.args.get('limit') or 20)
|
||||
|
||||
if not market or not keyword:
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': []})
|
||||
@@ -172,29 +170,30 @@ def search_symbols():
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
|
||||
|
||||
@market_bp.route('/symbols/hot', methods=['POST'])
|
||||
@market_bp.route('/symbols/hot', methods=['GET'])
|
||||
def get_hot_symbols():
|
||||
"""Return a small curated hot list per market (local-only)."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
market = (data.get('market') or '').strip()
|
||||
limit = int(data.get('limit') or 10)
|
||||
market = (request.args.get('market') or '').strip()
|
||||
limit = int(request.args.get('limit') or 10)
|
||||
hot = seed_get_hot_symbols(market=market, limit=limit)
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': hot})
|
||||
except Exception as e:
|
||||
logger.error(f"get_hot_symbols failed: {str(e)}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
|
||||
|
||||
@market_bp.route('/watchlist/get', methods=['POST'])
|
||||
@market_bp.route('/watchlist/get', methods=['GET'])
|
||||
@login_required
|
||||
def get_watchlist():
|
||||
"""Get local watchlist for the single user."""
|
||||
"""Get watchlist for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
_ensure_watchlist_table()
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, market, symbol, name FROM qd_watchlist WHERE user_id = ? ORDER BY id DESC",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
|
||||
@@ -213,8 +212,8 @@ def get_watchlist():
|
||||
if resolved and resolved != current_name:
|
||||
row['name'] = resolved
|
||||
cur.execute(
|
||||
"UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?",
|
||||
(resolved, _now_ts(), DEFAULT_USER_ID, market, symbol)
|
||||
"UPDATE qd_watchlist SET name = ?, updated_at = NOW() WHERE user_id = ? AND market = ? AND symbol = ?",
|
||||
(resolved, user_id, market, symbol)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -227,9 +226,11 @@ def get_watchlist():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
|
||||
|
||||
@market_bp.route('/watchlist/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_watchlist():
|
||||
"""Add a symbol to local watchlist (no credits/fees in local mode)."""
|
||||
"""Add a symbol to watchlist for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
market = (data.get('market') or '').strip()
|
||||
symbol = _normalize_symbol(data.get('symbol'))
|
||||
@@ -241,18 +242,18 @@ def add_watchlist():
|
||||
resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol)
|
||||
name = name_in or resolved or symbol
|
||||
|
||||
now = _now_ts()
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# Insert or ignore duplicates.
|
||||
# Insert or update (PostgreSQL UPSERT)
|
||||
cur.execute(
|
||||
"INSERT OR IGNORE INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(DEFAULT_USER_ID, market, symbol, name, now, now)
|
||||
)
|
||||
# If already exists, keep it fresh and sync name.
|
||||
cur.execute(
|
||||
"UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?",
|
||||
(name, now, DEFAULT_USER_ID, market, symbol)
|
||||
"""
|
||||
INSERT INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NOW(), NOW())
|
||||
ON CONFLICT(user_id, market, symbol) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(user_id, market, symbol, name)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -264,9 +265,11 @@ def add_watchlist():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
@market_bp.route('/watchlist/remove', methods=['POST'])
|
||||
@login_required
|
||||
def remove_watchlist():
|
||||
"""Remove a symbol from watchlist. Frontend only passes symbol, so we delete across markets."""
|
||||
"""Remove a symbol from watchlist for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
symbol = _normalize_symbol(data.get('symbol'))
|
||||
if not symbol:
|
||||
@@ -276,7 +279,7 @@ def remove_watchlist():
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_watchlist WHERE user_id = ? AND symbol = ?",
|
||||
(DEFAULT_USER_ID, symbol)
|
||||
(user_id, symbol)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -290,51 +293,17 @@ def remove_watchlist():
|
||||
def get_single_price(market: str, symbol: str) -> dict:
|
||||
"""获取单个标的的价格数据"""
|
||||
try:
|
||||
# 先尝试从缓存获取(60秒缓存)
|
||||
cache_key = f"watchlist_price:{market}:{symbol}"
|
||||
cached_data = cache.get(cache_key)
|
||||
# 使用 get_realtime_price 获取实时价格(内部已有30秒缓存)
|
||||
# 相比原先的 '1D' K线逻辑,这能更及时地反映 Crypto 等 24h 市场的变化
|
||||
price_data = kline_service.get_realtime_price(market, symbol)
|
||||
|
||||
if cached_data:
|
||||
logger.debug(f"Cache hit: {market}:{symbol}")
|
||||
return {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': cached_data.get('price', 0),
|
||||
'change': cached_data.get('change', 0),
|
||||
'changePercent': cached_data.get('changePercent', 0)
|
||||
}
|
||||
|
||||
# 获取最新的一根K线
|
||||
klines = kline_service.get_kline(market, symbol, '1D', 2)
|
||||
|
||||
if klines and len(klines) > 0:
|
||||
latest = klines[-1]
|
||||
prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0)
|
||||
current_price = latest.get('close', 0)
|
||||
|
||||
change = round(current_price - prev_close, 4) if prev_close else 0
|
||||
change_percent = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0
|
||||
|
||||
result = {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': current_price,
|
||||
'change': change,
|
||||
'changePercent': change_percent
|
||||
}
|
||||
|
||||
# 缓存60秒
|
||||
cache.set(cache_key, result, 60)
|
||||
|
||||
return result
|
||||
else:
|
||||
return {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': 0,
|
||||
'change': 0,
|
||||
'changePercent': 0
|
||||
}
|
||||
return {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': price_data.get('price', 0),
|
||||
'change': price_data.get('change', 0),
|
||||
'changePercent': price_data.get('changePercent', 0)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}")
|
||||
return {
|
||||
@@ -346,45 +315,26 @@ def get_single_price(market: str, symbol: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@market_bp.route('/watchlist/prices', methods=['POST'])
|
||||
@market_bp.route('/watchlist/prices', methods=['GET'])
|
||||
def get_watchlist_prices():
|
||||
"""
|
||||
批量获取自选股价格
|
||||
|
||||
请求体:
|
||||
{
|
||||
"watchlist": [
|
||||
{"market": "USStock", "symbol": "AAPL"},
|
||||
{"market": "Crypto", "symbol": "BTC"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
响应:
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{"market": "USStock", "symbol": "AAPL", "price": 150.0, "change": 1.5, "changePercent": 1.0},
|
||||
{"market": "Crypto", "symbol": "BTC", "price": 95000.0, "change": 1000, "changePercent": 1.05}
|
||||
]
|
||||
}
|
||||
Params (Query String):
|
||||
watchlist: JSON string of list of {market, symbol} objects
|
||||
e.g. ?watchlist=[{"market":"USStock","symbol":"AAPL"}]
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Request body is required',
|
||||
'data': []
|
||||
}), 400
|
||||
|
||||
watchlist = data.get('watchlist', [])
|
||||
watchlist_str = request.args.get('watchlist', '[]')
|
||||
try:
|
||||
watchlist = json.loads(watchlist_str)
|
||||
except Exception:
|
||||
watchlist = []
|
||||
|
||||
if not watchlist or not isinstance(watchlist, list):
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Invalid watchlist format',
|
||||
'msg': 'Invalid watchlist format (expected JSON list in query param)',
|
||||
'data': []
|
||||
}), 400
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Portfolio API routes (local-only).
|
||||
Manages manual positions (user's existing holdings) and AI monitoring tasks.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
import json
|
||||
import traceback
|
||||
import time
|
||||
@@ -13,6 +13,7 @@ from app.services.kline import KlineService
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.cache import CacheManager
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.auth import login_required
|
||||
from app.services.symbol_name import resolve_symbol_name
|
||||
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
|
||||
|
||||
@@ -23,18 +24,16 @@ kline_service = KlineService()
|
||||
cache = CacheManager()
|
||||
|
||||
# Thread pool for parallel price fetching
|
||||
# 降低并发数避免触发API限制(尤其是外汇/美股等有速率限制的API)
|
||||
# Lower concurrency to avoid triggering API limits (especially for forex/US stocks)
|
||||
executor = ThreadPoolExecutor(max_workers=3)
|
||||
|
||||
# 请求间隔(秒),避免请求过快
|
||||
# Request interval (seconds) to avoid too frequent requests
|
||||
REQUEST_INTERVAL = 0.3
|
||||
|
||||
# 速率限制相关
|
||||
# Rate limiting related
|
||||
_request_lock = threading.Lock()
|
||||
_last_request_time = {} # {market: timestamp}
|
||||
|
||||
DEFAULT_USER_ID = 1
|
||||
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
@@ -109,10 +108,12 @@ def _get_single_price(market: str, symbol: str, force_refresh: bool = False) ->
|
||||
# ==================== Position CRUD ====================
|
||||
|
||||
@portfolio_bp.route('/positions', methods=['GET'])
|
||||
@login_required
|
||||
def get_positions():
|
||||
"""Get all manual positions with current prices."""
|
||||
"""Get all manual positions with current prices for the current user."""
|
||||
try:
|
||||
# 检查是否强制刷新(跳过缓存)
|
||||
user_id = g.user_id
|
||||
# Check if force refresh (skip cache)
|
||||
force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes')
|
||||
|
||||
with get_db_connection() as db:
|
||||
@@ -124,7 +125,7 @@ def get_positions():
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -212,9 +213,11 @@ def get_positions():
|
||||
|
||||
|
||||
@portfolio_bp.route('/positions', methods=['POST'])
|
||||
@login_required
|
||||
def add_position():
|
||||
"""Add a new manual position."""
|
||||
"""Add a new manual position for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
market = (data.get('market') or '').strip()
|
||||
symbol = _normalize_symbol(data.get('symbol'))
|
||||
@@ -244,7 +247,6 @@ def add_position():
|
||||
name = name_in or resolved or symbol
|
||||
|
||||
tags_json = json.dumps(tags if isinstance(tags, list) else [], ensure_ascii=False)
|
||||
now = _now_ts()
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -252,7 +254,7 @@ def add_position():
|
||||
"""
|
||||
INSERT INTO qd_manual_positions
|
||||
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
ON CONFLICT(user_id, market, symbol, side) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
quantity = excluded.quantity,
|
||||
@@ -261,9 +263,9 @@ def add_position():
|
||||
notes = excluded.notes,
|
||||
tags = excluded.tags,
|
||||
group_name = excluded.group_name,
|
||||
updated_at = excluded.updated_at
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(DEFAULT_USER_ID, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name, now, now)
|
||||
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name)
|
||||
)
|
||||
position_id = cur.lastrowid
|
||||
db.commit()
|
||||
@@ -277,9 +279,11 @@ def add_position():
|
||||
|
||||
|
||||
@portfolio_bp.route('/positions/<int:position_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_position(position_id):
|
||||
"""Update an existing position."""
|
||||
"""Update an existing position for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
|
||||
updates = []
|
||||
@@ -323,10 +327,9 @@ def update_position(position_id):
|
||||
if not updates:
|
||||
return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400
|
||||
|
||||
updates.append('updated_at = ?')
|
||||
params.append(_now_ts())
|
||||
updates.append('updated_at = NOW()')
|
||||
params.append(position_id)
|
||||
params.append(DEFAULT_USER_ID)
|
||||
params.append(user_id)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -345,14 +348,16 @@ def update_position(position_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/positions/<int:position_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_position(position_id):
|
||||
"""Delete a position."""
|
||||
"""Delete a position for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?",
|
||||
(position_id, DEFAULT_USER_ID)
|
||||
(position_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -365,10 +370,12 @@ def delete_position(position_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/summary', methods=['GET'])
|
||||
@login_required
|
||||
def get_portfolio_summary():
|
||||
"""Get portfolio summary with total value, PnL, and market distribution."""
|
||||
"""Get portfolio summary with total value, PnL, and market distribution for the current user."""
|
||||
try:
|
||||
# 检查是否强制刷新
|
||||
user_id = g.user_id
|
||||
# Check if force refresh
|
||||
force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes')
|
||||
|
||||
with get_db_connection() as db:
|
||||
@@ -379,7 +386,7 @@ def get_portfolio_summary():
|
||||
FROM qd_manual_positions
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -485,9 +492,11 @@ def get_portfolio_summary():
|
||||
# ==================== Monitor CRUD ====================
|
||||
|
||||
@portfolio_bp.route('/monitors', methods=['GET'])
|
||||
@login_required
|
||||
def get_monitors():
|
||||
"""Get all position monitors."""
|
||||
"""Get all position monitors for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -498,7 +507,7 @@ def get_monitors():
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -529,9 +538,11 @@ def get_monitors():
|
||||
|
||||
|
||||
@portfolio_bp.route('/monitors', methods=['POST'])
|
||||
@login_required
|
||||
def add_monitor():
|
||||
"""Add a new position monitor."""
|
||||
"""Add a new position monitor for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
position_ids = data.get('position_ids') or []
|
||||
@@ -547,9 +558,7 @@ def add_monitor():
|
||||
monitor_type = 'ai'
|
||||
|
||||
# Calculate next_run_at based on interval
|
||||
now = _now_ts()
|
||||
interval_minutes = int(config.get('interval_minutes') or 60)
|
||||
next_run_at = now + (interval_minutes * 60)
|
||||
|
||||
position_ids_json = json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False)
|
||||
config_json = json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False)
|
||||
@@ -561,10 +570,10 @@ def add_monitor():
|
||||
"""
|
||||
INSERT INTO qd_position_monitors
|
||||
(user_id, name, position_ids, monitor_type, config, notification_config, is_active, next_run_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW() + INTERVAL '%s minutes', NOW(), NOW())
|
||||
""",
|
||||
(DEFAULT_USER_ID, name, position_ids_json, monitor_type, config_json, notification_config_json,
|
||||
1 if is_active else 0, next_run_at, now, now)
|
||||
(user_id, name, position_ids_json, monitor_type, config_json, notification_config_json,
|
||||
1 if is_active else 0, interval_minutes)
|
||||
)
|
||||
monitor_id = cur.lastrowid
|
||||
db.commit()
|
||||
@@ -578,9 +587,11 @@ def add_monitor():
|
||||
|
||||
|
||||
@portfolio_bp.route('/monitors/<int:monitor_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_monitor(monitor_id):
|
||||
"""Update an existing monitor."""
|
||||
"""Update an existing monitor for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
|
||||
updates = []
|
||||
@@ -599,15 +610,14 @@ def update_monitor(monitor_id):
|
||||
updates.append('monitor_type = ?')
|
||||
params.append((data.get('monitor_type') or 'ai').strip())
|
||||
|
||||
next_run_interval = None # Will store interval for special handling
|
||||
if 'config' in data:
|
||||
config = data.get('config') or {}
|
||||
updates.append('config = ?')
|
||||
params.append(json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False))
|
||||
|
||||
# Recalculate next_run_at if interval changed
|
||||
interval_minutes = int(config.get('interval_minutes') or 60)
|
||||
updates.append('next_run_at = ?')
|
||||
params.append(_now_ts() + (interval_minutes * 60))
|
||||
# Recalculate next_run_at if interval changed (handled separately for PostgreSQL)
|
||||
next_run_interval = int(config.get('interval_minutes') or 60)
|
||||
|
||||
if 'notification_config' in data:
|
||||
notification_config = data.get('notification_config') or {}
|
||||
@@ -621,10 +631,13 @@ def update_monitor(monitor_id):
|
||||
if not updates:
|
||||
return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400
|
||||
|
||||
updates.append('updated_at = ?')
|
||||
params.append(_now_ts())
|
||||
# Add next_run_at update if interval was changed
|
||||
if next_run_interval is not None:
|
||||
updates.append(f"next_run_at = NOW() + INTERVAL '{next_run_interval} minutes'")
|
||||
|
||||
updates.append('updated_at = NOW()')
|
||||
params.append(monitor_id)
|
||||
params.append(DEFAULT_USER_ID)
|
||||
params.append(user_id)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -643,14 +656,16 @@ def update_monitor(monitor_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/monitors/<int:monitor_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_monitor(monitor_id):
|
||||
"""Delete a monitor."""
|
||||
"""Delete a monitor for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?",
|
||||
(monitor_id, DEFAULT_USER_ID)
|
||||
(monitor_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -663,6 +678,7 @@ def delete_monitor(monitor_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/monitors/<int:monitor_id>/run', methods=['POST'])
|
||||
@login_required
|
||||
def run_monitor_now(monitor_id):
|
||||
"""Manually trigger a monitor to run immediately."""
|
||||
try:
|
||||
@@ -692,9 +708,11 @@ def run_monitor_now(monitor_id):
|
||||
# ==================== Alerts CRUD ====================
|
||||
|
||||
@portfolio_bp.route('/alerts', methods=['GET'])
|
||||
@login_required
|
||||
def get_alerts():
|
||||
"""Get all position alerts."""
|
||||
"""Get all position alerts for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -708,7 +726,7 @@ def get_alerts():
|
||||
WHERE a.user_id = ?
|
||||
ORDER BY a.id DESC
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -743,9 +761,11 @@ def get_alerts():
|
||||
|
||||
|
||||
@portfolio_bp.route('/alerts', methods=['POST'])
|
||||
@login_required
|
||||
def add_alert():
|
||||
"""Add a new position alert."""
|
||||
"""Add a new position alert for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
position_id = data.get('position_id') # Can be None for symbol-level alerts
|
||||
market = (data.get('market') or '').strip()
|
||||
@@ -768,7 +788,7 @@ def add_alert():
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT market, symbol FROM qd_manual_positions WHERE id = ? AND user_id = ?",
|
||||
(position_id, DEFAULT_USER_ID)
|
||||
(position_id, user_id)
|
||||
)
|
||||
pos = cur.fetchone()
|
||||
cur.close()
|
||||
@@ -782,7 +802,6 @@ def add_alert():
|
||||
if threshold <= 0 and alert_type.startswith('price_'):
|
||||
return jsonify({'code': 0, 'msg': 'Threshold must be positive for price alerts', 'data': None}), 400
|
||||
|
||||
now = _now_ts()
|
||||
notification_config_json = json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False)
|
||||
|
||||
with get_db_connection() as db:
|
||||
@@ -793,7 +812,7 @@ def add_alert():
|
||||
if position_id:
|
||||
cur.execute(
|
||||
"SELECT id FROM qd_position_alerts WHERE position_id = ? AND user_id = ?",
|
||||
(position_id, DEFAULT_USER_ID)
|
||||
(position_id, user_id)
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
@@ -805,11 +824,11 @@ def add_alert():
|
||||
"""
|
||||
UPDATE qd_position_alerts
|
||||
SET alert_type = ?, threshold = ?, notification_config = ?,
|
||||
is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = ?
|
||||
is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(alert_type, threshold, notification_config_json,
|
||||
1 if is_active else 0, repeat_interval, notes, now, existing_alert_id)
|
||||
1 if is_active else 0, repeat_interval, notes, existing_alert_id)
|
||||
)
|
||||
alert_id = existing_alert_id
|
||||
else:
|
||||
@@ -819,10 +838,10 @@ def add_alert():
|
||||
INSERT INTO qd_position_alerts
|
||||
(user_id, position_id, market, symbol, alert_type, threshold, notification_config,
|
||||
is_active, repeat_interval, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(DEFAULT_USER_ID, position_id, market, symbol, alert_type, threshold, notification_config_json,
|
||||
1 if is_active else 0, repeat_interval, notes, now, now)
|
||||
(user_id, position_id, market, symbol, alert_type, threshold, notification_config_json,
|
||||
1 if is_active else 0, repeat_interval, notes)
|
||||
)
|
||||
alert_id = cur.lastrowid
|
||||
|
||||
@@ -837,9 +856,11 @@ def add_alert():
|
||||
|
||||
|
||||
@portfolio_bp.route('/alerts/<int:alert_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_alert(alert_id):
|
||||
"""Update an existing alert."""
|
||||
"""Update an existing alert for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
|
||||
updates = []
|
||||
@@ -877,10 +898,9 @@ def update_alert(alert_id):
|
||||
if not updates:
|
||||
return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400
|
||||
|
||||
updates.append('updated_at = ?')
|
||||
params.append(_now_ts())
|
||||
updates.append('updated_at = NOW()')
|
||||
params.append(alert_id)
|
||||
params.append(DEFAULT_USER_ID)
|
||||
params.append(user_id)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -899,14 +919,16 @@ def update_alert(alert_id):
|
||||
|
||||
|
||||
@portfolio_bp.route('/alerts/<int:alert_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_alert(alert_id):
|
||||
"""Delete an alert."""
|
||||
"""Delete an alert for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?",
|
||||
(alert_id, DEFAULT_USER_ID)
|
||||
(alert_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -921,9 +943,11 @@ def delete_alert(alert_id):
|
||||
# ==================== Groups ====================
|
||||
|
||||
@portfolio_bp.route('/groups', methods=['GET'])
|
||||
@login_required
|
||||
def get_groups():
|
||||
"""Get list of all groups with position counts."""
|
||||
"""Get list of all groups with position counts for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -934,14 +958,14 @@ def get_groups():
|
||||
GROUP BY group_name
|
||||
ORDER BY group_name
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
|
||||
# Also get count of ungrouped
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as count FROM qd_manual_positions WHERE user_id = ? AND (group_name IS NULL OR group_name = '')",
|
||||
(DEFAULT_USER_ID,)
|
||||
(user_id,)
|
||||
)
|
||||
ungrouped = cur.fetchone()
|
||||
cur.close()
|
||||
@@ -971,9 +995,11 @@ def get_groups():
|
||||
|
||||
|
||||
@portfolio_bp.route('/groups/rename', methods=['POST'])
|
||||
@login_required
|
||||
def rename_group():
|
||||
"""Rename a group."""
|
||||
"""Rename a group for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json() or {}
|
||||
old_name = (data.get('old_name') or '').strip()
|
||||
new_name = (data.get('new_name') or '').strip()
|
||||
@@ -981,12 +1007,11 @@ def rename_group():
|
||||
if not old_name:
|
||||
return jsonify({'code': 0, 'msg': 'old_name is required', 'data': None}), 400
|
||||
|
||||
now = _now_ts()
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"UPDATE qd_manual_positions SET group_name = ?, updated_at = ? WHERE user_id = ? AND group_name = ?",
|
||||
(new_name, now, DEFAULT_USER_ID, old_name)
|
||||
"UPDATE qd_manual_positions SET group_name = ?, updated_at = NOW() WHERE user_id = ? AND group_name = ?",
|
||||
(new_name, user_id, old_name)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
Settings API - 读取和保存 .env 配置
|
||||
|
||||
Admin-only endpoints for system configuration management.
|
||||
"""
|
||||
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
|
||||
from app.utils.auth import login_required, admin_required
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -791,8 +794,10 @@ def write_env_file(env_values):
|
||||
|
||||
|
||||
@settings_bp.route('/schema', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_settings_schema():
|
||||
"""获取配置项定义"""
|
||||
"""获取配置项定义 (admin only)"""
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
@@ -801,8 +806,10 @@ def get_settings_schema():
|
||||
|
||||
|
||||
@settings_bp.route('/values', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_settings_values():
|
||||
"""获取当前配置值 - 包括敏感信息(真实值)"""
|
||||
"""获取当前配置值 - 包括敏感信息(真实值)(admin only)"""
|
||||
env_values = read_env_file()
|
||||
|
||||
# 构建返回数据,返回真实值
|
||||
@@ -825,8 +832,10 @@ def get_settings_values():
|
||||
|
||||
|
||||
@settings_bp.route('/save', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def save_settings():
|
||||
"""保存配置"""
|
||||
"""保存配置 (admin only)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
@@ -878,8 +887,10 @@ def save_settings():
|
||||
|
||||
|
||||
@settings_bp.route('/openrouter-balance', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_openrouter_balance():
|
||||
"""查询 OpenRouter 账户余额"""
|
||||
"""查询 OpenRouter 账户余额 (admin only)"""
|
||||
try:
|
||||
import requests
|
||||
from app.config.api_keys import APIKeys
|
||||
@@ -954,8 +965,10 @@ def get_openrouter_balance():
|
||||
|
||||
|
||||
@settings_bp.route('/test-connection', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def test_connection():
|
||||
"""测试API连接"""
|
||||
"""测试API连接 (admin only)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
service = data.get('service')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
交易策略 API 路由
|
||||
Trading Strategy API Routes
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
import traceback
|
||||
import time
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.services.backtest import BacktestService
|
||||
from app import get_trading_executor
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.auth import login_required
|
||||
from app.data_sources import DataSourceFactory
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -29,12 +30,13 @@ def get_strategy_service() -> StrategyService:
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies', methods=['GET'])
|
||||
@login_required
|
||||
def list_strategies():
|
||||
"""
|
||||
策略列表(本地版:单用户)
|
||||
List strategies for the current user.
|
||||
"""
|
||||
try:
|
||||
user_id = request.args.get('user_id', type=int) or 1
|
||||
user_id = g.user_id
|
||||
items = get_strategy_service().list_strategies(user_id=user_id)
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'strategies': items}})
|
||||
except Exception as e:
|
||||
@@ -44,12 +46,14 @@ def list_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/detail', methods=['GET'])
|
||||
@login_required
|
||||
def get_strategy_detail():
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400
|
||||
st = get_strategy_service().get_strategy(strategy_id)
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': st})
|
||||
@@ -60,11 +64,13 @@ def get_strategy_detail():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_strategy():
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
# Local mode default user
|
||||
payload['user_id'] = int(payload.get('user_id') or 1)
|
||||
# Use current user's ID
|
||||
payload['user_id'] = user_id
|
||||
payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy'
|
||||
new_id = get_strategy_service().create_strategy(payload)
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}})
|
||||
@@ -75,18 +81,20 @@ def create_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/batch-create', methods=['POST'])
|
||||
@login_required
|
||||
def batch_create_strategies():
|
||||
"""
|
||||
批量创建策略(多币种)
|
||||
Batch create strategies (multiple symbols)
|
||||
|
||||
请求体:
|
||||
strategy_name: 策略基础名称
|
||||
symbols: 币种数组,如 ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
|
||||
... 其他策略配置
|
||||
Request body:
|
||||
strategy_name: Base strategy name
|
||||
symbols: Array of symbols, e.g. ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
|
||||
... other strategy config
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
payload['user_id'] = int(payload.get('user_id') or 1)
|
||||
payload['user_id'] = user_id
|
||||
payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy'
|
||||
|
||||
result = get_strategy_service().batch_create_strategies(payload)
|
||||
@@ -94,13 +102,13 @@ def batch_create_strategies():
|
||||
if result['success']:
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': f"成功创建 {result['total_created']} 个策略",
|
||||
'msg': f"Successfully created {result['total_created']} strategies",
|
||||
'data': result
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': '批量创建失败',
|
||||
'msg': 'Batch creation failed',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -110,31 +118,33 @@ def batch_create_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/batch-start', methods=['POST'])
|
||||
@login_required
|
||||
def batch_start_strategies():
|
||||
"""
|
||||
批量启动策略
|
||||
Batch start strategies
|
||||
|
||||
请求体:
|
||||
strategy_ids: 策略ID数组
|
||||
或
|
||||
strategy_group_id: 策略组ID
|
||||
Request body:
|
||||
strategy_ids: Array of strategy IDs
|
||||
or
|
||||
strategy_group_id: Strategy group ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
strategy_ids = payload.get('strategy_ids') or []
|
||||
strategy_group_id = payload.get('strategy_group_id')
|
||||
|
||||
# 如果提供了策略组ID,获取组内所有策略
|
||||
# If strategy_group_id provided, get all strategies in the group
|
||||
if strategy_group_id and not strategy_ids:
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id)
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id)
|
||||
|
||||
if not strategy_ids:
|
||||
return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400
|
||||
return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400
|
||||
|
||||
# 先更新数据库状态
|
||||
result = get_strategy_service().batch_start_strategies(strategy_ids)
|
||||
# Update database status first
|
||||
result = get_strategy_service().batch_start_strategies(strategy_ids, user_id=user_id)
|
||||
|
||||
# 然后启动执行器
|
||||
# Then start executor
|
||||
executor = get_trading_executor()
|
||||
for sid in result.get('success_ids', []):
|
||||
try:
|
||||
@@ -144,7 +154,7 @@ def batch_start_strategies():
|
||||
|
||||
return jsonify({
|
||||
'code': 1 if result['success'] else 0,
|
||||
'msg': f"成功启动 {len(result.get('success_ids', []))} 个策略",
|
||||
'msg': f"Successfully started {len(result.get('success_ids', []))} strategies",
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -154,27 +164,29 @@ def batch_start_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/batch-stop', methods=['POST'])
|
||||
@login_required
|
||||
def batch_stop_strategies():
|
||||
"""
|
||||
批量停止策略
|
||||
Batch stop strategies
|
||||
|
||||
请求体:
|
||||
strategy_ids: 策略ID数组
|
||||
或
|
||||
strategy_group_id: 策略组ID
|
||||
Request body:
|
||||
strategy_ids: Array of strategy IDs
|
||||
or
|
||||
strategy_group_id: Strategy group ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
strategy_ids = payload.get('strategy_ids') or []
|
||||
strategy_group_id = payload.get('strategy_group_id')
|
||||
|
||||
if strategy_group_id and not strategy_ids:
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id)
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id)
|
||||
|
||||
if not strategy_ids:
|
||||
return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400
|
||||
return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400
|
||||
|
||||
# 先停止执行器
|
||||
# Stop executor first
|
||||
executor = get_trading_executor()
|
||||
for sid in strategy_ids:
|
||||
try:
|
||||
@@ -182,12 +194,12 @@ def batch_stop_strategies():
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop executor for strategy {sid}: {e}")
|
||||
|
||||
# 然后更新数据库状态
|
||||
result = get_strategy_service().batch_stop_strategies(strategy_ids)
|
||||
# Then update database status
|
||||
result = get_strategy_service().batch_stop_strategies(strategy_ids, user_id=user_id)
|
||||
|
||||
return jsonify({
|
||||
'code': 1 if result['success'] else 0,
|
||||
'msg': f"成功停止 {len(result.get('success_ids', []))} 个策略",
|
||||
'msg': f"Successfully stopped {len(result.get('success_ids', []))} strategies",
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -197,40 +209,42 @@ def batch_stop_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/batch-delete', methods=['DELETE'])
|
||||
@login_required
|
||||
def batch_delete_strategies():
|
||||
"""
|
||||
批量删除策略
|
||||
Batch delete strategies
|
||||
|
||||
请求体:
|
||||
strategy_ids: 策略ID数组
|
||||
或
|
||||
strategy_group_id: 策略组ID
|
||||
Request body:
|
||||
strategy_ids: Array of strategy IDs
|
||||
or
|
||||
strategy_group_id: Strategy group ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
payload = request.get_json() or {}
|
||||
strategy_ids = payload.get('strategy_ids') or []
|
||||
strategy_group_id = payload.get('strategy_group_id')
|
||||
|
||||
if strategy_group_id and not strategy_ids:
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id)
|
||||
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id)
|
||||
|
||||
if not strategy_ids:
|
||||
return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400
|
||||
return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400
|
||||
|
||||
# 先停止执行器
|
||||
# Stop executor first
|
||||
executor = get_trading_executor()
|
||||
for sid in strategy_ids:
|
||||
try:
|
||||
executor.stop_strategy(sid)
|
||||
except Exception as e:
|
||||
pass # 忽略停止错误
|
||||
pass # Ignore stop errors
|
||||
|
||||
# 然后删除
|
||||
result = get_strategy_service().batch_delete_strategies(strategy_ids)
|
||||
# Then delete
|
||||
result = get_strategy_service().batch_delete_strategies(strategy_ids, user_id=user_id)
|
||||
|
||||
return jsonify({
|
||||
'code': 1 if result['success'] else 0,
|
||||
'msg': f"成功删除 {len(result.get('success_ids', []))} 个策略",
|
||||
'msg': f"Successfully deleted {len(result.get('success_ids', []))} strategies",
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -240,13 +254,15 @@ def batch_delete_strategies():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/update', methods=['PUT'])
|
||||
@login_required
|
||||
def update_strategy():
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400
|
||||
payload = request.get_json() or {}
|
||||
ok = get_strategy_service().update_strategy(strategy_id, payload)
|
||||
ok = get_strategy_service().update_strategy(strategy_id, payload, user_id=user_id)
|
||||
if not ok:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': None})
|
||||
@@ -257,12 +273,14 @@ def update_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_strategy():
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400
|
||||
ok = get_strategy_service().delete_strategy(strategy_id)
|
||||
ok = get_strategy_service().delete_strategy(strategy_id, user_id=user_id)
|
||||
return jsonify({'code': 1 if ok else 0, 'msg': 'success' if ok else 'failed', 'data': None})
|
||||
except Exception as e:
|
||||
logger.error(f"delete_strategy failed: {str(e)}")
|
||||
@@ -271,12 +289,20 @@ def delete_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/trades', methods=['GET'])
|
||||
@login_required
|
||||
def get_trades():
|
||||
"""交易记录(从本地 SQLite 读取)"""
|
||||
"""Get trade records for the current user's strategy."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'trades': [], 'items': []}}), 400
|
||||
|
||||
# Verify strategy belongs to user
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': {'trades': [], 'items': []}}), 404
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -299,12 +325,20 @@ def get_trades():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/positions', methods=['GET'])
|
||||
@login_required
|
||||
def get_positions():
|
||||
"""持仓记录(从本地 SQLite 读取)"""
|
||||
"""Get position records for the current user's strategy."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'positions': [], 'items': []}}), 400
|
||||
|
||||
# Verify strategy belongs to user
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': {'positions': [], 'items': []}}), 404
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -382,10 +416,10 @@ def get_positions():
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_strategy_positions
|
||||
SET current_price = ?, unrealized_pnl = ?, pnl_percent = ?, updated_at = ?
|
||||
SET current_price = ?, unrealized_pnl = ?, pnl_percent = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(float(cp or 0.0), float(pnl), float(pct), int(now), int(rr.get("id"))),
|
||||
(float(cp or 0.0), float(pnl), float(pct), int(rr.get("id"))),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -400,14 +434,18 @@ def get_positions():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/equityCurve', methods=['GET'])
|
||||
@login_required
|
||||
def get_equity_curve():
|
||||
"""净值曲线(本地简单计算:initial_capital + 累计 profit)"""
|
||||
"""Get equity curve for the current user's strategy."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': []}), 400
|
||||
|
||||
st = get_strategy_service().get_strategy(strategy_id) or {}
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) or {}
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': []}), 404
|
||||
initial = float(st.get('initial_capital') or (st.get('trading_config') or {}).get('initial_capital') or 0)
|
||||
if initial <= 0:
|
||||
initial = 1000.0
|
||||
@@ -447,14 +485,16 @@ def get_equity_curve():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/stop', methods=['POST'])
|
||||
@login_required
|
||||
def stop_strategy():
|
||||
"""
|
||||
停止策略
|
||||
Stop a strategy for the current user.
|
||||
|
||||
参数:
|
||||
id: 策略ID
|
||||
Params:
|
||||
id: Strategy ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
|
||||
if not strategy_id:
|
||||
@@ -464,18 +504,23 @@ def stop_strategy():
|
||||
'data': None
|
||||
}), 400
|
||||
|
||||
# 获取策略类型
|
||||
# Verify strategy belongs to user
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
|
||||
|
||||
# Get strategy type
|
||||
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
|
||||
|
||||
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
|
||||
if strategy_type == 'PromptBasedStrategy':
|
||||
return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting/stopping AI strategies', 'data': None}), 400
|
||||
|
||||
# 指标策略
|
||||
# Indicator strategy
|
||||
get_trading_executor().stop_strategy(strategy_id)
|
||||
|
||||
# 更新策略状态
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'stopped')
|
||||
# Update strategy status
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'stopped', user_id=user_id)
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
@@ -494,14 +539,16 @@ def stop_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/start', methods=['POST'])
|
||||
@login_required
|
||||
def start_strategy():
|
||||
"""
|
||||
启动策略
|
||||
Start a strategy for the current user.
|
||||
|
||||
参数:
|
||||
id: 策略ID
|
||||
Params:
|
||||
id: Strategy ID
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
|
||||
if not strategy_id:
|
||||
@@ -511,22 +558,27 @@ def start_strategy():
|
||||
'data': None
|
||||
}), 400
|
||||
|
||||
# 获取策略类型
|
||||
# Verify strategy belongs to user
|
||||
st = get_strategy_service().get_strategy(strategy_id, user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
|
||||
|
||||
# Get strategy type
|
||||
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
|
||||
|
||||
# 更新策略状态
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'running')
|
||||
# Update strategy status
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'running', user_id=user_id)
|
||||
|
||||
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
|
||||
if strategy_type == 'PromptBasedStrategy':
|
||||
return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting AI strategies', 'data': None}), 400
|
||||
|
||||
# 指标策略
|
||||
# Indicator strategy
|
||||
success = get_trading_executor().start_strategy(strategy_id)
|
||||
|
||||
if not success:
|
||||
# 如果启动失败,恢复状态
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'stopped')
|
||||
# If start failed, restore status
|
||||
get_strategy_service().update_strategy_status(strategy_id, 'stopped', user_id=user_id)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Failed to start strategy executor',
|
||||
@@ -550,12 +602,13 @@ def start_strategy():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/test-connection', methods=['POST'])
|
||||
@login_required
|
||||
def test_connection():
|
||||
"""
|
||||
测试交易所连接
|
||||
Test exchange connection.
|
||||
|
||||
请求体:
|
||||
exchange_config: 交易所配置
|
||||
Request body:
|
||||
exchange_config: Exchange configuration
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
@@ -619,12 +672,13 @@ def test_connection():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/get-symbols', methods=['POST'])
|
||||
@login_required
|
||||
def get_symbols():
|
||||
"""
|
||||
获取交易所交易对列表
|
||||
Get exchange trading pairs list.
|
||||
|
||||
请求体:
|
||||
exchange_config: 交易所配置
|
||||
Request body:
|
||||
exchange_config: Exchange configuration
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
@@ -662,9 +716,10 @@ def get_symbols():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/preview-compile', methods=['POST'])
|
||||
@login_required
|
||||
def preview_compile():
|
||||
"""
|
||||
预览编译后的策略结果
|
||||
Preview compiled strategy result.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
@@ -708,9 +763,10 @@ def preview_compile():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/notifications', methods=['GET'])
|
||||
@login_required
|
||||
def get_strategy_notifications():
|
||||
"""
|
||||
Strategy signal notifications (browser channel persistence).
|
||||
Strategy signal notifications for the current user.
|
||||
|
||||
Query:
|
||||
- id: strategy id (optional)
|
||||
@@ -718,16 +774,39 @@ def get_strategy_notifications():
|
||||
- since_id: return rows with id > since_id (optional)
|
||||
"""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id', type=int)
|
||||
limit = request.args.get('limit', type=int) or 50
|
||||
limit = max(1, min(200, int(limit)))
|
||||
since_id = request.args.get('since_id', type=int) or 0
|
||||
|
||||
# Get user's strategy IDs for filtering notifications
|
||||
user_strategy_ids = []
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT id FROM qd_strategies_trading WHERE user_id = ?", (user_id,))
|
||||
rows = cur.fetchall() or []
|
||||
user_strategy_ids = [r.get('id') for r in rows if r.get('id')]
|
||||
cur.close()
|
||||
|
||||
if not user_strategy_ids:
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}})
|
||||
|
||||
where = []
|
||||
args = []
|
||||
|
||||
# Filter by user's strategies
|
||||
if strategy_id:
|
||||
where.append("strategy_id = ?")
|
||||
args.append(int(strategy_id))
|
||||
if strategy_id in user_strategy_ids:
|
||||
where.append("strategy_id = ?")
|
||||
args.append(int(strategy_id))
|
||||
else:
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}})
|
||||
else:
|
||||
placeholders = ",".join(["?"] * len(user_strategy_ids))
|
||||
where.append(f"strategy_id IN ({placeholders})")
|
||||
args.extend(user_strategy_ids)
|
||||
|
||||
if since_id:
|
||||
where.append("id > ?")
|
||||
args.append(int(since_id))
|
||||
@@ -756,19 +835,25 @@ def get_strategy_notifications():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/notifications/read', methods=['POST'])
|
||||
@login_required
|
||||
def mark_notification_read():
|
||||
"""Mark a single notification as read."""
|
||||
"""Mark a single notification as read for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
notification_id = data.get('id')
|
||||
if not notification_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing id'}), 400
|
||||
|
||||
# Only update notifications for user's strategies
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"UPDATE qd_strategy_notifications SET is_read = 1 WHERE id = ?",
|
||||
(int(notification_id),)
|
||||
"""
|
||||
UPDATE qd_strategy_notifications SET is_read = 1
|
||||
WHERE id = ? AND strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
""",
|
||||
(int(notification_id), user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -780,12 +865,20 @@ def mark_notification_read():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/notifications/read-all', methods=['POST'])
|
||||
@login_required
|
||||
def mark_all_notifications_read():
|
||||
"""Mark all notifications as read."""
|
||||
"""Mark all notifications as read for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("UPDATE qd_strategy_notifications SET is_read = 1")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_strategy_notifications SET is_read = 1
|
||||
WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
@@ -796,12 +889,20 @@ def mark_all_notifications_read():
|
||||
|
||||
|
||||
@strategy_bp.route('/strategies/notifications/clear', methods=['DELETE'])
|
||||
@login_required
|
||||
def clear_notifications():
|
||||
"""Clear all notifications (delete from database)."""
|
||||
"""Clear all notifications for the current user."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("DELETE FROM qd_strategy_notifications")
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM qd_strategy_notifications
|
||||
WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
@@ -41,11 +41,10 @@ def _extract_meta_from_code(code: str) -> Dict[str, str]:
|
||||
return {"name": name, "description": description}
|
||||
|
||||
|
||||
@strategy_code_bp.route("/strategy/getStrategies", methods=["POST"])
|
||||
@strategy_code_bp.route("/strategy/getStrategies", methods=["GET"])
|
||||
def get_strategies():
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = int(data.get("userid") or 1)
|
||||
user_id = int(request.args.get("userid") or 1)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
User Management API Routes
|
||||
|
||||
Provides endpoints for user CRUD operations, role management, etc.
|
||||
Only accessible by admin users.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app.services.user_service import get_user_service
|
||||
from app.utils.auth import login_required, admin_required
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
user_bp = Blueprint('user_manage', __name__)
|
||||
|
||||
|
||||
@user_bp.route('/list', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def list_users():
|
||||
"""
|
||||
List all users (admin only).
|
||||
|
||||
Query params:
|
||||
page: int (default 1)
|
||||
page_size: int (default 20, max 100)
|
||||
"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
page_size = request.args.get('page_size', 20, type=int)
|
||||
page_size = min(100, max(1, page_size))
|
||||
|
||||
result = get_user_service().list_users(page=page, page_size=page_size)
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"list_users failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/detail', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_user_detail():
|
||||
"""Get user detail by ID (admin only)"""
|
||||
try:
|
||||
user_id = request.args.get('id', type=int)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400
|
||||
|
||||
user = get_user_service().get_user_by_id(user_id)
|
||||
if not user:
|
||||
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': user
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_detail failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
"""
|
||||
Create a new user (admin only).
|
||||
|
||||
Request body:
|
||||
username: str (required)
|
||||
password: str (required)
|
||||
email: str (optional)
|
||||
nickname: str (optional)
|
||||
role: str (optional, default 'user')
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
user_id = get_user_service().create_user(data)
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'User created successfully',
|
||||
'data': {'id': user_id}
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"create_user failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/update', methods=['PUT'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def update_user():
|
||||
"""
|
||||
Update user information (admin only).
|
||||
|
||||
Query params:
|
||||
id: int (required)
|
||||
|
||||
Request body:
|
||||
email: str (optional)
|
||||
nickname: str (optional)
|
||||
role: str (optional)
|
||||
status: str (optional)
|
||||
"""
|
||||
try:
|
||||
user_id = request.args.get('id', type=int)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
success = get_user_service().update_user(user_id, data)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'User updated successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"update_user failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user():
|
||||
"""Delete a user (admin only)"""
|
||||
try:
|
||||
user_id = request.args.get('id', type=int)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400
|
||||
|
||||
# Prevent deleting self
|
||||
if hasattr(g, 'user_id') and g.user_id == user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Cannot delete yourself', 'data': None}), 400
|
||||
|
||||
success = get_user_service().delete_user(user_id)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'User deleted successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Delete failed', 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"delete_user failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/reset-password', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def reset_user_password():
|
||||
"""
|
||||
Reset a user's password (admin only).
|
||||
|
||||
Request body:
|
||||
user_id: int (required)
|
||||
new_password: str (required)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
user_id = data.get('user_id')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400
|
||||
|
||||
if len(new_password) < 6:
|
||||
return jsonify({'code': 0, 'msg': 'Password must be at least 6 characters', 'data': None}), 400
|
||||
|
||||
success = get_user_service().reset_password(user_id, new_password)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'Password reset successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Reset failed', 'data': None}), 400
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"reset_user_password failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/roles', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_roles():
|
||||
"""Get available roles and their permissions"""
|
||||
service = get_user_service()
|
||||
|
||||
roles = []
|
||||
for role in service.ROLES:
|
||||
roles.append({
|
||||
'id': role,
|
||||
'name': role.capitalize(),
|
||||
'permissions': service.get_user_permissions(role)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': {'roles': roles}
|
||||
})
|
||||
|
||||
|
||||
# Self-service endpoints (accessible by any logged-in user)
|
||||
|
||||
@user_bp.route('/profile', methods=['GET'])
|
||||
@login_required
|
||||
def get_profile():
|
||||
"""Get current user's profile"""
|
||||
try:
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
|
||||
|
||||
user = get_user_service().get_user_by_id(user_id)
|
||||
if not user:
|
||||
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
|
||||
|
||||
# Add permissions
|
||||
user['permissions'] = get_user_service().get_user_permissions(user.get('role', 'user'))
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': user
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"get_profile failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/profile/update', methods=['PUT'])
|
||||
@login_required
|
||||
def update_profile():
|
||||
"""
|
||||
Update current user's profile (limited fields).
|
||||
|
||||
Request body:
|
||||
nickname: str (optional)
|
||||
email: str (optional)
|
||||
avatar: str (optional)
|
||||
"""
|
||||
try:
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Only allow updating certain fields for self-service
|
||||
allowed = {}
|
||||
for field in ['nickname', 'email', 'avatar']:
|
||||
if field in data:
|
||||
allowed[field] = data[field]
|
||||
|
||||
if not allowed:
|
||||
return jsonify({'code': 0, 'msg': 'No valid fields to update', 'data': None}), 400
|
||||
|
||||
success = get_user_service().update_user(user_id, allowed)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'Profile updated successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"update_profile failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/change-password', methods=['POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""
|
||||
Change current user's password.
|
||||
|
||||
Request body:
|
||||
old_password: str (required)
|
||||
new_password: str (required)
|
||||
"""
|
||||
try:
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
old_password = data.get('old_password', '')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not old_password or not new_password:
|
||||
return jsonify({'code': 0, 'msg': 'Both old and new password required', 'data': None}), 400
|
||||
|
||||
if len(new_password) < 6:
|
||||
return jsonify({'code': 0, 'msg': 'New password must be at least 6 characters', 'data': None}), 400
|
||||
|
||||
success = get_user_service().change_password(user_id, old_password, new_password)
|
||||
|
||||
if success:
|
||||
return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None})
|
||||
else:
|
||||
return jsonify({'code': 0, 'msg': 'Old password incorrect', 'data': None}), 400
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"change_password failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
@@ -97,6 +97,227 @@ class AgentCoordinator:
|
||||
self.neutral_analyst = NeutralAnalyst()
|
||||
self.safe_analyst = SafeAnalyst()
|
||||
|
||||
def run_analysis_stream(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None, timeframe: str = "1D", on_progress=None):
|
||||
"""
|
||||
Run the full multi-agent analysis workflow with progress callbacks.
|
||||
|
||||
Args:
|
||||
on_progress: Callback function that receives (agent_name: str, status: str, result: Optional[dict])
|
||||
status can be: 'started', 'completed', 'error'
|
||||
|
||||
Yields:
|
||||
Progress events as dicts: { 'agent': str, 'status': str, 'result': dict or None }
|
||||
"""
|
||||
logger.info(f"Multi-agent stream analysis start: {market}:{symbol}, model={model}, language={language}")
|
||||
|
||||
def emit_progress(agent: str, status: str, result: dict = None):
|
||||
"""Emit progress event."""
|
||||
if on_progress:
|
||||
on_progress(agent, status, result)
|
||||
|
||||
# Build base context
|
||||
from .tools import AgentTools
|
||||
tools = AgentTools()
|
||||
|
||||
emit_progress('data_collection', 'started', None)
|
||||
|
||||
# 1) Base data
|
||||
current_price = tools.get_current_price(market, symbol)
|
||||
company_data = tools.get_company_data(market, symbol, language=language)
|
||||
|
||||
# Normalize timeframe
|
||||
tf = (timeframe or "1D").strip()
|
||||
|
||||
# 2) Kline + fundamentals
|
||||
kline_data = tools.get_stock_data(market, symbol, days=30, timeframe=tf)
|
||||
fundamental_data = tools.get_fundamental_data(market, symbol)
|
||||
indicators = tools.calculate_technical_indicators(kline_data or [])
|
||||
|
||||
# 3) News (Finnhub + web search)
|
||||
company_name = company_data.get('name', symbol) if company_data else symbol
|
||||
news_data = tools.get_news(market, symbol, days=7, company_name=company_name)
|
||||
|
||||
base_data = {
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"current_price": current_price,
|
||||
"kline_data": kline_data,
|
||||
"fundamental_data": fundamental_data,
|
||||
"company_data": company_data,
|
||||
"news_data": news_data,
|
||||
"indicators": indicators,
|
||||
}
|
||||
|
||||
context = {
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"language": language,
|
||||
"model": model,
|
||||
"timeframe": tf,
|
||||
"memory_features": {
|
||||
"timeframe": tf,
|
||||
"price": (current_price or {}).get("price"),
|
||||
"changePercent": (current_price or {}).get("changePercent"),
|
||||
"indicators": indicators,
|
||||
},
|
||||
"base_data": base_data
|
||||
}
|
||||
|
||||
emit_progress('data_collection', 'completed', None)
|
||||
|
||||
# Phase 1: Analysts (parallel but report individually)
|
||||
logger.info("Phase 1: Analyst team")
|
||||
|
||||
# Fundamental Analyst
|
||||
emit_progress('fundamental', 'started', None)
|
||||
fundamental_report = self.fundamental_analyst.analyze(context)
|
||||
emit_progress('fundamental', 'completed', fundamental_report.get('data', {}))
|
||||
|
||||
# Technical Analyst (market_analyst)
|
||||
emit_progress('technical', 'started', None)
|
||||
market_report = self.market_analyst.analyze(context)
|
||||
emit_progress('technical', 'completed', market_report.get('data', {}))
|
||||
|
||||
# News Analyst
|
||||
emit_progress('news', 'started', None)
|
||||
news_report = self.news_analyst.analyze(context)
|
||||
emit_progress('news', 'completed', news_report.get('data', {}))
|
||||
|
||||
# Sentiment Analyst
|
||||
emit_progress('sentiment', 'started', None)
|
||||
sentiment_report = self.sentiment_analyst.analyze(context)
|
||||
emit_progress('sentiment', 'completed', sentiment_report.get('data', {}))
|
||||
|
||||
# Risk Analyst
|
||||
emit_progress('risk', 'started', None)
|
||||
risk_report = self.risk_analyst.analyze(context)
|
||||
emit_progress('risk', 'completed', risk_report.get('data', {}))
|
||||
|
||||
# Update context with analyst outputs
|
||||
context.update({
|
||||
"market_report": market_report,
|
||||
"fundamental_report": fundamental_report,
|
||||
"news_report": news_report,
|
||||
"sentiment_report": sentiment_report,
|
||||
"risk_report": risk_report,
|
||||
})
|
||||
|
||||
# Phase 2: Bull/Bear debate
|
||||
logger.info("Phase 2: Research debate")
|
||||
|
||||
emit_progress('debate_bull', 'started', None)
|
||||
bull_argument = self.bull_researcher.analyze(context)
|
||||
emit_progress('debate_bull', 'completed', bull_argument.get('data', {}))
|
||||
|
||||
emit_progress('debate_bear', 'started', None)
|
||||
bear_argument = self.bear_researcher.analyze(context)
|
||||
emit_progress('debate_bear', 'completed', bear_argument.get('data', {}))
|
||||
|
||||
context["bull_argument"] = bull_argument
|
||||
context["bear_argument"] = bear_argument
|
||||
|
||||
# Research manager decision
|
||||
emit_progress('debate_research', 'started', None)
|
||||
research_decision = self._make_research_decision(bull_argument, bear_argument, context)
|
||||
context["research_decision"] = research_decision
|
||||
emit_progress('debate_research', 'completed', {'research_decision': research_decision})
|
||||
|
||||
# Phase 3: Trader decision
|
||||
logger.info("Phase 3: Trader decision")
|
||||
emit_progress('trader_decision', 'started', None)
|
||||
trader_result = self.trader_agent.analyze(context)
|
||||
trader_plan = trader_result.get('data', {}).get('trading_plan', {})
|
||||
context["trader_plan"] = trader_plan
|
||||
emit_progress('trader_decision', 'completed', trader_result.get('data', {}))
|
||||
|
||||
# Phase 4: Risk debate
|
||||
logger.info("Phase 4: Risk debate")
|
||||
|
||||
emit_progress('risk_debate_risky', 'started', None)
|
||||
risky_result = self.risky_analyst.analyze(context)
|
||||
emit_progress('risk_debate_risky', 'completed', risky_result.get('data', {}))
|
||||
|
||||
emit_progress('risk_debate_neutral', 'started', None)
|
||||
neutral_result = self.neutral_analyst.analyze(context)
|
||||
emit_progress('risk_debate_neutral', 'completed', neutral_result.get('data', {}))
|
||||
|
||||
emit_progress('risk_debate_safe', 'started', None)
|
||||
safe_result = self.safe_analyst.analyze(context)
|
||||
emit_progress('risk_debate_safe', 'completed', safe_result.get('data', {}))
|
||||
|
||||
# Final decision
|
||||
logger.info("Phase 5: Final decision")
|
||||
emit_progress('final_decision', 'started', None)
|
||||
final_decision = self._make_risk_decision(risky_result, neutral_result, safe_result, trader_result, context)
|
||||
emit_progress('final_decision', 'completed', final_decision)
|
||||
|
||||
# Generate overview
|
||||
emit_progress('overview', 'started', None)
|
||||
overview = self._generate_overview(context, final_decision)
|
||||
emit_progress('overview', 'completed', overview)
|
||||
|
||||
# Record for reflection
|
||||
if self.reflection_service and final_decision.get('decision') in ['BUY', 'SELL', 'HOLD']:
|
||||
try:
|
||||
self.reflection_service.record_analysis(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
price=base_data.get('current_price', {}).get('price'),
|
||||
decision=final_decision.get('decision'),
|
||||
confidence=final_decision.get('confidence', 50),
|
||||
reasoning=final_decision.get('reasoning', ''),
|
||||
check_days=7
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Record reflection failed: {e}")
|
||||
|
||||
# Build final result
|
||||
debate_data = {
|
||||
"bull": bull_argument.get('data', {}) if bull_argument.get('data') else {},
|
||||
"bear": bear_argument.get('data', {}) if bear_argument.get('data') else {},
|
||||
"research_decision": research_decision if research_decision else "Analyzing..."
|
||||
}
|
||||
|
||||
trader_decision_data = trader_result.get('data', {}) if trader_result.get('data') else {
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": "Analyzing...",
|
||||
"trading_plan": {},
|
||||
"report": "Analyzing..."
|
||||
}
|
||||
|
||||
risk_debate_data = {
|
||||
"risky": risky_result.get('data', {}) if risky_result.get('data') else {},
|
||||
"neutral": neutral_result.get('data', {}) if neutral_result.get('data') else {},
|
||||
"safe": safe_result.get('data', {}) if safe_result.get('data') else {}
|
||||
}
|
||||
|
||||
if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0):
|
||||
final_decision = {
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": "Analyzing...",
|
||||
"risk_summary": {},
|
||||
"recommendation": "Analyzing..."
|
||||
}
|
||||
|
||||
result = {
|
||||
"overview": overview,
|
||||
"fundamental": fundamental_report.get('data', {}),
|
||||
"technical": market_report.get('data', {}),
|
||||
"news": news_report.get('data', {}),
|
||||
"sentiment": sentiment_report.get('data', {}),
|
||||
"risk": risk_report.get('data', {}),
|
||||
"debate": debate_data,
|
||||
"trader_decision": trader_decision_data,
|
||||
"risk_debate": risk_debate_data,
|
||||
"final_decision": final_decision,
|
||||
"error": None
|
||||
}
|
||||
|
||||
logger.info(f"Multi-agent stream analysis completed: {market}:{symbol}")
|
||||
return result
|
||||
|
||||
def run_analysis(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
|
||||
"""
|
||||
Run the full multi-agent analysis workflow.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Agent memory system (local-only).
|
||||
Agent memory system (PostgreSQL).
|
||||
|
||||
This module stores agent experiences in SQLite and retrieves relevant past cases
|
||||
This module stores agent experiences in PostgreSQL and retrieves relevant past cases
|
||||
to inject into prompts (RAG-style). It does NOT finetune model weights.
|
||||
|
||||
Retrieval (configurable):
|
||||
@@ -14,7 +14,6 @@ Ranking combines:
|
||||
- optional returns weight
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
import math
|
||||
@@ -23,31 +22,24 @@ from datetime import datetime, timezone
|
||||
import difflib
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from .embedding import EmbeddingService, cosine_sim
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AgentMemory:
|
||||
"""智能体记忆系统"""
|
||||
"""Agent memory system using PostgreSQL"""
|
||||
|
||||
def __init__(self, agent_name: str, db_path: Optional[str] = None):
|
||||
"""
|
||||
初始化记忆系统
|
||||
Initialize memory system.
|
||||
|
||||
Args:
|
||||
agent_name: 智能体名称
|
||||
db_path: 数据库路径(可选)
|
||||
agent_name: Agent identifier (e.g., 'trader_agent', 'risk_analyst')
|
||||
db_path: Deprecated parameter, kept for backward compatibility
|
||||
"""
|
||||
self.agent_name = agent_name
|
||||
|
||||
if db_path is None:
|
||||
# 默认数据库路径
|
||||
db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory')
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
db_path = os.path.join(db_dir, f'{agent_name}_memory.db')
|
||||
|
||||
self.db_path = db_path
|
||||
self.embedder = EmbeddingService()
|
||||
self.enable_vector = os.getenv("AGENT_MEMORY_ENABLE_VECTOR", "true").lower() == "true"
|
||||
self.candidate_limit = int(os.getenv("AGENT_MEMORY_CANDIDATE_LIMIT", "500") or 500)
|
||||
@@ -55,58 +47,6 @@ class AgentMemory:
|
||||
self.w_sim = float(os.getenv("AGENT_MEMORY_W_SIM", "0.75") or 0.75)
|
||||
self.w_recency = float(os.getenv("AGENT_MEMORY_W_RECENCY", "0.20") or 0.20)
|
||||
self.w_returns = float(os.getenv("AGENT_MEMORY_W_RETURNS", "0.05") or 0.05)
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化数据库表"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
situation TEXT NOT NULL,
|
||||
recommendation TEXT NOT NULL,
|
||||
result TEXT,
|
||||
returns REAL,
|
||||
market TEXT,
|
||||
symbol TEXT,
|
||||
timeframe TEXT,
|
||||
features_json TEXT,
|
||||
embedding BLOB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Best-effort migration for older DBs
|
||||
# NOTE: For existing tables, we must add missing columns BEFORE creating indexes
|
||||
# that reference them (otherwise we'll hit: "no such column: market").
|
||||
cursor.execute("PRAGMA table_info(memories)")
|
||||
existing_cols = {row[1] for row in cursor.fetchall() or []}
|
||||
for col, ddl in {
|
||||
"market": "TEXT",
|
||||
"symbol": "TEXT",
|
||||
"timeframe": "TEXT",
|
||||
"features_json": "TEXT",
|
||||
"embedding": "BLOB",
|
||||
}.items():
|
||||
if col not in existing_cols:
|
||||
cursor.execute(f"ALTER TABLE memories ADD COLUMN {col} {ddl}")
|
||||
|
||||
# 创建索引(放在迁移之后,兼容旧库)
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON memories(created_at)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_market_symbol ON memories(market, symbol)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"初始化记忆数据库失败: {e}")
|
||||
|
||||
def _now_utc(self) -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -156,13 +96,13 @@ class AgentMemory:
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
添加记忆
|
||||
Add a memory entry.
|
||||
|
||||
Args:
|
||||
situation: 情况描述
|
||||
recommendation: 建议/决策
|
||||
result: 结果描述(可选)
|
||||
returns: 收益(可选)
|
||||
situation: Situation description
|
||||
recommendation: Decision/recommendation made
|
||||
result: Outcome description (optional)
|
||||
returns: Return percentage (optional)
|
||||
metadata: Optional structured metadata (market/symbol/timeframe/features...)
|
||||
"""
|
||||
try:
|
||||
@@ -182,45 +122,50 @@ class AgentMemory:
|
||||
vec = self.embedder.embed(text)
|
||||
embedding_blob = self.embedder.to_bytes(vec)
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO memories (situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding_blob))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"{self.agent_name} 添加新记忆")
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_agent_memories
|
||||
(agent_name, situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(self.agent_name, situation, recommendation, result, returns, market, symbol, timeframe, features_json, embedding_blob)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
logger.info(f"{self.agent_name} added new memory")
|
||||
except Exception as e:
|
||||
logger.error(f"添加记忆失败: {e}")
|
||||
logger.error(f"Failed to add memory: {e}")
|
||||
|
||||
def get_memories(self, current_situation: str, n_matches: int = 5, metadata: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
检索相似记忆
|
||||
Retrieve similar memories.
|
||||
|
||||
Args:
|
||||
current_situation: 当前情况描述
|
||||
n_matches: 返回的匹配数量
|
||||
current_situation: Current situation description
|
||||
n_matches: Number of matches to return
|
||||
metadata: Optional metadata for filtering/weighting
|
||||
|
||||
Returns:
|
||||
匹配的记忆列表
|
||||
List of matching memory entries
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 获取所有记忆
|
||||
cursor.execute('''
|
||||
SELECT id, situation, recommendation, result, returns, created_at, market, symbol, timeframe, features_json, embedding
|
||||
FROM memories
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
''', (int(self.candidate_limit),))
|
||||
|
||||
all_memories = cursor.fetchall()
|
||||
conn.close()
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, situation, recommendation, result, returns, created_at,
|
||||
market, symbol, timeframe, features_json, embedding
|
||||
FROM qd_agent_memories
|
||||
WHERE agent_name = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(self.agent_name, int(self.candidate_limit))
|
||||
)
|
||||
all_memories = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
if not all_memories:
|
||||
return []
|
||||
@@ -240,23 +185,24 @@ class AgentMemory:
|
||||
|
||||
ranked = []
|
||||
for row in all_memories:
|
||||
(
|
||||
mem_id,
|
||||
situation,
|
||||
recommendation,
|
||||
result,
|
||||
returns,
|
||||
created_at,
|
||||
market,
|
||||
symbol,
|
||||
timeframe,
|
||||
features_json,
|
||||
embedding_blob,
|
||||
) = row
|
||||
mem_id = row['id']
|
||||
situation = row['situation']
|
||||
recommendation = row['recommendation']
|
||||
result = row['result']
|
||||
returns = row['returns']
|
||||
created_at = row['created_at']
|
||||
market = row['market']
|
||||
symbol = row['symbol']
|
||||
timeframe = row['timeframe']
|
||||
features_json = row['features_json']
|
||||
embedding_blob = row['embedding']
|
||||
|
||||
sim = 0.0
|
||||
if self.enable_vector and embedding_blob:
|
||||
try:
|
||||
# Handle memoryview/bytes from PostgreSQL
|
||||
if isinstance(embedding_blob, memoryview):
|
||||
embedding_blob = bytes(embedding_blob)
|
||||
mem_vec = self.embedder.from_bytes(embedding_blob)
|
||||
sim = cosine_sim(query_vec, mem_vec)
|
||||
except Exception:
|
||||
@@ -292,50 +238,60 @@ class AgentMemory:
|
||||
return ranked[: max(0, int(n_matches or 0))]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检索记忆失败: {e}")
|
||||
logger.error(f"Failed to retrieve memories: {e}")
|
||||
return []
|
||||
|
||||
def update_memory_result(self, memory_id: int, result: str, returns: Optional[float] = None):
|
||||
"""
|
||||
更新记忆的结果
|
||||
Update memory result.
|
||||
|
||||
Args:
|
||||
memory_id: 记忆ID
|
||||
result: 结果描述
|
||||
returns: 收益
|
||||
memory_id: Memory ID
|
||||
result: Outcome description
|
||||
returns: Return percentage
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE memories
|
||||
SET result = ?, returns = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (result, returns, memory_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"{self.agent_name} 更新记忆 {memory_id}")
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_agent_memories
|
||||
SET result = ?, returns = ?, updated_at = NOW()
|
||||
WHERE id = ? AND agent_name = ?
|
||||
""",
|
||||
(result, returns, memory_id, self.agent_name)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
logger.info(f"{self.agent_name} updated memory {memory_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"更新记忆失败: {e}")
|
||||
logger.error(f"Failed to update memory: {e}")
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""获取记忆统计信息"""
|
||||
"""Get memory statistics for this agent."""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memories')
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT AVG(returns) FROM memories WHERE returns IS NOT NULL')
|
||||
avg_returns = cursor.fetchone()[0] or 0
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memories WHERE returns > 0')
|
||||
positive = cursor.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
'SELECT COUNT(*) as cnt FROM qd_agent_memories WHERE agent_name = ?',
|
||||
(self.agent_name,)
|
||||
)
|
||||
total = cur.fetchone()['cnt']
|
||||
|
||||
cur.execute(
|
||||
'SELECT AVG(returns) as avg_ret FROM qd_agent_memories WHERE agent_name = ? AND returns IS NOT NULL',
|
||||
(self.agent_name,)
|
||||
)
|
||||
avg_returns = cur.fetchone()['avg_ret'] or 0
|
||||
|
||||
cur.execute(
|
||||
'SELECT COUNT(*) as cnt FROM qd_agent_memories WHERE agent_name = ? AND returns > 0',
|
||||
(self.agent_name,)
|
||||
)
|
||||
positive = cur.fetchone()['cnt']
|
||||
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'total_memories': total,
|
||||
@@ -344,5 +300,20 @@ class AgentMemory:
|
||||
'success_rate': round(positive / total * 100, 2) if total > 0 else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计信息失败: {e}")
|
||||
logger.error(f"Failed to get statistics: {e}")
|
||||
return {}
|
||||
|
||||
def clear_memories(self):
|
||||
"""Clear all memories for this agent (use with caution)."""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
'DELETE FROM qd_agent_memories WHERE agent_name = ?',
|
||||
(self.agent_name,)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
logger.warning(f"{self.agent_name} cleared all memories")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear memories: {e}")
|
||||
|
||||
@@ -1,220 +1,249 @@
|
||||
"""
|
||||
自动反思与验证服务
|
||||
用于记录分析预测,并在未来自动验证结果,实现闭环学习
|
||||
Auto-reflection and verification service (PostgreSQL).
|
||||
|
||||
Records analysis predictions and auto-verifies results in the future
|
||||
to achieve closed-loop learning for AI agents.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from .memory import AgentMemory
|
||||
from .tools import AgentTools
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ReflectionService:
|
||||
"""反思服务:管理分析记录的存储和验证"""
|
||||
"""Reflection service: manages storage and verification of analysis records."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
if db_path is None:
|
||||
# 默认数据库路径
|
||||
db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory')
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
db_path = os.path.join(db_dir, 'reflection_records.db')
|
||||
|
||||
self.db_path = db_path
|
||||
self.tools = AgentTools()
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化数据库表"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建分析记录表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS analysis_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
market TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
initial_price REAL,
|
||||
decision TEXT,
|
||||
confidence INTEGER,
|
||||
reasoning TEXT,
|
||||
analysis_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
target_check_date TIMESTAMP,
|
||||
status TEXT DEFAULT 'PENDING', -- PENDING, COMPLETED, FAILED
|
||||
final_price REAL,
|
||||
actual_return REAL,
|
||||
check_result TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_status_date ON analysis_records(status, target_check_date)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"初始化反思数据库失败: {e}")
|
||||
|
||||
def record_analysis(self, market: str, symbol: str, price: float,
|
||||
decision: str, confidence: int, reasoning: str,
|
||||
check_days: int = 7):
|
||||
"""
|
||||
记录一次分析,以便未来验证
|
||||
Initialize reflection service.
|
||||
|
||||
Args:
|
||||
market: 市场
|
||||
symbol: 代码
|
||||
price: 当前价格
|
||||
decision: 决策 (BUY/SELL/HOLD)
|
||||
confidence: 置信度
|
||||
reasoning: 理由
|
||||
check_days: 几天后验证 (默认7天)
|
||||
db_path: Deprecated parameter, kept for backward compatibility
|
||||
"""
|
||||
self.tools = AgentTools()
|
||||
|
||||
def record_analysis(
|
||||
self,
|
||||
market: str,
|
||||
symbol: str,
|
||||
price: float,
|
||||
decision: str,
|
||||
confidence: int,
|
||||
reasoning: str,
|
||||
check_days: int = 7
|
||||
):
|
||||
"""
|
||||
Record an analysis for future verification.
|
||||
|
||||
Args:
|
||||
market: Market type
|
||||
symbol: Symbol code
|
||||
price: Current price
|
||||
decision: Decision (BUY/SELL/HOLD)
|
||||
confidence: Confidence level (0-100)
|
||||
reasoning: Reasoning text
|
||||
check_days: Days until verification (default 7)
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
target_date = datetime.now() + timedelta(days=check_days)
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO analysis_records
|
||||
(market, symbol, initial_price, decision, confidence, reasoning, target_check_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (market, symbol, price, decision, confidence, reasoning, target_date))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_reflection_records
|
||||
(market, symbol, initial_price, decision, confidence, reasoning, target_check_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(market, symbol, price, decision, confidence, reasoning, target_date)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
logger.info(f"Recorded analysis for reflection: {market}:{symbol}, will verify after {check_days} day(s)")
|
||||
except Exception as e:
|
||||
logger.error(f"记录分析失败: {e}")
|
||||
logger.error(f"Failed to record analysis: {e}")
|
||||
|
||||
def run_verification_cycle(self):
|
||||
"""
|
||||
执行验证周期:检查到期的记录,验证结果,并写入记忆
|
||||
Execute verification cycle: check due records, verify results, and write to memory.
|
||||
"""
|
||||
logger.info("开始执行自动反思验证周期...")
|
||||
logger.info("Starting auto-reflection verification cycle...")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. 查找所有已到期且未处理的记录
|
||||
cursor.execute('''
|
||||
SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date
|
||||
FROM analysis_records
|
||||
WHERE status = 'PENDING' AND target_check_date <= CURRENT_TIMESTAMP
|
||||
''')
|
||||
|
||||
records = cursor.fetchall()
|
||||
|
||||
if not records:
|
||||
logger.info("没有需要验证的记录")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
logger.info(f"发现 {len(records)} 条待验证记录")
|
||||
|
||||
# 初始化记忆系统(用于写入验证结果)
|
||||
trader_memory = AgentMemory('trader_agent')
|
||||
|
||||
for record in records:
|
||||
record_id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date = record
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# 2. 获取当前最新价格
|
||||
current_price_data = self.tools.get_current_price(market, symbol)
|
||||
current_price = current_price_data.get('price')
|
||||
# 1. Find all due and pending records
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date
|
||||
FROM qd_reflection_records
|
||||
WHERE status = 'PENDING' AND target_check_date <= NOW()
|
||||
"""
|
||||
)
|
||||
records = cur.fetchall() or []
|
||||
|
||||
if not records:
|
||||
logger.info("No records to verify")
|
||||
cur.close()
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(records)} records to verify")
|
||||
|
||||
# Initialize memory system for writing verification results
|
||||
trader_memory = AgentMemory('trader_agent')
|
||||
|
||||
for record in records:
|
||||
record_id = record['id']
|
||||
market = record['market']
|
||||
symbol = record['symbol']
|
||||
initial_price = record['initial_price']
|
||||
decision = record['decision']
|
||||
confidence = record['confidence']
|
||||
reasoning = record['reasoning']
|
||||
analysis_date = record['analysis_date']
|
||||
|
||||
if not current_price:
|
||||
logger.warning(f"无法获取 {market}:{symbol} 的当前价格,跳过")
|
||||
continue
|
||||
try:
|
||||
# 2. Get current price
|
||||
current_price_data = self.tools.get_current_price(market, symbol)
|
||||
current_price = current_price_data.get('price')
|
||||
|
||||
# 3. 计算收益和结果
|
||||
if not initial_price or initial_price == 0:
|
||||
actual_return = 0.0
|
||||
else:
|
||||
actual_return = (current_price - initial_price) / initial_price * 100
|
||||
|
||||
# 评估结果
|
||||
result_desc = ""
|
||||
is_good_prediction = False
|
||||
|
||||
if decision == "BUY":
|
||||
if actual_return > 2.0:
|
||||
result_desc = "Correct: price rose after BUY"
|
||||
is_good_prediction = True
|
||||
elif actual_return < -2.0:
|
||||
result_desc = "Wrong: price fell after BUY"
|
||||
if not current_price:
|
||||
logger.warning(f"Cannot get current price for {market}:{symbol}, skipping")
|
||||
continue
|
||||
|
||||
# 3. Calculate return and result
|
||||
if not initial_price or initial_price == 0:
|
||||
actual_return = 0.0
|
||||
else:
|
||||
result_desc = "Neutral: limited price movement"
|
||||
elif decision == "SELL":
|
||||
if actual_return < -2.0:
|
||||
result_desc = "Correct: price fell after SELL"
|
||||
is_good_prediction = True
|
||||
elif actual_return > 2.0:
|
||||
result_desc = "Wrong: price rose after SELL"
|
||||
else:
|
||||
result_desc = "Neutral: limited price movement"
|
||||
else: # HOLD
|
||||
if -2.0 <= actual_return <= 2.0:
|
||||
result_desc = "Correct: limited movement during HOLD"
|
||||
is_good_prediction = True
|
||||
else:
|
||||
result_desc = f"Deviated: large movement during HOLD ({actual_return:.2f}%)"
|
||||
actual_return = (current_price - initial_price) / initial_price * 100
|
||||
|
||||
# Evaluate result
|
||||
result_desc = ""
|
||||
is_good_prediction = False
|
||||
|
||||
if decision == "BUY":
|
||||
if actual_return > 2.0:
|
||||
result_desc = "Correct: price rose after BUY"
|
||||
is_good_prediction = True
|
||||
elif actual_return < -2.0:
|
||||
result_desc = "Wrong: price fell after BUY"
|
||||
else:
|
||||
result_desc = "Neutral: limited price movement"
|
||||
elif decision == "SELL":
|
||||
if actual_return < -2.0:
|
||||
result_desc = "Correct: price fell after SELL"
|
||||
is_good_prediction = True
|
||||
elif actual_return > 2.0:
|
||||
result_desc = "Wrong: price rose after SELL"
|
||||
else:
|
||||
result_desc = "Neutral: limited price movement"
|
||||
else: # HOLD
|
||||
if -2.0 <= actual_return <= 2.0:
|
||||
result_desc = "Correct: limited movement during HOLD"
|
||||
is_good_prediction = True
|
||||
else:
|
||||
result_desc = f"Deviated: large movement during HOLD ({actual_return:.2f}%)"
|
||||
|
||||
# 4. 写入记忆系统 (Let the agent learn)
|
||||
memory_situation = f"{market}:{symbol} auto-verified (analysis_date: {analysis_date})"
|
||||
memory_recommendation = f"Decision: {decision} (confidence {confidence}), reasoning: {(reasoning or '')[:120]}"
|
||||
memory_result = f"Verification: {result_desc}; return={actual_return:.2f}% (initial {initial_price} -> final {current_price})"
|
||||
|
||||
trader_memory.add_memory(
|
||||
memory_situation,
|
||||
memory_recommendation,
|
||||
memory_result,
|
||||
actual_return,
|
||||
metadata={
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"timeframe": "1D",
|
||||
"features": {
|
||||
"source": "auto_verify",
|
||||
"decision": decision,
|
||||
"confidence": confidence,
|
||||
"initial_price": initial_price,
|
||||
"final_price": current_price,
|
||||
"analysis_date": str(analysis_date),
|
||||
"result_desc": result_desc,
|
||||
"is_good_prediction": bool(is_good_prediction),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 5. 更新记录状态
|
||||
cursor.execute('''
|
||||
UPDATE analysis_records
|
||||
SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ?
|
||||
WHERE id = ?
|
||||
''', (current_price, actual_return, result_desc, record_id))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"验证完成 {market}:{symbol}: {result_desc}")
|
||||
|
||||
except Exception as inner_e:
|
||||
logger.error(f"处理记录 {record_id} 失败: {inner_e}")
|
||||
# 标记为失败,避免重复处理
|
||||
# cursor.execute("UPDATE analysis_records SET status = 'FAILED' WHERE id = ?", (record_id,))
|
||||
# conn.commit()
|
||||
|
||||
conn.close()
|
||||
logger.info("反思验证周期结束")
|
||||
# 4. Write to memory system (agent learning)
|
||||
memory_situation = f"{market}:{symbol} auto-verified (analysis_date: {analysis_date})"
|
||||
memory_recommendation = f"Decision: {decision} (confidence {confidence}), reasoning: {(reasoning or '')[:120]}"
|
||||
memory_result = f"Verification: {result_desc}; return={actual_return:.2f}% (initial {initial_price} -> final {current_price})"
|
||||
|
||||
trader_memory.add_memory(
|
||||
memory_situation,
|
||||
memory_recommendation,
|
||||
memory_result,
|
||||
actual_return,
|
||||
metadata={
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"timeframe": "1D",
|
||||
"features": {
|
||||
"source": "auto_verify",
|
||||
"decision": decision,
|
||||
"confidence": confidence,
|
||||
"initial_price": initial_price,
|
||||
"final_price": current_price,
|
||||
"analysis_date": str(analysis_date),
|
||||
"result_desc": result_desc,
|
||||
"is_good_prediction": bool(is_good_prediction),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 5. Update record status
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_reflection_records
|
||||
SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(current_price, actual_return, result_desc, record_id)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Verification completed {market}:{symbol}: {result_desc}")
|
||||
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Failed to process record {record_id}: {inner_e}")
|
||||
# Optionally mark as failed to avoid repeated processing
|
||||
# cur.execute("UPDATE qd_reflection_records SET status = 'FAILED' WHERE id = ?", (record_id,))
|
||||
# conn.commit()
|
||||
|
||||
cur.close()
|
||||
logger.info("Reflection verification cycle completed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行验证周期失败: {e}")
|
||||
logger.error(f"Failed to execute verification cycle: {e}")
|
||||
|
||||
def get_pending_count(self) -> int:
|
||||
"""Get count of pending verification records."""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'PENDING'")
|
||||
count = cur.fetchone()['cnt']
|
||||
cur.close()
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get pending count: {e}")
|
||||
return 0
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""Get reflection statistics."""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records")
|
||||
total = cur.fetchone()['cnt']
|
||||
|
||||
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'PENDING'")
|
||||
pending = cur.fetchone()['cnt']
|
||||
|
||||
cur.execute("SELECT COUNT(*) as cnt FROM qd_reflection_records WHERE status = 'COMPLETED'")
|
||||
completed = cur.fetchone()['cnt']
|
||||
|
||||
cur.execute(
|
||||
"SELECT AVG(actual_return) as avg_ret FROM qd_reflection_records WHERE status = 'COMPLETED' AND actual_return IS NOT NULL"
|
||||
)
|
||||
avg_return = cur.fetchone()['avg_ret'] or 0
|
||||
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'total_records': total,
|
||||
'pending_records': pending,
|
||||
'completed_records': completed,
|
||||
'average_return': round(avg_return, 2)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get statistics: {e}")
|
||||
return {}
|
||||
|
||||
@@ -14,6 +14,19 @@ from typing import Any, Dict, Optional, Tuple
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
|
||||
def _get_user_id_from_strategy(strategy_id: int) -> int:
|
||||
"""Get user_id from strategy table. Defaults to 1 if not found."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return int((row or {}).get('user_id') or 1)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
|
||||
def record_trade(
|
||||
*,
|
||||
strategy_id: int,
|
||||
@@ -24,19 +37,23 @@ def record_trade(
|
||||
commission: float = 0.0,
|
||||
commission_ccy: str = "",
|
||||
profit: Optional[float] = None,
|
||||
user_id: int = None,
|
||||
) -> None:
|
||||
now = int(time.time())
|
||||
value = float(amount or 0.0) * float(price or 0.0)
|
||||
if user_id is None:
|
||||
user_id = _get_user_id_from_strategy(strategy_id)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_trades
|
||||
(strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at)
|
||||
(user_id, strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
int(user_id),
|
||||
int(strategy_id),
|
||||
str(symbol),
|
||||
str(trade_type),
|
||||
@@ -86,25 +103,28 @@ def upsert_position(
|
||||
current_price: float,
|
||||
highest_price: float = 0.0,
|
||||
lowest_price: float = 0.0,
|
||||
user_id: int = None,
|
||||
) -> None:
|
||||
now = int(time.time())
|
||||
if user_id is None:
|
||||
user_id = _get_user_id_from_strategy(strategy_id)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_positions
|
||||
(strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at)
|
||||
(user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
entry_price = excluded.entry_price,
|
||||
current_price = excluded.current_price,
|
||||
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END,
|
||||
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END,
|
||||
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE qd_strategy_positions.highest_price END,
|
||||
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0), now),
|
||||
(int(user_id), int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0), now),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
@@ -417,9 +417,8 @@ class PendingOrderWorker:
|
||||
cur = db.cursor()
|
||||
for rid in to_delete_ids:
|
||||
cur.execute("DELETE FROM qd_strategy_positions WHERE id = %s", (int(rid),))
|
||||
now_ts = int(time.time())
|
||||
for u in to_update:
|
||||
cur.execute("UPDATE qd_strategy_positions SET size = %s, updated_at = %s WHERE id = %s", (float(u["size"]), now_ts, int(u["id"])))
|
||||
cur.execute("UPDATE qd_strategy_positions SET size = %s, updated_at = NOW() WHERE id = %s", (float(u["size"]), int(u["id"])))
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
@@ -436,24 +435,22 @@ class PendingOrderWorker:
|
||||
except Exception:
|
||||
stale_sec = 0
|
||||
if stale_sec > 0:
|
||||
now = int(time.time())
|
||||
cutoff = now - stale_sec
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE pending_orders
|
||||
SET status = 'pending',
|
||||
updated_at = %s,
|
||||
updated_at = NOW(),
|
||||
dispatch_note = CASE
|
||||
WHEN dispatch_note IS NULL OR dispatch_note = '' THEN 'requeued_stale_processing'
|
||||
ELSE dispatch_note
|
||||
END
|
||||
WHERE status = 'processing'
|
||||
AND (updated_at IS NULL OR updated_at < %s)
|
||||
AND (updated_at IS NULL OR updated_at < NOW() - INTERVAL '%s seconds')
|
||||
AND (attempts < max_attempts)
|
||||
""",
|
||||
(now, cutoff),
|
||||
(stale_sec,),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
@@ -79,10 +79,11 @@ def _safe_json_loads(value, default=None):
|
||||
return default
|
||||
|
||||
|
||||
def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str, Any]]:
|
||||
"""Get positions, optionally filtered by IDs."""
|
||||
def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = None) -> List[Dict[str, Any]]:
|
||||
"""Get positions, optionally filtered by IDs and user_id."""
|
||||
try:
|
||||
kline_service = KlineService()
|
||||
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -94,7 +95,7 @@ def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str,
|
||||
FROM qd_manual_positions
|
||||
WHERE user_id = ? AND id IN ({placeholders})
|
||||
""",
|
||||
[DEFAULT_USER_ID] + list(position_ids)
|
||||
[effective_user_id] + list(position_ids)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
@@ -103,7 +104,7 @@ def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str,
|
||||
FROM qd_manual_positions
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(effective_user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -726,15 +727,17 @@ def _send_monitor_notification(
|
||||
positions: List[Dict[str, Any]] = None,
|
||||
position_analyses: List[Dict[str, Any]] = None,
|
||||
language: str = 'en-US',
|
||||
custom_prompt: str = ''
|
||||
custom_prompt: str = '',
|
||||
user_id: int = None
|
||||
) -> None:
|
||||
"""Send notification with analysis result using appropriate format for each channel."""
|
||||
try:
|
||||
notifier = SignalNotifier()
|
||||
|
||||
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
|
||||
|
||||
channels = notification_config.get('channels', ['browser'])
|
||||
targets = notification_config.get('targets', {})
|
||||
|
||||
|
||||
title = f"📊 资产监测: {monitor_name}" if language.startswith('zh') else f"📊 Portfolio Monitor: {monitor_name}"
|
||||
|
||||
if not result.get('success'):
|
||||
@@ -751,10 +754,10 @@ def _send_monitor_notification(
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(0, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg,
|
||||
(effective_user_id, 0, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg,
|
||||
json.dumps(result, ensure_ascii=False), now)
|
||||
)
|
||||
db.commit()
|
||||
@@ -798,10 +801,10 @@ def _send_monitor_notification(
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(0, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report,
|
||||
(effective_user_id, 0, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report,
|
||||
json.dumps(result, ensure_ascii=False), now)
|
||||
)
|
||||
db.commit()
|
||||
@@ -848,24 +851,28 @@ def _send_monitor_notification(
|
||||
logger.error(f"_send_monitor_notification failed: {e}")
|
||||
|
||||
|
||||
def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[str, Any]:
|
||||
def run_single_monitor(monitor_id: int, override_language: str = None, user_id: int = None) -> Dict[str, Any]:
|
||||
"""Run a single monitor and return the result.
|
||||
|
||||
Args:
|
||||
monitor_id: The monitor ID to run
|
||||
override_language: Optional language override (e.g., 'zh-CN', 'en-US')
|
||||
If provided, will override the language in monitor config
|
||||
user_id: Optional user ID for user isolation
|
||||
"""
|
||||
try:
|
||||
# Use provided user_id or default
|
||||
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, position_ids, monitor_type, config, notification_config
|
||||
SELECT id, user_id, name, position_ids, monitor_type, config, notification_config
|
||||
FROM qd_position_monitors
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(monitor_id, DEFAULT_USER_ID)
|
||||
(monitor_id, effective_user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
@@ -873,6 +880,7 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
if not row:
|
||||
return {'success': False, 'error': 'Monitor not found'}
|
||||
|
||||
monitor_user_id = int(row.get('user_id') or effective_user_id)
|
||||
name = row.get('name') or f'Monitor #{monitor_id}'
|
||||
position_ids = _safe_json_loads(row.get('position_ids'), [])
|
||||
monitor_type = row.get('monitor_type') or 'ai'
|
||||
@@ -883,8 +891,8 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
if override_language:
|
||||
config['language'] = override_language
|
||||
|
||||
# Get positions
|
||||
positions = _get_positions_for_monitor(position_ids if position_ids else None)
|
||||
# Get positions for this user
|
||||
positions = _get_positions_for_monitor(position_ids if position_ids else None, user_id=monitor_user_id)
|
||||
|
||||
if not positions:
|
||||
return {'success': False, 'error': 'No positions to analyze'}
|
||||
@@ -897,19 +905,21 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'}
|
||||
|
||||
# Update monitor record
|
||||
now = _now_ts()
|
||||
interval_minutes = int(config.get('interval_minutes') or 60)
|
||||
next_run_at = now + (interval_minutes * 60)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_position_monitors
|
||||
SET last_run_at = ?, next_run_at = ?, last_result = ?, run_count = run_count + 1, updated_at = ?
|
||||
SET last_run_at = NOW(),
|
||||
next_run_at = NOW() + INTERVAL '%s minutes',
|
||||
last_result = ?,
|
||||
run_count = run_count + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, next_run_at, json.dumps(result, ensure_ascii=False), now, monitor_id)
|
||||
(interval_minutes, json.dumps(result, ensure_ascii=False), monitor_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -926,7 +936,8 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
positions=positions,
|
||||
position_analyses=position_analyses,
|
||||
language=language,
|
||||
custom_prompt=custom_prompt
|
||||
custom_prompt=custom_prompt,
|
||||
user_id=monitor_user_id
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -938,24 +949,24 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
|
||||
def _check_position_alerts():
|
||||
"""Check all active alerts and trigger notifications if conditions are met."""
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
kline_service = KlineService()
|
||||
notifier = SignalNotifier()
|
||||
now = _now_ts()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# Get active alerts that haven't been triggered (or can repeat)
|
||||
# Get active alerts for all users that haven't been triggered (or can repeat)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold,
|
||||
SELECT a.id, a.user_id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold,
|
||||
a.notification_config, a.is_triggered, a.last_triggered_at, a.repeat_interval,
|
||||
p.entry_price, p.quantity, p.side, p.name as position_name
|
||||
FROM qd_position_alerts a
|
||||
LEFT JOIN qd_manual_positions p ON a.position_id = p.id
|
||||
WHERE a.user_id = ? AND a.is_active = 1
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
WHERE a.is_active = 1
|
||||
"""
|
||||
)
|
||||
alerts = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -963,19 +974,24 @@ def _check_position_alerts():
|
||||
for alert in alerts:
|
||||
try:
|
||||
alert_id = alert.get('id')
|
||||
alert_user_id = int(alert.get('user_id') or 1)
|
||||
alert_type = alert.get('alert_type')
|
||||
threshold = float(alert.get('threshold') or 0)
|
||||
market = alert.get('market')
|
||||
symbol = alert.get('symbol')
|
||||
is_triggered = bool(alert.get('is_triggered'))
|
||||
last_triggered_at = alert.get('last_triggered_at') or 0
|
||||
last_triggered_at = alert.get('last_triggered_at') # datetime or None
|
||||
repeat_interval = int(alert.get('repeat_interval') or 0)
|
||||
notification_config = _safe_json_loads(alert.get('notification_config'), {})
|
||||
|
||||
# Check if we can trigger (not triggered yet, or repeat interval passed)
|
||||
can_trigger = not is_triggered
|
||||
if is_triggered and repeat_interval > 0:
|
||||
if now - last_triggered_at >= repeat_interval:
|
||||
if is_triggered and repeat_interval > 0 and last_triggered_at:
|
||||
# Convert last_triggered_at to timezone-aware if needed
|
||||
if last_triggered_at.tzinfo is None:
|
||||
last_triggered_at = last_triggered_at.replace(tzinfo=timezone.utc)
|
||||
elapsed_seconds = (now - last_triggered_at).total_seconds()
|
||||
if elapsed_seconds >= repeat_interval:
|
||||
can_trigger = True
|
||||
|
||||
if not can_trigger:
|
||||
@@ -1048,10 +1064,10 @@ def _check_position_alerts():
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_position_alerts
|
||||
SET is_triggered = 1, last_triggered_at = ?, trigger_count = trigger_count + 1, updated_at = ?
|
||||
SET is_triggered = 1, last_triggered_at = NOW(), trigger_count = trigger_count + 1, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, now, alert_id)
|
||||
(alert_id,)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -1070,10 +1086,10 @@ def _check_position_alerts():
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(0, symbol, 'price_alert', 'browser', alert_title, alert_message,
|
||||
(alert_user_id, 0, symbol, 'price_alert', 'browser', alert_title, alert_message,
|
||||
json.dumps({'alert_id': alert_id, 'alert_type': alert_type}, ensure_ascii=False), now)
|
||||
)
|
||||
db.commit()
|
||||
@@ -1096,7 +1112,7 @@ def _check_position_alerts():
|
||||
logger.error(f"_check_position_alerts failed: {e}")
|
||||
|
||||
|
||||
def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: str, signal_detail: str):
|
||||
def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: str, signal_detail: str, user_id: int = None):
|
||||
"""
|
||||
Called when a strategy signal is triggered.
|
||||
Check if user has manual positions in this symbol and send notification.
|
||||
@@ -1108,14 +1124,25 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, market, symbol, name, side, quantity, entry_price, group_name
|
||||
FROM qd_manual_positions
|
||||
WHERE user_id = ? AND symbol = ?
|
||||
""",
|
||||
(DEFAULT_USER_ID, symbol)
|
||||
)
|
||||
# Query positions for all users or specific user
|
||||
if user_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, market, symbol, name, side, quantity, entry_price, group_name
|
||||
FROM qd_manual_positions
|
||||
WHERE user_id = ? AND symbol = ?
|
||||
""",
|
||||
(user_id, symbol)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, market, symbol, name, side, quantity, entry_price, group_name
|
||||
FROM qd_manual_positions
|
||||
WHERE symbol = ?
|
||||
""",
|
||||
(symbol,)
|
||||
)
|
||||
positions = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
@@ -1127,6 +1154,7 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
|
||||
now = _now_ts()
|
||||
|
||||
for pos in positions:
|
||||
pos_user_id = int(pos.get('user_id') or 1)
|
||||
pos_name = pos.get('name') or symbol
|
||||
pos_side = pos.get('side') or 'long'
|
||||
quantity = float(pos.get('quantity') or 0)
|
||||
@@ -1149,10 +1177,10 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(0, symbol, 'strategy_linkage', 'browser', title, message,
|
||||
(pos_user_id, 0, symbol, 'strategy_linkage', 'browser', title, message,
|
||||
json.dumps({'signal_type': signal_type}, ensure_ascii=False), now)
|
||||
)
|
||||
db.commit()
|
||||
@@ -1170,22 +1198,19 @@ def _monitor_loop():
|
||||
|
||||
while not _stop_event.is_set():
|
||||
try:
|
||||
now = _now_ts()
|
||||
|
||||
# 1. Check position alerts (price/pnl alerts)
|
||||
# 1. Check position alerts (price/pnl alerts) for all users
|
||||
_check_position_alerts()
|
||||
|
||||
# 2. Find AI monitors that are due
|
||||
# 2. Find AI monitors that are due for all users
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM qd_position_monitors
|
||||
WHERE user_id = ? AND is_active = 1 AND next_run_at <= ?
|
||||
SELECT id, user_id FROM qd_position_monitors
|
||||
WHERE is_active = 1 AND next_run_at <= NOW()
|
||||
ORDER BY next_run_at ASC
|
||||
LIMIT 10
|
||||
""",
|
||||
(DEFAULT_USER_ID, now)
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -1194,10 +1219,11 @@ def _monitor_loop():
|
||||
if _stop_event.is_set():
|
||||
break
|
||||
monitor_id = row.get('id')
|
||||
monitor_user_id = int(row.get('user_id') or 1)
|
||||
if monitor_id:
|
||||
logger.info(f"Running due monitor #{monitor_id}")
|
||||
logger.info(f"Running due monitor #{monitor_id} for user #{monitor_user_id}")
|
||||
try:
|
||||
run_single_monitor(monitor_id)
|
||||
run_single_monitor(monitor_id, user_id=monitor_user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor #{monitor_id} execution failed: {e}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -447,18 +447,31 @@ class SignalNotifier:
|
||||
title: str,
|
||||
message: str,
|
||||
payload: Dict[str, Any],
|
||||
user_id: int = None,
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
now = int(time.time())
|
||||
# Get user_id from strategy if not provided
|
||||
if user_id is None:
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
user_id = int((row or {}).get('user_id') or 1)
|
||||
except Exception:
|
||||
user_id = 1
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(user_id),
|
||||
int(strategy_id),
|
||||
str(symbol or ""),
|
||||
str(signal_type or ""),
|
||||
|
||||
@@ -427,16 +427,21 @@ class StrategyService:
|
||||
except Exception:
|
||||
return 'IndicatorStrategy'
|
||||
|
||||
def update_strategy_status(self, strategy_id: int, status: str) -> bool:
|
||||
"""Update strategy status."""
|
||||
def update_strategy_status(self, strategy_id: int, status: str, user_id: int = None) -> bool:
|
||||
"""Update strategy status. If user_id is provided, verify ownership."""
|
||||
try:
|
||||
now = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"UPDATE qd_strategies_trading SET status = ?, updated_at = ? WHERE id = ?",
|
||||
(status, now, strategy_id)
|
||||
)
|
||||
if user_id is not None:
|
||||
cur.execute(
|
||||
"UPDATE qd_strategies_trading SET status = ?, updated_at = NOW() WHERE id = ? AND user_id = ?",
|
||||
(status, strategy_id, user_id)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"UPDATE qd_strategies_trading SET status = ?, updated_at = NOW() WHERE id = ?",
|
||||
(status, strategy_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
@@ -467,7 +472,7 @@ class StrategyService:
|
||||
return json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
def list_strategies(self, user_id: int = 1) -> List[Dict[str, Any]]:
|
||||
"""List strategies for local single-user."""
|
||||
"""List strategies for the specified user."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
@@ -475,8 +480,10 @@ class StrategyService:
|
||||
"""
|
||||
SELECT *
|
||||
FROM qd_strategies_trading
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
"""
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -501,11 +508,15 @@ class StrategyService:
|
||||
logger.error(f"list_strategies failed: {e}")
|
||||
return []
|
||||
|
||||
def get_strategy(self, strategy_id: int) -> Optional[Dict[str, Any]]:
|
||||
def get_strategy(self, strategy_id: int, user_id: int = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get strategy by ID. If user_id is provided, verify ownership."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||||
if user_id is not None:
|
||||
cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id))
|
||||
else:
|
||||
cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||||
r = cur.fetchone()
|
||||
cur.close()
|
||||
if not r:
|
||||
@@ -521,11 +532,11 @@ class StrategyService:
|
||||
return None
|
||||
|
||||
def create_strategy(self, payload: Dict[str, Any]) -> int:
|
||||
now = int(time.time())
|
||||
name = (payload.get('strategy_name') or '').strip()
|
||||
if not name:
|
||||
raise ValueError("strategy_name is required")
|
||||
|
||||
user_id = payload.get('user_id') or 1
|
||||
strategy_type = payload.get('strategy_type') or 'IndicatorStrategy'
|
||||
market_category = payload.get('market_category') or 'Crypto'
|
||||
execution_mode = payload.get('execution_mode') or 'signal'
|
||||
@@ -551,14 +562,15 @@ class StrategyService:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategies_trading
|
||||
(strategy_name, strategy_type, market_category, execution_mode, notification_config,
|
||||
(user_id, strategy_name, strategy_type, market_category, execution_mode, notification_config,
|
||||
status, symbol, timeframe, initial_capital, leverage, market_type,
|
||||
exchange_config, indicator_config, trading_config, ai_model_config, decide_interval,
|
||||
strategy_group_id, group_base_name,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
name,
|
||||
strategy_type,
|
||||
market_category,
|
||||
@@ -576,9 +588,7 @@ class StrategyService:
|
||||
self._dump_json_or_encrypt(payload.get('ai_model_config') or {}, encrypt=False),
|
||||
int(payload.get('decide_interval') or 300),
|
||||
strategy_group_id,
|
||||
group_base_name,
|
||||
now,
|
||||
now
|
||||
group_base_name
|
||||
)
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
@@ -657,14 +667,14 @@ class StrategyService:
|
||||
'total_failed': len(failed_symbols)
|
||||
}
|
||||
|
||||
def batch_start_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]:
|
||||
"""Batch start strategies"""
|
||||
def batch_start_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]:
|
||||
"""Batch start strategies. If user_id is provided, verify ownership."""
|
||||
success_ids = []
|
||||
failed_ids = []
|
||||
|
||||
for sid in strategy_ids:
|
||||
try:
|
||||
self.update_strategy_status(sid, 'running')
|
||||
self.update_strategy_status(sid, 'running', user_id=user_id)
|
||||
success_ids.append(sid)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start strategy {sid}: {e}")
|
||||
@@ -676,14 +686,14 @@ class StrategyService:
|
||||
'failed_ids': failed_ids
|
||||
}
|
||||
|
||||
def batch_stop_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]:
|
||||
"""Batch stop strategies"""
|
||||
def batch_stop_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]:
|
||||
"""Batch stop strategies. If user_id is provided, verify ownership."""
|
||||
success_ids = []
|
||||
failed_ids = []
|
||||
|
||||
for sid in strategy_ids:
|
||||
try:
|
||||
self.update_strategy_status(sid, 'stopped')
|
||||
self.update_strategy_status(sid, 'stopped', user_id=user_id)
|
||||
success_ids.append(sid)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop strategy {sid}: {e}")
|
||||
@@ -695,14 +705,14 @@ class StrategyService:
|
||||
'failed_ids': failed_ids
|
||||
}
|
||||
|
||||
def batch_delete_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]:
|
||||
"""Batch delete strategies"""
|
||||
def batch_delete_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]:
|
||||
"""Batch delete strategies. If user_id is provided, verify ownership."""
|
||||
success_ids = []
|
||||
failed_ids = []
|
||||
|
||||
for sid in strategy_ids:
|
||||
try:
|
||||
self.delete_strategy(sid)
|
||||
self.delete_strategy(sid, user_id=user_id)
|
||||
success_ids.append(sid)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete strategy {sid}: {e}")
|
||||
@@ -714,15 +724,21 @@ class StrategyService:
|
||||
'failed_ids': failed_ids
|
||||
}
|
||||
|
||||
def get_strategies_by_group(self, strategy_group_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all strategies in a group"""
|
||||
def get_strategies_by_group(self, strategy_group_id: str, user_id: int = None) -> List[Dict[str, Any]]:
|
||||
"""Get all strategies in a group. If user_id is provided, filter by user."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ?",
|
||||
(strategy_group_id,)
|
||||
)
|
||||
if user_id is not None:
|
||||
cur.execute(
|
||||
"SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ? AND user_id = ?",
|
||||
(strategy_group_id, user_id)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ?",
|
||||
(strategy_group_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
return [row['id'] for row in rows]
|
||||
@@ -730,9 +746,8 @@ class StrategyService:
|
||||
logger.error(f"get_strategies_by_group failed: {e}")
|
||||
return []
|
||||
|
||||
def update_strategy(self, strategy_id: int, payload: Dict[str, Any]) -> bool:
|
||||
now = int(time.time())
|
||||
existing = self.get_strategy(strategy_id)
|
||||
def update_strategy(self, strategy_id: int, payload: Dict[str, Any], user_id: int = None) -> bool:
|
||||
existing = self.get_strategy(strategy_id, user_id=user_id)
|
||||
if not existing:
|
||||
return False
|
||||
|
||||
@@ -771,7 +786,7 @@ class StrategyService:
|
||||
indicator_config = ?,
|
||||
trading_config = ?,
|
||||
ai_model_config = ?,
|
||||
updated_at = ?
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
@@ -788,7 +803,6 @@ class StrategyService:
|
||||
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
|
||||
)
|
||||
)
|
||||
@@ -796,11 +810,15 @@ class StrategyService:
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
def delete_strategy(self, strategy_id: int) -> bool:
|
||||
def delete_strategy(self, strategy_id: int, user_id: int = None) -> bool:
|
||||
"""Delete strategy. If user_id is provided, verify ownership."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("DELETE FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||||
if user_id is not None:
|
||||
cur.execute("DELETE FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id))
|
||||
else:
|
||||
cur.execute("DELETE FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
@@ -2191,19 +2191,32 @@ class TradingExecutor:
|
||||
title: str,
|
||||
message: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
user_id: int = None,
|
||||
) -> None:
|
||||
"""Best-effort persist notification row for the frontend '通知' panel (browser channel)."""
|
||||
try:
|
||||
now = int(time.time())
|
||||
# Get user_id from strategy if not provided
|
||||
if user_id is None:
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
user_id = int((row or {}).get('user_id') or 1)
|
||||
except Exception:
|
||||
user_id = 1
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(user_id),
|
||||
int(strategy_id),
|
||||
str(symbol or ""),
|
||||
str(signal_type or ""),
|
||||
@@ -2419,18 +2432,28 @@ class TradingExecutor:
|
||||
# Best-effort only; do not block enqueue on dedup query errors.
|
||||
pass
|
||||
|
||||
# Get user_id from strategy
|
||||
user_id = 1
|
||||
try:
|
||||
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
|
||||
row = cur.fetchone()
|
||||
user_id = int((row or {}).get('user_id') or 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pending_orders
|
||||
(strategy_id, symbol, signal_type, signal_ts, market_type, order_type, amount, price,
|
||||
(user_id, strategy_id, symbol, signal_type, signal_ts, market_type, order_type, amount, price,
|
||||
execution_mode, status, priority, attempts, max_attempts, last_error, payload_json,
|
||||
created_at, updated_at, processed_at, sent_at)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s,
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
int(user_id),
|
||||
int(strategy_id),
|
||||
symbol,
|
||||
signal_type,
|
||||
@@ -2473,16 +2496,24 @@ class TradingExecutor:
|
||||
def _record_trade(self, strategy_id: int, symbol: str, type: str, price: float, amount: float, value: float, profit: float = None, commission: float = None):
|
||||
"""记录交易到数据库"""
|
||||
try:
|
||||
# Get user_id from strategy
|
||||
user_id = 1
|
||||
with get_db_connection() as db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
|
||||
row = cursor.fetchone()
|
||||
user_id = int((row or {}).get('user_id') or 1)
|
||||
except Exception:
|
||||
pass
|
||||
query = """
|
||||
INSERT INTO qd_strategy_trades (
|
||||
strategy_id, symbol, type, price, amount, value, commission, profit, created_at
|
||||
user_id, strategy_id, symbol, type, price, amount, value, commission, profit, created_at
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
||||
)
|
||||
"""
|
||||
cursor.execute(query, (strategy_id, symbol, type, price, amount, value, commission or 0, profit, int(time.time())))
|
||||
cursor.execute(query, (user_id, strategy_id, symbol, type, price, amount, value, commission or 0, profit, int(time.time())))
|
||||
db.commit()
|
||||
cursor.close()
|
||||
except Exception as e:
|
||||
@@ -2501,24 +2532,32 @@ class TradingExecutor:
|
||||
):
|
||||
"""更新持仓状态"""
|
||||
try:
|
||||
# Get user_id from strategy
|
||||
user_id = 1
|
||||
with get_db_connection() as db:
|
||||
cursor = db.cursor()
|
||||
try:
|
||||
cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
|
||||
row = cursor.fetchone()
|
||||
user_id = int((row or {}).get('user_id') or 1)
|
||||
except Exception:
|
||||
pass
|
||||
# 简化:直接 Update 或 Insert
|
||||
upsert_query = """
|
||||
INSERT INTO qd_strategy_positions (
|
||||
strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at
|
||||
user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
||||
) ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
entry_price = excluded.entry_price,
|
||||
current_price = excluded.current_price,
|
||||
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END,
|
||||
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END,
|
||||
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE qd_strategy_positions.highest_price END,
|
||||
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END,
|
||||
updated_at = excluded.updated_at
|
||||
"""
|
||||
cursor.execute(upsert_query, (
|
||||
strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, int(time.time())
|
||||
user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, int(time.time())
|
||||
))
|
||||
db.commit()
|
||||
cursor.close()
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
User Service - Multi-user management
|
||||
|
||||
Handles user CRUD operations, password hashing, and role management.
|
||||
"""
|
||||
import hashlib
|
||||
import time
|
||||
import os
|
||||
from typing import Optional, Dict, Any, List
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Try to import bcrypt for secure password hashing
|
||||
try:
|
||||
import bcrypt
|
||||
HAS_BCRYPT = True
|
||||
except ImportError:
|
||||
HAS_BCRYPT = False
|
||||
logger.warning("bcrypt not installed. Using SHA256 for password hashing (less secure).")
|
||||
|
||||
|
||||
class UserService:
|
||||
"""User management service"""
|
||||
|
||||
# Available roles (ordered by privilege level)
|
||||
ROLES = ['viewer', 'user', 'manager', 'admin']
|
||||
|
||||
# Role permissions mapping
|
||||
ROLE_PERMISSIONS = {
|
||||
'viewer': ['dashboard', 'view'],
|
||||
'user': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio'],
|
||||
'manager': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings'],
|
||||
'admin': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings', 'user_manage', 'credentials'],
|
||||
}
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""Hash password using bcrypt (preferred) or SHA256 (fallback)"""
|
||||
if HAS_BCRYPT:
|
||||
salt = bcrypt.gensalt(rounds=12)
|
||||
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
else:
|
||||
# Fallback to SHA256 with salt
|
||||
salt = os.urandom(16).hex()
|
||||
hashed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest()
|
||||
return f"sha256${salt}${hashed}"
|
||||
|
||||
def verify_password(self, password: str, password_hash: str) -> bool:
|
||||
"""Verify password against hash"""
|
||||
if password_hash.startswith('$2b$') or password_hash.startswith('$2a$'):
|
||||
# bcrypt hash
|
||||
if HAS_BCRYPT:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
elif password_hash.startswith('sha256$'):
|
||||
# SHA256 fallback hash
|
||||
parts = password_hash.split('$')
|
||||
if len(parts) != 3:
|
||||
return False
|
||||
salt = parts[1]
|
||||
stored_hash = parts[2]
|
||||
computed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest()
|
||||
return computed == stored_hash
|
||||
return False
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get user by ID"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, email, nickname, avatar, status, role,
|
||||
last_login_at, created_at, updated_at
|
||||
FROM qd_users WHERE id = ?
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_by_id failed: {e}")
|
||||
return None
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get user by username (includes password_hash for auth)"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, password_hash, email, nickname, avatar,
|
||||
status, role, last_login_at, created_at, updated_at
|
||||
FROM qd_users WHERE username = ?
|
||||
""",
|
||||
(username,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_by_username failed: {e}")
|
||||
return None
|
||||
|
||||
def authenticate(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Authenticate user with username and password.
|
||||
Returns user info (without password_hash) if successful, None otherwise.
|
||||
"""
|
||||
user = self.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if user.get('status') != 'active':
|
||||
logger.warning(f"Login attempt for disabled user: {username}")
|
||||
return None
|
||||
|
||||
if not self.verify_password(password, user.get('password_hash', '')):
|
||||
return None
|
||||
|
||||
# Update last login time
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET last_login_at = NOW() WHERE id = ?",
|
||||
(user['id'],)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update last_login_at: {e}")
|
||||
|
||||
# Remove password_hash from return value
|
||||
user.pop('password_hash', None)
|
||||
return user
|
||||
|
||||
def create_user(self, data: Dict[str, Any]) -> Optional[int]:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
Args:
|
||||
data: {
|
||||
username: str (required),
|
||||
password: str (required),
|
||||
email: str (optional),
|
||||
nickname: str (optional),
|
||||
role: str (optional, default 'user'),
|
||||
status: str (optional, default 'active')
|
||||
}
|
||||
|
||||
Returns:
|
||||
New user ID or None if failed
|
||||
"""
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
|
||||
if not username or not password:
|
||||
raise ValueError("Username and password are required")
|
||||
|
||||
if len(username) < 3 or len(username) > 50:
|
||||
raise ValueError("Username must be 3-50 characters")
|
||||
|
||||
if len(password) < 6:
|
||||
raise ValueError("Password must be at least 6 characters")
|
||||
|
||||
# Check if username already exists
|
||||
existing = self.get_user_by_username(username)
|
||||
if existing:
|
||||
raise ValueError("Username already exists")
|
||||
|
||||
password_hash = self.hash_password(password)
|
||||
email = (data.get('email') or '').strip() or None
|
||||
nickname = (data.get('nickname') or '').strip() or username
|
||||
role = data.get('role', 'user')
|
||||
status = data.get('status', 'active')
|
||||
|
||||
if role not in self.ROLES:
|
||||
role = 'user'
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_users
|
||||
(username, password_hash, email, nickname, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(username, password_hash, email, nickname, role, status)
|
||||
)
|
||||
db.commit()
|
||||
user_id = cur.lastrowid
|
||||
cur.close()
|
||||
|
||||
# For PostgreSQL, get the ID differently
|
||||
if user_id is None:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT id FROM qd_users WHERE username = ?", (username,))
|
||||
row = cur.fetchone()
|
||||
user_id = row['id'] if row else None
|
||||
cur.close()
|
||||
|
||||
logger.info(f"Created user: {username} (id={user_id})")
|
||||
return user_id
|
||||
except Exception as e:
|
||||
logger.error(f"create_user failed: {e}")
|
||||
raise
|
||||
|
||||
def update_user(self, user_id: int, data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update user information.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
data: Fields to update (email, nickname, avatar, role, status)
|
||||
"""
|
||||
allowed_fields = ['email', 'nickname', 'avatar', 'role', 'status']
|
||||
updates = []
|
||||
values = []
|
||||
|
||||
for field in allowed_fields:
|
||||
if field in data:
|
||||
value = data[field]
|
||||
if field == 'role' and value not in self.ROLES:
|
||||
continue
|
||||
updates.append(f"{field} = ?")
|
||||
values.append(value)
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
values.append(user_id)
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
sql = f"UPDATE qd_users SET {', '.join(updates)} WHERE id = ?"
|
||||
cur.execute(sql, tuple(values))
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"update_user failed: {e}")
|
||||
return False
|
||||
|
||||
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
"""Change user password (requires old password verification)"""
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# Get full user with password_hash
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if not row:
|
||||
return False
|
||||
|
||||
if not self.verify_password(old_password, row['password_hash']):
|
||||
return False
|
||||
|
||||
return self.reset_password(user_id, new_password)
|
||||
|
||||
def reset_password(self, user_id: int, new_password: str) -> bool:
|
||||
"""Reset user password (admin operation, no old password required)"""
|
||||
if len(new_password) < 6:
|
||||
raise ValueError("Password must be at least 6 characters")
|
||||
|
||||
password_hash = self.hash_password(new_password)
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET password_hash = ?, updated_at = NOW() WHERE id = ?",
|
||||
(password_hash, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"reset_password failed: {e}")
|
||||
return False
|
||||
|
||||
def delete_user(self, user_id: int) -> bool:
|
||||
"""Delete a user"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("DELETE FROM qd_users WHERE id = ?", (user_id,))
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"delete_user failed: {e}")
|
||||
return False
|
||||
|
||||
def list_users(self, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||
"""List all users with pagination"""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Get total count
|
||||
cur.execute("SELECT COUNT(*) as count FROM qd_users")
|
||||
total = cur.fetchone()['count']
|
||||
|
||||
# Get users
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, email, nickname, avatar, status, role,
|
||||
last_login_at, created_at, updated_at
|
||||
FROM qd_users
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(page_size, offset)
|
||||
)
|
||||
users = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'items': users,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total + page_size - 1) // page_size
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"list_users failed: {e}")
|
||||
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
|
||||
|
||||
def get_user_permissions(self, role: str) -> List[str]:
|
||||
"""Get permissions for a role"""
|
||||
return self.ROLE_PERMISSIONS.get(role, self.ROLE_PERMISSIONS['viewer'])
|
||||
|
||||
def ensure_admin_exists(self):
|
||||
"""
|
||||
Ensure at least one admin user exists.
|
||||
Creates admin using ADMIN_USER/ADMIN_PASSWORD from env if no users exist.
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT COUNT(*) as count FROM qd_users")
|
||||
count = cur.fetchone()['count']
|
||||
cur.close()
|
||||
|
||||
if count == 0:
|
||||
# Create admin using env credentials
|
||||
admin_user = os.getenv('ADMIN_USER', 'admin')
|
||||
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
||||
|
||||
self.create_user({
|
||||
'username': admin_user,
|
||||
'password': admin_password,
|
||||
'nickname': 'Administrator',
|
||||
'role': 'admin',
|
||||
'status': 'active'
|
||||
})
|
||||
logger.info(f"Created admin user: {admin_user}")
|
||||
except Exception as e:
|
||||
logger.error(f"ensure_admin_exists failed: {e}")
|
||||
|
||||
|
||||
# Global singleton
|
||||
_user_service = None
|
||||
|
||||
def get_user_service() -> UserService:
|
||||
"""Get UserService singleton"""
|
||||
global _user_service
|
||||
if _user_service is None:
|
||||
_user_service = UserService()
|
||||
return _user_service
|
||||
@@ -1,6 +1,12 @@
|
||||
"""
|
||||
Authentication Utilities
|
||||
|
||||
JWT token generation, verification, and middleware decorators.
|
||||
Supports multi-user authentication with role-based access control.
|
||||
"""
|
||||
import jwt
|
||||
import datetime
|
||||
import os
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, g
|
||||
from app.config.settings import Config
|
||||
@@ -8,13 +14,26 @@ from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def generate_token(username):
|
||||
"""Generate JWT token."""
|
||||
|
||||
def generate_token(user_id: int, username: str, role: str = 'user') -> str:
|
||||
"""
|
||||
Generate JWT token with user information.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
username: Username
|
||||
role: User role (admin/manager/user/viewer)
|
||||
|
||||
Returns:
|
||||
JWT token string
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7),
|
||||
'iat': datetime.datetime.utcnow(),
|
||||
'sub': username
|
||||
'sub': username,
|
||||
'user_id': user_id,
|
||||
'role': role,
|
||||
}
|
||||
return jwt.encode(
|
||||
payload,
|
||||
@@ -25,18 +44,44 @@ def generate_token(username):
|
||||
logger.error(f"Token generation failed: {e}")
|
||||
return None
|
||||
|
||||
def verify_token(token):
|
||||
"""Verify JWT token."""
|
||||
|
||||
def verify_token(token: str) -> dict:
|
||||
"""
|
||||
Verify JWT token and return payload.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Token payload dict or None if invalid
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, Config.SECRET_KEY, algorithms=['HS256'])
|
||||
return payload['sub']
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.debug("Token expired")
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.debug(f"Invalid token: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user_id() -> int:
|
||||
"""Get current user ID from flask.g context"""
|
||||
return getattr(g, 'user_id', None)
|
||||
|
||||
|
||||
def get_current_user_role() -> str:
|
||||
"""Get current user role from flask.g context"""
|
||||
return getattr(g, 'user_role', 'user')
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator that enforces Bearer token auth."""
|
||||
"""
|
||||
Decorator that enforces Bearer token auth.
|
||||
|
||||
Sets g.user, g.user_id, g.user_role on successful auth.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = None
|
||||
@@ -51,13 +96,96 @@ def login_required(f):
|
||||
if not token:
|
||||
return jsonify({'code': 401, 'msg': 'Token missing', 'data': None}), 401
|
||||
|
||||
username = verify_token(token)
|
||||
if not username:
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
return jsonify({'code': 401, 'msg': 'Token invalid or expired', 'data': None}), 401
|
||||
|
||||
# Store user in flask.g
|
||||
g.user = username
|
||||
|
||||
# Store user info in flask.g
|
||||
g.user = payload.get('sub')
|
||||
g.user_id = payload.get('user_id')
|
||||
g.user_role = payload.get('role', 'user')
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""
|
||||
Decorator that requires admin role.
|
||||
Must be used after @login_required.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
role = getattr(g, 'user_role', None)
|
||||
if role != 'admin':
|
||||
return jsonify({'code': 403, 'msg': 'Admin access required', 'data': None}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def manager_required(f):
|
||||
"""
|
||||
Decorator that requires manager or admin role.
|
||||
Must be used after @login_required.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
role = getattr(g, 'user_role', None)
|
||||
if role not in ('admin', 'manager'):
|
||||
return jsonify({'code': 403, 'msg': 'Manager access required', 'data': None}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def permission_required(permission: str):
|
||||
"""
|
||||
Decorator factory that checks for a specific permission.
|
||||
Must be used after @login_required.
|
||||
|
||||
Usage:
|
||||
@login_required
|
||||
@permission_required('strategy')
|
||||
def my_endpoint():
|
||||
...
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
role = getattr(g, 'user_role', 'user')
|
||||
|
||||
# Import here to avoid circular import
|
||||
from app.services.user_service import get_user_service
|
||||
permissions = get_user_service().get_user_permissions(role)
|
||||
|
||||
if permission not in permissions:
|
||||
return jsonify({
|
||||
'code': 403,
|
||||
'msg': f'Permission denied: {permission}',
|
||||
'data': None
|
||||
}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
|
||||
|
||||
# Legacy compatibility: single-user mode fallback
|
||||
def _is_single_user_mode() -> bool:
|
||||
"""Check if system is in single-user (legacy) mode"""
|
||||
return os.getenv('SINGLE_USER_MODE', 'false').lower() == 'true'
|
||||
|
||||
|
||||
def authenticate_legacy(username: str, password: str) -> dict:
|
||||
"""
|
||||
Legacy single-user authentication (for backward compatibility).
|
||||
Uses ADMIN_USER and ADMIN_PASSWORD from environment.
|
||||
"""
|
||||
if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD:
|
||||
return {
|
||||
'user_id': 1,
|
||||
'username': username,
|
||||
'role': 'admin',
|
||||
'nickname': 'Admin',
|
||||
}
|
||||
return None
|
||||
|
||||
@@ -1,662 +1,65 @@
|
||||
|
||||
"""
|
||||
SQLite 数据库连接工具 (本地化适配版)
|
||||
Database Connection Utility - PostgreSQL Only
|
||||
|
||||
Provides unified interface for PostgreSQL database operations.
|
||||
|
||||
Usage:
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.commit()
|
||||
|
||||
Configuration:
|
||||
DATABASE_URL=postgresql://user:password@host:port/dbname
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import threading
|
||||
import shutil
|
||||
from typing import Optional, Any, List, Dict
|
||||
from contextlib import contextmanager
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# SQLite 主库路径解析
|
||||
#
|
||||
# 目标默认行为:把主库放到 `backend_api_python/data/quantdinger.db`
|
||||
# 兼容旧行为:老版本会在 `backend_api_python/quantdinger.db` 建库
|
||||
_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
_DEFAULT_DB_FILE = os.path.join(_BASE_DIR, 'data', 'quantdinger.db')
|
||||
_LEGACY_DB_FILE = os.path.join(_BASE_DIR, 'quantdinger.db')
|
||||
# Re-export from PostgreSQL module
|
||||
from app.utils.db_postgres import (
|
||||
get_pg_connection as get_db_connection,
|
||||
get_pg_connection_sync as get_db_connection_sync,
|
||||
is_postgres_available,
|
||||
close_pool as close_db,
|
||||
)
|
||||
|
||||
|
||||
def _get_db_file() -> str:
|
||||
def get_db_type() -> str:
|
||||
"""Get database type (always postgresql)"""
|
||||
return 'postgresql'
|
||||
|
||||
|
||||
def is_postgres() -> bool:
|
||||
"""Check if using PostgreSQL (always True)"""
|
||||
return True
|
||||
|
||||
|
||||
def init_database():
|
||||
"""
|
||||
Resolve SQLite DB file path.
|
||||
|
||||
Priority:
|
||||
- SQLITE_DATABASE_FILE env (Docker 推荐:/app/data/quantdinger.db)
|
||||
- default: backend_api_python/data/quantdinger.db (local recommended)
|
||||
|
||||
Also performs a best-effort one-time migration:
|
||||
If the configured/default path doesn't exist but legacy db exists, copy legacy to configured/default path.
|
||||
Initialize database connection.
|
||||
Schema is created via migrations/init.sql on PostgreSQL container start.
|
||||
"""
|
||||
env_path = os.getenv('SQLITE_DATABASE_FILE')
|
||||
db_path = (env_path or '').strip() or _DEFAULT_DB_FILE
|
||||
if is_postgres_available():
|
||||
from app.utils.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.info("PostgreSQL connection verified")
|
||||
else:
|
||||
raise RuntimeError("Cannot connect to PostgreSQL. Check DATABASE_URL.")
|
||||
|
||||
# Ensure parent dir exists
|
||||
parent = os.path.dirname(db_path)
|
||||
if parent and not os.path.exists(parent):
|
||||
try:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Best-effort migration from legacy path
|
||||
try:
|
||||
if os.path.abspath(db_path) != os.path.abspath(_LEGACY_DB_FILE):
|
||||
if (not os.path.exists(db_path)) and os.path.exists(_LEGACY_DB_FILE):
|
||||
shutil.copy2(_LEGACY_DB_FILE, db_path)
|
||||
logger.info(f"Migrated SQLite DB from legacy path to {db_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"SQLite DB migration skipped/failed: {e}")
|
||||
|
||||
return db_path
|
||||
|
||||
# 线程锁,用于简单的并发控制(SQLite 对写操作有限制)
|
||||
_db_lock = threading.Lock()
|
||||
|
||||
def _init_db_schema(conn):
|
||||
"""初始化数据库表结构"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
def ensure_columns(table: str, columns: Dict[str, str]) -> None:
|
||||
"""
|
||||
Ensure columns exist for an existing SQLite table (simple migration).
|
||||
columns: {column_name: "TYPE DEFAULT ..."}
|
||||
"""
|
||||
try:
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
existing = {row[1] for row in cursor.fetchall() or []} # row[1] is column name
|
||||
for col, ddl in columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||
except Exception as e:
|
||||
logger.warning(f"ensure_columns failed for table={table}: {e}")
|
||||
|
||||
# 1. 策略表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategies_trading (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_name TEXT NOT NULL,
|
||||
strategy_type TEXT DEFAULT 'IndicatorStrategy',
|
||||
market_category TEXT DEFAULT 'Crypto',
|
||||
execution_mode TEXT DEFAULT 'signal',
|
||||
notification_config TEXT DEFAULT '', -- JSON string
|
||||
status TEXT DEFAULT 'stopped',
|
||||
symbol TEXT,
|
||||
timeframe TEXT,
|
||||
initial_capital REAL DEFAULT 1000,
|
||||
leverage INTEGER DEFAULT 1,
|
||||
market_type TEXT DEFAULT 'swap',
|
||||
exchange_config TEXT, -- JSON string
|
||||
indicator_config TEXT, -- JSON string
|
||||
trading_config TEXT, -- JSON string
|
||||
ai_model_config TEXT, -- JSON string
|
||||
decide_interval INTEGER DEFAULT 300,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategies_trading", {
|
||||
"market_category": "TEXT DEFAULT 'Crypto'",
|
||||
"execution_mode": "TEXT DEFAULT 'signal'",
|
||||
"notification_config": "TEXT DEFAULT ''",
|
||||
"strategy_group_id": "TEXT DEFAULT ''", # 策略组ID,批量创建的策略共享同一个组ID
|
||||
"group_base_name": "TEXT DEFAULT ''" # 策略组基础名称(用于显示)
|
||||
})
|
||||
|
||||
# 2. 持仓表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
symbol TEXT,
|
||||
side TEXT, -- long/short
|
||||
size REAL,
|
||||
entry_price REAL,
|
||||
current_price REAL,
|
||||
highest_price REAL DEFAULT 0,
|
||||
lowest_price REAL DEFAULT 0,
|
||||
unrealized_pnl REAL DEFAULT 0,
|
||||
pnl_percent REAL DEFAULT 0,
|
||||
equity REAL DEFAULT 0,
|
||||
updated_at INTEGER,
|
||||
UNIQUE(strategy_id, symbol, side)
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategy_positions", {
|
||||
"highest_price": "REAL DEFAULT 0",
|
||||
"lowest_price": "REAL DEFAULT 0",
|
||||
})
|
||||
|
||||
# 3. 交易记录表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
symbol TEXT,
|
||||
type TEXT, -- open_long, close_short, etc.
|
||||
price REAL,
|
||||
amount REAL,
|
||||
value REAL,
|
||||
commission REAL DEFAULT 0,
|
||||
commission_ccy TEXT DEFAULT '',
|
||||
profit REAL DEFAULT 0,
|
||||
created_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategy_trades", {
|
||||
"commission_ccy": "TEXT DEFAULT ''",
|
||||
})
|
||||
|
||||
# NOTE:
|
||||
# We intentionally do not persist runtime logs in DB for local deployments.
|
||||
# Use console logs / stdout prints instead.
|
||||
|
||||
# 3.1 Pending orders queue (signal dispatch / live execution)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pending_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
symbol TEXT NOT NULL,
|
||||
signal_type TEXT NOT NULL, -- open_long/close_long/open_short/close_short/add_long/add_short
|
||||
signal_ts INTEGER, -- candle timestamp (seconds). used for strict de-dup per candle
|
||||
market_type TEXT DEFAULT 'swap',
|
||||
order_type TEXT DEFAULT 'market',
|
||||
amount REAL DEFAULT 0, -- base amount (or stake amount depending on execution)
|
||||
price REAL DEFAULT 0, -- reference price at enqueue time
|
||||
execution_mode TEXT DEFAULT 'signal', -- signal/live
|
||||
status TEXT DEFAULT 'pending', -- pending/processing/sent/failed/deferred
|
||||
priority INTEGER DEFAULT 0,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
max_attempts INTEGER DEFAULT 10,
|
||||
last_error TEXT DEFAULT '',
|
||||
payload_json TEXT DEFAULT '', -- JSON string for dispatcher
|
||||
-- Live execution result fields (best-effort)
|
||||
dispatch_note TEXT DEFAULT '',
|
||||
exchange_id TEXT DEFAULT '',
|
||||
exchange_order_id TEXT DEFAULT '',
|
||||
exchange_response_json TEXT DEFAULT '',
|
||||
filled REAL DEFAULT 0,
|
||||
avg_price REAL DEFAULT 0,
|
||||
executed_at INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
processed_at INTEGER,
|
||||
sent_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("pending_orders", {
|
||||
"signal_ts": "INTEGER",
|
||||
"market_type": "TEXT DEFAULT 'swap'",
|
||||
"order_type": "TEXT DEFAULT 'market'",
|
||||
"price": "REAL DEFAULT 0",
|
||||
"execution_mode": "TEXT DEFAULT 'signal'",
|
||||
"status": "TEXT DEFAULT 'pending'",
|
||||
"priority": "INTEGER DEFAULT 0",
|
||||
"attempts": "INTEGER DEFAULT 0",
|
||||
"max_attempts": "INTEGER DEFAULT 10",
|
||||
"last_error": "TEXT DEFAULT ''",
|
||||
"payload_json": "TEXT DEFAULT ''",
|
||||
"dispatch_note": "TEXT DEFAULT ''",
|
||||
"exchange_id": "TEXT DEFAULT ''",
|
||||
"exchange_order_id": "TEXT DEFAULT ''",
|
||||
"exchange_response_json": "TEXT DEFAULT ''",
|
||||
"filled": "REAL DEFAULT 0",
|
||||
"avg_price": "REAL DEFAULT 0",
|
||||
"executed_at": "INTEGER",
|
||||
"created_at": "INTEGER",
|
||||
"updated_at": "INTEGER",
|
||||
"processed_at": "INTEGER",
|
||||
"sent_at": "INTEGER",
|
||||
})
|
||||
|
||||
# 3.2 Strategy notifications (browser polling / audit trail)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
symbol TEXT DEFAULT '',
|
||||
signal_type TEXT DEFAULT '',
|
||||
channels TEXT DEFAULT '',
|
||||
title TEXT DEFAULT '',
|
||||
message TEXT DEFAULT '',
|
||||
payload_json TEXT DEFAULT '',
|
||||
created_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategy_notifications", {
|
||||
"strategy_id": "INTEGER",
|
||||
"symbol": "TEXT DEFAULT ''",
|
||||
"signal_type": "TEXT DEFAULT ''",
|
||||
"channels": "TEXT DEFAULT ''",
|
||||
"title": "TEXT DEFAULT ''",
|
||||
"message": "TEXT DEFAULT ''",
|
||||
"payload_json": "TEXT DEFAULT ''",
|
||||
"created_at": "INTEGER",
|
||||
"is_read": "INTEGER DEFAULT 0",
|
||||
})
|
||||
|
||||
# 4. 指标代码表(参考 MySQL: qd_indicator_codes)
|
||||
# 说明:
|
||||
# - 本地化后统一使用 SQLite,但字段保持与 MySQL 结构接近,便于前端/业务复用。
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_indicator_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
is_buy INTEGER NOT NULL DEFAULT 0,
|
||||
end_time INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
code TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
publish_to_community INTEGER NOT NULL DEFAULT 0,
|
||||
pricing_type TEXT NOT NULL DEFAULT 'free',
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
is_encrypted INTEGER NOT NULL DEFAULT 0,
|
||||
preview_image TEXT DEFAULT '',
|
||||
createtime INTEGER,
|
||||
updatetime INTEGER,
|
||||
-- legacy local columns (kept for backward compatibility)
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
# Migrate older local DBs (missing columns) to the new schema shape.
|
||||
ensure_columns("qd_indicator_codes", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"is_buy": "INTEGER NOT NULL DEFAULT 0",
|
||||
"end_time": "INTEGER NOT NULL DEFAULT 1",
|
||||
"publish_to_community": "INTEGER NOT NULL DEFAULT 0",
|
||||
"pricing_type": "TEXT NOT NULL DEFAULT 'free'",
|
||||
"price": "REAL NOT NULL DEFAULT 0",
|
||||
"is_encrypted": "INTEGER NOT NULL DEFAULT 0",
|
||||
"preview_image": "TEXT DEFAULT ''",
|
||||
"createtime": "INTEGER",
|
||||
"updatetime": "INTEGER"
|
||||
})
|
||||
|
||||
# 4.1 策略代码表(indicator-analysis 本地策略;与交易执行器的 qd_strategies_trading 区分)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
code TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
createtime INTEGER,
|
||||
updatetime INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_strategy_codes", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"name": "TEXT NOT NULL DEFAULT ''",
|
||||
"code": "TEXT",
|
||||
"description": "TEXT DEFAULT ''",
|
||||
"createtime": "INTEGER",
|
||||
"updatetime": "INTEGER"
|
||||
})
|
||||
|
||||
# 5. AI决策记录表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_ai_decisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id INTEGER,
|
||||
decision_data TEXT, -- JSON
|
||||
context_data TEXT, -- JSON
|
||||
created_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
# 6. 插件/系统配置表(原来由 MySQL 提供,这里用 SQLite 本地化)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_addon_config (
|
||||
config_key TEXT PRIMARY KEY,
|
||||
config_value TEXT,
|
||||
type TEXT DEFAULT 'string'
|
||||
)
|
||||
""")
|
||||
|
||||
# 7. Watchlist (local-only, single-user by default)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
market TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
name TEXT DEFAULT '',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
UNIQUE(user_id, market, symbol)
|
||||
)
|
||||
""")
|
||||
|
||||
# 8. Analysis tasks / history (local-only)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_analysis_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
market TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
model TEXT DEFAULT '',
|
||||
language TEXT DEFAULT 'en-US',
|
||||
status TEXT DEFAULT 'completed', -- completed/failed/processing/pending
|
||||
result_json TEXT DEFAULT '',
|
||||
error_message TEXT DEFAULT '',
|
||||
created_at INTEGER,
|
||||
completed_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
# 9. Backtest runs (for AI optimization / history)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_backtest_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
indicator_id INTEGER,
|
||||
market TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
end_date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
initial_capital REAL DEFAULT 10000,
|
||||
commission REAL DEFAULT 0.001,
|
||||
slippage REAL DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 1,
|
||||
trade_direction TEXT DEFAULT 'long',
|
||||
strategy_config TEXT DEFAULT '', -- JSON string
|
||||
status TEXT DEFAULT 'success', -- success/failed
|
||||
error_message TEXT DEFAULT '',
|
||||
result_json TEXT DEFAULT '', -- JSON string
|
||||
created_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_backtest_runs", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"indicator_id": "INTEGER",
|
||||
"market": "TEXT NOT NULL DEFAULT ''",
|
||||
"symbol": "TEXT NOT NULL DEFAULT ''",
|
||||
"timeframe": "TEXT NOT NULL DEFAULT ''",
|
||||
"start_date": "TEXT NOT NULL DEFAULT ''",
|
||||
"end_date": "TEXT NOT NULL DEFAULT ''",
|
||||
"initial_capital": "REAL DEFAULT 10000",
|
||||
"commission": "REAL DEFAULT 0.001",
|
||||
"slippage": "REAL DEFAULT 0",
|
||||
"leverage": "INTEGER DEFAULT 1",
|
||||
"trade_direction": "TEXT DEFAULT 'long'",
|
||||
"strategy_config": "TEXT DEFAULT ''",
|
||||
"status": "TEXT DEFAULT 'success'",
|
||||
"error_message": "TEXT DEFAULT ''",
|
||||
"result_json": "TEXT DEFAULT ''",
|
||||
"created_at": "INTEGER"
|
||||
})
|
||||
|
||||
# 10. Exchange credentials vault (local-only)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_exchange_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT DEFAULT '',
|
||||
exchange_id TEXT NOT NULL,
|
||||
api_key_hint TEXT DEFAULT '',
|
||||
encrypted_config TEXT NOT NULL, -- encrypted JSON string
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_exchange_credentials", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"name": "TEXT DEFAULT ''",
|
||||
"exchange_id": "TEXT NOT NULL DEFAULT ''",
|
||||
"api_key_hint": "TEXT DEFAULT ''",
|
||||
"encrypted_config": "TEXT NOT NULL DEFAULT ''",
|
||||
"created_at": "INTEGER",
|
||||
"updated_at": "INTEGER"
|
||||
})
|
||||
|
||||
# 11. Manual positions (user's existing holdings outside the system)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_manual_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
market TEXT NOT NULL, -- Crypto/USStock/AShare/HShare/Forex/Futures
|
||||
symbol TEXT NOT NULL,
|
||||
name TEXT DEFAULT '', -- Display name
|
||||
side TEXT DEFAULT 'long', -- long/short
|
||||
quantity REAL NOT NULL DEFAULT 0,
|
||||
entry_price REAL NOT NULL DEFAULT 0,
|
||||
entry_time INTEGER, -- Position open timestamp
|
||||
notes TEXT DEFAULT '', -- User notes
|
||||
tags TEXT DEFAULT '', -- JSON array of tags
|
||||
group_name TEXT DEFAULT '', -- Group name for categorization
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
UNIQUE(user_id, market, symbol, side)
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_manual_positions", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"market": "TEXT NOT NULL DEFAULT ''",
|
||||
"symbol": "TEXT NOT NULL DEFAULT ''",
|
||||
"name": "TEXT DEFAULT ''",
|
||||
"side": "TEXT DEFAULT 'long'",
|
||||
"quantity": "REAL NOT NULL DEFAULT 0",
|
||||
"entry_price": "REAL NOT NULL DEFAULT 0",
|
||||
"entry_time": "INTEGER",
|
||||
"notes": "TEXT DEFAULT ''",
|
||||
"tags": "TEXT DEFAULT ''",
|
||||
"group_name": "TEXT DEFAULT ''",
|
||||
"created_at": "INTEGER",
|
||||
"updated_at": "INTEGER"
|
||||
})
|
||||
|
||||
# 11.1 Position alerts (price alerts and PnL alerts for manual positions)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_position_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
position_id INTEGER, -- FK to qd_manual_positions (NULL = symbol-level alert)
|
||||
market TEXT DEFAULT '',
|
||||
symbol TEXT DEFAULT '',
|
||||
alert_type TEXT NOT NULL, -- price_above / price_below / pnl_above / pnl_below
|
||||
threshold REAL NOT NULL DEFAULT 0,
|
||||
notification_config TEXT DEFAULT '', -- JSON: channels, targets
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_triggered INTEGER DEFAULT 0,
|
||||
last_triggered_at INTEGER,
|
||||
trigger_count INTEGER DEFAULT 0,
|
||||
repeat_interval INTEGER DEFAULT 0, -- 0=once, >0=repeat every N seconds
|
||||
notes TEXT DEFAULT '',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_position_alerts", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"position_id": "INTEGER",
|
||||
"market": "TEXT DEFAULT ''",
|
||||
"symbol": "TEXT DEFAULT ''",
|
||||
"alert_type": "TEXT NOT NULL DEFAULT 'price_above'",
|
||||
"threshold": "REAL NOT NULL DEFAULT 0",
|
||||
"notification_config": "TEXT DEFAULT ''",
|
||||
"is_active": "INTEGER DEFAULT 1",
|
||||
"is_triggered": "INTEGER DEFAULT 0",
|
||||
"last_triggered_at": "INTEGER",
|
||||
"trigger_count": "INTEGER DEFAULT 0",
|
||||
"repeat_interval": "INTEGER DEFAULT 0",
|
||||
"notes": "TEXT DEFAULT ''",
|
||||
"created_at": "INTEGER",
|
||||
"updated_at": "INTEGER"
|
||||
})
|
||||
|
||||
# 12. Position monitors (AI analysis tasks for manual positions)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_position_monitors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT DEFAULT '', -- Monitor name
|
||||
position_ids TEXT DEFAULT '', -- JSON array of position IDs (empty = all positions)
|
||||
monitor_type TEXT DEFAULT 'ai', -- ai / price_alert / pnl_alert
|
||||
config TEXT DEFAULT '', -- JSON config (interval_minutes, prompt, thresholds, etc.)
|
||||
notification_config TEXT DEFAULT '', -- JSON notification config (channels, targets)
|
||||
is_active INTEGER DEFAULT 1,
|
||||
last_run_at INTEGER,
|
||||
next_run_at INTEGER,
|
||||
last_result TEXT DEFAULT '', -- Last analysis result (JSON)
|
||||
run_count INTEGER DEFAULT 0,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
ensure_columns("qd_position_monitors", {
|
||||
"user_id": "INTEGER NOT NULL DEFAULT 1",
|
||||
"name": "TEXT DEFAULT ''",
|
||||
"position_ids": "TEXT DEFAULT ''",
|
||||
"monitor_type": "TEXT DEFAULT 'ai'",
|
||||
"config": "TEXT DEFAULT ''",
|
||||
"notification_config": "TEXT DEFAULT ''",
|
||||
"is_active": "INTEGER DEFAULT 1",
|
||||
"last_run_at": "INTEGER",
|
||||
"next_run_at": "INTEGER",
|
||||
"last_result": "TEXT DEFAULT ''",
|
||||
"run_count": "INTEGER DEFAULT 0",
|
||||
"created_at": "INTEGER",
|
||||
"updated_at": "INTEGER"
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database schema initialized (SQLite)")
|
||||
|
||||
# 初始化一次(按 db_file 维度)
|
||||
_has_initialized = False
|
||||
_initialized_db_file = None
|
||||
|
||||
class SQLiteCursor:
|
||||
"""模拟 pymysql DictCursor"""
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def execute(self, query: str, args: Any = None):
|
||||
# 适配 MySQL -> SQLite 语法
|
||||
# 1. 替换占位符: %s -> ?
|
||||
query = query.replace('%s', '?')
|
||||
# 2. 替换 INSERT IGNORE -> INSERT OR IGNORE
|
||||
query = query.replace('INSERT IGNORE', 'INSERT OR IGNORE')
|
||||
# 3. 替换 ON DUPLICATE KEY UPDATE -> 简化为 UPSERT (SQLite 3.24+)
|
||||
# 注意:复杂的 ON DUPLICATE KEY UPDATE 很难自动转换,建议业务代码改写
|
||||
# 这里做一个简单的替换尝试,如果失败则需要人工介入代码
|
||||
if 'ON DUPLICATE KEY UPDATE' in query:
|
||||
# 简单的正则替换很难完美,这里记录日志提醒
|
||||
logger.warning(f"Complex SQL may require manual SQLite adaptation: {query}")
|
||||
# 尝试转换为 SQLite 的 ON CONFLICT (id) DO UPDATE SET ...
|
||||
# 但由于不知道主键冲突列,很难自动转换。
|
||||
# 临时方案:如果遇到这种 SQL,可能报错。我们假设主要业务逻辑已经重构。
|
||||
pass
|
||||
|
||||
if args:
|
||||
return self._cursor.execute(query, args)
|
||||
return self._cursor.execute(query)
|
||||
|
||||
def fetchone(self):
|
||||
row = self._cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
# Convert sqlite3.Row to dict
|
||||
return dict(row)
|
||||
|
||||
def fetchall(self):
|
||||
rows = self._cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def close(self):
|
||||
self._cursor.close()
|
||||
|
||||
@property
|
||||
def lastrowid(self):
|
||||
return self._cursor.lastrowid
|
||||
|
||||
class SQLiteConnection:
|
||||
"""数据库连接包装类"""
|
||||
def __init__(self, db_path):
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False, timeout=30.0)
|
||||
# 设置 Row factory 以支持字段名访问
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
|
||||
def cursor(self):
|
||||
return SQLiteCursor(self._conn.cursor())
|
||||
|
||||
def commit(self):
|
||||
self._conn.commit()
|
||||
|
||||
def rollback(self):
|
||||
self._conn.rollback()
|
||||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection():
|
||||
"""
|
||||
获取数据库连接 (Context Manager)
|
||||
"""
|
||||
global _has_initialized, _initialized_db_file
|
||||
|
||||
# 简单的连接创建,不使用连接池(SQLite 文件锁机制决定了连接池意义不大)
|
||||
# 使用线程锁防止写冲突(虽然 SQLite 有 WAL 模式,但稳妥起见)
|
||||
# 注意:这里加锁粒度较大,如果是高并发场景可能会慢,但对于个人量化系统足够。
|
||||
|
||||
# 初始化表结构(确保每个 db_file 都被初始化过)
|
||||
db_file = _get_db_file()
|
||||
if (not _has_initialized) or (_initialized_db_file != db_file):
|
||||
try:
|
||||
conn_init = sqlite3.connect(db_file)
|
||||
_init_db_schema(conn_init)
|
||||
conn_init.close()
|
||||
_has_initialized = True
|
||||
_initialized_db_file = db_file
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {e}")
|
||||
|
||||
conn = SQLiteConnection(db_file)
|
||||
try:
|
||||
# with _db_lock: # SQLite 内部有锁,这里如果不跨线程共享连接其实不用强加锁
|
||||
yield conn
|
||||
except Exception as e:
|
||||
logger.error(f"Database operation error: {e}")
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_db_connection_sync():
|
||||
"""兼容旧接口"""
|
||||
global _has_initialized, _initialized_db_file
|
||||
db_file = _get_db_file()
|
||||
if (not _has_initialized) or (_initialized_db_file != db_file):
|
||||
try:
|
||||
conn_init = sqlite3.connect(db_file)
|
||||
_init_db_schema(conn_init)
|
||||
conn_init.close()
|
||||
_has_initialized = True
|
||||
_initialized_db_file = db_file
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {e}")
|
||||
|
||||
return SQLiteConnection(db_file)
|
||||
|
||||
# Legacy alias
|
||||
def close_db_connection():
|
||||
"""Legacy alias for close_db"""
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
'get_db_connection',
|
||||
'get_db_connection_sync',
|
||||
'close_db_connection',
|
||||
'init_database',
|
||||
'close_db',
|
||||
'get_db_type',
|
||||
'is_postgres',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
PostgreSQL Database Connection Utility
|
||||
|
||||
Supports multi-user mode with connection pooling and SQLite compatibility layer.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional, Any, List, Dict
|
||||
from contextlib import contextmanager
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Try to import psycopg2
|
||||
try:
|
||||
import psycopg2
|
||||
from psycopg2 import pool
|
||||
from psycopg2.extras import RealDictCursor
|
||||
HAS_PSYCOPG2 = True
|
||||
except ImportError:
|
||||
HAS_PSYCOPG2 = False
|
||||
logger.warning("psycopg2 not installed. PostgreSQL support disabled.")
|
||||
|
||||
# Connection pool (global singleton)
|
||||
_connection_pool: Optional[Any] = None
|
||||
_pool_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_database_url() -> str:
|
||||
"""Get database connection URL from environment"""
|
||||
return os.getenv('DATABASE_URL', '').strip()
|
||||
|
||||
|
||||
def _parse_database_url(url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse DATABASE_URL format: postgresql://user:password@host:port/dbname
|
||||
"""
|
||||
if not url:
|
||||
return {}
|
||||
|
||||
# Remove protocol prefix
|
||||
if url.startswith('postgresql://'):
|
||||
url = url[13:]
|
||||
elif url.startswith('postgres://'):
|
||||
url = url[11:]
|
||||
else:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
|
||||
# Split user:password@host:port/dbname
|
||||
if '@' in url:
|
||||
auth, hostpart = url.rsplit('@', 1)
|
||||
if ':' in auth:
|
||||
result['user'], result['password'] = auth.split(':', 1)
|
||||
else:
|
||||
result['user'] = auth
|
||||
else:
|
||||
hostpart = url
|
||||
|
||||
# Split host:port/dbname
|
||||
if '/' in hostpart:
|
||||
hostport, result['dbname'] = hostpart.split('/', 1)
|
||||
else:
|
||||
hostport = hostpart
|
||||
|
||||
if ':' in hostport:
|
||||
result['host'], port_str = hostport.split(':', 1)
|
||||
result['port'] = int(port_str)
|
||||
else:
|
||||
result['host'] = hostport
|
||||
result['port'] = 5432
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_connection_pool():
|
||||
"""Get or create connection pool"""
|
||||
global _connection_pool
|
||||
|
||||
if _connection_pool is not None:
|
||||
return _connection_pool
|
||||
|
||||
with _pool_lock:
|
||||
if _connection_pool is not None:
|
||||
return _connection_pool
|
||||
|
||||
if not HAS_PSYCOPG2:
|
||||
raise RuntimeError("psycopg2 is not installed. Cannot use PostgreSQL.")
|
||||
|
||||
db_url = _get_database_url()
|
||||
if not db_url:
|
||||
raise RuntimeError("DATABASE_URL environment variable is not set.")
|
||||
|
||||
params = _parse_database_url(db_url)
|
||||
if not params:
|
||||
raise RuntimeError(f"Invalid DATABASE_URL format: {db_url}")
|
||||
|
||||
try:
|
||||
_connection_pool = pool.ThreadedConnectionPool(
|
||||
minconn=2,
|
||||
maxconn=20,
|
||||
host=params.get('host', 'localhost'),
|
||||
port=params.get('port', 5432),
|
||||
user=params.get('user', 'quantdinger'),
|
||||
password=params.get('password', ''),
|
||||
dbname=params.get('dbname', 'quantdinger'),
|
||||
connect_timeout=10,
|
||||
)
|
||||
logger.info(f"PostgreSQL connection pool created: {params.get('host')}:{params.get('port')}/{params.get('dbname')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create PostgreSQL connection pool: {e}")
|
||||
raise
|
||||
|
||||
return _connection_pool
|
||||
|
||||
|
||||
class PostgresCursor:
|
||||
"""PostgreSQL cursor wrapper with SQLite placeholder compatibility"""
|
||||
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
self._last_insert_id = None
|
||||
|
||||
def _convert_placeholders(self, query: str) -> str:
|
||||
"""
|
||||
Convert SQLite-style ? placeholders to PostgreSQL %s
|
||||
Also handle some SQL syntax differences
|
||||
"""
|
||||
# Replace ? -> %s
|
||||
query = query.replace('?', '%s')
|
||||
|
||||
# SQLite: INSERT OR IGNORE -> PostgreSQL: INSERT ... ON CONFLICT DO NOTHING
|
||||
query = query.replace('INSERT OR IGNORE', 'INSERT')
|
||||
|
||||
return query
|
||||
|
||||
def execute(self, query: str, args: Any = None):
|
||||
"""Execute SQL statement"""
|
||||
query = self._convert_placeholders(query)
|
||||
|
||||
# Check if this is an INSERT and add RETURNING id if not present
|
||||
is_insert = query.strip().upper().startswith('INSERT')
|
||||
if is_insert and 'RETURNING' not in query.upper():
|
||||
query = query.rstrip(';').rstrip() + ' RETURNING id'
|
||||
|
||||
if args:
|
||||
if not isinstance(args, (tuple, list)):
|
||||
args = (args,)
|
||||
result = self._cursor.execute(query, args)
|
||||
else:
|
||||
result = self._cursor.execute(query)
|
||||
|
||||
# Capture last insert id for INSERT statements
|
||||
if is_insert:
|
||||
try:
|
||||
row = self._cursor.fetchone()
|
||||
if row and 'id' in row:
|
||||
self._last_insert_id = row['id']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def fetchone(self) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch single row"""
|
||||
row = self._cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row) if row else None
|
||||
|
||||
def fetchall(self) -> List[Dict[str, Any]]:
|
||||
"""Fetch all rows"""
|
||||
rows = self._cursor.fetchall()
|
||||
return [dict(row) for row in rows] if rows else []
|
||||
|
||||
def close(self):
|
||||
"""Close cursor"""
|
||||
self._cursor.close()
|
||||
|
||||
@property
|
||||
def lastrowid(self) -> Optional[int]:
|
||||
"""Get last inserted row ID"""
|
||||
return self._last_insert_id
|
||||
|
||||
@property
|
||||
def rowcount(self) -> int:
|
||||
"""Get affected row count"""
|
||||
return self._cursor.rowcount
|
||||
|
||||
|
||||
class PostgresConnection:
|
||||
"""PostgreSQL connection wrapper"""
|
||||
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
self._pool = _get_connection_pool()
|
||||
|
||||
def cursor(self) -> PostgresCursor:
|
||||
"""Create cursor"""
|
||||
return PostgresCursor(self._conn.cursor(cursor_factory=RealDictCursor))
|
||||
|
||||
def commit(self):
|
||||
"""Commit transaction"""
|
||||
self._conn.commit()
|
||||
|
||||
def rollback(self):
|
||||
"""Rollback transaction"""
|
||||
self._conn.rollback()
|
||||
|
||||
def close(self):
|
||||
"""Return connection to pool"""
|
||||
if self._pool and self._conn:
|
||||
try:
|
||||
self._pool.putconn(self._conn)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to return connection to pool: {e}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_pg_connection():
|
||||
"""
|
||||
Get PostgreSQL database connection (Context Manager)
|
||||
"""
|
||||
pool = _get_connection_pool()
|
||||
conn = None
|
||||
try:
|
||||
conn = pool.getconn()
|
||||
pg_conn = PostgresConnection(conn)
|
||||
yield pg_conn
|
||||
except Exception as e:
|
||||
if conn:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"PostgreSQL operation error: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
try:
|
||||
pool.putconn(conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_pg_connection_sync() -> PostgresConnection:
|
||||
"""
|
||||
Get connection synchronously (caller must close)
|
||||
"""
|
||||
pool = _get_connection_pool()
|
||||
conn = pool.getconn()
|
||||
return PostgresConnection(conn)
|
||||
|
||||
|
||||
def execute_sql(sql: str, params: tuple = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute SQL and return results (convenience function)
|
||||
"""
|
||||
with get_pg_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql, params)
|
||||
if sql.strip().upper().startswith('SELECT'):
|
||||
return cursor.fetchall()
|
||||
conn.commit()
|
||||
return []
|
||||
|
||||
|
||||
def is_postgres_available() -> bool:
|
||||
"""Check if PostgreSQL is available"""
|
||||
if not HAS_PSYCOPG2:
|
||||
return False
|
||||
|
||||
db_url = _get_database_url()
|
||||
if not db_url:
|
||||
return False
|
||||
|
||||
try:
|
||||
with get_pg_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"PostgreSQL not available: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def close_pool():
|
||||
"""Close connection pool (call on app shutdown)"""
|
||||
global _connection_pool
|
||||
if _connection_pool:
|
||||
try:
|
||||
_connection_pool.closeall()
|
||||
_connection_pool = None
|
||||
logger.info("PostgreSQL connection pool closed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing connection pool: {e}")
|
||||
@@ -8,6 +8,13 @@ SECRET_KEY=quantdinger-secret-key-change-me
|
||||
ADMIN_USER=quantdinger
|
||||
ADMIN_PASSWORD=123456
|
||||
|
||||
# =========================
|
||||
# Demo Mode
|
||||
# =========================
|
||||
# Set to true to enable read-only mode for public demo.
|
||||
# Blocks all POST/PUT/DELETE requests except login.
|
||||
IS_DEMO_MODE=false
|
||||
|
||||
# =========================
|
||||
# Network / App
|
||||
# =========================
|
||||
@@ -16,12 +23,13 @@ PYTHON_API_PORT=5000
|
||||
PYTHON_API_DEBUG=False
|
||||
|
||||
# =========================
|
||||
# Database (SQLite)
|
||||
# Database Configuration (PostgreSQL)
|
||||
# =========================
|
||||
# 主库文件路径(可选)
|
||||
# - 不设置时,默认使用:backend_api_python/data/quantdinger.db
|
||||
# - Docker 推荐:/app/data/quantdinger.db
|
||||
SQLITE_DATABASE_FILE=
|
||||
# PostgreSQL connection URL (required for multi-user mode)
|
||||
# Format: postgresql://user:password@host:port/dbname
|
||||
# Docker: uses this default, no changes needed
|
||||
# Local: change 'postgres' to 'localhost' and update password
|
||||
DATABASE_URL=postgresql://quantdinger:quantdinger123@postgres:5432/quantdinger
|
||||
|
||||
# =========================
|
||||
# Pending orders worker (optional)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,537 @@
|
||||
-- QuantDinger PostgreSQL Schema Initialization
|
||||
-- This script runs automatically when PostgreSQL container starts for the first time.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Users & Authentication
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
nickname VARCHAR(50),
|
||||
avatar VARCHAR(255) DEFAULT '/avatar2.jpg',
|
||||
status VARCHAR(20) DEFAULT 'active', -- active/disabled/pending
|
||||
role VARCHAR(20) DEFAULT 'user', -- admin/manager/user/viewer
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Note: Admin user is created automatically by the application on startup
|
||||
-- using ADMIN_USER and ADMIN_PASSWORD from environment variables
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Trading Strategies
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_strategies_trading (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
strategy_name VARCHAR(255) NOT NULL,
|
||||
strategy_type VARCHAR(50) DEFAULT 'IndicatorStrategy',
|
||||
market_category VARCHAR(50) DEFAULT 'Crypto',
|
||||
execution_mode VARCHAR(20) DEFAULT 'signal',
|
||||
notification_config TEXT DEFAULT '',
|
||||
status VARCHAR(20) DEFAULT 'stopped',
|
||||
symbol VARCHAR(50),
|
||||
timeframe VARCHAR(10),
|
||||
initial_capital DECIMAL(20,8) DEFAULT 1000,
|
||||
leverage INTEGER DEFAULT 1,
|
||||
market_type VARCHAR(20) DEFAULT 'swap',
|
||||
exchange_config TEXT,
|
||||
indicator_config TEXT,
|
||||
trading_config TEXT,
|
||||
ai_model_config TEXT,
|
||||
decide_interval INTEGER DEFAULT 300,
|
||||
strategy_group_id VARCHAR(100) DEFAULT '',
|
||||
group_base_name VARCHAR(255) DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_strategies_user_id ON qd_strategies_trading(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_strategies_status ON qd_strategies_trading(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_strategies_group_id ON qd_strategies_trading(strategy_group_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Strategy Positions
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_positions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
|
||||
symbol VARCHAR(50),
|
||||
side VARCHAR(10), -- long/short
|
||||
size DECIMAL(20,8),
|
||||
entry_price DECIMAL(20,8),
|
||||
current_price DECIMAL(20,8),
|
||||
highest_price DECIMAL(20,8) DEFAULT 0,
|
||||
lowest_price DECIMAL(20,8) DEFAULT 0,
|
||||
unrealized_pnl DECIMAL(20,8) DEFAULT 0,
|
||||
pnl_percent DECIMAL(10,4) DEFAULT 0,
|
||||
equity DECIMAL(20,8) DEFAULT 0,
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(strategy_id, symbol, side)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_positions_user_id ON qd_strategy_positions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_positions_strategy_id ON qd_strategy_positions(strategy_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Strategy Trades
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_trades (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
|
||||
symbol VARCHAR(50),
|
||||
type VARCHAR(30), -- open_long, close_short, etc.
|
||||
price DECIMAL(20,8),
|
||||
amount DECIMAL(20,8),
|
||||
value DECIMAL(20,8),
|
||||
commission DECIMAL(20,8) DEFAULT 0,
|
||||
commission_ccy VARCHAR(20) DEFAULT '',
|
||||
profit DECIMAL(20,8) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_user_id ON qd_strategy_trades(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_strategy_id ON qd_strategy_trades(strategy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_created_at ON qd_strategy_trades(created_at);
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Pending Orders Queue
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE SET NULL,
|
||||
symbol VARCHAR(50) NOT NULL,
|
||||
signal_type VARCHAR(30) NOT NULL,
|
||||
signal_ts BIGINT,
|
||||
market_type VARCHAR(20) DEFAULT 'swap',
|
||||
order_type VARCHAR(20) DEFAULT 'market',
|
||||
amount DECIMAL(20,8) DEFAULT 0,
|
||||
price DECIMAL(20,8) DEFAULT 0,
|
||||
execution_mode VARCHAR(20) DEFAULT 'signal',
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
priority INTEGER DEFAULT 0,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
max_attempts INTEGER DEFAULT 10,
|
||||
last_error TEXT DEFAULT '',
|
||||
payload_json TEXT DEFAULT '',
|
||||
dispatch_note TEXT DEFAULT '',
|
||||
exchange_id VARCHAR(50) DEFAULT '',
|
||||
exchange_order_id VARCHAR(100) DEFAULT '',
|
||||
exchange_response_json TEXT DEFAULT '',
|
||||
filled DECIMAL(20,8) DEFAULT 0,
|
||||
avg_price DECIMAL(20,8) DEFAULT 0,
|
||||
executed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
processed_at TIMESTAMP,
|
||||
sent_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_orders_user_id ON pending_orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_orders_status ON pending_orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_orders_strategy_id ON pending_orders(strategy_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. Strategy Notifications
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
|
||||
symbol VARCHAR(50) DEFAULT '',
|
||||
signal_type VARCHAR(30) DEFAULT '',
|
||||
channels VARCHAR(255) DEFAULT '',
|
||||
title VARCHAR(255) DEFAULT '',
|
||||
message TEXT DEFAULT '',
|
||||
payload_json TEXT DEFAULT '',
|
||||
is_read INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON qd_strategy_notifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_strategy_id ON qd_strategy_notifications(strategy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON qd_strategy_notifications(is_read);
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. Indicator Codes
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_indicator_codes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
is_buy INTEGER NOT NULL DEFAULT 0,
|
||||
end_time BIGINT NOT NULL DEFAULT 1,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
code TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
publish_to_community INTEGER NOT NULL DEFAULT 0,
|
||||
pricing_type VARCHAR(20) NOT NULL DEFAULT 'free',
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
is_encrypted INTEGER NOT NULL DEFAULT 0,
|
||||
preview_image VARCHAR(500) DEFAULT '',
|
||||
createtime BIGINT,
|
||||
updatetime BIGINT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_indicator_codes_user_id ON qd_indicator_codes(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. Strategy Codes
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_codes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
code TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
createtime BIGINT,
|
||||
updatetime BIGINT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_strategy_codes_user_id ON qd_strategy_codes(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 9. AI Decisions
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_ai_decisions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
|
||||
decision_data TEXT,
|
||||
context_data TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_decisions_user_id ON qd_ai_decisions(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 10. Addon Config
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_addon_config (
|
||||
config_key VARCHAR(100) PRIMARY KEY,
|
||||
config_value TEXT,
|
||||
type VARCHAR(20) DEFAULT 'string'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 11. Watchlist
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_watchlist (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
market VARCHAR(50) NOT NULL,
|
||||
symbol VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(100) DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, market, symbol)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_user_id ON qd_watchlist(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 12. Analysis Tasks
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_analysis_tasks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
market VARCHAR(50) NOT NULL,
|
||||
symbol VARCHAR(50) NOT NULL,
|
||||
model VARCHAR(100) DEFAULT '',
|
||||
language VARCHAR(20) DEFAULT 'en-US',
|
||||
status VARCHAR(20) DEFAULT 'completed',
|
||||
result_json TEXT DEFAULT '',
|
||||
error_message TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_tasks_user_id ON qd_analysis_tasks(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 13. Backtest Runs
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_backtest_runs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
indicator_id INTEGER,
|
||||
market VARCHAR(50) NOT NULL DEFAULT '',
|
||||
symbol VARCHAR(50) NOT NULL DEFAULT '',
|
||||
timeframe VARCHAR(10) NOT NULL DEFAULT '',
|
||||
start_date VARCHAR(20) NOT NULL DEFAULT '',
|
||||
end_date VARCHAR(20) NOT NULL DEFAULT '',
|
||||
initial_capital DECIMAL(20,8) DEFAULT 10000,
|
||||
commission DECIMAL(10,6) DEFAULT 0.001,
|
||||
slippage DECIMAL(10,6) DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 1,
|
||||
trade_direction VARCHAR(20) DEFAULT 'long',
|
||||
strategy_config TEXT DEFAULT '',
|
||||
status VARCHAR(20) DEFAULT 'success',
|
||||
error_message TEXT DEFAULT '',
|
||||
result_json TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_backtest_runs_user_id ON qd_backtest_runs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_backtest_runs_indicator_id ON qd_backtest_runs(indicator_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 14. Exchange Credentials
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_exchange_credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) DEFAULT '',
|
||||
exchange_id VARCHAR(50) NOT NULL,
|
||||
api_key_hint VARCHAR(50) DEFAULT '',
|
||||
encrypted_config TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exchange_credentials_user_id ON qd_exchange_credentials(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 15. Manual Positions
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_manual_positions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
market VARCHAR(50) NOT NULL,
|
||||
symbol VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(100) DEFAULT '',
|
||||
side VARCHAR(10) DEFAULT 'long',
|
||||
quantity DECIMAL(20,8) NOT NULL DEFAULT 0,
|
||||
entry_price DECIMAL(20,8) NOT NULL DEFAULT 0,
|
||||
entry_time BIGINT,
|
||||
notes TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '',
|
||||
group_name VARCHAR(100) DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, market, symbol, side)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_positions_user_id ON qd_manual_positions(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 16. Position Alerts
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_position_alerts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
position_id INTEGER,
|
||||
market VARCHAR(50) DEFAULT '',
|
||||
symbol VARCHAR(50) DEFAULT '',
|
||||
alert_type VARCHAR(30) NOT NULL,
|
||||
threshold DECIMAL(20,8) NOT NULL DEFAULT 0,
|
||||
notification_config TEXT DEFAULT '',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_triggered INTEGER DEFAULT 0,
|
||||
last_triggered_at TIMESTAMP,
|
||||
trigger_count INTEGER DEFAULT 0,
|
||||
repeat_interval INTEGER DEFAULT 0,
|
||||
notes TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_position_alerts_user_id ON qd_position_alerts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_position_alerts_position_id ON qd_position_alerts(position_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 17. Position Monitors
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_position_monitors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) DEFAULT '',
|
||||
position_ids TEXT DEFAULT '',
|
||||
monitor_type VARCHAR(20) DEFAULT 'ai',
|
||||
config TEXT DEFAULT '',
|
||||
notification_config TEXT DEFAULT '',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
last_run_at TIMESTAMP,
|
||||
next_run_at TIMESTAMP,
|
||||
last_result TEXT DEFAULT '',
|
||||
run_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_position_monitors_user_id ON qd_position_monitors(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 18. Market Symbols (Seed Data)
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_market_symbols (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market VARCHAR(50) NOT NULL,
|
||||
symbol VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(255) DEFAULT '',
|
||||
exchange VARCHAR(50) DEFAULT '',
|
||||
currency VARCHAR(10) DEFAULT '',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_hot INTEGER DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(market, symbol)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_market_symbols_market ON qd_market_symbols(market);
|
||||
CREATE INDEX IF NOT EXISTS idx_market_symbols_is_hot ON qd_market_symbols(market, is_hot);
|
||||
|
||||
-- Seed data: Hot symbols for each market
|
||||
INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_active, is_hot, sort_order) VALUES
|
||||
-- AShare (China A-Shares)
|
||||
('AShare', '000001', '平安银行', 'SZSE', 'CNY', 1, 1, 100),
|
||||
('AShare', '000002', '万科A', 'SZSE', 'CNY', 1, 1, 99),
|
||||
('AShare', '600000', '浦发银行', 'SSE', 'CNY', 1, 1, 98),
|
||||
('AShare', '600036', '招商银行', 'SSE', 'CNY', 1, 1, 97),
|
||||
('AShare', '600519', '贵州茅台', 'SSE', 'CNY', 1, 1, 96),
|
||||
('AShare', '000858', '五粮液', 'SZSE', 'CNY', 1, 1, 95),
|
||||
('AShare', '002415', '海康威视', 'SZSE', 'CNY', 1, 1, 94),
|
||||
('AShare', '300059', '东方财富', 'SZSE', 'CNY', 1, 1, 93),
|
||||
('AShare', '000725', '京东方A', 'SZSE', 'CNY', 1, 1, 92),
|
||||
('AShare', '002594', '比亚迪', 'SZSE', 'CNY', 1, 1, 91),
|
||||
-- USStock (US Stocks)
|
||||
('USStock', 'AAPL', 'Apple Inc.', 'NASDAQ', 'USD', 1, 1, 100),
|
||||
('USStock', 'MSFT', 'Microsoft Corporation', 'NASDAQ', 'USD', 1, 1, 99),
|
||||
('USStock', 'GOOGL', 'Alphabet Inc.', 'NASDAQ', 'USD', 1, 1, 98),
|
||||
('USStock', 'AMZN', 'Amazon.com Inc.', 'NASDAQ', 'USD', 1, 1, 97),
|
||||
('USStock', 'TSLA', 'Tesla, Inc.', 'NASDAQ', 'USD', 1, 1, 96),
|
||||
('USStock', 'META', 'Meta Platforms Inc.', 'NASDAQ', 'USD', 1, 1, 95),
|
||||
('USStock', 'NVDA', 'NVIDIA Corporation', 'NASDAQ', 'USD', 1, 1, 94),
|
||||
('USStock', 'JPM', 'JPMorgan Chase & Co.', 'NYSE', 'USD', 1, 1, 93),
|
||||
('USStock', 'V', 'Visa Inc.', 'NYSE', 'USD', 1, 1, 92),
|
||||
('USStock', 'JNJ', 'Johnson & Johnson', 'NYSE', 'USD', 1, 1, 91),
|
||||
-- HShare (Hong Kong Stocks)
|
||||
('HShare', '00700', 'Tencent Holdings', 'HKEX', 'HKD', 1, 1, 100),
|
||||
('HShare', '09988', 'Alibaba Group', 'HKEX', 'HKD', 1, 1, 99),
|
||||
('HShare', '03690', 'Meituan', 'HKEX', 'HKD', 1, 1, 98),
|
||||
('HShare', '01810', 'Xiaomi Corporation', 'HKEX', 'HKD', 1, 1, 97),
|
||||
('HShare', '02318', 'Ping An Insurance', 'HKEX', 'HKD', 1, 1, 96),
|
||||
('HShare', '01398', 'ICBC', 'HKEX', 'HKD', 1, 1, 95),
|
||||
('HShare', '00939', 'CCB', 'HKEX', 'HKD', 1, 1, 94),
|
||||
('HShare', '01299', 'AIA Group', 'HKEX', 'HKD', 1, 1, 93),
|
||||
('HShare', '02020', 'Anta Sports', 'HKEX', 'HKD', 1, 1, 92),
|
||||
('HShare', '01024', 'Kuaishou Technology', 'HKEX', 'HKD', 1, 1, 91),
|
||||
-- Crypto
|
||||
('Crypto', 'BTC/USDT', 'Bitcoin', 'Binance', 'USDT', 1, 1, 100),
|
||||
('Crypto', 'ETH/USDT', 'Ethereum', 'Binance', 'USDT', 1, 1, 99),
|
||||
('Crypto', 'BNB/USDT', 'BNB', 'Binance', 'USDT', 1, 1, 98),
|
||||
('Crypto', 'SOL/USDT', 'Solana', 'Binance', 'USDT', 1, 1, 97),
|
||||
('Crypto', 'XRP/USDT', 'Ripple', 'Binance', 'USDT', 1, 1, 96),
|
||||
('Crypto', 'ADA/USDT', 'Cardano', 'Binance', 'USDT', 1, 1, 95),
|
||||
('Crypto', 'DOGE/USDT', 'Dogecoin', 'Binance', 'USDT', 1, 1, 94),
|
||||
('Crypto', 'DOT/USDT', 'Polkadot', 'Binance', 'USDT', 1, 1, 93),
|
||||
('Crypto', 'MATIC/USDT', 'Polygon', 'Binance', 'USDT', 1, 1, 92),
|
||||
('Crypto', 'AVAX/USDT', 'Avalanche', 'Binance', 'USDT', 1, 1, 91),
|
||||
-- Forex
|
||||
('Forex', 'XAUUSD', 'Gold/USD', 'Forex', 'USD', 1, 1, 100),
|
||||
('Forex', 'XAGUSD', 'Silver/USD', 'Forex', 'USD', 1, 1, 99),
|
||||
('Forex', 'EURUSD', 'Euro/US Dollar', 'Forex', 'USD', 1, 1, 98),
|
||||
('Forex', 'GBPUSD', 'British Pound/US Dollar', 'Forex', 'USD', 1, 1, 97),
|
||||
('Forex', 'USDJPY', 'US Dollar/Japanese Yen', 'Forex', 'USD', 1, 1, 96),
|
||||
('Forex', 'AUDUSD', 'Australian Dollar/US Dollar', 'Forex', 'USD', 1, 1, 95),
|
||||
('Forex', 'USDCAD', 'US Dollar/Canadian Dollar', 'Forex', 'USD', 1, 1, 94),
|
||||
('Forex', 'NZDUSD', 'New Zealand Dollar/US Dollar', 'Forex', 'USD', 1, 1, 93),
|
||||
('Forex', 'USDCHF', 'US Dollar/Swiss Franc', 'Forex', 'EUR', 1, 1, 92),
|
||||
('Forex', 'EURJPY', 'Euro/Japanese Yen', 'Forex', 'EUR', 1, 1, 91),
|
||||
-- Futures
|
||||
('Futures', 'CL', 'WTI Crude Oil', 'NYMEX', 'USD', 1, 1, 100),
|
||||
('Futures', 'GC', 'Gold', 'COMEX', 'USD', 1, 1, 99),
|
||||
('Futures', 'SI', 'Silver', 'COMEX', 'USD', 1, 1, 98),
|
||||
('Futures', 'NG', 'Natural Gas', 'NYMEX', 'USD', 1, 1, 97),
|
||||
('Futures', 'HG', 'Copper', 'COMEX', 'USD', 1, 1, 96),
|
||||
('Futures', 'ZC', 'Corn', 'CBOT', 'USD', 1, 1, 95),
|
||||
('Futures', 'ZS', 'Soybeans', 'CBOT', 'USD', 1, 1, 94),
|
||||
('Futures', 'ZW', 'Wheat', 'CBOT', 'USD', 1, 1, 93),
|
||||
('Futures', 'ES', 'S&P 500 E-mini', 'CME', 'USD', 1, 1, 92),
|
||||
('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91)
|
||||
ON CONFLICT (market, symbol) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
-- 19. Agent Memories (AI Learning System)
|
||||
-- =============================================================================
|
||||
-- Stores agent decision experiences for RAG-style retrieval during analysis.
|
||||
-- Each agent (trader, risk_analyst, etc.) shares this table but is identified by agent_name.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_agent_memories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
agent_name VARCHAR(100) NOT NULL,
|
||||
situation TEXT NOT NULL,
|
||||
recommendation TEXT NOT NULL,
|
||||
result TEXT,
|
||||
returns REAL,
|
||||
market VARCHAR(50),
|
||||
symbol VARCHAR(50),
|
||||
timeframe VARCHAR(20),
|
||||
features_json TEXT,
|
||||
embedding BYTEA,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_memories_agent ON qd_agent_memories(agent_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_memories_created ON qd_agent_memories(agent_name, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_memories_market ON qd_agent_memories(agent_name, market, symbol);
|
||||
|
||||
-- =============================================================================
|
||||
-- 20. Reflection Records (AI Auto-Verification System)
|
||||
-- =============================================================================
|
||||
-- Records analysis predictions for future auto-verification and closed-loop learning.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_reflection_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market VARCHAR(50) NOT NULL,
|
||||
symbol VARCHAR(50) NOT NULL,
|
||||
initial_price REAL,
|
||||
decision VARCHAR(20),
|
||||
confidence INTEGER,
|
||||
reasoning TEXT,
|
||||
analysis_date TIMESTAMP DEFAULT NOW(),
|
||||
target_check_date TIMESTAMP,
|
||||
status VARCHAR(20) DEFAULT 'PENDING',
|
||||
final_price REAL,
|
||||
actual_return REAL,
|
||||
check_result TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reflection_status ON qd_reflection_records(status, target_check_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_reflection_market ON qd_reflection_records(market, symbol);
|
||||
|
||||
-- =============================================================================
|
||||
-- Completion Notice
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'QuantDinger PostgreSQL schema initialized successfully!';
|
||||
END $$;
|
||||
@@ -11,9 +11,13 @@ pymysql>=1.0.2
|
||||
SQLAlchemy>=2.0.0
|
||||
PyJWT==2.8.0
|
||||
python-dotenv>=1.0.1
|
||||
# PostgreSQL support (multi-user mode)
|
||||
psycopg2-binary>=2.9.9
|
||||
# Password hashing
|
||||
bcrypt>=4.1.0
|
||||
# Interactive Brokers trading (optional, for US/HK stock trading via TWS/IB Gateway)
|
||||
ib_insync>=0.9.86
|
||||
# MetaTrader 5 trading (optional, for forex trading via MT5 terminal, Windows only)
|
||||
# Note: MetaTrader5 is Windows-only and not available on Linux/macOS
|
||||
# Install separately on Windows: pip install MetaTrader5>=5.0.45
|
||||
# MetaTrader5>=5.0.45
|
||||
# MetaTrader5>=5.0.45
|
||||
|
||||
@@ -83,6 +83,15 @@ def main():
|
||||
"""启动应用"""
|
||||
# Keep startup messages ASCII-only and short.
|
||||
print("QuantDinger Python API v2.0.0")
|
||||
|
||||
# Check demo mode status for debugging
|
||||
demo_status = os.getenv('IS_DEMO_MODE', 'false').lower()
|
||||
print(f"Status Check: IS_DEMO_MODE={demo_status}")
|
||||
if demo_status == 'true':
|
||||
print("!!! RUNNING IN DEMO MODE (READ-ONLY) !!!")
|
||||
else:
|
||||
print("Running in FULL ACCESS mode")
|
||||
|
||||
print(f"Service starting at: http://{Config.HOST}:{Config.PORT}")
|
||||
|
||||
# Flask dev server is for local development only.
|
||||
|
||||
Reference in New Issue
Block a user