feat: Multi-user system with PostgreSQL - WIP temporary save

This commit is contained in:
TIANHE
2026-01-14 05:29:55 +08:00
parent 996e3b38fe
commit 61a5e5e6aa
68 changed files with 91057 additions and 1920 deletions
+137 -56
View File
@@ -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 dont want strategies to auto-restore on startup, set `DISABLE_RESTORE_RUNNING_STRATEGIES=true`.
- If you dont 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`.
+79
View File
@@ -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)
+143 -106
View File
@@ -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 []
+4 -1
View File
@@ -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')
+1 -1
View File
@@ -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': []})
+143 -88
View File
@@ -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.
+147 -38
View File
@@ -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']
+39 -44
View File
@@ -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:
+15 -11
View File
@@ -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
+28 -12
View File
@@ -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()
+20 -19
View File
@@ -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.
+7 -12
View File
@@ -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)
+54 -104
View File
@@ -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
+90 -65
View File
@@ -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()
+18 -5
View File
@@ -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')
+192 -91
View File
@@ -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(
+317
View File
@@ -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.
+118 -147
View File
@@ -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 ""),
+57 -39
View File
@@ -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
+141 -13
View File
@@ -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
+54 -651
View File
@@ -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',
]
+297
View File
@@ -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}")
+13 -5
View File
@@ -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
+537
View File
@@ -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 $$;
+5 -1
View File
@@ -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
+9
View File
@@ -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.