Initial commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# 最高优先级规则 - 必须严格遵守(Windsurf 专用)
|
||||
|
||||
⚠️⚠️⚠️ 每次回复结束前必须执行以下命令!没有任何例外!⚠️⚠️⚠️
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 完成任务后,执行弹窗命令(跨平台,Windows/Mac/Linux 通用):
|
||||
node "/Users/freedak/.ranjin-mode/dialog-trigger.js" "windsurf" "kfy95h" "AI想要结束的原因摘要"
|
||||
|
||||
📱 **手机端通知**:执行此命令时会自动通知手机端(如果远程服务已开启),用户可以在手机上查看消息并发送反馈。
|
||||
|
||||
2. 仔细阅读命令输出的每一行:
|
||||
- ACTION: continue 或 end(用户的选择)
|
||||
- FEEDBACK: 用户输入的具体指令或反馈内容(⚠️ 必须完整阅读并执行!)
|
||||
* 反馈可能来自电脑端弹窗或手机端远程输入
|
||||
- IMAGES: 用户上传的图片路径(如有,必须用read_file工具查看)
|
||||
|
||||
3. 根据输出决定下一步:
|
||||
- 如果 ACTION 是 "continue":
|
||||
* ⚠️ 必须仔细阅读 FEEDBACK 后面的全部内容
|
||||
* ⚠️ 必须按照 FEEDBACK 中用户的具体指令执行任务
|
||||
* ⚠️ 不要忽略 FEEDBACK,不要只说"用户选择继续"
|
||||
- 如果 ACTION 是 "end" 或 "stop":停止工作
|
||||
- ⚠️ 如果命令被取消/跳过/无输出:必须重新执行弹窗命令!
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. 完整输出 - 输出完整代码和答案,禁止省略
|
||||
2. 持续执行 - 复杂任务一次性完成
|
||||
3. 结束确认 - 必须执行弹窗命令后才能结束
|
||||
4. FEEDBACK必读 - 用户的FEEDBACK是新的任务指令,必须执行
|
||||
|
||||
## 手机端远程功能
|
||||
|
||||
- 📱 当执行 dialog-trigger.js 命令时,会自动通知手机端(如果远程服务已开启)
|
||||
- 📱 用户可以在手机浏览器中查看 AI 的消息和状态
|
||||
- 📱 用户可以在手机上输入反馈,反馈会通过 FEEDBACK 字段传递给你
|
||||
- 📱 手机端和电脑端弹窗功能完全同步,支持双向通信
|
||||
|
||||
## ⚠️ 唯一合法的结束条件
|
||||
|
||||
只有当命令输出包含 ACTION: end 或 ACTION: stop 时才能结束对话!
|
||||
以下情况都不能结束,必须重新执行弹窗命令:
|
||||
- 命令被用户点击 Skip 跳过
|
||||
- 命令执行失败或超时
|
||||
- 命令没有输出
|
||||
- 工作区取消了命令
|
||||
|
||||
## 禁止
|
||||
|
||||
- 禁止不执行弹窗命令就结束
|
||||
- 禁止跳过弹窗流程
|
||||
- 禁止忽略FEEDBACK内容
|
||||
- 禁止在没有收到 ACTION: end/stop 的情况下结束
|
||||
+1106
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
# 股票资金流向分析系统
|
||||
|
||||
## 功能说明
|
||||
|
||||
这是一个基于Web的股票资金流向分析系统,可以分析大额净流入和大额净流出对股价的影响。
|
||||
|
||||
### 主要功能
|
||||
|
||||
1. **输入股票代码和时间段**:支持输入任意股票代码和自定义时间段
|
||||
2. **大额资金流向分析**:自动识别大额净流入(≥2%)和大额净流出(≤-2%)的交易日
|
||||
3. **多维度影响分析**:分析大额资金流向对当日、+1日、+2日、+3日、+4日、+5日股价的影响
|
||||
4. **可视化展示**:提供清晰的数据概览、对比分析和详细统计表格
|
||||
|
||||
## 安装和运行
|
||||
|
||||
### 1. 激活虚拟环境
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 2. 安装依赖(如果还未安装)
|
||||
|
||||
```bash
|
||||
pip install flask flask-cors akshare pandas numpy
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
服务将在 `http://localhost:5001` 启动
|
||||
|
||||
### 4. 访问Web界面
|
||||
|
||||
在浏览器中打开:`http://localhost:5001`
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. **输入股票代码**:例如 `000001`(平安银行)、`600000`(浦发银行)等
|
||||
2. **选择时间段**:
|
||||
- 开始日期:默认为 2025-01-01
|
||||
- 结束日期:默认为昨天
|
||||
- 可以手动修改
|
||||
3. **点击"开始分析"**:系统会自动获取数据并进行分析
|
||||
4. **查看分析结果**:
|
||||
- **数据概览**:显示总交易日数、大额流入/流出日数等
|
||||
- **对比分析**:对比大额流入和流出的平均涨跌幅差异
|
||||
- **详细统计**:可以切换查看当日、未来1-5日的详细统计数据
|
||||
|
||||
## API接口
|
||||
|
||||
### POST /api/analyze
|
||||
|
||||
分析股票资金流向
|
||||
|
||||
**请求参数:**
|
||||
```json
|
||||
{
|
||||
"stock_code": "000001",
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-01-22"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"stock_code": "000001",
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-01-22",
|
||||
"data": {
|
||||
"数据概览": {...},
|
||||
"详细统计": {...},
|
||||
"对比分析": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/health
|
||||
|
||||
健康检查接口
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**:Flask (Python)
|
||||
- **前端**:Vue 3 (CDN)
|
||||
- **数据源**:akshare
|
||||
- **数据处理**:pandas, numpy
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 首次运行可能需要下载股票数据,请耐心等待
|
||||
2. 股票代码需要正确(6开头为上海,0/3开头为深圳)
|
||||
3. 时间段内必须有交易数据,否则会提示错误
|
||||
4. 大额流入/流出的阈值设定为净占比2%,可以根据需要调整
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
stock/
|
||||
├── app.py # Flask后端服务
|
||||
├── templates/
|
||||
│ └── index.html # 前端页面
|
||||
├── static/ # 静态资源目录
|
||||
├── analysis.py # 分析脚本(独立使用)
|
||||
├── get-data.py # 数据获取脚本
|
||||
└── venv/ # Python虚拟环境
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
# Routes 模块
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
股票投资分析系统 - 主应用入口
|
||||
"""
|
||||
from flask import Flask, render_template, jsonify
|
||||
from flask_cors import CORS
|
||||
from config import Config
|
||||
|
||||
# 初始化配置
|
||||
Config.init_app()
|
||||
|
||||
# 创建Flask应用
|
||||
app = Flask(__name__, template_folder='templates', static_folder='static')
|
||||
app.config['SECRET_KEY'] = Config.SECRET_KEY
|
||||
CORS(app, supports_credentials=True)
|
||||
|
||||
# 注册路由蓝图
|
||||
from routes.auth import bp as auth_bp
|
||||
from routes.trades import bp as trades_bp
|
||||
from routes.watchlist import bp as watchlist_bp
|
||||
from routes.analysis import bp as analysis_bp
|
||||
from routes.market import bp as market_bp
|
||||
from routes.sim_trade import bp as sim_trade_bp
|
||||
from routes.admin import bp as admin_bp
|
||||
from routes.smart_trade import bp as smart_trade_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(trades_bp)
|
||||
app.register_blueprint(watchlist_bp)
|
||||
app.register_blueprint(analysis_bp)
|
||||
app.register_blueprint(market_bp)
|
||||
app.register_blueprint(sim_trade_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(smart_trade_bp)
|
||||
|
||||
|
||||
# ========== 基础路由 ==========
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""首页"""
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
def health():
|
||||
"""健康检查"""
|
||||
return jsonify({'status': 'ok', 'message': '服务运行正常'})
|
||||
|
||||
|
||||
# ========== 启动 ==========
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("股票投资分析系统启动")
|
||||
print("=" * 60)
|
||||
print(f"Web界面: http://localhost:{Config.PORT}")
|
||||
print(f"API地址: http://localhost:{Config.PORT}/api/")
|
||||
print(f"健康检查: http://localhost:{Config.PORT}/api/health")
|
||||
print("=" * 60)
|
||||
|
||||
# 启动模拟交易定时任务调度器
|
||||
from services.scheduler import start_scheduler
|
||||
start_scheduler()
|
||||
|
||||
app.run(debug=True, host='0.0.0.0', port=Config.PORT)
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# 每日自动全景扫描脚本
|
||||
# 收盘后16:00执行,包含当天完整K线数据
|
||||
# 仅在交易日(周一到周五)执行,跳过周末
|
||||
|
||||
DAY_OF_WEEK=$(date +%u)
|
||||
|
||||
# 周六(6)、周日(7)跳过
|
||||
if [ "$DAY_OF_WEEK" -ge 6 ]; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 今天是周末,跳过扫描"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 开始自动全景扫描..."
|
||||
|
||||
cd /opt/stock-app
|
||||
export DB_PASSWORD=stock_password_2025
|
||||
|
||||
# 强制重新扫描:确保使用收盘后最新的K线数据
|
||||
# 如果今天早上已有旧扫描结果(开盘前),需要覆盖
|
||||
export FORCE_RESCAN=1
|
||||
/opt/stock-app/venv/bin/python full_signal_scan.py
|
||||
|
||||
EXIT_CODE=$?
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 扫描完成,退出码: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# 资金流向数据每日采集脚本
|
||||
# 从5分钟K线数据自行计算资金流向(无需外部API)
|
||||
# 建议在15:10后运行(收盘后5分钟K线数据完整)
|
||||
#
|
||||
# 数据源:stock_kline_5min 表
|
||||
# 执行速度:几秒即可完成(纯数据库计算)
|
||||
|
||||
DAY_OF_WEEK=$(date +%u)
|
||||
|
||||
# 周六(6)、周日(7)跳过
|
||||
if [ "$DAY_OF_WEEK" -ge 6 ]; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 今天是周末,跳过资金流向计算"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 开始资金流向计算..."
|
||||
|
||||
cd /opt/stock-app
|
||||
export DB_PASSWORD=stock_password_2025
|
||||
# 计算今日 + 补算历史
|
||||
/opt/stock-app/venv/bin/python sync_fund_flow.py --backfill
|
||||
|
||||
EXIT_CODE=$?
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 资金流向计算完成,退出码: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# K线数据增量同步脚本
|
||||
# 交易日收盘后执行,同步最新K线数据到本地数据库
|
||||
# 在全景扫描之前运行,确保扫描使用本地数据
|
||||
|
||||
DAY_OF_WEEK=$(date +%u)
|
||||
|
||||
# 周六(6)、周日(7)跳过
|
||||
if [ "$DAY_OF_WEEK" -ge 6 ]; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 今天是周末,跳过K线同步"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 开始K线增量同步..."
|
||||
|
||||
cd /opt/stock-app
|
||||
export DB_PASSWORD=stock_password_2025
|
||||
/opt/stock-app/venv/bin/python sync_kline.py
|
||||
|
||||
EXIT_CODE=$?
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') K线同步完成,退出码: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# 5分钟K线数据每日采集脚本
|
||||
# 交易日收盘后执行,采集当天全市场的5分钟K线数据
|
||||
# 建议在 17:30 后运行(收盘后数据完整)
|
||||
#
|
||||
# 使用新浪API(stock_zh_a_minute),稳定不限流
|
||||
# 3线程并发(东财API),全市场约5800只,预计耗时约60-90分钟
|
||||
|
||||
DAY_OF_WEEK=$(date +%u)
|
||||
|
||||
# 周六(6)、周日(7)跳过
|
||||
if [ "$DAY_OF_WEEK" -ge 6 ]; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 今天是周末,跳过5分钟K线采集"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 开始5分钟K线采集..."
|
||||
|
||||
cd /opt/stock-app
|
||||
export DB_PASSWORD=stock_password_2025
|
||||
|
||||
# 清理可能残留的锁文件(防止权限问题导致无法启动)
|
||||
LOCK_FILE="/tmp/sync_kline_5min.lock"
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
# 检查锁文件中记录的PID是否仍在运行
|
||||
OLD_PID=$(cat "$LOCK_FILE" 2>/dev/null)
|
||||
if [ -n "$OLD_PID" ] && ! kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 清理残留锁文件 (旧PID: $OLD_PID 已不存在)"
|
||||
rm -f "$LOCK_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
/opt/stock-app/venv/bin/python sync_kline_5min.py --delay 0.8 --workers 3
|
||||
|
||||
EXIT_CODE=$?
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') 5分钟K线采集完成,退出码: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 清理端口5001(如果被占用)
|
||||
echo "正在清理端口5001..."
|
||||
lsof -ti:5001 | xargs kill -9 2>/dev/null && echo "端口5001已清理" || echo "端口5001未被占用"
|
||||
|
||||
# 等待一下确保端口释放
|
||||
sleep 1
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 启动Flask服务
|
||||
echo "正在启动服务..."
|
||||
python app.py
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
数据库维护脚本
|
||||
- 不删除任何历史数据
|
||||
- 只执行 VACUUM ANALYZE 优化查询性能
|
||||
- 统计各表数据量和磁盘占用
|
||||
建议通过 crontab 每周日凌晨执行一次
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from datetime import datetime
|
||||
|
||||
# 数据库配置
|
||||
DB_CONFIG = {
|
||||
'host': os.environ.get('DB_HOST', 'localhost'),
|
||||
'port': int(os.environ.get('DB_PORT', 5432)),
|
||||
'database': os.environ.get('DB_NAME', 'stock_app'),
|
||||
'user': os.environ.get('DB_USER', 'postgres'),
|
||||
'password': os.environ.get('DB_PASSWORD', ''),
|
||||
}
|
||||
|
||||
|
||||
def cleanup():
|
||||
"""数据库维护:统计数据量 + VACUUM ANALYZE 优化性能"""
|
||||
print(f"{'='*60}")
|
||||
print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] 数据库维护开始")
|
||||
print(f" 策略:保留全部历史数据,仅执行性能优化")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
|
||||
# 1. 统计各表数据量
|
||||
print(f"\n 📊 各表数据量统计:")
|
||||
cur.execute("""
|
||||
SELECT relname as table_name,
|
||||
n_live_tup as row_count,
|
||||
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
|
||||
pg_total_relation_size(relid) as raw_size
|
||||
FROM pg_catalog.pg_statio_user_tables
|
||||
ORDER BY pg_total_relation_size(relid) DESC
|
||||
""")
|
||||
total_size = 0
|
||||
tables = []
|
||||
for name, rows, size, raw in cur.fetchall():
|
||||
total_size += raw
|
||||
tables.append(name)
|
||||
print(f" {name:>30}: {rows:>12,} 行 {size:>10}")
|
||||
print(f" {'─'*55}")
|
||||
print(f" {'总计':>30}: {'':>12} {total_size / 1024 / 1024:.0f} MB")
|
||||
|
||||
# 2. VACUUM ANALYZE 优化查询性能
|
||||
print(f"\n 🔧 VACUUM ANALYZE (更新统计信息,优化查询)...")
|
||||
for table in tables:
|
||||
try:
|
||||
t0 = datetime.now()
|
||||
cur.execute(f"VACUUM ANALYZE {table}")
|
||||
elapsed = (datetime.now() - t0).total_seconds()
|
||||
if elapsed > 1:
|
||||
print(f" {table}: {elapsed:.1f}s")
|
||||
except Exception as e:
|
||||
print(f" {table}: ⚠ {e}")
|
||||
|
||||
print(f"\n[{datetime.now():%Y-%m-%d %H:%M:%S}] 维护完成 ✅")
|
||||
print(f"{'='*60}")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"维护失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cleanup()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
应用配置
|
||||
"""
|
||||
import os
|
||||
|
||||
class Config:
|
||||
# Flask 配置
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'stock-app-secret-key-2026')
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST = os.environ.get('DB_HOST', 'localhost')
|
||||
DB_PORT = int(os.environ.get('DB_PORT', 5432))
|
||||
DB_NAME = os.environ.get('DB_NAME', 'stock_app')
|
||||
DB_USER = os.environ.get('DB_USER', 'postgres')
|
||||
DB_PASSWORD = os.environ.get('DB_PASSWORD', '')
|
||||
|
||||
# 文件路径
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STOCK_DATA_CACHE_DIR = os.path.join(BASE_DIR, 'stock_data_cache')
|
||||
STOCK_NAME_CACHE_FILE = os.path.join(BASE_DIR, 'stock_names.json')
|
||||
TRADES_FILE = os.path.join(BASE_DIR, 'trades.json')
|
||||
WATCHLIST_FILE = os.path.join(BASE_DIR, 'watchlist.json')
|
||||
ALERTS_CACHE_FILE = os.path.join(BASE_DIR, 'alerts_cache.json')
|
||||
|
||||
# 阿里云K线API
|
||||
ALICLOUD_APPCODE = os.environ.get('ALICLOUD_APPCODE', '50528b6544ac4234a8ccb5c9f2c01607')
|
||||
ALICLOUD_KLINE_URL = 'https://jmqqgphqcx.market.alicloudapi.com/finance/a-shares-kline'
|
||||
|
||||
# 服务端口
|
||||
PORT = int(os.environ.get('PORT', 3333))
|
||||
|
||||
# 确保缓存目录存在
|
||||
@classmethod
|
||||
def init_app(cls):
|
||||
if not os.path.exists(cls.STOCK_DATA_CACHE_DIR):
|
||||
os.makedirs(cls.STOCK_DATA_CACHE_DIR)
|
||||
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
数据库连接和用户认证
|
||||
"""
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask import session
|
||||
import functools
|
||||
from config import Config
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库连接"""
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=Config.DB_HOST,
|
||||
port=Config.DB_PORT,
|
||||
database=Config.DB_NAME,
|
||||
user=Config.DB_USER,
|
||||
password=Config.DB_PASSWORD
|
||||
)
|
||||
return conn
|
||||
except Exception as e:
|
||||
print(f"数据库连接失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""登录验证装饰器"""
|
||||
@functools.wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
from flask import jsonify
|
||||
return jsonify({'success': False, 'error': '请先登录'}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def get_current_user_id():
|
||||
"""获取当前登录用户ID"""
|
||||
return session.get('user_id')
|
||||
|
||||
|
||||
def get_current_username():
|
||||
"""获取当前登录用户名"""
|
||||
return session.get('username')
|
||||
|
||||
|
||||
# ========== 可用资金操作 ==========
|
||||
|
||||
def db_get_available_cash(user_id):
|
||||
"""获取用户可用资金"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return 0
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("SELECT available_cash FROM users WHERE id = %s", (user_id,))
|
||||
result = cur.fetchone()
|
||||
return float(result['available_cash'] or 0) if result else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_update_available_cash(user_id, amount):
|
||||
"""更新用户可用资金"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return False, '数据库连接失败'
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE users SET available_cash = %s WHERE id = %s", (amount, user_id))
|
||||
conn.commit()
|
||||
return True, None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return False, str(e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ========== 用户操作 ==========
|
||||
|
||||
def create_user(email, password):
|
||||
"""创建用户(使用邮箱)"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None, '数据库连接失败'
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
cur.execute("SELECT id FROM users WHERE email = %s OR username = %s", (email, email))
|
||||
if cur.fetchone():
|
||||
return None, '该邮箱已注册'
|
||||
|
||||
# 创建用户(username和email都存邮箱)
|
||||
password_hash = generate_password_hash(password)
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, email, password_hash) VALUES (%s, %s, %s) RETURNING id, username, email",
|
||||
(email, email, password_hash)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
return user, None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return None, str(e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def verify_user(email, password):
|
||||
"""验证用户登录(使用邮箱)"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None, '数据库连接失败'
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
# 同时检查email和username字段(兼容旧数据)
|
||||
cur.execute("SELECT * FROM users WHERE email = %s OR username = %s", (email, email))
|
||||
user = cur.fetchone()
|
||||
|
||||
if not user or not check_password_hash(user['password_hash'], password):
|
||||
return None, '邮箱或密码错误'
|
||||
|
||||
return {'id': user['id'], 'username': user.get('email') or user['username']}, None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def change_user_password(user_id, old_password, new_password):
|
||||
"""修改用户密码"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return False, '数据库连接失败'
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||
user = cur.fetchone()
|
||||
|
||||
if not user:
|
||||
return False, '用户不存在'
|
||||
|
||||
if not check_password_hash(user['password_hash'], old_password):
|
||||
return False, '当前密码错误'
|
||||
|
||||
new_hash = generate_password_hash(new_password)
|
||||
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (new_hash, user_id))
|
||||
conn.commit()
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ========== 交易记录操作(数据库版) ==========
|
||||
|
||||
def db_get_trades(user_id):
|
||||
"""从数据库获取用户交易记录"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date::text, reason, result, profit_amount, stop_loss_price, notes,
|
||||
created_at::text
|
||||
FROM trades
|
||||
WHERE user_id = %s
|
||||
ORDER BY trade_date DESC, created_at DESC
|
||||
""", (user_id,))
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_get_trade(user_id, trade_id):
|
||||
"""获取单条交易记录"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date::text, reason, result, profit_amount, stop_loss_price, notes,
|
||||
created_at::text
|
||||
FROM trades WHERE id = %s AND user_id = %s
|
||||
""", (trade_id, user_id))
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_add_trade(user_id, data):
|
||||
"""添加交易记录到数据库"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None, '数据库连接失败'
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
INSERT INTO trades (user_id, stock_code, stock_name, trade_type, price,
|
||||
quantity, trade_date, reason, result, profit_amount,
|
||||
stop_loss_price, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date::text, reason, result, profit_amount, stop_loss_price,
|
||||
notes, created_at::text
|
||||
""", (
|
||||
user_id,
|
||||
data.get('stock_code'),
|
||||
data.get('stock_name'),
|
||||
data.get('trade_type'),
|
||||
data.get('price'),
|
||||
data.get('quantity'),
|
||||
data.get('trade_date'),
|
||||
data.get('reason'),
|
||||
data.get('result'),
|
||||
data.get('profit_amount'),
|
||||
data.get('stop_loss_price'),
|
||||
data.get('notes')
|
||||
))
|
||||
trade = cur.fetchone()
|
||||
conn.commit()
|
||||
return trade, None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return None, str(e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_update_trade(user_id, trade_id, data):
|
||||
"""更新交易记录"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None, '数据库连接失败'
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
UPDATE trades SET
|
||||
stock_code = COALESCE(%s, stock_code),
|
||||
stock_name = COALESCE(%s, stock_name),
|
||||
trade_type = COALESCE(%s, trade_type),
|
||||
price = COALESCE(%s, price),
|
||||
quantity = COALESCE(%s, quantity),
|
||||
trade_date = COALESCE(%s, trade_date),
|
||||
reason = COALESCE(%s, reason),
|
||||
result = COALESCE(%s, result),
|
||||
profit_amount = COALESCE(%s, profit_amount),
|
||||
stop_loss_price = COALESCE(%s, stop_loss_price),
|
||||
notes = COALESCE(%s, notes)
|
||||
WHERE id = %s AND user_id = %s
|
||||
RETURNING id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date::text, reason, result, profit_amount, stop_loss_price,
|
||||
notes, created_at::text
|
||||
""", (
|
||||
data.get('stock_code'),
|
||||
data.get('stock_name'),
|
||||
data.get('trade_type'),
|
||||
data.get('price'),
|
||||
data.get('quantity'),
|
||||
data.get('trade_date'),
|
||||
data.get('reason'),
|
||||
data.get('result'),
|
||||
data.get('profit_amount'),
|
||||
data.get('stop_loss_price'),
|
||||
data.get('notes'),
|
||||
trade_id,
|
||||
user_id
|
||||
))
|
||||
trade = cur.fetchone()
|
||||
conn.commit()
|
||||
return trade, None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return None, str(e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_delete_trade(user_id, trade_id):
|
||||
"""删除交易记录"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return False
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM trades WHERE id = %s AND user_id = %s", (trade_id, user_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ========== 关注列表操作(数据库版) ==========
|
||||
|
||||
def db_get_watchlist(user_id):
|
||||
"""从数据库获取用户关注列表"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT stock_code as code, stock_name as name, added_time::text
|
||||
FROM watchlist
|
||||
WHERE user_id = %s
|
||||
ORDER BY added_time DESC
|
||||
""", (user_id,))
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_add_to_watchlist(user_id, code, name):
|
||||
"""添加到关注列表"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None, '数据库连接失败'
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
INSERT INTO watchlist (user_id, stock_code, stock_name)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (user_id, stock_code) DO NOTHING
|
||||
RETURNING stock_code as code, stock_name as name
|
||||
""", (user_id, code, name))
|
||||
conn.commit()
|
||||
return db_get_watchlist(user_id), None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return None, str(e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_remove_from_watchlist(user_id, code):
|
||||
"""从关注列表移除"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM watchlist WHERE user_id = %s AND stock_code = %s", (user_id, code))
|
||||
conn.commit()
|
||||
return db_get_watchlist(user_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ========== 分析缓存操作(数据库版) ==========
|
||||
|
||||
def db_get_alerts_cache(user_id):
|
||||
"""从数据库获取分析缓存"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT data, updated_at::text as lastUpdate
|
||||
FROM alerts_cache
|
||||
WHERE user_id = %s
|
||||
""", (user_id,))
|
||||
result = cur.fetchone()
|
||||
if result:
|
||||
return {
|
||||
'alerts': result['data'] or [],
|
||||
'lastUpdate': result['lastupdate']
|
||||
}
|
||||
return {'alerts': [], 'lastUpdate': None}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_save_alerts_cache(user_id, alerts):
|
||||
"""保存分析缓存到数据库"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return False
|
||||
|
||||
try:
|
||||
import json
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO alerts_cache (user_id, data, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
data = EXCLUDED.data,
|
||||
updated_at = NOW()
|
||||
""", (user_id, json.dumps(alerts)))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"保存分析缓存失败: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ========== 基本面数据操作(数据库版) ==========
|
||||
|
||||
def db_get_fundamental(code):
|
||||
"""从数据库获取基本面数据(当日缓存)"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None
|
||||
|
||||
try:
|
||||
from datetime import date
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT code, name, pe, pb, total_market_cap, industry,
|
||||
latest_price, change_pct, update_date::text, updated_at::text,
|
||||
roe, eps, bps, revenue_yoy, profit_yoy, gross_margin, net_margin
|
||||
FROM stock_fundamental
|
||||
WHERE code = %s AND update_date = %s
|
||||
""", (code, date.today()))
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_save_fundamental(code, data):
|
||||
"""保存基本面数据到数据库"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return False
|
||||
|
||||
try:
|
||||
from datetime import date
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO stock_fundamental
|
||||
(code, name, pe, pb, total_market_cap, industry, latest_price, change_pct, update_date,
|
||||
roe, eps, bps, revenue_yoy, profit_yoy, gross_margin, net_margin)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
pe = EXCLUDED.pe,
|
||||
pb = EXCLUDED.pb,
|
||||
total_market_cap = EXCLUDED.total_market_cap,
|
||||
industry = EXCLUDED.industry,
|
||||
latest_price = EXCLUDED.latest_price,
|
||||
change_pct = EXCLUDED.change_pct,
|
||||
update_date = EXCLUDED.update_date,
|
||||
roe = EXCLUDED.roe,
|
||||
eps = EXCLUDED.eps,
|
||||
bps = EXCLUDED.bps,
|
||||
revenue_yoy = EXCLUDED.revenue_yoy,
|
||||
profit_yoy = EXCLUDED.profit_yoy,
|
||||
gross_margin = EXCLUDED.gross_margin,
|
||||
net_margin = EXCLUDED.net_margin,
|
||||
updated_at = NOW()
|
||||
""", (
|
||||
code,
|
||||
data.get('name') or data.get('stock_name'),
|
||||
data.get('pe') or data.get('pe_ttm'),
|
||||
data.get('pb'),
|
||||
data.get('total_market_cap'),
|
||||
data.get('industry'),
|
||||
data.get('latest_price'),
|
||||
data.get('change_pct'),
|
||||
date.today(),
|
||||
data.get('roe'),
|
||||
data.get('eps'),
|
||||
data.get('bps'),
|
||||
data.get('revenue_yoy'),
|
||||
data.get('profit_yoy'),
|
||||
data.get('gross_margin'),
|
||||
data.get('net_margin'),
|
||||
))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"保存基本面数据失败: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ========== 资金流向历史数据操作(数据库版) ==========
|
||||
|
||||
def db_get_fund_flow_history(code):
|
||||
"""获取股票的资金流向历史数据"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT code, trade_date::text, close_price, change_pct,
|
||||
main_net_inflow, main_net_inflow_pct,
|
||||
super_net_inflow, super_net_inflow_pct,
|
||||
big_net_inflow, big_net_inflow_pct
|
||||
FROM stock_fund_flow_history
|
||||
WHERE code = %s
|
||||
ORDER BY trade_date DESC
|
||||
""", (code,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
# 获取最新日期
|
||||
latest_date = rows[0]['trade_date'] if rows else None
|
||||
|
||||
return [dict(row) for row in rows], latest_date
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_save_fund_flow_history(code, records):
|
||||
"""保存资金流向历史数据到数据库"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return False
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for r in records:
|
||||
cur.execute("""
|
||||
INSERT INTO stock_fund_flow_history
|
||||
(code, trade_date, close_price, change_pct,
|
||||
main_net_inflow, main_net_inflow_pct,
|
||||
super_net_inflow, super_net_inflow_pct,
|
||||
big_net_inflow, big_net_inflow_pct,
|
||||
mid_net_inflow, mid_net_inflow_pct,
|
||||
small_net_inflow, small_net_inflow_pct)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (code, trade_date) DO UPDATE SET
|
||||
close_price = EXCLUDED.close_price,
|
||||
change_pct = EXCLUDED.change_pct,
|
||||
main_net_inflow = EXCLUDED.main_net_inflow,
|
||||
main_net_inflow_pct = EXCLUDED.main_net_inflow_pct,
|
||||
super_net_inflow = EXCLUDED.super_net_inflow,
|
||||
super_net_inflow_pct = EXCLUDED.super_net_inflow_pct,
|
||||
big_net_inflow = EXCLUDED.big_net_inflow,
|
||||
big_net_inflow_pct = EXCLUDED.big_net_inflow_pct,
|
||||
mid_net_inflow = EXCLUDED.mid_net_inflow,
|
||||
mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct,
|
||||
small_net_inflow = EXCLUDED.small_net_inflow,
|
||||
small_net_inflow_pct = EXCLUDED.small_net_inflow_pct,
|
||||
updated_at = NOW()
|
||||
""", (
|
||||
code,
|
||||
r.get('日期') or r.get('trade_date'),
|
||||
r.get('收盘价') or r.get('close_price'),
|
||||
r.get('涨跌幅') or r.get('change_pct'),
|
||||
r.get('主力净流入-净额') or r.get('main_net_inflow'),
|
||||
r.get('主力净流入-净占比') or r.get('main_net_inflow_pct'),
|
||||
r.get('超大单净流入-净额') or r.get('super_net_inflow'),
|
||||
r.get('超大单净流入-净占比') or r.get('super_net_inflow_pct'),
|
||||
r.get('大单净流入-净额') or r.get('big_net_inflow'),
|
||||
r.get('大单净流入-净占比') or r.get('big_net_inflow_pct'),
|
||||
r.get('中单净流入-净额') or r.get('mid_net_inflow'),
|
||||
r.get('中单净流入-净占比') or r.get('mid_net_inflow_pct'),
|
||||
r.get('小单净流入-净额') or r.get('small_net_inflow'),
|
||||
r.get('小单净流入-净占比') or r.get('small_net_inflow_pct')
|
||||
))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"保存资金流向历史失败: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,131 @@
|
||||
# 股票投资分析系统 - 远程服务器部署情况
|
||||
|
||||
> 最后更新:2026-03-17
|
||||
|
||||
## 一、服务器概览
|
||||
|
||||
| 服务器 | IP | 域名 | 用途 | 状态 |
|
||||
|--------|-----|------|------|------|
|
||||
| **主服务器(原有)** | 43.135.128.39 | - | 腾讯云,IP直连 | 运行中 |
|
||||
| **新服务器** | 152.136.182.184 | stock.allbyai.cn | 腾讯云,HTTPS域名 | 已配置 |
|
||||
|
||||
---
|
||||
|
||||
## 二、新服务器 (stock.allbyai.cn) 部署架构
|
||||
|
||||
### 2.1 访问方式
|
||||
- **HTTPS(推荐)**:https://stock.allbyai.cn
|
||||
- **IP直连**:http://152.136.182.184:3333
|
||||
|
||||
### 2.2 技术栈
|
||||
- **Web 应用**:Flask (Python) 端口 3333
|
||||
- **反向代理**:Nginx + HTTPS (acme.sh 证书)
|
||||
- **数据库**:PostgreSQL (stock_app)
|
||||
- **数据采集**:stock-data-service (systemd 后台服务)
|
||||
|
||||
### 2.3 部署路径
|
||||
- 应用目录:`/opt/stock-app`
|
||||
- Nginx 配置:`/etc/nginx/sites-available/stock.allbyai.cn`
|
||||
- SSL 证书:`/etc/nginx/ssl/stock.allbyai.cn.crt` / `.key`
|
||||
|
||||
---
|
||||
|
||||
## 三、部署脚本清单
|
||||
|
||||
| 脚本 | 用途 | 执行位置 |
|
||||
|------|------|----------|
|
||||
| `deploy/deploy-to-new-server.sh` | 一键完整部署(同步+初始化+Nginx+重启) | 本地 |
|
||||
| `deploy/sync-to-new-server.sh` | 快速同步代码并重启(日常更新) | 本地 |
|
||||
| `deploy/setup-server.sh` | 服务器环境初始化(首次部署) | 服务器 |
|
||||
| `deploy/setup-ssl.sh` | SSL 证书申请/安装(acme.sh) | 服务器 |
|
||||
| `setup_cron_scan.sh` | 配置全景扫描定时任务 | 本地→服务器 |
|
||||
|
||||
---
|
||||
|
||||
## 四、部署流程
|
||||
|
||||
### 4.1 首次部署新服务器
|
||||
|
||||
```bash
|
||||
cd /Users/freedak/Documents/go-new/stock/stock-html
|
||||
|
||||
# 1. 执行一键部署(会同步代码、配置 Nginx、重启服务)
|
||||
./deploy/deploy-to-new-server.sh
|
||||
|
||||
# 2. 首次部署需取消 deploy-to-new-server.sh 第43行注释,执行服务器初始化
|
||||
# ssh ubuntu@152.136.182.184 "${APP_DIR}/deploy/setup-server.sh"
|
||||
|
||||
# 3. 若需 SSL,在服务器上执行
|
||||
# ssh ubuntu@152.136.182.184
|
||||
# 先配置 acme.sh + DNS TXT 记录,再运行:
|
||||
# /opt/stock-app/deploy/setup-ssl.sh
|
||||
|
||||
# 4. 配置定时任务(全景扫描、K线采集、资金流向)
|
||||
./setup_cron_scan.sh
|
||||
# 或手动指定:STOCK_SERVER=ubuntu@152.136.182.184 STOCK_APP_DIR=/opt/stock-app ./setup_cron_scan.sh
|
||||
```
|
||||
|
||||
### 4.2 日常代码更新
|
||||
|
||||
```bash
|
||||
cd /Users/freedak/Documents/go-new/stock/stock-html
|
||||
./deploy/sync-to-new-server.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、服务管理命令
|
||||
|
||||
### 5.1 新服务器 (152.136.182.184)
|
||||
|
||||
```bash
|
||||
# Web 应用
|
||||
ssh ubuntu@152.136.182.184 "systemctl start stock-app"
|
||||
ssh ubuntu@152.136.182.184 "systemctl stop stock-app"
|
||||
ssh ubuntu@152.136.182.184 "systemctl restart stock-app"
|
||||
ssh ubuntu@152.136.182.184 "systemctl status stock-app"
|
||||
|
||||
# 数据采集服务
|
||||
ssh ubuntu@152.136.182.184 "systemctl start stock-data-service"
|
||||
ssh ubuntu@152.136.182.184 "systemctl restart stock-data-service"
|
||||
ssh ubuntu@152.136.182.184 "systemctl status stock-data-service"
|
||||
|
||||
# 查看日志
|
||||
ssh ubuntu@152.136.182.184 "journalctl -u stock-app -f"
|
||||
ssh ubuntu@152.136.182.184 "journalctl -u stock-data-service -f"
|
||||
```
|
||||
|
||||
### 5.2 SSL 证书续期
|
||||
|
||||
- **自动**:acme.sh 已配置 cron 自动续期
|
||||
- **手动**:`/home/ubuntu/.acme.sh/acme.sh --renew -d stock.allbyai.cn --ecc --force`
|
||||
|
||||
---
|
||||
|
||||
## 六、注意事项与待办
|
||||
|
||||
### 6.1 已知差异
|
||||
- `sync-to-new-server.sh` 仅重启 `stock-app`,不重启 `stock-data-service`
|
||||
- 完整部署时 `deploy-to-new-server.sh` 会重启两个服务
|
||||
|
||||
### 6.2 同步排除项
|
||||
部署时排除:`.git`、`venv`、`__pycache__`、`stock_data_cache`、`*.pyc`、`.DS_Store`、`stock_names.json`、`alerts_cache.json`、`trades.json`、`watchlist.json`、`*.log`、`.playwright-mcp`、根目录 `/app.js`、`/index.html`、`/main.css`、`/css`、`/js`、`.windsurfrules`
|
||||
|
||||
### 6.3 定时任务(新服务器需单独配置)
|
||||
- 11:50 午休扫描
|
||||
- 16:30 收盘扫描
|
||||
- 17:30 5分钟K线采集
|
||||
- 18:00 资金流向采集
|
||||
|
||||
使用 `setup_cron_scan.sh` 或按 `run.md` 手动配置 crontab。
|
||||
|
||||
---
|
||||
|
||||
## 七、快速参考
|
||||
|
||||
| 操作 | 命令 |
|
||||
|------|------|
|
||||
| 同步并重启新服务器 | `./deploy/sync-to-new-server.sh` |
|
||||
| 完整部署新服务器 | `./deploy/deploy-to-new-server.sh` |
|
||||
| 查看服务状态 | `ssh ubuntu@152.136.182.184 "systemctl status stock-app stock-data-service"` |
|
||||
| 访问地址 | https://stock.allbyai.cn |
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# 一键部署脚本 - 从本地部署到新服务器 152.136.182.184 (stock.allbyai.cn)
|
||||
# 在本地执行此脚本
|
||||
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
NEW_SERVER="ubuntu@152.136.182.184"
|
||||
APP_DIR="/opt/stock-app"
|
||||
LOCAL_DIR="/Users/freedak/Documents/go-new/stock/stock-html"
|
||||
|
||||
echo "=========================================="
|
||||
echo "部署到新服务器: 152.136.182.184"
|
||||
echo "域名: stock.allbyai.cn"
|
||||
echo "=========================================="
|
||||
|
||||
# 1. 同步代码(使用 sudo 写入 /opt/stock-app)
|
||||
echo "[1/5] 同步代码到服务器..."
|
||||
rsync -avz --progress --rsync-path="sudo rsync" ${LOCAL_DIR}/ ${NEW_SERVER}:${APP_DIR}/ \
|
||||
--exclude='.git' \
|
||||
--exclude='venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='stock_data_cache' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='stock_names.json' \
|
||||
--exclude='alerts_cache.json' \
|
||||
--exclude='trades.json' \
|
||||
--exclude='watchlist.json' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.playwright-mcp' \
|
||||
--exclude='/app.js' \
|
||||
--exclude='/index.html' \
|
||||
--exclude='/main.css' \
|
||||
--exclude='/css' \
|
||||
--exclude='/js' \
|
||||
--exclude='/.windsurfrules'
|
||||
|
||||
# 2. 在服务器上执行初始化(首次部署时需要)
|
||||
echo "[2/5] 执行服务器初始化..."
|
||||
ssh ${NEW_SERVER} "sudo chmod +x ${APP_DIR}/deploy/setup-server.sh"
|
||||
# 如果是首次部署,取消下行注释
|
||||
# ssh ${NEW_SERVER} "sudo ${APP_DIR}/deploy/setup-server.sh"
|
||||
|
||||
# 3. 配置Nginx
|
||||
echo "[3/5] 配置Nginx..."
|
||||
ssh ${NEW_SERVER} "sudo cp ${APP_DIR}/deploy/nginx-stock.conf /etc/nginx/sites-available/stock.allbyai.cn"
|
||||
ssh ${NEW_SERVER} "sudo ln -sf /etc/nginx/sites-available/stock.allbyai.cn /etc/nginx/sites-enabled/"
|
||||
ssh ${NEW_SERVER} "sudo nginx -t && sudo systemctl reload nginx"
|
||||
|
||||
# 4. 重启服务
|
||||
echo "[4/5] 重启应用服务..."
|
||||
ssh ${NEW_SERVER} "sudo systemctl restart stock-app"
|
||||
ssh ${NEW_SERVER} "sudo systemctl restart stock-data-service"
|
||||
|
||||
# 5. 检查状态
|
||||
echo "[5/5] 检查服务状态..."
|
||||
ssh ${NEW_SERVER} "sudo systemctl status stock-app --no-pager"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "部署完成!"
|
||||
echo ""
|
||||
echo "访问地址:"
|
||||
echo " - IP直连: http://152.136.182.184:3333"
|
||||
echo " - HTTPS访问: https://stock.allbyai.cn"
|
||||
echo ""
|
||||
echo "SSL证书信息:"
|
||||
echo " - 证书路径: /etc/nginx/ssl/stock.allbyai.cn.crt"
|
||||
echo " - 密钥路径: /etc/nginx/ssl/stock.allbyai.cn.key"
|
||||
echo " - 自动续期: 已配置 (acme.sh cron)"
|
||||
echo " - 手动续期: /home/ubuntu/.acme.sh/acme.sh --renew -d stock.allbyai.cn --ecc --force"
|
||||
echo "=========================================="
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/bin/bash
|
||||
# 修复 stock.allbyai.cn 数据库连接问题
|
||||
# 在服务器上执行: sudo bash /opt/stock-app/deploy/fix-db-connection.sh
|
||||
|
||||
DB_PASS="stock_password_2025"
|
||||
APP_DIR="/opt/stock-app"
|
||||
|
||||
echo "=========================================="
|
||||
echo "诊断并修复 PostgreSQL 数据库连接"
|
||||
echo "=========================================="
|
||||
|
||||
# 0. 诊断:收集当前状态
|
||||
echo ""
|
||||
echo "[诊断] 检查 PostgreSQL 服务状态..."
|
||||
systemctl status postgresql --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
|
||||
echo "[诊断] 检查 PostgreSQL 版本和集群..."
|
||||
pg_lsclusters 2>/dev/null || echo "pg_lsclusters 不可用"
|
||||
echo ""
|
||||
|
||||
echo "[诊断] 检查 PostgreSQL 监听端口..."
|
||||
ss -tlnp | grep 5432 || netstat -tlnp 2>/dev/null | grep 5432 || echo "未检测到5432端口监听"
|
||||
echo ""
|
||||
|
||||
echo "[诊断] 检查 pg_hba.conf 配置..."
|
||||
PG_HBA=$(find /etc/postgresql -name pg_hba.conf 2>/dev/null | head -1)
|
||||
if [ -n "$PG_HBA" ]; then
|
||||
echo "文件位置: $PG_HBA"
|
||||
echo "--- 当前认证配置 ---"
|
||||
grep -v '^#' "$PG_HBA" | grep -v '^$'
|
||||
echo "---"
|
||||
else
|
||||
echo "未找到 pg_hba.conf"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "[诊断] 检查 stock-app 服务环境变量..."
|
||||
systemctl show stock-app --property=Environment 2>/dev/null || echo "无法读取服务配置"
|
||||
echo ""
|
||||
|
||||
echo "[诊断] 尝试 peer 认证连接..."
|
||||
sudo -u postgres psql -c "SELECT 1 as peer_auth_ok;" 2>&1 || echo "peer 认证失败"
|
||||
echo ""
|
||||
|
||||
echo "[诊断] 检查 stock_app 数据库是否存在..."
|
||||
sudo -u postgres psql -c "SELECT datname FROM pg_database WHERE datname='stock_app';" 2>&1
|
||||
echo ""
|
||||
|
||||
# 1. 确保 PostgreSQL 服务运行
|
||||
echo "=========================================="
|
||||
echo "[1/6] 确保 PostgreSQL 服务运行..."
|
||||
systemctl start postgresql 2>/dev/null || true
|
||||
systemctl enable postgresql 2>/dev/null || true
|
||||
|
||||
# 2. 重置 postgres 用户密码
|
||||
echo "[2/6] 重置 postgres 用户密码..."
|
||||
sudo -u postgres psql -c "ALTER USER postgres PASSWORD '${DB_PASS}';" 2>/dev/null || {
|
||||
echo "尝试使用 peer 认证重置密码..."
|
||||
sudo -u postgres psql -c "ALTER USER postgres PASSWORD '${DB_PASS}';" || {
|
||||
echo "❌ 密码重置失败,尝试重启 PostgreSQL 后重试..."
|
||||
systemctl restart postgresql
|
||||
sleep 2
|
||||
sudo -u postgres psql -c "ALTER USER postgres PASSWORD '${DB_PASS}';"
|
||||
}
|
||||
}
|
||||
echo "✅ 密码已重置"
|
||||
|
||||
# 3. 确保数据库存在
|
||||
echo "[3/6] 确保 stock_app 数据库存在..."
|
||||
sudo -u postgres createdb stock_app 2>/dev/null || echo "数据库已存在"
|
||||
|
||||
# 4. 修复 pg_hba.conf 认证配置
|
||||
echo "[4/6] 检查并修复 pg_hba.conf..."
|
||||
if [ -n "$PG_HBA" ]; then
|
||||
if ! grep -q "host.*all.*all.*127.0.0.1/32.*md5\|host.*all.*all.*127.0.0.1/32.*scram-sha-256" "$PG_HBA"; then
|
||||
echo "添加 localhost md5 认证规则..."
|
||||
cp "$PG_HBA" "${PG_HBA}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
|
||||
# 在文件末尾前插入规则(确保在其他 host 规则之前或文件末尾)
|
||||
if ! grep -q "^host.*all.*all.*127.0.0.1/32" "$PG_HBA"; then
|
||||
echo "host all all 127.0.0.1/32 md5" >> "$PG_HBA"
|
||||
fi
|
||||
if ! grep -q "^host.*all.*all.*::1/128" "$PG_HBA"; then
|
||||
echo "host all all ::1/128 md5" >> "$PG_HBA"
|
||||
fi
|
||||
|
||||
echo "✅ pg_hba.conf 已更新,重启 PostgreSQL..."
|
||||
systemctl restart postgresql
|
||||
sleep 2
|
||||
else
|
||||
echo "✅ pg_hba.conf 认证配置正常"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 未找到 pg_hba.conf,跳过"
|
||||
fi
|
||||
|
||||
# 5. 测试数据库连接
|
||||
echo "[5/6] 测试数据库连接..."
|
||||
PGPASSWORD="${DB_PASS}" psql -h localhost -U postgres -d stock_app -c "SELECT 1 as ok;" 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ 数据库连接测试通过"
|
||||
else
|
||||
echo "❌ 连接测试失败!"
|
||||
echo ""
|
||||
echo "尝试替代修复方案..."
|
||||
|
||||
# 尝试将所有 host 认证改为 md5
|
||||
if [ -n "$PG_HBA" ]; then
|
||||
echo "将 scram-sha-256 改为 md5..."
|
||||
sed -i 's/scram-sha-256/md5/g' "$PG_HBA"
|
||||
systemctl restart postgresql
|
||||
sleep 2
|
||||
|
||||
# 重新设置密码(用 md5 格式)
|
||||
sudo -u postgres psql -c "ALTER USER postgres PASSWORD '${DB_PASS}';"
|
||||
|
||||
PGPASSWORD="${DB_PASS}" psql -h localhost -U postgres -d stock_app -c "SELECT 1 as ok;" 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ 替代方案成功"
|
||||
else
|
||||
echo "❌ 仍然失败,请手动检查 PostgreSQL 日志:"
|
||||
echo " journalctl -u postgresql -n 50"
|
||||
echo " cat /var/log/postgresql/*.log | tail -50"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 6. 重启应用服务
|
||||
echo "[6/6] 重启应用服务..."
|
||||
systemctl restart stock-app
|
||||
systemctl restart stock-data-service
|
||||
sleep 3
|
||||
|
||||
# 验证应用是否正常
|
||||
echo ""
|
||||
echo "验证应用状态..."
|
||||
systemctl status stock-app --no-pager | head -5
|
||||
echo ""
|
||||
|
||||
# 用 Python 测试应用级 DB 连接
|
||||
${APP_DIR}/venv/bin/python -c "
|
||||
import sys
|
||||
sys.path.insert(0, '${APP_DIR}')
|
||||
import os
|
||||
os.environ['DB_HOST'] = 'localhost'
|
||||
os.environ['DB_PORT'] = '5432'
|
||||
os.environ['DB_NAME'] = 'stock_app'
|
||||
os.environ['DB_USER'] = 'postgres'
|
||||
os.environ['DB_PASSWORD'] = '${DB_PASS}'
|
||||
from db import get_db
|
||||
conn = get_db()
|
||||
if conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT COUNT(*) FROM users')
|
||||
count = cur.fetchone()[0]
|
||||
print(f'✅ Python 应用级 DB 连接成功!用户数: {count}')
|
||||
conn.close()
|
||||
else:
|
||||
print('❌ Python 应用级 DB 连接失败')
|
||||
sys.exit(1)
|
||||
" 2>&1
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "修复完成!请访问 https://stock.allbyai.cn 验证"
|
||||
echo "=========================================="
|
||||
@@ -0,0 +1,49 @@
|
||||
# Nginx配置 - stock.allbyai.cn (HTTPS)
|
||||
# 部署路径: /etc/nginx/sites-available/stock.allbyai.cn
|
||||
# SSL证书由 acme.sh 管理,自动续期
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name stock.allbyai.cn;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS 配置
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name stock.allbyai.cn;
|
||||
|
||||
# SSL 证书
|
||||
ssl_certificate /etc/nginx/ssl/stock.allbyai.cn.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/stock.allbyai.cn.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# 日志
|
||||
access_log /var/log/nginx/stock.allbyai.cn.access.log;
|
||||
error_log /var/log/nginx/stock.allbyai.cn.error.log;
|
||||
|
||||
# 代理到Flask应用
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3333;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
# 静态文件缓存
|
||||
location /static/ {
|
||||
alias /opt/stock-app/static/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# 从本地执行:同步修复脚本到服务器并执行
|
||||
# 用法: ./deploy/run-fix-db.sh
|
||||
|
||||
set -e
|
||||
|
||||
NEW_SERVER="ubuntu@152.136.182.184"
|
||||
APP_DIR="/opt/stock-app"
|
||||
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
echo "=========================================="
|
||||
echo "修复 stock.allbyai.cn 数据库连接"
|
||||
echo "=========================================="
|
||||
|
||||
echo "[1/2] 同步修复脚本到服务器..."
|
||||
rsync -avz "${LOCAL_DIR}/deploy/fix-db-connection.sh" ${NEW_SERVER}:${APP_DIR}/deploy/
|
||||
|
||||
echo "[2/2] 在服务器上执行修复..."
|
||||
ssh ${NEW_SERVER} "sudo bash ${APP_DIR}/deploy/fix-db-connection.sh"
|
||||
|
||||
echo ""
|
||||
echo "验证: curl -s https://stock.allbyai.cn/api/db/data_status"
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
# 新服务器初始化脚本 - 152.136.182.184 (stock.allbyai.cn)
|
||||
# 在服务器上执行此脚本完成环境配置
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "股票投资分析系统 - 服务器初始化"
|
||||
echo "服务器: 152.136.182.184"
|
||||
echo "域名: stock.allbyai.cn"
|
||||
echo "=========================================="
|
||||
|
||||
# 更新系统
|
||||
echo "[1/8] 更新系统包..."
|
||||
apt-get update && apt-get upgrade -y
|
||||
|
||||
# 安装依赖
|
||||
echo "[2/8] 安装系统依赖..."
|
||||
apt-get install -y python3 python3-pip python3-venv nginx postgresql postgresql-contrib
|
||||
|
||||
# 配置PostgreSQL
|
||||
echo "[3/8] 配置PostgreSQL..."
|
||||
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'stock_password_2025';" || true
|
||||
sudo -u postgres createdb stock_app || echo "数据库已存在"
|
||||
|
||||
# 创建应用目录
|
||||
echo "[4/8] 创建应用目录..."
|
||||
mkdir -p /opt/stock-app
|
||||
chown -R root:root /opt/stock-app
|
||||
|
||||
# 创建Python虚拟环境
|
||||
echo "[5/8] 创建Python虚拟环境..."
|
||||
cd /opt/stock-app
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
|
||||
# 安装Python依赖
|
||||
echo "[6/8] 安装Python依赖..."
|
||||
if [ -f requirements.txt ]; then
|
||||
pip install -r requirements.txt
|
||||
else
|
||||
pip install flask flask-cors akshare pandas numpy schedule psycopg2-binary
|
||||
fi
|
||||
|
||||
# 初始化数据库
|
||||
echo "[7/8] 初始化数据库..."
|
||||
if [ -f init_db.sql ]; then
|
||||
sudo -u postgres psql -d stock_app -f init_db.sql || true
|
||||
fi
|
||||
if [ -f init_sim_trade.sql ]; then
|
||||
sudo -u postgres psql -d stock_app -f init_sim_trade.sql || true
|
||||
fi
|
||||
if [ -f init_smart_trade.sql ]; then
|
||||
sudo -u postgres psql -d stock_app -f init_smart_trade.sql || true
|
||||
fi
|
||||
if [ -f init_stock_data_db.sql ]; then
|
||||
sudo -u postgres psql -d stock_app -f init_stock_data_db.sql || true
|
||||
fi
|
||||
|
||||
# 配置systemd服务
|
||||
echo "[8/8] 配置systemd服务..."
|
||||
cat > /etc/systemd/system/stock-app.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Stock Investment Analysis Web Application
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/stock-app
|
||||
Environment="DB_HOST=localhost"
|
||||
Environment="DB_PORT=5432"
|
||||
Environment="DB_NAME=stock_app"
|
||||
Environment="DB_USER=postgres"
|
||||
Environment="DB_PASSWORD=stock_password_2025"
|
||||
ExecStart=/opt/stock-app/venv/bin/python app.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat > /etc/systemd/system/stock-data-service.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Stock Data Collection Service
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/stock-app
|
||||
Environment="DB_HOST=localhost"
|
||||
Environment="DB_PORT=5432"
|
||||
Environment="DB_NAME=stock_app"
|
||||
Environment="DB_USER=postgres"
|
||||
Environment="DB_PASSWORD=stock_password_2025"
|
||||
ExecStart=/opt/stock-app/venv/bin/python stock_data_service.py daemon
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable stock-app
|
||||
systemctl enable stock-data-service
|
||||
|
||||
echo "=========================================="
|
||||
echo "初始化完成!"
|
||||
echo ""
|
||||
echo "后续步骤:"
|
||||
echo "1. 从本地同步代码到服务器"
|
||||
echo "2. 配置Nginx反向代理"
|
||||
echo "3. 启动服务"
|
||||
echo "=========================================="
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
# SSL证书申请脚本 - stock.allbyai.cn
|
||||
# 使用 acme.sh + DNS 验证方式申请免费SSL证书
|
||||
# 适用于 DNSPod 有 Web 防护导致 HTTP 验证失败的情况
|
||||
# 在服务器上执行此脚本
|
||||
|
||||
set -e
|
||||
|
||||
DOMAIN="stock.allbyai.cn"
|
||||
SSL_DIR="/etc/nginx/ssl"
|
||||
ACME_HOME="/home/ubuntu/.acme.sh"
|
||||
|
||||
echo "=========================================="
|
||||
echo "为 ${DOMAIN} 完成SSL证书安装"
|
||||
echo "=========================================="
|
||||
|
||||
# 检查 acme.sh 是否已安装
|
||||
if [ ! -f "${ACME_HOME}/acme.sh" ]; then
|
||||
echo "错误: acme.sh 未安装,请先运行初始化"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 DNS TXT 记录
|
||||
echo "[1/4] 检查 DNS TXT 记录..."
|
||||
TXT_RECORD=$(dig +short TXT _acme-challenge.stock.allbyai.cn 2>/dev/null || echo "")
|
||||
if [ -z "$TXT_RECORD" ]; then
|
||||
echo ""
|
||||
echo "错误: DNS TXT 记录未找到!"
|
||||
echo ""
|
||||
echo "请在 DNSPod 添加以下 TXT 记录:"
|
||||
echo " 主机记录: _acme-challenge.stock"
|
||||
echo " 记录值: cM9lSA4-uKk-yjrvA38HYtmA1h7E3DzgIXVJq8IsSNo"
|
||||
echo ""
|
||||
echo "添加后等待 1-2 分钟,然后重新运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
echo "DNS TXT 记录已找到: $TXT_RECORD"
|
||||
|
||||
# 创建 SSL 目录
|
||||
echo "[2/4] 创建 SSL 目录..."
|
||||
mkdir -p ${SSL_DIR}
|
||||
|
||||
# 完成证书申请
|
||||
echo "[3/4] 完成证书申请..."
|
||||
${ACME_HOME}/acme.sh --renew -d ${DOMAIN} --yes-I-know-dns-manual-mode-enough-go-ahead-please
|
||||
|
||||
# 安装证书
|
||||
echo "[4/4] 安装证书到 Nginx..."
|
||||
${ACME_HOME}/acme.sh --install-cert -d ${DOMAIN} \
|
||||
--key-file ${SSL_DIR}/${DOMAIN}.key \
|
||||
--fullchain-file ${SSL_DIR}/${DOMAIN}.crt \
|
||||
--reloadcmd "systemctl reload nginx"
|
||||
|
||||
# 更新 Nginx 配置
|
||||
echo "[5/5] 更新 Nginx 配置..."
|
||||
cat > /etc/nginx/sites-available/${DOMAIN} << 'NGINX_CONF'
|
||||
# Nginx配置 - stock.allbyai.cn (HTTPS)
|
||||
server {
|
||||
listen 80;
|
||||
server_name stock.allbyai.cn;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name stock.allbyai.cn;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/stock.allbyai.cn.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/stock.allbyai.cn.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
access_log /var/log/nginx/stock.allbyai.cn.access.log;
|
||||
error_log /var/log/nginx/stock.allbyai.cn.error.log;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3333;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /opt/stock-app/static/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
NGINX_CONF
|
||||
|
||||
# 重载 Nginx
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "SSL证书安装完成!"
|
||||
echo ""
|
||||
echo "访问地址: https://${DOMAIN}"
|
||||
echo ""
|
||||
echo "证书信息:"
|
||||
echo " - 证书路径: ${SSL_DIR}/${DOMAIN}.crt"
|
||||
echo " - 密钥路径: ${SSL_DIR}/${DOMAIN}.key"
|
||||
echo " - 有效期: 90天"
|
||||
echo " - 自动续期: 已配置 (acme.sh cron)"
|
||||
echo ""
|
||||
echo "手动续期命令: ${ACME_HOME}/acme.sh --renew -d ${DOMAIN} --force"
|
||||
echo "=========================================="
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Stock Investment Analysis Web Application
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/stock-app
|
||||
Environment="DB_HOST=localhost"
|
||||
Environment="DB_PORT=5432"
|
||||
Environment="DB_NAME=stock_app"
|
||||
Environment="DB_USER=postgres"
|
||||
Environment="DB_PASSWORD=stock_password_2025"
|
||||
ExecStart=/opt/stock-app/venv/bin/python app.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# 快速同步代码到新服务器并重启 - 152.136.182.184 (stock.allbyai.cn)
|
||||
# 用于日常代码更新
|
||||
|
||||
set -e
|
||||
|
||||
NEW_SERVER="ubuntu@152.136.182.184"
|
||||
APP_DIR="/opt/stock-app"
|
||||
LOCAL_DIR="/Users/freedak/Documents/go-new/stock/stock-html"
|
||||
|
||||
echo "同步代码到 stock.allbyai.cn (152.136.182.184)..."
|
||||
|
||||
rsync -avz ${LOCAL_DIR}/ ${NEW_SERVER}:${APP_DIR}/ \
|
||||
--exclude='.git' \
|
||||
--exclude='venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='stock_data_cache' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='stock_names.json' \
|
||||
--exclude='alerts_cache.json' \
|
||||
--exclude='trades.json' \
|
||||
--exclude='watchlist.json' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.playwright-mcp' \
|
||||
--exclude='/app.js' \
|
||||
--exclude='/index.html' \
|
||||
--exclude='/main.css' \
|
||||
--exclude='/css' \
|
||||
--exclude='/js' \
|
||||
--exclude='/.windsurfrules'
|
||||
|
||||
ssh ${NEW_SERVER} "systemctl restart stock-app && echo '✅ 服务已重启'"
|
||||
|
||||
echo ""
|
||||
echo "访问: http://stock.allbyai.cn"
|
||||
@@ -0,0 +1,91 @@
|
||||
# 🔍 系统性算法搜索结果 (v5.3 内存回测引擎)
|
||||
|
||||
> 生成时间: 2026-02-26 08:19
|
||||
|
||||
## 搜索配置
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|----|
|
||||
| 本金 | ¥200,000 |
|
||||
| Phase 1 筛选期 | 2025-07-01 ~ 2025-09-30 |
|
||||
| Phase 2 验证期 | 2025-01-02 ~ 2026-02-25 |
|
||||
| 组合总数 | 70,400 → 剪枝后 21,120 |
|
||||
| Phase 1 耗时 | 913s (23.1次/秒) |
|
||||
| Phase 2 耗时 | 8s |
|
||||
| 总耗时 | 922s (15.4分钟) |
|
||||
|
||||
## 🏆 全期间 Top 30
|
||||
|
||||
| 排名 | 全期盈利 | Q3盈利 | 真实收益 | 年化 | 胜率 | 盈亏比 | 回撤 | 交易 | 持仓天 | 策略 |
|
||||
|------|---------|-------|---------|------|------|--------|------|------|--------|------|
|
||||
| 🏆 | ¥+68,330 | ¥+43,258 | +25.6% | +22.0% | 44.6% | 1.80 | 8.6% | 488 | 16d | `TP12|SL6|delay3|h≤30|10%|SW` |
|
||||
| 🥈 | ¥+68,330 | ¥+43,258 | +25.6% | +22.0% | 44.6% | 1.80 | 8.6% | 488 | 16d | `TP12|SL6|trig≥1|delay3|h≤30|10%|SW` |
|
||||
| 🥉 | ¥+57,452 | ¥+42,404 | +22.0% | +18.9% | 46.3% | 1.62 | 11.2% | 414 | 16d | `TP12|SL6|delay3|h≤30|15%|SW` |
|
||||
| #4 | ¥+57,452 | ¥+42,404 | +22.0% | +18.9% | 46.3% | 1.62 | 11.2% | 414 | 16d | `TP12|SL6|trig≥1|delay3|h≤30|15%|SW` |
|
||||
| #5 | ¥+48,554 | ¥+44,714 | +19.4% | +16.7% | 54.7% | 1.60 | 14.6% | 324 | 32d | `TP10|SL8|ign|15%|SW` |
|
||||
| #6 | ¥+48,554 | ¥+44,714 | +19.4% | +16.7% | 54.7% | 1.60 | 14.6% | 324 | 32d | `TP10|SL8|trig≥1|ign|15%|SW` |
|
||||
| #7 | ¥+36,958 | ¥+44,291 | +14.0% | +12.1% | 39.4% | 1.88 | 15.4% | 421 | 16d | `TP12|SL6|h≤60|15%|SW` |
|
||||
| #8 | ¥+36,958 | ¥+44,291 | +14.0% | +12.1% | 39.4% | 1.88 | 15.4% | 421 | 16d | `TP12|SL6|delay1|h≤60|15%|SW` |
|
||||
| #9 | ¥+36,958 | ¥+44,291 | +14.0% | +12.1% | 39.4% | 1.88 | 15.4% | 421 | 16d | `TP12|SL6|trig≥1|h≤60|15%|SW` |
|
||||
| #10 | ¥+36,958 | ¥+44,291 | +14.0% | +12.1% | 39.4% | 1.88 | 15.4% | 421 | 16d | `TP12|SL6|trig≥1|delay1|h≤60|15%|SW` |
|
||||
| #11 | ¥+34,113 | ¥+42,664 | +14.1% | +12.2% | 45.9% | 1.47 | 9.5% | 470 | 17d | `TP12|SL8|delay3|h≤30|10%|SW` |
|
||||
| #12 | ¥+34,113 | ¥+42,664 | +14.1% | +12.2% | 45.9% | 1.47 | 9.5% | 470 | 17d | `TP12|SL8|trig≥1|delay3|h≤30|10%|SW` |
|
||||
| #13 | ¥+24,766 | ¥+44,310 | +9.8% | +8.5% | 40.1% | 1.64 | 16.1% | 419 | 15d | `TP12|SL8|h≤60|15%|SW` |
|
||||
| #14 | ¥+24,766 | ¥+44,310 | +9.8% | +8.5% | 40.1% | 1.64 | 16.1% | 419 | 15d | `TP12|SL8|delay1|h≤60|15%|SW` |
|
||||
| #15 | ¥+24,766 | ¥+44,310 | +9.8% | +8.5% | 40.1% | 1.64 | 16.1% | 419 | 15d | `TP12|SL8|trig≥1|h≤60|15%|SW` |
|
||||
| #16 | ¥+24,766 | ¥+44,310 | +9.8% | +8.5% | 40.1% | 1.64 | 16.1% | 419 | 15d | `TP12|SL8|trig≥1|delay1|h≤60|15%|SW` |
|
||||
| #17 | ¥+24,481 | ¥+44,291 | +9.7% | +8.4% | 41.5% | 1.71 | 19.0% | 414 | 15d | `TP12|SL6|15%|SW` |
|
||||
| #18 | ¥+24,481 | ¥+44,291 | +9.7% | +8.4% | 41.5% | 1.71 | 19.0% | 414 | 15d | `TP12|SL6|delay1|15%|SW` |
|
||||
| #19 | ¥+24,481 | ¥+44,291 | +9.7% | +8.4% | 41.5% | 1.71 | 19.0% | 414 | 15d | `TP12|SL6|trig≥1|15%|SW` |
|
||||
| #20 | ¥+24,481 | ¥+44,291 | +9.7% | +8.4% | 41.5% | 1.71 | 19.0% | 414 | 15d | `TP12|SL6|trig≥1|delay1|15%|SW` |
|
||||
| #21 | ¥+24,300 | ¥+43,692 | +10.7% | +9.2% | 45.5% | 1.31 | 16.3% | 322 | 26d | `TP12|SL6|ign|h≤60|15%|SW` |
|
||||
| #22 | ¥+24,300 | ¥+43,692 | +10.7% | +9.2% | 45.5% | 1.31 | 16.3% | 322 | 26d | `TP12|SL6|trig≥1|ign|h≤60|15%|SW` |
|
||||
| #23 | ¥+23,782 | ¥+46,040 | +10.4% | +9.1% | 61.5% | 1.41 | 11.3% | 381 | 31d | `T8/3|SL12|ign|h≤60|8%|SW` |
|
||||
| #24 | ¥+23,782 | ¥+46,040 | +10.4% | +9.1% | 61.5% | 1.41 | 11.3% | 381 | 31d | `T8/3|SL12|trig≥1|ign|h≤60|8%|SW` |
|
||||
| #25 | ¥+22,348 | ¥+43,048 | +9.9% | +8.6% | 45.8% | 1.31 | 13.2% | 271 | 32d | `TP12|SL6|ign|15%|SW` |
|
||||
| #26 | ¥+22,348 | ¥+43,048 | +9.9% | +8.6% | 45.8% | 1.31 | 13.2% | 271 | 32d | `TP12|SL6|trig≥1|ign|15%|SW` |
|
||||
| #27 | ¥+17,570 | ¥+44,310 | +7.2% | +6.2% | 42.1% | 1.55 | 19.6% | 437 | 15d | `TP12|SL8|15%|SW` |
|
||||
| #28 | ¥+17,570 | ¥+44,310 | +7.2% | +6.2% | 42.1% | 1.55 | 19.6% | 437 | 15d | `TP12|SL8|delay1|15%|SW` |
|
||||
| #29 | ¥+17,570 | ¥+44,310 | +7.2% | +6.2% | 42.1% | 1.55 | 19.6% | 437 | 15d | `TP12|SL8|trig≥1|15%|SW` |
|
||||
| #30 | ¥+17,570 | ¥+44,310 | +7.2% | +6.2% | 42.1% | 1.55 | 19.6% | 437 | 15d | `TP12|SL8|trig≥1|delay1|15%|SW` |
|
||||
|
||||
## 新冠军 vs 之前冠军
|
||||
|
||||
| 指标 | 之前冠军 | 新冠军 |
|
||||
|------|---------|-------|
|
||||
| 策略 | `v5.2|忽略卖出+TP10+SL8+信号加权` | `TP12|SL6|delay3|h≤30|10%|SW` |
|
||||
| 全期盈利 | ¥+56,375 | ¥+68,330.5 |
|
||||
| 真实收益 | +21.5% | +25.6% |
|
||||
| 年化 | +18.5% | +22.0% |
|
||||
| 胜率 | 61.2% | 44.6% |
|
||||
| 盈亏比 | 2.30 | 1.80 |
|
||||
| 回撤 | - | 8.6% |
|
||||
|
||||
🎉 **新纪录!**
|
||||
|
||||
## 维度影响分析
|
||||
|
||||
### 仓位比例
|
||||
|
||||
| 仓位 | 数量 | 平均盈利 | 最优盈利 |
|
||||
|------|------|---------|--------|
|
||||
| 8% | 12 | ¥+15,474 | ¥+23,782 |
|
||||
| 10% | 10 | ¥+24,178 | ¥+68,330 |
|
||||
| 15% | 38 | ¥+22,253 | ¥+57,452 |
|
||||
|
||||
### 信号加权
|
||||
|
||||
| 模式 | 数量 | 平均盈利 | 最优盈利 |
|
||||
|------|------|---------|--------|
|
||||
| 等权 | 12 | ¥+9,208 | ¥+12,809 |
|
||||
| 加权 | 48 | ¥+24,220 | ¥+68,330 |
|
||||
|
||||
### 止损线
|
||||
|
||||
| 止损 | 数量 | 平均盈利 | 最优盈利 |
|
||||
|------|------|---------|--------|
|
||||
| 6% | 16 | ¥+36,914 | ¥+68,330 |
|
||||
| 8% | 14 | ¥+24,955 | ¥+48,554 |
|
||||
| 10% | 18 | ¥+11,668 | ¥+17,066 |
|
||||
| 12% | 12 | ¥+10,254 | ¥+23,782 |
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# 2025年 vs 2026年 行情对比分析
|
||||
|
||||
> 生成时间: 2026-02-25
|
||||
> 回测算法: Top 3 最挣钱算法 × 日线/5分钟定价
|
||||
|
||||
---
|
||||
|
||||
## 一、各季度利润分布(最优算法 v4|触发≥2+止盈10+损8)
|
||||
|
||||
| 季度 | 盈亏(元) | 真实收益 | 胜率 | 交易数 | 盈亏比 | 回撤% |
|
||||
|------|---------|---------|------|-------|--------|-------|
|
||||
| 2025-Q1 | +195 | +4.8% | 33.3% | 6 | 1.83 | 5.7% |
|
||||
| 2025-Q2 | 0 | 0% | - | 0 | - | - |
|
||||
| 2025-Q3 | +5,415 | +4.2% | 39.5% | 76 | 1.46 | 4.4% |
|
||||
| 2025-Q4 | +6,450 | +4.0% | 47.5% | 80 | 1.41 | 5.1% |
|
||||
| **2026-Q1(32天)** | **+20,720** | **+15.5%** | **57.7%** | **52** | **4.02** | **2.8%** |
|
||||
|
||||
> **亮点**: 2026年32天盈利(+20,720) > 2025年全年243天盈利(+20,600)
|
||||
|
||||
---
|
||||
|
||||
## 二、2025全年 vs 2026开年 关键指标对比
|
||||
|
||||
| 指标 | 2025全年(243个交易日) | 2026开年(32个交易日) | 差异 |
|
||||
|------|---------------------|---------------------|------|
|
||||
| 总盈利 | +¥20,600 | **+¥20,720** | 32天 > 243天 ✨ |
|
||||
| 真实收益率 | 13.4% | **15.5%** | +2.1% |
|
||||
| 年化收益(CAGR) | 13.5% | **111.2%**(简单) | 效率差8倍 |
|
||||
| 胜率 | 44.9% | **57.7%** | +12.8% |
|
||||
| 盈亏比 | 1.73 | **4.02** | 2.3倍 |
|
||||
| 最大回撤 | 4.3% | **2.8%** | 更安全 |
|
||||
| 日均盈利 | ¥85/天 | **¥648/天** | **7.6倍** |
|
||||
| 交易次数 | 156笔 | 52笔 | - |
|
||||
|
||||
---
|
||||
|
||||
## 三、Top 3 算法全部数据
|
||||
|
||||
### 3.1 完整期间(2025-01-02 ~ 2026-02-25, 420天)
|
||||
|
||||
| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 年化(CAGR) | 胜率 | 回撤% | 盈亏比 | 交易 |
|
||||
|------|------|---------|---------|---------|-----------|------|-------|--------|------|
|
||||
| 🏆 v4\|触发≥2+止盈10+损8 | 日线 | +34,285 | ¥153,490 | +22.3% | **+19.2%** | 46.5% | 4.3% | 1.98 | 202 |
|
||||
| 🏆 v4\|触发≥2+止盈10+损8 | 5分钟 | +22,860 | ¥160,740 | +14.2% | **+12.3%** | 42.5% | 4.2% | 1.70 | 188 |
|
||||
| 🥈 v3\|止盈10+损8 | 日线 | +18,575 | ¥158,655 | +11.7% | +10.1% | 40.0% | 5.0% | 1.60 | 210 |
|
||||
| 🥈 v3\|止盈10+损8 | 5分钟 | +13,555 | ¥160,740 | +8.4% | +7.3% | 39.0% | 6.3% | 1.50 | 190 |
|
||||
| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 日线 | +24,700 | ¥158,655 | +15.6% | +13.4% | 43.1% | 6.9% | 1.71 | 204 |
|
||||
| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 5分钟 | +13,015 | ¥161,995 | +8.0% | +7.0% | 44.1% | 5.7% | 1.46 | 186 |
|
||||
|
||||
### 3.2 仅2025年(2025-01-02 ~ 2025-12-31, 243个交易日)
|
||||
|
||||
| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 年化(CAGR) | 胜率 | 回撤% | 盈亏比 | 交易 |
|
||||
|------|------|---------|---------|---------|-----------|------|-------|--------|------|
|
||||
| 🏆 v4\|触发≥2+止盈10+损8 | 日线 | +20,600 | ¥153,490 | +13.4% | +13.5% | 44.9% | 4.3% | 1.73 | 156 |
|
||||
| 🏆 v4\|触发≥2+止盈10+损8 | 5分钟 | +9,765 | ¥160,740 | +6.1% | +6.1% | 43.1% | 4.2% | 1.38 | 144 |
|
||||
| 🥈 v3\|止盈10+损8 | 日线 | +6,380 | ¥158,655 | +4.0% | +4.0% | 36.2% | 5.0% | 1.23 | 160 |
|
||||
| 🥈 v3\|止盈10+损8 | 5分钟 | +2,230 | ¥160,740 | +1.4% | +1.4% | 37.3% | 6.3% | 1.10 | 150 |
|
||||
| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 日线 | +7,280 | ¥158,655 | +4.6% | +4.6% | 38.5% | 6.9% | 1.23 | 156 |
|
||||
| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 5分钟 | +7,515 | ¥161,995 | +4.6% | +4.7% | 41.9% | 5.7% | 1.31 | 148 |
|
||||
|
||||
### 3.3 仅2026年(2026-01-05 ~ 2026-02-25, 32个交易日)
|
||||
|
||||
| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 年化(简单) | 胜率 | 回撤% | 盈亏比 | 交易 |
|
||||
|------|------|---------|---------|---------|-----------|------|-------|--------|------|
|
||||
| 🏆 v4\|触发≥2+止盈10+损8 | 日线 | +20,720 | ¥133,345 | +15.5% | +111.2% | 57.7% | 2.8% | 4.02 | 52 |
|
||||
| 🏆 v4\|触发≥2+止盈10+损8 | 5分钟 | +14,230 | ¥131,700 | +10.8% | +77.3% | 48.0% | 3.0% | 3.00 | 50 |
|
||||
| 🥈 v3\|止盈10+损8 | 日线 | +21,590 | ¥136,470 | +15.8% | +113.2% | 60.7% | 2.6% | 6.03 | 56 |
|
||||
| 🥈 v3\|止盈10+损8 | 5分钟 | +13,290 | ¥139,570 | +9.5% | +68.2% | 56.0% | 3.2% | 4.02 | 50 |
|
||||
| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 日线 | **+23,340** | ¥136,470 | **+17.1%** | **+122.4%** | **65.4%** | 3.2% | **7.72** | 52 |
|
||||
| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 5分钟 | **+16,835** | ¥136,500 | **+12.3%** | **+88.3%** | **65.2%** | 3.2% | **6.23** | 46 |
|
||||
|
||||
> **注意**: 2026年仅32个交易日(约51天),使用**简单年化**(收益率×365/天数),数值偏高仅供参考
|
||||
|
||||
---
|
||||
|
||||
## 四、月度市场表现(全市场平均日涨跌幅)
|
||||
|
||||
| 月份 | 平均日涨跌% | 趋势 | 平均收盘价 | 覆盖股票数 |
|
||||
|------|-----------|------|-----------|-----------|
|
||||
| 2025-01 | +0.30% | 🟢 | ¥3.97 | 26 |
|
||||
| 2025-02 | +0.42% | 🟢 | ¥3.60 | 25 |
|
||||
| 2025-03 | +0.40% | 🟢 | ¥3.21 | 28 |
|
||||
| 2025-04 | +0.45% | 🟢 | ¥4.08 | 31 |
|
||||
| 2025-05 | +0.13% | 🟢 | ¥19.98 | 5,142 |
|
||||
| 2025-06 | +0.37% | 🟢 | ¥20.81 | 5,156 |
|
||||
| 2025-07 | +0.24% | 🟢 | ¥22.19 | 5,425 |
|
||||
| 2025-08 | +0.36% | 🟢 | ¥24.74 | 5,427 |
|
||||
| 2025-09 | +0.04% | 🟢 | ¥26.02 | 5,438 |
|
||||
| 2025-10 | +0.23% | 🟢 | ¥26.13 | 5,444 |
|
||||
| 2025-11 | +0.02% | 🟢 | ¥26.03 | 5,454 |
|
||||
| 2025-12 | +0.11% | 🟢 | ¥26.30 | 5,472 |
|
||||
| **2026-01** | **+0.40%** | 🟢 | **¥29.38** | 5,478 |
|
||||
| **2026-02** | **+0.15%** | 🟢 | **¥29.50** | 5,484 |
|
||||
|
||||
---
|
||||
|
||||
## 五、季度市场表现
|
||||
|
||||
| 季度 | 交易日 | 日均涨跌% | 趋势 | 波动率 |
|
||||
|------|-------|----------|------|--------|
|
||||
| 2025-Q1 | 57 | +0.370% | 🟢 强 | 4.066 |
|
||||
| 2025-Q2 | 60 | +0.336% | 🟢 中 | 2.536 |
|
||||
| 2025-Q3 | 66 | +0.212% | 🟢 弱 | 2.612 |
|
||||
| 2025-Q4 | 60 | +0.115% | 🟢 最弱 | 2.671 |
|
||||
| **2026-Q1(至今)** | **32** | **+0.304%** | **🟢 强** | **2.823** |
|
||||
|
||||
---
|
||||
|
||||
## 六、扫描信号分布对比
|
||||
|
||||
| 年份 | 总信号 | 买入信号 | 买入占比 | 强买(≥2触发) | 卖出信号 | 卖出占比 |
|
||||
|------|-------|---------|---------|-------------|---------|---------|
|
||||
| 2025 | 1,429,106 | 3,601 | 0.3% | 1,151 | 674,076 | **47.2%** |
|
||||
| 2026 | 369,614 | 682 | 0.2% | 218 | 148,141 | **40.1%** |
|
||||
|
||||
> 2026年卖出信号占比下降7.1个百分点 → 市场做多环境改善
|
||||
|
||||
---
|
||||
|
||||
## 七、为什么2026年开年行情远好于2025年?
|
||||
|
||||
### 1. 🟢 市场整体回暖
|
||||
|
||||
2025年Q4是全年最弱季度(日均+0.115%),经过调整后,2026年1月日均涨幅反弹至 **+0.400%**,是Q4的 **3.5倍**。这是典型的**春季行情**特征:
|
||||
|
||||
- 年初资金回流
|
||||
- 政策利好预期(两会前)
|
||||
- 前期调整充分,估值修复
|
||||
|
||||
### 2. 📈 胜率暴增 12.8%
|
||||
|
||||
| 指标 | 2025全年 | 2026年 |
|
||||
|------|---------|--------|
|
||||
| 胜率 | 44.9% | **57.7%** |
|
||||
|
||||
当市场整体趋势向上时,买入信号本身的成功概率天然更高。这不是算法改变了,而是**市场环境配合了算法**。
|
||||
|
||||
### 3. 📊 盈亏比从 1.73 飙升到 4.02
|
||||
|
||||
- 赚钱时赚得更多(趋势延续性好,更容易触发止盈)
|
||||
- 亏钱时亏得更少(回调幅度小,止损线更不容易被触及)
|
||||
|
||||
### 4. 🔻 卖出信号减少 → 做多空间更大
|
||||
|
||||
- 2025年:47.2% 信号是卖出 → 市场偏空
|
||||
- 2026年:40.1% 信号是卖出 → 做多环境明显改善
|
||||
|
||||
### 5. 💡 延迟卖出策略在2026年最优
|
||||
|
||||
2026年 v4.2-K1(延迟2天卖出)成为最挣钱算法:
|
||||
- +23,340(17.1%)vs 2025年仅 +7,280(4.6%)
|
||||
- 说明2026年趋势更强,延迟卖出可以捕获更多上涨空间
|
||||
|
||||
---
|
||||
|
||||
## 八、结论与展望
|
||||
|
||||
### ✅ 核心结论
|
||||
|
||||
1. **2026年开年行情确实远好于2025年**,32天盈利超过2025全年
|
||||
2. 同一算法在不同市场环境下表现差异巨大:牛市年化100%+ vs 熊市年化4%
|
||||
3. 算法的核心价值不在于"预测涨跌",而在于**在好行情中放大收益,在差行情中控制亏损**
|
||||
|
||||
### 📊 算法表现的市场敏感度
|
||||
|
||||
| 市场环境 | 代表期间 | 最优算法年化 | 胜率 |
|
||||
|---------|---------|------------|------|
|
||||
| 强趋势(日均>+0.3%) | 2026-Q1 | **111%+** | 57%+ |
|
||||
| 中等趋势(日均+0.2-0.3%) | 2025-Q3 | ~10% | ~40% |
|
||||
| 弱势/震荡(日均<+0.2%) | 2025-Q4 | ~4% | ~47% |
|
||||
|
||||
### 🔮 2026全年展望
|
||||
|
||||
- 如果维持Q1水平 → 年化可达 **80-120%**
|
||||
- 如果回落到2025平均水平 → 年化约 **15-20%**
|
||||
- **保守预期**: 全年年化 **20-30%**(仍是银行存款2.5%的 8-12倍)
|
||||
|
||||
---
|
||||
|
||||
## 附录:年化计算方法说明
|
||||
|
||||
| 回测天数 | 年化方法 | 公式 |
|
||||
|---------|---------|------|
|
||||
| < 90天 | **简单年化** | `收益率 × 365 / 天数` |
|
||||
| ≥ 90天 | **复利年化(CAGR)** | `(1 + 收益率)^(365/天数) - 1` |
|
||||
|
||||
> 短期(<90天)使用简单年化避免复利效应过度放大;长期(≥90天)使用CAGR更准确反映实际复利增长。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 回测场景对比 v4.2(真实资金收益率版)
|
||||
|
||||
回测区间: 2025-01-02 ~ 2026-02-25
|
||||
|
||||
> ⚠️ **真实收益率** = 盈亏 / 最大同时占用资金(非总周转金额)
|
||||
|
||||
## 对比结果
|
||||
|
||||
| 场景 | 盈亏(元) | 占用资金 | 真实收益 | 真实年化 | 胜率 | 回撤% | 持仓天 | 盈亏比 | 交易 | 标注 |
|
||||
|------|---------|---------|---------|---------|------|-------|--------|--------|------|------|
|
||||
| v3|仅信号 | +6,310 | ¥155,430 | +4.1% | +3.5% | 40.5% | 6.4% | 20天 | 1.22 | 178 | |
|
||||
| v3|止盈10+损8 | +49,500 | ¥174,120 | +28.4% | +24.4% | 47.8% | 3.3% | 14天 | 2.65 | 222 | 🛡️回撤最小 |
|
||||
| v4|触发≥2+止盈10+损8 | +53,100 | ¥160,410 | +33.1% | +28.3% | 47.2% | 3.8% | 13天 | 2.42 | 212 | 🏆收益最高 |
|
||||
| v4|触发≥2+跟踪6-3+损5 | +22,250 | ¥153,260 | +14.5% | +12.5% | 43.5% | 4.3% | 12天 | 1.65 | 216 | |
|
||||
| v4.1|忽略卖出+止盈10+损8 | +23,670 | ¥130,180 | +18.2% | +15.7% | 57.9% | 6.0% | 31天 | 2.09 | 118 | 🎯胜率最高 |
|
||||
| v4.1|忽略+跟踪8-3+损5+20天 | +33,170 | ¥152,540 | +21.8% | +18.7% | 49.6% | 4.8% | 13天 | 1.98 | 243 | |
|
||||
| v4.2-K1|延迟2天+止盈10+损8 | +48,920 | ¥174,120 | +28.1% | +24.1% | 50.0% | 3.3% | 16天 | 2.78 | 208 | ⚖️盈亏比最佳 |
|
||||
| v4.2-K2|延迟2天+触发≥2+止盈10+损8 | +35,260 | ¥160,950 | +21.9% | +18.8% | 47.5% | 4.0% | 14天 | 1.93 | 202 | |
|
||||
| v4.2-K3|延迟2天+跟踪8-3+损5 | -550 | ¥158,690 | -0.3% | -0.3% | 41.1% | 10.9% | 14天 | 0.99 | 224 | |
|
||||
| v4.2-K4|延迟2天+跟踪6-3+损5 | +10,660 | ¥158,690 | +6.7% | +5.8% | 49.1% | 6.8% | 14天 | 1.32 | 220 | |
|
||||
| v4.2-K5|延迟2天+触发≥2+跟踪8-3+损5 | +21,420 | ¥153,260 | +14.0% | +12.1% | 42.9% | 4.6% | 13天 | 1.52 | 210 | |
|
||||
| v4.2-K6|延迟2天+触发≥2+跟踪6-3+损8 | +22,470 | ¥155,140 | +14.5% | +12.5% | 52.0% | 5.5% | 14天 | 1.68 | 196 | |
|
||||
| v4.2-L1|延迟3天+止盈10+损8 | +20,020 | ¥136,730 | +14.6% | +12.6% | 48.9% | 7.8% | 18天 | 1.66 | 180 | |
|
||||
| v4.2-L2|延迟3天+触发≥2+止盈10+损8 | +36,940 | ¥160,950 | +22.9% | +19.7% | 49.5% | 4.2% | 16天 | 2.02 | 186 | |
|
||||
| v4.2-L3|延迟3天+跟踪8-3+损5 | +1,680 | ¥136,840 | +1.2% | +1.1% | 44.4% | 14.4% | 17天 | 1.04 | 198 | |
|
||||
| v4.2-L4|延迟3天+触发≥2+跟踪6-3+损5 | +30,540 | ¥153,260 | +19.9% | +17.1% | 50.0% | 7.3% | 13天 | 1.89 | 208 | |
|
||||
| v4.2-M1|延迟2天+盈保5%+止盈10+损8 | +44,330 | ¥174,120 | +25.5% | +21.9% | 49.0% | 3.3% | 16天 | 2.55 | 208 | |
|
||||
| v4.2-M2|延迟2天+盈保5%+触发≥2+止盈10+损8 | +24,860 | ¥160,950 | +15.4% | +13.3% | 46.5% | 4.0% | 14天 | 1.67 | 198 | |
|
||||
| v4.2-M3|延迟3天+跟踪8-3+损5+30天 | +7,850 | ¥153,260 | +5.1% | +4.5% | 50.0% | 8.3% | 14天 | 1.20 | 228 | |
|
||||
| v4.2-M4|延迟2天+触发≥2+跟踪8-3+损5+30天 | +25,020 | ¥153,260 | +16.3% | +14.1% | 45.8% | 4.5% | 12天 | 1.65 | 214 | |
|
||||
|
||||
## 指标说明
|
||||
|
||||
| 指标 | 说明 |
|
||||
|------|------|
|
||||
| 占用资金 | 回测期间最大同时持仓成本 |
|
||||
| 真实收益 | 盈亏 / 最大占用资金 × 100% |
|
||||
| 真实年化 | 按持续期折算年化(复利公式) |
|
||||
| 回撤% | 最大回撤 / 最大占用资金 × 100% |
|
||||
| 盈亏比 | 总盈利金额 / 总亏损金额 |
|
||||
| 延迟N天 | 连续N天推荐卖出才执行卖出 |
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 💰 20万本金 × 按季度投资 × 多算法对比回测
|
||||
|
||||
> 生成时间: 2026-02-25 21:09
|
||||
|
||||
## 回测配置 (v5.2 动态仓位)
|
||||
|
||||
| 参数 | 值 |
|
||||
|------|----|
|
||||
| 本金 | ¥200,000 (唯一约束) |
|
||||
| 单只上限 | 无(受总资金约束) |
|
||||
| 最大持仓 | 无(受总资金约束) |
|
||||
| 每笔仓位 | 动态: 总资金×5% = ¥10,000/笔 |
|
||||
| 每笔股数 | 动态(根据股价自动计算,取整到100股) |
|
||||
| 股价区间 | 无 |
|
||||
| 每日最多买入 | 无 |
|
||||
| 冷却期 | 3天 |
|
||||
| 年化方法 | <90天用简单(S),≥90天用复利CAGR(C) |
|
||||
|
||||
## 算法说明
|
||||
|
||||
| # | 算法 | 参数说明 |
|
||||
|---|------|--------|
|
||||
| 1 | v3|基线(TP10+SL8) | take_profit_pct=10, stop_loss_pct=8 |
|
||||
| 2 | v4|触发≥2+TP10+SL8 | min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=8 |
|
||||
| 3 | v4.2|延迟2天+TP10+SL8 | sell_confirm_days=2, take_profit_pct=10, stop_loss_pct=8 |
|
||||
| 4 | v4|忽略卖出+TP10+SL8 | ignore_sell_signal=True, take_profit_pct=10, stop_loss_pct=8 |
|
||||
| 5 | v4|触发≥2+TP15+SL8 | min_buy_triggered=2, take_profit_pct=15, stop_loss_pct=8 |
|
||||
| 6 | v4|触发≥2+TP10+SL5 | min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=5 |
|
||||
| 7 | v4.2|延迟2天+触发≥2+TP10+SL8 | sell_confirm_days=2, min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=8 |
|
||||
| 8 | v4.1|跟踪止盈8/3+SL5 | ignore_sell_signal=True, trailing_start_pct=8, trailing_gap_pct=3, stop_loss_pct=5 |
|
||||
| 9 | v4.1|跟踪止盈8/3+触发≥2+SL5 | ignore_sell_signal=True, trailing_start_pct=8, trailing_gap_pct=3, stop_loss_pct=5, min_buy_triggered=2 |
|
||||
| 10 | v4|触发≥2+TP10+SL8+持仓≤30天 | min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=8, max_hold_days=30 |
|
||||
| 11 | v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | sell_confirm_days=2, min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=8, signal_weight=True |
|
||||
| 12 | v5.2|忽略卖出+TP10+SL8+信号加权 | ignore_sell_signal=True, take_profit_pct=10, stop_loss_pct=8, signal_weight=True |
|
||||
| 13 | v5.2|跟踪止盈8/3+SL5+信号加权 | ignore_sell_signal=True, trailing_start_pct=8, trailing_gap_pct=3, stop_loss_pct=5, signal_weight=True |
|
||||
|
||||
## 一、盈亏对比(元)
|
||||
|
||||
| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 |
|
||||
|------|--------|--------|--------|--------|--------|--------|
|
||||
| v3|基线(TP10+SL8) | -5,413 | +726 | +8,865 | -23,015 | +19,566 | +10,128 |
|
||||
| v4|触发≥2+TP10+SL8 | +235 | +0 | +9,380 | +3,954 | +15,138 | +38,214 |
|
||||
| v4.2|延迟2天+TP10+SL8 | -7,253 | +726 | +8,510 | -14,404 | +20,594 | +24,818 |
|
||||
| v4|忽略卖出+TP10+SL8 | -7,469 | +726 | +18,660 | -12,454 | +21,573 | +46,303 |
|
||||
| v4|触发≥2+TP15+SL8 | +235 | +0 | +7,064 | +95 | +13,126 | +31,840 |
|
||||
| v4|触发≥2+TP10+SL5 | +235 | +0 | +4,319 | +3,500 | +9,442 | +28,637 |
|
||||
| v4.2|延迟2天+触发≥2+TP10+SL8 | -1,605 | +0 | +12,402 | +8 | +13,774 | +34,754 |
|
||||
| v4.1|跟踪止盈8/3+SL5 | -4,934 | +726 | +4,990 | -4,034 | +13,361 | -18,546 |
|
||||
| v4.1|跟踪止盈8/3+触发≥2+SL5 | -2,609 | +0 | +20,002 | +2,733 | +7,244 | +43,784 |
|
||||
| v4|触发≥2+TP10+SL8+持仓≤30天 | +235 | +0 | +8,976 | +4,491 | +15,318 | +36,754 |
|
||||
| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | -2,010 | +0 | +17,160 | -713 | +22,334 | +44,039 |
|
||||
| v5.2|忽略卖出+TP10+SL8+信号加权 | -8,369 | +726 | +29,224 | -16,560 | +20,517 | +56,375 |
|
||||
| v5.2|跟踪止盈8/3+SL5+信号加权 | -5,591 | +726 | +12,802 | -6,054 | +10,585 | -14,492 |
|
||||
|
||||
## 二、真实收益率(%)
|
||||
|
||||
| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 |
|
||||
|------|--------|--------|--------|--------|--------|--------|
|
||||
| v3|基线(TP10+SL8) | -18.2% | +3.6% | +4.2% | -11.2% | +9.0% | +4.4% |
|
||||
| v4|触发≥2+TP10+SL8 | +2.4% | +0.0% | +5.4% | +1.9% | +7.8% | +17.2% |
|
||||
| v4.2|延迟2天+TP10+SL8 | -24.4% | +3.6% | +4.0% | -7.0% | +9.6% | +10.5% |
|
||||
| v4|忽略卖出+TP10+SL8 | -25.1% | +3.6% | +8.2% | -5.9% | +9.7% | +18.5% |
|
||||
| v4|触发≥2+TP15+SL8 | +2.4% | +0.0% | +3.9% | +0.1% | +6.3% | +14.8% |
|
||||
| v4|触发≥2+TP10+SL5 | +2.4% | +0.0% | +2.8% | +1.7% | +5.2% | +13.2% |
|
||||
| v4.2|延迟2天+触发≥2+TP10+SL8 | -16.1% | +0.0% | +6.8% | +0.0% | +6.5% | +15.4% |
|
||||
| v4.1|跟踪止盈8/3+SL5 | -16.6% | +3.6% | +2.3% | -2.0% | +6.4% | -8.9% |
|
||||
| v4.1|跟踪止盈8/3+触发≥2+SL5 | -26.2% | +0.0% | +10.2% | +1.4% | +3.5% | +18.1% |
|
||||
| v4|触发≥2+TP10+SL8+持仓≤30天 | +2.4% | +0.0% | +5.2% | +2.2% | +7.9% | +16.5% |
|
||||
| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | -13.6% | +0.0% | +8.6% | -0.3% | +10.7% | +19.1% |
|
||||
| v5.2|忽略卖出+TP10+SL8+信号加权 | -26.4% | +3.6% | +12.5% | -7.8% | +9.3% | +21.5% |
|
||||
| v5.2|跟踪止盈8/3+SL5+信号加权 | -18.9% | +3.6% | +5.8% | -3.0% | +5.1% | -6.8% |
|
||||
|
||||
## 三、年化收益率(%)
|
||||
|
||||
> (S)=简单年化(<90天),(C)=复利CAGR(≥90天)
|
||||
|
||||
| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 |
|
||||
|------|--------|--------|--------|--------|--------|--------|
|
||||
| v3|基线(TP10+SL8) | -75.6%(S) | +15.6%(C) | +17.9%(C) | -37.9%(C) | +64.5%(S) | +3.9%(C) |
|
||||
| v4|触发≥2+TP10+SL8 | +9.8%(S) | +0.0%(C) | +23.6%(C) | +8.0%(C) | +56.1%(S) | +14.8%(C) |
|
||||
| v4.2|延迟2天+TP10+SL8 | -101.3%(S) | +15.6%(C) | +17.1%(C) | -25.3%(C) | +68.7%(S) | +9.1%(C) |
|
||||
| v4|忽略卖出+TP10+SL8 | -104.3%(S) | +15.6%(C) | +37.1%(C) | -21.7%(C) | +69.7%(S) | +15.9%(C) |
|
||||
| v4|触发≥2+TP15+SL8 | +9.8%(S) | +0.0%(C) | +16.5%(C) | +0.2%(C) | +45.1%(S) | +12.8%(C) |
|
||||
| v4|触发≥2+TP10+SL5 | +9.8%(S) | +0.0%(C) | +11.7%(C) | +7.1%(C) | +37.5%(S) | +11.4%(C) |
|
||||
| v4.2|延迟2天+触发≥2+TP10+SL8 | -66.9%(S) | +0.0%(C) | +30.1%(C) | +0.0%(C) | +46.7%(S) | +13.3%(C) |
|
||||
| v4.1|跟踪止盈8/3+SL5 | -69.0%(S) | +15.6%(C) | +9.6%(C) | -7.8%(C) | +45.6%(S) | -7.8%(C) |
|
||||
| v4.1|跟踪止盈8/3+触发≥2+SL5 | -108.8%(S) | +0.0%(C) | +47.6%(C) | +5.5%(C) | +25.3%(S) | +15.6%(C) |
|
||||
| v4|触发≥2+TP10+SL8+持仓≤30天 | +9.8%(S) | +0.0%(C) | +22.6%(C) | +9.2%(C) | +56.8%(S) | +14.2%(C) |
|
||||
| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | -56.4%(S) | +0.0%(C) | +39.1%(C) | -1.4%(C) | +76.2%(S) | +16.4%(C) |
|
||||
| v5.2|忽略卖出+TP10+SL8+信号加权 | -109.6%(S) | +15.6%(C) | +60.4%(C) | -27.9%(C) | +66.4%(S) | +18.5%(C) |
|
||||
| v5.2|跟踪止盈8/3+SL5+信号加权 | -78.2%(S) | +15.6%(C) | +25.3%(C) | -11.4%(C) | +36.2%(S) | -5.9%(C) |
|
||||
|
||||
## 四、胜率(%)
|
||||
|
||||
| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 |
|
||||
|------|--------|--------|--------|--------|--------|--------|
|
||||
| v3|基线(TP10+SL8) | 11.1% | 100.0% | 42.4% | 41.9% | 53.1% | 45.0% |
|
||||
| v4|触发≥2+TP10+SL8 | 25.0% | 0.0% | 42.6% | 44.8% | 49.3% | 44.9% |
|
||||
| v4.2|延迟2天+TP10+SL8 | 11.1% | 100.0% | 45.5% | 40.8% | 56.7% | 47.8% |
|
||||
| v4|忽略卖出+TP10+SL8 | 11.1% | 100.0% | 53.1% | 40.7% | 67.1% | 59.8% |
|
||||
| v4|触发≥2+TP15+SL8 | 25.0% | 0.0% | 42.6% | 38.2% | 45.5% | 41.2% |
|
||||
| v4|触发≥2+TP10+SL5 | 25.0% | 0.0% | 38.2% | 41.7% | 44.4% | 41.2% |
|
||||
| v4.2|延迟2天+触发≥2+TP10+SL8 | 25.0% | 0.0% | 47.1% | 43.2% | 50.8% | 44.3% |
|
||||
| v4.1|跟踪止盈8/3+SL5 | 30.0% | 100.0% | 46.8% | 39.8% | 61.2% | 43.3% |
|
||||
| v4.1|跟踪止盈8/3+触发≥2+SL5 | 25.0% | 0.0% | 54.7% | 45.1% | 48.4% | 48.5% |
|
||||
| v4|触发≥2+TP10+SL8+持仓≤30天 | 25.0% | 0.0% | 42.6% | 44.6% | 49.3% | 46.1% |
|
||||
| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | 25.0% | 0.0% | 47.0% | 43.3% | 52.5% | 44.8% |
|
||||
| v5.2|忽略卖出+TP10+SL8+信号加权 | 11.1% | 100.0% | 57.5% | 42.9% | 66.1% | 61.2% |
|
||||
| v5.2|跟踪止盈8/3+SL5+信号加权 | 30.0% | 100.0% | 45.6% | 39.6% | 56.2% | 44.2% |
|
||||
|
||||
## 五、盈亏比
|
||||
|
||||
| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 |
|
||||
|------|--------|--------|--------|--------|--------|--------|
|
||||
| v3|基线(TP10+SL8) | 0.26 | 999.99 | 1.36 | 1.14 | 3.16 | 1.43 |
|
||||
| v4|触发≥2+TP10+SL8 | 1.15 | 999.99 | 1.72 | 1.14 | 2.12 | 1.66 |
|
||||
| v4.2|延迟2天+TP10+SL8 | 0.21 | 999.99 | 1.32 | 1.23 | 3.25 | 1.55 |
|
||||
| v4|忽略卖出+TP10+SL8 | 0.20 | 999.99 | 2.22 | 1.27 | 4.53 | 2.16 |
|
||||
| v4|触发≥2+TP15+SL8 | 1.15 | 999.99 | 1.54 | 1.00 | 2.02 | 1.58 |
|
||||
| v4|触发≥2+TP10+SL5 | 1.15 | 999.99 | 1.30 | 1.12 | 1.64 | 1.48 |
|
||||
| v4.2|延迟2天+触发≥2+TP10+SL8 | 0.54 | 999.99 | 1.93 | 1.00 | 1.91 | 1.56 |
|
||||
| v4.1|跟踪止盈8/3+SL5 | 0.35 | 999.99 | 1.20 | 0.87 | 2.48 | 1.03 |
|
||||
| v4.1|跟踪止盈8/3+触发≥2+SL5 | 0.27 | 999.99 | 2.51 | 1.12 | 1.54 | 1.76 |
|
||||
| v4|触发≥2+TP10+SL8+持仓≤30天 | 1.15 | 999.99 | 1.69 | 1.16 | 2.14 | 1.63 |
|
||||
| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | 0.53 | 999.99 | 2.07 | 0.98 | 2.87 | 1.62 |
|
||||
| v5.2|忽略卖出+TP10+SL8+信号加权 | 0.19 | 999.99 | 3.29 | 1.19 | 4.08 | 2.30 |
|
||||
| v5.2|跟踪止盈8/3+SL5+信号加权 | 0.34 | 999.99 | 1.69 | 0.82 | 1.94 | 1.10 |
|
||||
|
||||
## 六、最大占用资金(元)
|
||||
|
||||
| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 |
|
||||
|------|--------|--------|--------|--------|--------|--------|
|
||||
| v3|基线(TP10+SL8) | ¥29,692 | ¥19,956 | ¥210,978 | ¥205,770 | ¥217,079 | ¥228,420 |
|
||||
| v4|触发≥2+TP10+SL8 | ¥9,950 | ¥0 | ¥172,570 | ¥203,834 | ¥193,078 | ¥222,459 |
|
||||
| v4.2|延迟2天+TP10+SL8 | ¥29,692 | ¥19,956 | ¥211,691 | ¥205,436 | ¥214,508 | ¥235,524 |
|
||||
| v4|忽略卖出+TP10+SL8 | ¥29,692 | ¥19,956 | ¥227,716 | ¥210,810 | ¥221,473 | ¥250,123 |
|
||||
| v4|触发≥2+TP15+SL8 | ¥9,950 | ¥0 | ¥181,564 | ¥200,470 | ¥208,478 | ¥215,200 |
|
||||
| v4|触发≥2+TP10+SL5 | ¥9,950 | ¥0 | ¥154,230 | ¥202,104 | ¥180,278 | ¥217,594 |
|
||||
| v4.2|延迟2天+触发≥2+TP10+SL8 | ¥9,950 | ¥0 | ¥182,934 | ¥201,774 | ¥211,020 | ¥226,260 |
|
||||
| v4.1|跟踪止盈8/3+SL5 | ¥29,668 | ¥19,956 | ¥215,702 | ¥202,549 | ¥209,646 | ¥208,376 |
|
||||
| v4.1|跟踪止盈8/3+触发≥2+SL5 | ¥9,950 | ¥0 | ¥196,167 | ¥202,462 | ¥204,924 | ¥241,644 |
|
||||
| v4|触发≥2+TP10+SL8+持仓≤30天 | ¥9,950 | ¥0 | ¥172,570 | ¥203,454 | ¥193,078 | ¥222,686 |
|
||||
| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | ¥14,778 | ¥0 | ¥200,032 | ¥204,190 | ¥209,762 | ¥230,860 |
|
||||
| v5.2|忽略卖出+TP10+SL8+信号加权 | ¥31,682 | ¥19,956 | ¥233,843 | ¥211,248 | ¥221,145 | ¥262,252 |
|
||||
| v5.2|跟踪止盈8/3+SL5+信号加权 | ¥29,668 | ¥19,956 | ¥221,058 | ¥203,087 | ¥209,122 | ¥214,326 |
|
||||
|
||||
## 七、🏆 各季度最优算法
|
||||
|
||||
| 季度 | 最优算法 | 盈利(元) | 真实收益 | 年化 | 胜率 | 盈亏比 |
|
||||
|------|---------|---------|---------|------|------|--------|
|
||||
| 2025-Q1 | **v4|触发≥2+TP10+SL8** | +235 | +2.4% | +9.8%(S) | 25.0% | 1.15 |
|
||||
| 2025-Q2 | **v3|基线(TP10+SL8)** | +726 | +3.6% | +15.6%(C) | 100.0% | 999.99 |
|
||||
| 2025-Q3 | **v5.2|忽略卖出+TP10+SL8+信号加权** | +29,224 | +12.5% | +60.4%(C) | 57.5% | 3.29 |
|
||||
| 2025-Q4 | **v4|触发≥2+TP10+SL8+持仓≤30天** | +4,491 | +2.2% | +9.2%(C) | 44.6% | 1.16 |
|
||||
| 2026-Q1 | **v5.2|延迟2天+触发≥2+TP10+SL8+信号加权** | +22,334 | +10.7% | +76.2%(S) | 52.5% | 2.87 |
|
||||
| 全期间 | **v5.2|忽略卖出+TP10+SL8+信号加权** | +56,375 | +21.5% | +18.5%(C) | 61.2% | 2.30 |
|
||||
|
||||
## 八、算法全期间总收益排名
|
||||
|
||||
| 排名 | 算法 | 全期间盈利 | 真实收益 | 年化(CAGR) | 胜率 | 盈亏比 | 最大回撤 | 占用资金 |
|
||||
|------|------|----------|---------|-----------|------|--------|---------|--------|
|
||||
| 🏆 | v5.2|忽略卖出+TP10+SL8+信号加权 | +56,375 | +21.5% | +18.5% | 61.2% | 2.30 | 7.9% | ¥262,252 |
|
||||
| 🥈 | v4|忽略卖出+TP10+SL8 | +46,303 | +18.5% | +15.9% | 59.8% | 2.16 | 7.7% | ¥250,123 |
|
||||
| 🥉 | v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | +44,039 | +19.1% | +16.4% | 44.8% | 1.62 | 8.4% | ¥230,860 |
|
||||
| #4 | v4.1|跟踪止盈8/3+触发≥2+SL5 | +43,784 | +18.1% | +15.6% | 48.5% | 1.76 | 5.0% | ¥241,644 |
|
||||
| #5 | v4|触发≥2+TP10+SL8 | +38,214 | +17.2% | +14.8% | 44.9% | 1.66 | 4.9% | ¥222,459 |
|
||||
| #6 | v4|触发≥2+TP10+SL8+持仓≤30天 | +36,754 | +16.5% | +14.2% | 46.1% | 1.63 | 4.5% | ¥222,686 |
|
||||
| #7 | v4.2|延迟2天+触发≥2+TP10+SL8 | +34,754 | +15.4% | +13.3% | 44.3% | 1.56 | 7.0% | ¥226,260 |
|
||||
| #8 | v4|触发≥2+TP15+SL8 | +31,840 | +14.8% | +12.8% | 41.2% | 1.58 | 4.9% | ¥215,200 |
|
||||
| #9 | v4|触发≥2+TP10+SL5 | +28,637 | +13.2% | +11.4% | 41.2% | 1.48 | 5.0% | ¥217,594 |
|
||||
| #10 | v4.2|延迟2天+TP10+SL8 | +24,818 | +10.5% | +9.1% | 47.8% | 1.55 | 10.5% | ¥235,524 |
|
||||
| #11 | v3|基线(TP10+SL8) | +10,128 | +4.4% | +3.9% | 45.0% | 1.43 | 15.3% | ¥228,420 |
|
||||
| #12 | v5.2|跟踪止盈8/3+SL5+信号加权 | -14,492 | -6.8% | -5.9% | 44.2% | 1.10 | 20.7% | ¥214,326 |
|
||||
| #13 | v4.1|跟踪止盈8/3+SL5 | -18,546 | -8.9% | -7.8% | 43.3% | 1.03 | 21.9% | ¥208,376 |
|
||||
|
||||
## 九、分析结论
|
||||
|
||||
### 🏆 全期间最优算法: v5.2|忽略卖出+TP10+SL8+信号加权
|
||||
|
||||
- 总盈利: **¥+56,375**
|
||||
- 真实收益率: **+21.5%**
|
||||
- 年化收益率: **+18.5%**
|
||||
- 胜率: **61.2%**
|
||||
- 盈亏比: **2.30**
|
||||
- 最大回撤: **7.9%**
|
||||
- 最大占用资金: **¥262,252**(131%本金利用率)
|
||||
|
||||
### 回报对比
|
||||
|
||||
| 投资方式 | 年化收益 | 20万本金一年收益 |
|
||||
|---------|---------|----------------|
|
||||
| 银行定存 | 2.5% | ¥5,000 |
|
||||
| 余额宝 | 1.8% | ¥3,600 |
|
||||
| **本算法** | **+18.5%** | **¥+56,375**(实际) |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
# Top 3 算法 × 5分钟实时价格 回测对比
|
||||
|
||||
回测区间: 2025-01-02 ~ 2025-12-31
|
||||
|
||||
## 定价模式
|
||||
|
||||
| 模式 | 买入价 | 卖出价 | 说明 |
|
||||
|------|--------|--------|------|
|
||||
| 日线(原版) | 当日开盘价 | 当日收盘价 | 原始基准 |
|
||||
| 5分钟实时 | 10:00 5min收盘 | 15:00 5min收盘 | 有5min数据用5min, 无则用mid=(开盘+收盘)/2 |
|
||||
|
||||
## 对比结果
|
||||
|
||||
| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 真实年化 | 胜率 | 回撤% | 盈亏比 | 交易 | 5min覆盖 |
|
||||
|------|------|---------|---------|---------|---------|------|-------|--------|------|----------|
|
||||
| 🏆 v4|触发≥2+止盈10+损8 | 日线(原版) | +20,600 | ¥153,490 | +13.4% | +13.5% | 44.9% | 4.3% | 1.73 | 156 | - |
|
||||
| 🏆 v4|触发≥2+止盈10+损8 | 5分钟实时价 | +9,765 | ¥160,740 | +6.1% | +6.1% | 43.1% | 4.2% | 1.38 | 144 | 0% |
|
||||
| 🥈 v3|止盈10+损8 | 日线(原版) | +6,380 | ¥158,655 | +4.0% | +4.0% | 36.2% | 5.0% | 1.23 | 160 | - |
|
||||
| 🥈 v3|止盈10+损8 | 5分钟实时价 | +2,230 | ¥160,740 | +1.4% | +1.4% | 37.3% | 6.3% | 1.10 | 150 | 1% |
|
||||
| 🥉 v4.2-K1|延迟2天+止盈10+损8 | 日线(原版) | +7,280 | ¥158,655 | +4.6% | +4.6% | 38.5% | 6.9% | 1.23 | 156 | - |
|
||||
| 🥉 v4.2-K1|延迟2天+止盈10+损8 | 5分钟实时价 | +7,515 | ¥161,995 | +4.6% | +4.7% | 41.9% | 5.7% | 1.31 | 148 | 4% |
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# ⏰ v7.0 交易时点网格搜索结果
|
||||
|
||||
> 生成时间: 2026-02-26 09:53
|
||||
|
||||
## 搜索配置
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|----|
|
||||
| 本金 | ¥200,000 |
|
||||
| 回测区间 | 2025-11-28 ~ 2026-02-26 |
|
||||
| 5分钟数据 | 2025-11-28 ~ 2026-02-25 (56天) |
|
||||
| 时间点 | 48 个 |
|
||||
| 组合数 | 2,304 × 3 算法 = 6,912 |
|
||||
| 耗时 | 797.8s (8.7次/s) |
|
||||
| 有效结果 | 6,912 |
|
||||
|
||||
## 🏆 全局 Top 20
|
||||
|
||||
| 排名 | 算法 | 买入 | 卖出 | 盈亏 | 收益% | 年化% | 胜率 | 回撤% | 交易 | 5min% |
|
||||
|------|------|------|------|------|-------|-------|------|-------|------|-------|
|
||||
| 🏆 | 🥈TP12|SL6|d3|h30|15%SW | 09:35 | 13:40 | ¥+46,130 | +18.8% | +101.1% | 59.4% | 2.8% | 139 | 21% |
|
||||
| 🥈 | 🥈TP12|SL6|d3|h30|15%SW | 09:35 | 13:35 | ¥+45,910 | +18.7% | +100.6% | 60.9% | 2.9% | 139 | 21% |
|
||||
| 🥉 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 13:40 | ¥+45,850 | +18.7% | +100.6% | 57.4% | 2.7% | 137 | 21% |
|
||||
| #4 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:15 | ¥+45,556 | +18.7% | +100.4% | 56.3% | 2.8% | 143 | 21% |
|
||||
| #5 | 🥈TP12|SL6|d3|h30|15%SW | 09:40 | 14:15 | ¥+45,436 | +18.7% | +100.2% | 59.7% | 2.9% | 135 | 21% |
|
||||
| #6 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 13:35 | ¥+45,425 | +18.6% | +99.5% | 56.1% | 2.9% | 133 | 21% |
|
||||
| #7 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 13:45 | ¥+45,418 | +18.7% | +100.1% | 58.2% | 2.8% | 135 | 21% |
|
||||
| #8 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:10 | ¥+45,328 | +18.6% | +99.8% | 58.0% | 2.9% | 139 | 21% |
|
||||
| #9 | 🥈TP12|SL6|d3|h30|15%SW | 09:35 | 13:45 | ¥+45,255 | +18.5% | +99.1% | 60.0% | 2.8% | 141 | 21% |
|
||||
| #10 | 🥈TP12|SL6|d3|h30|15%SW | 14:35 | 13:35 | ¥+45,204 | +18.5% | +98.9% | 57.5% | 2.9% | 147 | 21% |
|
||||
| #11 | 🥈TP12|SL6|d3|h30|15%SW | 13:20 | 13:35 | ¥+45,202 | +18.5% | +98.8% | 57.8% | 2.9% | 143 | 21% |
|
||||
| #12 | 🥈TP12|SL6|d3|h30|15%SW | 13:20 | 13:40 | ¥+45,160 | +18.4% | +98.7% | 58.0% | 2.7% | 139 | 21% |
|
||||
| #13 | 🥈TP12|SL6|d3|h30|15%SW | 14:35 | 13:40 | ¥+45,142 | +18.5% | +98.8% | 55.6% | 2.7% | 145 | 21% |
|
||||
| #14 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:25 | ¥+45,139 | +18.6% | +99.4% | 58.6% | 3.2% | 141 | 21% |
|
||||
| #15 | 🥈TP12|SL6|d3|h30|15%SW | 14:30 | 13:40 | ¥+45,103 | +18.4% | +98.6% | 62.3% | 2.7% | 139 | 21% |
|
||||
| #16 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:35 | ¥+45,088 | +18.5% | +99.1% | 58.2% | 3.1% | 135 | 21% |
|
||||
| #17 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 13:55 | ¥+45,087 | +18.5% | +99.3% | 55.7% | 3.0% | 141 | 21% |
|
||||
| #18 | 🥈TP12|SL6|d3|h30|15%SW | 09:40 | 13:40 | ¥+45,075 | +18.4% | +98.5% | 56.7% | 2.8% | 135 | 21% |
|
||||
| #19 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:20 | ¥+45,046 | +18.5% | +99.0% | 57.4% | 3.1% | 137 | 21% |
|
||||
| #20 | 🥈TP12|SL6|d3|h30|15%SW | 09:40 | 13:35 | ¥+44,999 | +18.4% | +98.6% | 58.2% | 2.9% | 135 | 21% |
|
||||
|
||||
## 📊 每算法最优时间点
|
||||
|
||||
| 算法 | 最优买入 | 最优卖出 | 最优盈利 | 默认盈利(10:00/15:00) | 提升 |
|
||||
|------|---------|---------|---------|---------------------|------|
|
||||
| 🏆TP12|SL6|d3|h30|10%SW | 09:45 | 13:40 | ¥+30,258 | ¥+22,006 | ¥+8,252 |
|
||||
| 🥈TP12|SL6|d3|h30|15%SW | 09:35 | 13:40 | ¥+46,130 | ¥+34,504 | ¥+11,625 |
|
||||
| 🥉TP10|SL8|ign|15%SW | 14:35 | 10:05 | ¥+34,890 | ¥+19,705 | ¥+15,184 |
|
||||
|
||||
## 📈 买入时间排名 (卖出固定@15:00)
|
||||
|
||||
| 排名 | 买入时间 | 平均盈利 | 最高盈利 | 最低盈利 |
|
||||
|------|---------|---------|---------|----------|
|
||||
| 🏆 | 09:35 | ¥+28,213 | ¥+40,034 | ¥+19,618 |
|
||||
| #2 | 09:45 | ¥+28,066 | ¥+39,810 | ¥+19,456 |
|
||||
| #3 | 14:35 | ¥+27,881 | ¥+38,760 | ¥+20,066 |
|
||||
| #4 | 09:40 | ¥+27,867 | ¥+39,487 | ¥+19,422 |
|
||||
| #5 | 14:30 | ¥+27,806 | ¥+38,814 | ¥+20,090 |
|
||||
| #6 | 10:35 | ¥+27,738 | ¥+38,905 | ¥+20,073 |
|
||||
| #7 | 13:35 | ¥+27,713 | ¥+38,984 | ¥+19,888 |
|
||||
| #8 | 13:30 | ¥+27,662 | ¥+38,724 | ¥+19,876 |
|
||||
| #9 | 14:40 | ¥+27,624 | ¥+38,769 | ¥+20,104 |
|
||||
| #10 | 13:40 | ¥+27,602 | ¥+38,372 | ¥+20,062 |
|
||||
| #11 | 13:25 | ¥+27,602 | ¥+38,601 | ¥+19,903 |
|
||||
| #12 | 13:20 | ¥+27,486 | ¥+38,613 | ¥+19,920 |
|
||||
| #13 | 14:50 | ¥+27,461 | ¥+38,129 | ¥+20,044 |
|
||||
| #14 | 13:45 | ¥+27,425 | ¥+38,350 | ¥+20,072 |
|
||||
| #15 | 10:30 | ¥+27,402 | ¥+38,782 | ¥+19,882 |
|
||||
| #16 | 09:50 | ¥+27,363 | ¥+38,290 | ¥+19,890 |
|
||||
| #17 | 13:15 | ¥+27,316 | ¥+38,326 | ¥+19,880 |
|
||||
| #18 | 14:15 | ¥+26,302 | ¥+35,799 | ¥+20,059 |
|
||||
| #19 | 13:10 | ¥+26,246 | ¥+35,840 | ¥+20,035 |
|
||||
| #20 | 14:10 | ¥+26,177 | ¥+35,631 | ¥+20,080 |
|
||||
| #21 | 14:05 | ¥+26,102 | ¥+35,546 | ¥+20,068 |
|
||||
| #22 | 14:20 | ¥+26,042 | ¥+35,728 | ¥+20,068 |
|
||||
| #23 | 10:40 | ¥+26,036 | ¥+35,531 | ¥+19,918 |
|
||||
| #24 | 13:55 | ¥+25,991 | ¥+35,035 | ¥+20,074 |
|
||||
| #25 | 13:50 | ¥+25,985 | ¥+34,956 | ¥+20,096 |
|
||||
| #26 | 10:45 | ¥+25,984 | ¥+35,174 | ¥+19,876 |
|
||||
| #27 | 13:05 | ¥+25,965 | ¥+35,660 | ¥+19,916 |
|
||||
| #28 | 10:25 | ¥+25,957 | ¥+35,309 | ¥+19,731 |
|
||||
| #29 | 14:25 | ¥+25,949 | ¥+35,449 | ¥+20,098 |
|
||||
| #30 | 14:00 | ¥+25,896 | ¥+35,378 | ¥+20,046 |
|
||||
| #31 | 10:15 | ¥+25,838 | ¥+35,464 | ¥+19,651 |
|
||||
| #32 | 15:00 | ¥+25,809 | ¥+35,078 | ¥+20,436 |
|
||||
| #33 | 10:20 | ¥+25,785 | ¥+34,903 | ¥+19,729 |
|
||||
| #34 | 11:05 | ¥+25,748 | ¥+34,926 | ¥+20,012 |
|
||||
| #35 | 14:55 | ¥+25,703 | ¥+34,855 | ¥+20,064 |
|
||||
| #36 | 10:10 | ¥+25,684 | ¥+35,283 | ¥+19,509 |
|
||||
| #37 | 14:45 | ¥+25,592 | ¥+34,740 | ¥+20,057 |
|
||||
| #38 | 10:05 | ¥+25,562 | ¥+34,987 | ¥+19,462 |
|
||||
| #39 | 11:30 | ¥+25,510 | ¥+35,096 | ¥+19,857 |
|
||||
| #40 | 11:25 | ¥+25,442 | ¥+34,946 | ¥+19,848 |
|
||||
| #41 | 10:55 | ¥+25,434 | ¥+34,964 | ¥+19,826 |
|
||||
| #42 | 10:00 | ¥+25,405 | ¥+34,504 | ¥+19,705 |
|
||||
| #43 | 11:00 | ¥+25,396 | ¥+34,818 | ¥+19,999 |
|
||||
| #44 | 10:50 | ¥+25,221 | ¥+34,489 | ¥+19,846 |
|
||||
| #45 | 11:15 | ¥+25,037 | ¥+33,496 | ¥+20,027 |
|
||||
| #46 | 11:10 | ¥+24,934 | ¥+33,214 | ¥+20,005 |
|
||||
| #47 | 11:20 | ¥+24,921 | ¥+33,684 | ¥+20,043 |
|
||||
| #48 | 09:55 | ¥+21,316 | ¥+27,112 | ¥+17,130 |
|
||||
|
||||
## 📉 卖出时间排名 (买入固定@10:00)
|
||||
|
||||
| 排名 | 卖出时间 | 平均盈利 | 最高盈利 | 最低盈利 |
|
||||
|------|---------|---------|---------|----------|
|
||||
| 🏆 | 13:30 | ¥+27,183 | ¥+34,910 | ¥+22,328 |
|
||||
| #2 | 13:35 | ¥+27,122 | ¥+34,952 | ¥+22,090 |
|
||||
| #3 | 13:25 | ¥+27,061 | ¥+34,880 | ¥+22,080 |
|
||||
| #4 | 13:40 | ¥+27,058 | ¥+34,984 | ¥+22,296 |
|
||||
| #5 | 13:20 | ¥+26,946 | ¥+34,682 | ¥+21,936 |
|
||||
| #6 | 14:15 | ¥+26,912 | ¥+34,924 | ¥+22,553 |
|
||||
| #7 | 13:45 | ¥+26,864 | ¥+34,479 | ¥+22,234 |
|
||||
| #8 | 14:00 | ¥+26,784 | ¥+35,058 | ¥+22,394 |
|
||||
| #9 | 13:55 | ¥+26,764 | ¥+34,634 | ¥+22,247 |
|
||||
| #10 | 14:05 | ¥+26,720 | ¥+34,598 | ¥+22,480 |
|
||||
| #11 | 13:50 | ¥+26,692 | ¥+34,131 | ¥+22,274 |
|
||||
| #12 | 14:20 | ¥+26,683 | ¥+34,528 | ¥+22,264 |
|
||||
| #13 | 14:30 | ¥+26,563 | ¥+34,292 | ¥+22,156 |
|
||||
| #14 | 14:25 | ¥+26,555 | ¥+34,281 | ¥+22,106 |
|
||||
| #15 | 10:05 | ¥+26,464 | ¥+29,920 | ¥+19,744 |
|
||||
| #16 | 14:50 | ¥+26,399 | ¥+33,882 | ¥+21,874 |
|
||||
| #17 | 14:40 | ¥+26,273 | ¥+33,675 | ¥+21,886 |
|
||||
| #18 | 14:45 | ¥+26,263 | ¥+33,710 | ¥+21,788 |
|
||||
| #19 | 10:15 | ¥+26,212 | ¥+30,418 | ¥+19,129 |
|
||||
| #20 | 09:50 | ¥+26,188 | ¥+29,928 | ¥+19,044 |
|
||||
| #21 | 13:05 | ¥+26,184 | ¥+33,670 | ¥+20,984 |
|
||||
| #22 | 14:55 | ¥+26,174 | ¥+33,702 | ¥+21,540 |
|
||||
| #23 | 09:40 | ¥+25,956 | ¥+29,410 | ¥+19,280 |
|
||||
| #24 | 14:10 | ¥+25,426 | ¥+35,278 | ¥+18,697 |
|
||||
| #25 | 15:00 | ¥+25,405 | ¥+34,504 | ¥+19,705 |
|
||||
| #26 | 09:35 | ¥+25,268 | ¥+29,105 | ¥+18,964 |
|
||||
| #27 | 14:35 | ¥+25,067 | ¥+34,774 | ¥+18,658 |
|
||||
| #28 | 10:00 | ¥+24,864 | ¥+29,896 | ¥+19,030 |
|
||||
| #29 | 09:55 | ¥+24,688 | ¥+29,404 | ¥+18,889 |
|
||||
| #30 | 13:10 | ¥+23,100 | ¥+34,476 | ¥+12,914 |
|
||||
| #31 | 13:15 | ¥+22,580 | ¥+33,689 | ¥+12,689 |
|
||||
| #32 | 10:35 | ¥+22,309 | ¥+32,507 | ¥+13,788 |
|
||||
| #33 | 10:55 | ¥+22,217 | ¥+33,049 | ¥+12,914 |
|
||||
| #34 | 10:40 | ¥+22,216 | ¥+32,393 | ¥+13,516 |
|
||||
| #35 | 10:50 | ¥+22,140 | ¥+32,744 | ¥+13,336 |
|
||||
| #36 | 10:45 | ¥+22,136 | ¥+32,626 | ¥+13,316 |
|
||||
| #37 | 10:30 | ¥+22,115 | ¥+32,128 | ¥+13,716 |
|
||||
| #38 | 11:00 | ¥+22,032 | ¥+32,746 | ¥+13,318 |
|
||||
| #39 | 11:10 | ¥+21,993 | ¥+32,580 | ¥+12,784 |
|
||||
| #40 | 11:05 | ¥+21,958 | ¥+32,818 | ¥+13,135 |
|
||||
| #41 | 11:20 | ¥+21,856 | ¥+32,472 | ¥+12,688 |
|
||||
| #42 | 11:25 | ¥+21,856 | ¥+32,743 | ¥+12,422 |
|
||||
| #43 | 11:30 | ¥+21,796 | ¥+32,470 | ¥+12,558 |
|
||||
| #44 | 09:45 | ¥+21,518 | ¥+26,647 | ¥+15,167 |
|
||||
| #45 | 11:15 | ¥+21,290 | ¥+30,784 | ¥+12,688 |
|
||||
| #46 | 10:10 | ¥+21,228 | ¥+29,897 | ¥+14,395 |
|
||||
| #47 | 10:25 | ¥+16,713 | ¥+22,278 | ¥+13,735 |
|
||||
| #48 | 10:20 | ¥+16,172 | ¥+21,536 | ¥+13,410 |
|
||||
|
||||
## 🔥 最优买卖时间组合 Top 10
|
||||
|
||||
| 排名 | 买入 | 卖出 | 平均盈利 |
|
||||
|------|------|------|----------|
|
||||
| 🏆 | 14:35 | 13:40 | ¥+33,906 |
|
||||
| #2 | 14:30 | 13:40 | ¥+33,799 |
|
||||
| #3 | 15:00 | 13:40 | ¥+33,798 |
|
||||
| #4 | 13:35 | 13:40 | ¥+33,713 |
|
||||
| #5 | 13:20 | 13:35 | ¥+33,707 |
|
||||
| #6 | 09:35 | 13:45 | ¥+33,658 |
|
||||
| #7 | 14:20 | 13:40 | ¥+33,645 |
|
||||
| #8 | 14:40 | 13:40 | ¥+33,633 |
|
||||
| #9 | 14:35 | 13:35 | ¥+33,618 |
|
||||
| #10 | 14:15 | 13:40 | ¥+33,612 |
|
||||
|
||||
## 💡 结论
|
||||
|
||||
1. **全局最优**: 🥈TP12|SL6|d3|h30|15%SW 买@09:35 卖@13:40 → ¥+46,130
|
||||
2. **默认(10:00/15:00)平均盈利**: ¥+25,405
|
||||
3. **最优时间组合(跨算法平均)**: 买@14:35 卖@13:40 → 平均¥+33,906
|
||||
4. **时点优化潜在提升**: ¥+8,501
|
||||
@@ -0,0 +1,58 @@
|
||||
# 提醒和交易中的刷新 — 如何获取股票现价
|
||||
|
||||
## 一、提醒(自动提醒)刷新
|
||||
|
||||
### 哪些股票会参与
|
||||
- **关注列表**:`searchHistory`(用户添加的关注股票)
|
||||
- **持有股票**:`holdingStocks`(从交易记录里计算出的当前持仓代码)
|
||||
- 两者合并去重后得到 `allStocks`,只对这些股票请求信号与价格。
|
||||
|
||||
### 价格从哪里来
|
||||
1. **主流程**(`POST /api/signal_alerts`)
|
||||
- 后端从 **`stock_signal_scan`** 取当日扫描结果(信号、推荐等)。
|
||||
- 从 **`stock_realtime_price`** 表按 `code IN (请求的股票)` 取 `price`、`change_pct` 作为初值。
|
||||
- 接口返回后,前端会**再跑一遍实时价**:调用 **`refreshAlertPricesFromRealtime()`**,对每条提醒分批请求 `GET /api/realtime_price/{code}`,用实时接口返回的价格覆盖显示。
|
||||
- 因此**最终展示的现价为实时价**(麦蕊等),不是表里旧数据。
|
||||
|
||||
2. **回退流程**(批量失败、逐个分析且 `forceRefresh`)
|
||||
- 前端对每只股票再请求 `GET /api/realtime_price/{code}` 更新价格,同样是实时接口。
|
||||
|
||||
### 小结
|
||||
- 刷新提醒:**股票范围** = 关注 + 持仓;**现价** = 先来自表,随后由 **实时 API** 后台更新为最新价。
|
||||
|
||||
---
|
||||
|
||||
## 二、交易中的刷新
|
||||
|
||||
### 哪些股票会参与
|
||||
- 仅 **当前持仓**:`holdingPositions` 里 `quantity > 0` 的股票(即 `holdingStocks` 对应的持仓)。
|
||||
|
||||
### 价格从哪里来
|
||||
1. **先从不发请求的缓存更新**
|
||||
- `updateHoldingPricesFromAlerts()`:用 **`stockAlerts`** 里对应 `code` 的 `alert.price` 更新持仓的 `currentPrice`。
|
||||
- 即:若之前加载过提醒,交易里会先用提醒结果里的价格(该价格本身来自 `stock_realtime_price` 或单只实时 API)。
|
||||
|
||||
2. **用户点击「刷新」**(`refreshHoldingPrices()`)
|
||||
- 对每个持仓 `code` 调用 `GET /api/realtime_price/{code}`。
|
||||
- 后端 `realtime_price` 使用 **实时 API**(如 `get_realtime_price` → 麦蕊智数等),**不读** `stock_realtime_price` 表。
|
||||
- 拿到的价格会写回:
|
||||
- `holdingPositions[code].currentPrice`
|
||||
- 以及 `stockAlerts` 里该 code 的 `price`(并可能触发保存提醒缓存)。
|
||||
|
||||
### 小结
|
||||
- 交易中的「现价」:
|
||||
- 未点刷新时:来自 **提醒缓存**(提醒的数据又来自 `stock_realtime_price` 或单只实时 API)。
|
||||
- 点击刷新后:来自 **实时 API**(`/api/realtime_price/:code`),与数据库表无关。
|
||||
|
||||
---
|
||||
|
||||
## 三、实施价格(现价)来源汇总
|
||||
|
||||
| 场景 | 股票范围 | 现价/实施价格来源 |
|
||||
|----------------|--------------|-------------------|
|
||||
| 提醒主流程 | 关注 + 持仓 | 先表后 **实时 API** 覆盖(`refreshAlertPricesFromRealtime`) |
|
||||
| 提醒回退+强制刷新 | 同上,逐只 | `GET /api/realtime_price/:code` |
|
||||
| 交易-不点刷新 | 持仓 | 提醒缓存 `stockAlerts[].price`(已含实时价) |
|
||||
| 交易-点刷新 | 持仓 | `GET /api/realtime_price/:code`(**与提醒刷新为同一接口**) |
|
||||
|
||||
**说明**:提醒里更新现价与交易里「刷新价格」都调用 **同一个接口** `GET /api/realtime_price/:code`,后端均为 `get_realtime_price(stock_code)`(如麦蕊智数等)。
|
||||
@@ -0,0 +1,202 @@
|
||||
# 全景扫描算法梳理
|
||||
|
||||
## 一、整体流程
|
||||
|
||||
全景扫描是对**全市场股票**做一次**技术信号全量检测**,结果写入 `stock_signal_scan` 表,供前端「全景扫描」页、策略建议、提醒、模拟交易等使用。
|
||||
|
||||
### 1.1 触发方式
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| 用户点击 | 前端「全景扫描」按钮 → 后台执行 `full_signal_scan.py`(可选) |
|
||||
| 定时任务 | crontab 配置,如 11:50、16:30 或凌晨 01:00(`0 1 * * 1-5`) |
|
||||
| 管理员 | 管理后台「启动全景扫描」触发 |
|
||||
|
||||
### 1.2 入口与脚本
|
||||
|
||||
- **脚本**:`full_signal_scan.py`
|
||||
- **扫描日期**:`scan_date = date.today()`,支持按日断点续扫
|
||||
- **环境变量**:`FORCE_RESCAN=1` 时先清空当日 `stock_signal_scan` 再扫
|
||||
|
||||
### 1.3 数据源
|
||||
|
||||
| 数据类型 | 来源 | 说明 |
|
||||
|----------|------|------|
|
||||
| 股票列表 | 表 `stock_realtime_price` | `SELECT code, name ORDER BY code`,全市场 |
|
||||
| K 线 | 多级回退 | 见下文「K 线获取顺序」 |
|
||||
| 结果存储 | 表 `stock_signal_scan` | 不写价格;现价由 `stock_realtime_price` 等更新 |
|
||||
|
||||
**K 线获取顺序**(`get_kline_data()`):
|
||||
|
||||
1. **本地 DB**:`get_kline_from_local_db_threaded(code, days)`(`stock_kline_daily`)
|
||||
2. **阿里云 K 线 API**:北交所跳过
|
||||
3. **腾讯 K 线 API**:全市场(含北交所)
|
||||
4. **麦蕊智数**:`get_kline(period='d', days=120)`
|
||||
5. **AKShare**:`stock_zh_a_hist` 日 K
|
||||
|
||||
取数长度:**K_DAYS = 120** 日。
|
||||
|
||||
---
|
||||
|
||||
## 二、单只股票扫描流程
|
||||
|
||||
对每只待扫描股票(`pending = 全市场 - 当日已扫`):
|
||||
|
||||
```
|
||||
1. get_kline_data(code, days=120) → DataFrame (date, open, high, low, close, volume)
|
||||
2. 若 df 为空或 len(df) < 30 → 跳过,记失败
|
||||
3. detect_all_signals(df, lookback=LOOKBACK) → 7 个信号 + 指标 + signal_status
|
||||
4. 汇总 triggered_count、signal_status、indicators、latest_signals
|
||||
5. 写入内存缓冲;满 BATCH_SAVE_SIZE 条后批量 INSERT/UPDATE stock_signal_scan
|
||||
```
|
||||
|
||||
**并发与批参数**(`full_signal_scan.py`):
|
||||
|
||||
- `WORKERS = 8`
|
||||
- `BATCH_SIZE = 80`(任务批)
|
||||
- `BATCH_SAVE_SIZE = 40`(每 40 条写一次库)
|
||||
- `LOOKBACK = 5`(检测最近 5 天内的信号)
|
||||
|
||||
---
|
||||
|
||||
## 三、核心算法:detect_all_signals
|
||||
|
||||
位置:`services/signal_detector.py` — `detect_all_signals(df, lookback=5)`。
|
||||
|
||||
### 3.1 输入输出
|
||||
|
||||
- **输入**:`df` 需含列 `date, open, high, low, close, volume`;`lookback` 默认 5。
|
||||
- **输出**:
|
||||
`signals`、`latest_signals`、`signal_summary`、`indicators`、**`signal_status`**(用于推荐与展示)。
|
||||
|
||||
### 3.2 内部步骤
|
||||
|
||||
1. **列类型**:必要列缺失则返回错误;对 `open/high/low/close/volume` 若非 float 则转成 float64。
|
||||
2. **指标计算**:`calc_all_indicators(df)`,得到 MACD、SKDJ、KDJ、EMA、MA 等。
|
||||
3. **7 个信号检测**(均在 `lookback` 窗口内):
|
||||
- `detect_main_rising_wave(df, lookback)`
|
||||
- `detect_daily_bottom_divergence(df, lookback)`
|
||||
- `detect_dragon_head(df, lookback)`
|
||||
- `detect_true_dragon(df, lookback)`
|
||||
- `detect_short_bottom_divergence(df, lookback)`
|
||||
- `detect_rat_trading(df, lookback)`
|
||||
- `detect_rebound(df, lookback)`
|
||||
4. **汇总**:
|
||||
- 所有信号按 `(strength 降序, date)` 排序;
|
||||
- 取 `latest_date` 当天的信号为 `latest_signals`;
|
||||
- 统计 `signal_summary`;
|
||||
- 取最后一根 K 的指标 → `indicators`(macd/skdj/kdj/ema/ma);
|
||||
- **`_check_all_signal_status(df)`** → `signal_status`(每条为 7 个信号的 `triggered/description/readiness` 等)。
|
||||
|
||||
全景扫描**写库**用的是 `signal_status` 与 `triggered_count`(`signal_status` 中 `triggered==True` 的个数),推荐与策略建议也用同一套 `signal_status` + `compute_recommend`。
|
||||
|
||||
---
|
||||
|
||||
## 四、七个信号的判定条件(与 _check_all_signal_status 一致)
|
||||
|
||||
| 序号 | 信号名 | 强度 | 判定逻辑概要(当前 K / 最近窗口) |
|
||||
|------|--------|------|----------------------------------|
|
||||
| 1 | **主升浪** | 85% | DIF>0 且 DEA>0,且前一根 DIF≤DEA、当前 DIF>DEA(零上金叉) |
|
||||
| 2 | **日线底背离** | 80% | 20 日内:收盘价在窗口新低附近(≤1.01×min);当前是窗口最低点;DIF 高于前低且 DIF<0 |
|
||||
| 3 | **龙抬头** | 75% | SKDJ 曾超跌(K<20 或 K<30);K 上穿 D;最近 3 根 K 的 K 值标准差<15(稳定) |
|
||||
| 4 | **真龙** | 70% | 4 条件中≥3 且必须含「价格>MA20」:价格>MA20、MA5>MA20、MACD 翻红(柱>0)、放量(>10 日均量 1.2 倍) |
|
||||
| 5 | **短底背离** | 65% | 10 日内:收盘在窗口新低附近;当前是窗口最低点;DIF 高于 10 日内 DIF 前低 |
|
||||
| 6 | **老鼠仓** | 60% | 盘中跌幅 (low-open)/open < -3%;回收率 (close-low)/(high-low) > 60%;收盘较开盘跌幅 > -1%;成交量 > 10 日均量 1.3 倍 |
|
||||
| 7 | **反弹** | 55% | EMA3 前根 ≤ EMA21,当前 EMA3 > EMA21(金叉) |
|
||||
|
||||
- **detect_*** 系列:在 `lookback` 天内逐日检测,若有满足条件的 K 线则生成一条信号(含 date/type/strength/description 等)。
|
||||
- **\_check_all_signal_status**:只对**最后一根 K 线**判断 7 个信号是否「当前触发」,并给出描述与就绪度;全景扫描的「是否触发」以 `signal_status[].triggered` 为准。
|
||||
|
||||
---
|
||||
|
||||
## 五、结果落库与表结构
|
||||
|
||||
- **表名**:`stock_signal_scan`
|
||||
- **写入字段**:`code, name, scan_date, triggered_count, signal_status, indicators, latest_signals`
|
||||
- **唯一约束**:`(code, scan_date)`,重复写入时 `ON CONFLICT DO UPDATE`。
|
||||
- **说明**:脚本不写 `stock_realtime_price`;列表页现价、涨跌幅等来自该表或实时接口。
|
||||
|
||||
---
|
||||
|
||||
## 六、与推荐算法的一致性
|
||||
|
||||
- **统一推荐函数**:`services/stock_algorithms.compute_recommend(signal_status, indicators, triggered_count, is_holding)`
|
||||
- 被「全景扫描结果列表」「策略建议」「提醒」「模拟交易自动买卖」共用。
|
||||
- **体系最强战法**(suanfa.md)在推荐中的体现:
|
||||
- 日线底背离 → 纳入关注
|
||||
- 龙抬头 → 买入(核心买点)
|
||||
- 真龙/主升浪 → 持有/加仓
|
||||
- 不见主升浪 → 可出场(如 MACD 死叉且无主升浪 → 卖出)
|
||||
|
||||
即:**全景扫描只负责「全量信号检测 + 落库」;「买/卖/加仓/持有/关注/观望」由同一套 `compute_recommend` 基于 `signal_status` 计算,保证与策略建议、提醒、模拟交易一致。**
|
||||
|
||||
---
|
||||
|
||||
## 七、小结
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 入口 | `full_signal_scan.py`,按日断点续扫 |
|
||||
| 股票池 | `stock_realtime_price` 全表 code+name |
|
||||
| K 线 | 120 日,本地 DB → 阿里 → 腾讯 → 麦蕊 → AKShare |
|
||||
| 核心函数 | `detect_all_signals(df, lookback=5)` → 7 信号 + `signal_status` |
|
||||
| 7 信号 | 主升浪(85%)、日线底背离(80%)、龙抬头(75%)、真龙(70%)、短底背离(65%)、老鼠仓(60%)、反弹(55%) |
|
||||
| 落库 | `stock_signal_scan`,不写价格 |
|
||||
| 推荐 | 与策略/提醒/模拟交易共用 `compute_recommend(signal_status, ...)` |
|
||||
|
||||
如需调整「哪些算触发」,只需改 `signal_detector.py` 中对应 `detect_*` 与 `_check_all_signal_status`;如需调整买卖建议,只需改 `stock_algorithms.compute_recommend`。
|
||||
|
||||
---
|
||||
|
||||
## 八、全景扫描结果的「每日推荐买入」是如何获取的?
|
||||
|
||||
### 8.1 数据来源
|
||||
|
||||
- **每日推荐**依赖的是**当日(或最近一次)全景扫描**的结果表 **`stock_signal_scan`**。
|
||||
- 若当天没有跑全量扫描,接口会**自动回退**到「最近一次有数据的 `scan_date`」(见 8.2)。
|
||||
|
||||
### 8.2 接口与参数
|
||||
|
||||
- **接口**:`GET /api/scan_results`(`routes/analysis.py` — `get_scan_results()`)。
|
||||
- **关键参数**:
|
||||
- `date`:扫描日期,默认当天;若该日无数据且未显式传 `date`,则用 `MAX(scan_date)` 回退。
|
||||
- `recommend_text`:推荐文案筛选,例如 **`买入`**、`加仓`、`持有`、`关注`、`观察`、`观望`、`卖出`。
|
||||
- `holding_codes`:当前用户持仓 code 列表(逗号分隔),用于区分「持仓 / 非持仓」下的推荐。
|
||||
|
||||
### 8.3 「推荐买入」的获取流程(recommend_text=买入)
|
||||
|
||||
1. **按日期取扫描数据**
|
||||
从 `stock_signal_scan` 中取出 `scan_date` 当天的**全部**记录:
|
||||
`code, signal_status, indicators, triggered_count`。
|
||||
|
||||
2. **逐条计算推荐**
|
||||
对每条记录调用统一推荐函数(`_compute_recommend` → `stock_algorithms.compute_recommend`):
|
||||
```text
|
||||
_compute_recommend(signal_status, indicators, triggered_count, is_holding)
|
||||
```
|
||||
- `is_holding = (code in holding_codes)`
|
||||
返回 `(signal_type, display_text, reason, recommend_rate)`,其中 **display_text** 即「买入 / 加仓 / 持有 / 关注 / 观察 / 观望 / 卖出」。
|
||||
|
||||
3. **筛选「买入」**
|
||||
只保留 **`display_text == recommend_text`**(例如 `display_text == '买入'`)的股票,得到「每日推荐买入」列表。
|
||||
|
||||
4. **排序与分页**
|
||||
按 `triggered_count` 降序、其次 `code` 升序排序;再按 `page`、`per_page` 分页,取当前页的 code 列表。
|
||||
|
||||
5. **补全当前页展示数据**
|
||||
用当前页的 code 再查 `stock_signal_scan` + `stock_realtime_price`,对每条再次调用 `_compute_recommend`,得到 `recommend_text`、`recommend_reason`、现价等,返回前端。
|
||||
|
||||
### 8.4 何时会得到「买入」?
|
||||
|
||||
由 **`compute_recommend`**(体系最强战法)决定,**非持仓**时出现「买入」的条件包括:
|
||||
|
||||
- **最佳买入**:日线底背离 + 龙抬头,且 MACD 未死叉 → `display_text = '买入'`(评分 95)。
|
||||
- **龙抬头买入**:有龙抬头且 MACD 金叉或暂无数据 → `display_text = '买入'`(评分 80);若同时有主升浪且 MACD 正常 → 也是「买入」(评分 90)。
|
||||
- 若 MACD 死叉则会被降级为「关注」等,不会出现在「推荐买入」筛选中。
|
||||
|
||||
因此:**「每日推荐买入」= 当日(或回退日)`stock_signal_scan` 中,在给定 `holding_codes` 下经 `compute_recommend` 计算得到 `display_text == '买入'` 的股票列表,经排序分页后返回。**
|
||||
|
||||
### 8.5 与策略建议的关系
|
||||
|
||||
- **策略建议**(`GET /api/scan_strategy`)同样读 `stock_signal_scan` 当日数据,对每条记录调用同一个 `_compute_recommend`,再按 `display_text` 分成 4 档(立即买入、持仓加仓、关注、纳入关注)。
|
||||
- 「立即买入」档即 **`display_text == '买入'`**,与全景扫描里筛选 `recommend_text=买入` 的列表**算法一致**,只是接口与展示形式不同(一为分档统计,一为分页列表)。
|
||||
@@ -0,0 +1,207 @@
|
||||
# 本应用全部算法总结
|
||||
|
||||
## 一、全景扫描与策略建议
|
||||
|
||||
### 1.1 全景扫描(全量信号扫描)
|
||||
|
||||
**触发**:用户点击「全景扫描」或定时任务(如凌晨 01:00,crontab `0 1 * * 1-5`)。
|
||||
|
||||
**数据源**:
|
||||
- 股票列表:从表 **`stock_realtime_price`** 读取全部 `code, name`(即全市场股票)。
|
||||
- K 线:优先 **麦蕊智数** `get_kline(period='d', days=120)`,备用 **AKShare** 日 K。
|
||||
|
||||
**算法流程**:
|
||||
1. 脚本 `full_signal_scan.py` 按日 `scan_date` 运行,支持断点续扫(已扫过的 code 跳过)。
|
||||
2. 每只股票:取约 120 日 K 线 → 调用 **`detect_all_signals(df, lookback=5)`**(`services/signal_detector.py`)检测 7 个信号。
|
||||
3. 7 个信号:主升浪(85%)、日线底背离(80%)、龙抬头(75%)、真龙(70%)、短底背离(65%)、老鼠仓(60%)、反弹(55%)。每个信号得到是否触发、描述等。
|
||||
4. 结果写入表 **`stock_signal_scan`**:`code, name, scan_date, triggered_count, signal_status, indicators, latest_signals`。
|
||||
(**注意**:本脚本不写入价格;表 `stock_realtime_price` 由其他定时任务或接口更新。)
|
||||
|
||||
**并发**:`WORKERS=3`,`BATCH_SIZE=30`,多线程按批处理。
|
||||
|
||||
---
|
||||
|
||||
### 1.2 策略建议(分档买卖建议)
|
||||
|
||||
**触发**:用户点击「策略建议」。
|
||||
|
||||
**接口**:`GET /api/scan_strategy`,可选 `date`、`holding_codes`(逗号分隔)。
|
||||
|
||||
**算法**:
|
||||
1. 从 **`stock_signal_scan`** 读取当日 `scan_date` 全部记录(`signal_status, indicators, triggered_count`)。
|
||||
2. 对每条记录调用 **统一推荐函数 `_compute_recommend(signal_status, indicators, triggered_count, is_holding)`**(与扫描结果、提醒共用):
|
||||
- **持仓**:MACD 死叉且无主升浪 → 卖出;有主升浪 → 加仓;有真龙 → 持有;否则 → 观望。
|
||||
- **非持仓**:底背离+龙抬头 → 买入;主升浪 → 加仓;真龙 → 关注;MACD 死叉 → 卖出;仅底背离 → 关注;仅龙抬头 → 关注;其他触发 → 观察;无 → 观望。
|
||||
3. 按推荐文案分档:
|
||||
- **档1 立即买入**:`disp == '买入'`(底背离+龙抬头)。
|
||||
- **档2 持仓加仓**:`disp in ('加仓','持有')`。
|
||||
- **档3 关注**:`disp == '关注'` 且龙抬头触发、无底背离。
|
||||
- **档4 纳入关注**:`disp == '关注'` 且其余(如仅底背离)。
|
||||
4. 返回 4 档的 `action/condition/desc/count/stocks`,前端展示。
|
||||
|
||||
**与全景扫描结果**:使用同一套 `_compute_recommend`,算法一致。
|
||||
|
||||
---
|
||||
|
||||
### 1.3 扫描结果列表(GET /api/scan_results)
|
||||
|
||||
**触发**:全景扫描完成后前端拉取或切换筛选/分页。
|
||||
|
||||
**接口**:`GET /api/scan_results`,参数:`date, min_triggered, signal_type, holding_codes, recommend_text, page, per_page, sort`。
|
||||
|
||||
**算法**:
|
||||
1. 从 **`stock_signal_scan`** 按 `scan_date` 筛选,可选按 `triggered_count`、信号类型过滤。
|
||||
2. 若有 **`recommend_text`**(如「买入」「关注」):全量读出当日扫描,逐条 `_compute_recommend`,统计各推荐数量,筛出 `disp == recommend_text` 的 code,再分页。
|
||||
3. 否则:按 `triggered_count` 等排序分页,LEFT JOIN **`stock_realtime_price`** 取 `price, change_pct`。
|
||||
4. 对当前页每条记录再算一次 **`_compute_recommend`**(带入 `holding_codes`),得到 `recommend_type/recommend_text` 等返回前端。
|
||||
5. 列表中的**现价**来自表 **`stock_realtime_price`**(与全量扫描脚本无直接关系,需另有时效性更新)。
|
||||
|
||||
---
|
||||
|
||||
## 二、检查信号与批量扫描关注
|
||||
|
||||
### 2.1 单只检查信号(技术信号详情)
|
||||
|
||||
**触发**:在「技术信号」里输入/选择股票并查询,或从扫描列表点击某只股票。
|
||||
|
||||
**接口**:`GET /api/technical_signals/<stock_code>?lookback=5&days=120`。
|
||||
|
||||
**算法**:
|
||||
1. 用 **`_get_kline_data(stock_code, days)`** 取 K 线(优先麦蕊智数,备用 AKShare)。
|
||||
2. 调用 **`detect_all_signals(kline_df, lookback=5)`**,得到 7 个信号的触发情况、指标、说明。
|
||||
3. 返回 `signals, latest_signals, signal_summary, indicators, signal_status`。
|
||||
**不读** `stock_signal_scan`,**不读** `stock_realtime_price`,纯实时 K 线+本地计算。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 批量扫描关注
|
||||
|
||||
**触发**:用户点击「批量扫描关注」。
|
||||
|
||||
**接口**:`POST /api/batch_technical_signals`,body:`{ codes: [关注列表的 code], lookback: 3, days: 120 }`。
|
||||
|
||||
**算法**:
|
||||
1. 对 `codes` 中每只(最多 20 只):取 K 线 → **`detect_all_signals`** → 得到 `signal_status, triggered_count, latest_signals, indicators`。
|
||||
2. 结果仅用于当前页展示,**不写入** `stock_signal_scan`,**不写入** `stock_realtime_price`。
|
||||
3. 即:批量扫描关注 = 多只股票各自走一遍「单只检查信号」逻辑,无持久化。
|
||||
|
||||
---
|
||||
|
||||
## 三、模拟交易及查看时的现价
|
||||
|
||||
### 3.1 模拟持仓与统计中的现价
|
||||
|
||||
**数据来源**:
|
||||
- 表 **`sim_positions`** 存有每只持仓的 **`current_price`**(上次更新时的现价)。
|
||||
- 列表/统计接口(如 `GET /api/sim/positions`、`GET /api/sim/stats`)直接读该字段,**不在此处调实时接口**。
|
||||
|
||||
**现价何时更新**:
|
||||
- **手动刷新**:在「分析 → 模拟交易」页的 **当前持仓** 区块,点击 **「刷新现价」** 按钮时,会调用 **`updateSimPrices()`**:
|
||||
- 对每条持仓并行请求 **`GET /api/realtime_price/<stock_code>`**(12s 超时);
|
||||
- 拿到价格后写回前端展示,并 **`POST /api/sim/update_prices`**,将 `{ stock_code: price }` 写入 **`sim_positions.current_price`**,并刷新统计。
|
||||
- 进入模拟交易子页(`loadSimData()`)时**不会**自动拉实时价,只读库中的 `current_price`。
|
||||
- 后端定时任务(如 scheduler)也可按配置更新 `sim_positions.current_price`(若已实现)。
|
||||
|
||||
**结论**:模拟交易「查看」时的现价 = 库中 **`sim_positions.current_price`**;**只有用户点击「刷新现价」**(或定时任务)时,才通过 **实时 API** 经 **`/api/realtime_price`** 再经 **`/api/sim/update_prices`** 写入。
|
||||
|
||||
---
|
||||
|
||||
## 四、提醒 tab 下的刷新
|
||||
|
||||
**触发**:进入提醒 tab 或用户点击刷新(含强制刷新)。
|
||||
|
||||
**涉及股票**:**关注列表**(searchHistory)+ **当前持仓**(holdingStocks),合并去重得到 `allStocks`。
|
||||
|
||||
**算法流程**:
|
||||
1. **优先读缓存**(未强制刷新时):`GET /api/alerts_cache`,若缓存存在且为当日且版本匹配,则用缓存填充 `stockAlerts`,并 `updateHoldingPricesFromAlerts()`,对新加入的股票做增量分析;然后**直接结束**,不再请求信号接口。
|
||||
2. **主流程**:
|
||||
**`POST /api/signal_alerts`**,body:`{ stocks: allStocks, holding_codes: this.holdingStocks }`。
|
||||
- 后端从 **`stock_signal_scan`** 取当日扫描结果(`signal_status, indicators, triggered_count`),从 **`stock_realtime_price`** 取 `price, change_pct`。
|
||||
- 对每只股票调用 **`_compute_recommend(..., is_holding)`**,得到推荐类型、理由、推荐率等,并与价格一起返回。
|
||||
- 前端用返回结果覆盖 `stockAlerts`,保存缓存,并 **`updateHoldingPricesFromAlerts()`**(用提醒里的 price 回填持仓的 currentPrice)。
|
||||
3. **后台补齐实时价**:
|
||||
调用 **`refreshAlertPricesFromRealtime()`**(不传参 = 刷新全部 `stockAlerts`):
|
||||
- 每批 5 只,**`GET /api/realtime_price/<code>`**(超时 12s),用返回价格覆盖对应 alert 的 `price` 及 `latest_data['收盘价']`;
|
||||
- 更新后 **`saveAlertsCache`**、**`updateHoldingPricesFromAlerts()`**。
|
||||
即:提醒列表的**最终展示价**来自**实时接口**,不是表里旧值。
|
||||
|
||||
**小结**:
|
||||
- 信号与推荐:**`stock_signal_scan`** + **`_compute_recommend`**,价格初值来自 **`stock_realtime_price`**。
|
||||
- 最终现价:**实时 API**(`/api/realtime_price` → 麦蕊智数等)通过 **`refreshAlertPricesFromRealtime()`** 覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 五、交易 tab 下的刷新价格
|
||||
|
||||
**触发**:用户在交易 tab 点击「刷新价格」。
|
||||
|
||||
**涉及股票**:仅 **当前持仓**(`holdingPositions` 中 `quantity > 0` 的 code)。
|
||||
|
||||
**算法**(与提醒共用一套逻辑):
|
||||
1. 取持仓 code 列表 **`holdingCodes`**。
|
||||
2. 设置 **`priceRefreshing = true`**,调用 **`refreshAlertPricesFromRealtime(holdingCodes, 12000)`**:
|
||||
- 若持仓在 **`stockAlerts`** 中存在:按与提醒相同的逻辑,每批 5 只请求 **`GET /api/realtime_price/<code>`**,更新 alert 的 `price`,并 **`updateHoldingPricesFromAlerts()`**,从而更新 **`holdingPositions[code].currentPrice`** 及统计。
|
||||
- 若某持仓不在 `stockAlerts` 中:仍对该 code 单独请求 **`GET /api/realtime_price/<code>`**,直接写 **`holdingPositions[code].currentPrice`**,并触发 `calculateTradeStats()`。
|
||||
3. 结束后 **`checkStopLoss()`**,**`priceRefreshing = false`**。
|
||||
|
||||
**接口统一**:
|
||||
- 提醒与交易刷新现价均使用 **同一接口** **`GET /api/realtime_price/<stock_code>`**,后端为 **`get_realtime_price(stock_code)`**(优先麦蕊智数,备用 AKShare),**不读** `stock_realtime_price` 表。
|
||||
|
||||
---
|
||||
|
||||
## 附录:关键数据流一览
|
||||
|
||||
| 场景 | 股票范围 | 信号/推荐来源 | 现价来源(最终展示) |
|
||||
|------|----------|----------------|----------------------|
|
||||
| 全景扫描 | 全市场(stock_realtime_price 表) | detect_all_signals,写入 stock_signal_scan | 扫描不写价格;列表用 stock_realtime_price |
|
||||
| 策略建议 | 当日 stock_signal_scan 全量 | _compute_recommend | 不展示单股现价 |
|
||||
| 扫描结果列表 | 按筛选/分页 | stock_signal_scan + _compute_recommend | stock_realtime_price 表 |
|
||||
| 单只/批量检查信号 | 用户选定/关注列表 | 实时 K 线 + detect_all_signals | 不涉及现价 |
|
||||
| 模拟交易查看 | 模拟持仓 | sim_positions.current_price | 刷新时:/api/realtime_price → update_prices |
|
||||
| 提醒刷新 | 关注+持仓 | stock_signal_scan + _compute_recommend;初价 stock_realtime_price | refreshAlertPricesFromRealtime → /api/realtime_price |
|
||||
| 交易刷新价格 | 持仓 | 无信号重算 | refreshAlertPricesFromRealtime(holdingCodes) → /api/realtime_price |
|
||||
|
||||
**统一推荐逻辑**:**`_compute_recommend(signal_status, indicators, triggered_count, is_holding)`** 用于:策略建议、扫描结果列表的推荐列、提醒的推荐与理由。信号类型键与 `signal_detector` 一致(如真龙为 **`true_dragon`**)。
|
||||
|
||||
---
|
||||
|
||||
## 算法与逻辑是否一致?
|
||||
|
||||
### 一致的部分
|
||||
|
||||
1. **信号检测**
|
||||
- 全景扫描(full_signal_scan)、单只检查信号、批量扫描关注,均使用 **同一套** **`detect_all_signals`**(`services/signal_detector.py`),7 个信号定义与判定一致。
|
||||
- 唯一区别:全景扫描写库(`stock_signal_scan`),单只/批量不写库。
|
||||
|
||||
2. **推荐逻辑**
|
||||
- 策略建议、扫描结果列表的推荐列、提醒的买卖/观望结论,均使用 **同一函数** **`_compute_recommend`**(`routes/analysis.py`),同一只股票在相同持仓状态下会得到相同推荐(买入/加仓/持有/关注/观察/观望/卖出)。
|
||||
- 策略建议的 4 档(立即买入、持仓加仓、关注、纳入关注)即按该推荐结果分组,无第二套规则。
|
||||
|
||||
3. **现价刷新(提醒与交易)**
|
||||
- 提醒 tab 的「用实时价覆盖」与交易 tab 的「刷新价格」共用 **同一方法** **`refreshAlertPricesFromRealtime(codesOnly?, timeoutMs)`**,同一接口 **`GET /api/realtime_price/<code>`**,同一后端 **`get_realtime_price`**(麦蕊智数优先,AKShare 备用)。
|
||||
- 逻辑一致:按 code 列表分批请求、写回 alert/持仓、更新缓存与统计。
|
||||
|
||||
4. **持仓状态**
|
||||
- 提醒、策略建议、扫描结果列表都使用同一套 **`holding_codes`**(前端传 `holdingStocks`),**`_compute_recommend`** 的 `is_holding` 与真实持仓一致,故「持有/加仓/卖出」等与是否持仓一致。
|
||||
|
||||
### 需注意的差异(非矛盾)
|
||||
|
||||
1. **数据来源与时效**
|
||||
- **全景扫描 / 策略建议 / 扫描结果 / 提醒(初值)**:依赖 **当日** `stock_signal_scan`(及 `stock_realtime_price`)。若今日未跑全量扫描,则无当日信号,提醒会显示「今日尚未扫描此股」等。
|
||||
- **单只/批量检查信号**:不读库,用**当前 K 线**实时算,与库内扫描结果可能不同(日期或数据源不同)。
|
||||
- 设计如此:全量扫描是「当日快照」,单只/批量是「实时计算」,二者用途不同,不要求数值完全一致。
|
||||
|
||||
2. **现价来源**
|
||||
- **列表/表内展示**(扫描结果、提醒初值):来自 **`stock_realtime_price`** 表(由定时或其它任务更新)。
|
||||
- **用户主动刷新后**(提醒、交易、模拟持仓):来自 **实时 API**(`/api/realtime_price`)。
|
||||
- 即:先表后实时,两段一致(同一实时接口),只是数据源阶段不同。
|
||||
|
||||
3. **模拟交易现价**
|
||||
- 模拟持仓的 **`current_price`** 仅在使用「刷新」或定时更新时从实时接口写入;查看时只读库,不自动调实时接口。与「真实交易 tab」的持仓现价逻辑相同(都是刷新时才拉实时价)。
|
||||
|
||||
### 结论
|
||||
|
||||
- **信号检测**:全应用共用 **`detect_all_signals`**,一致。
|
||||
- **推荐与分档**:策略/扫描结果/提醒共用 **`_compute_recommend`**,一致。
|
||||
- **现价刷新**:提醒与交易共用 **`refreshAlertPricesFromRealtime`** 与 **`/api/realtime_price`**,一致。
|
||||
- **差异**仅在于:谁写库、谁读库、何时用表价/何时用实时价,属设计上的分工,不是算法或逻辑不一致。
|
||||
@@ -0,0 +1,131 @@
|
||||
# 推荐算法回测方案 — 约定与设计
|
||||
|
||||
## 一、回测规则约定(已确认)
|
||||
|
||||
| 项目 | 约定 | 说明 |
|
||||
|------|------|------|
|
||||
| **10:00 买入依据** | 用**前一交易日收盘后**的全景扫描推荐 | 与当前应用一致,无未来数据 |
|
||||
| **15:00 加仓/清仓依据** | 用**当天中午**的全景扫描数据 | 即当日 11:50 左右的扫描结果(与现有定时任务一致) |
|
||||
| **成交价格** | 用**当时的实时价格** | 10:00 买入用 10:00 附近价,15:00 操作用 15:00 附近价;若无分钟数据则需约定近似方式 |
|
||||
| **选股** | **每天最多买 2 只** | 全市场从「买入」中按推荐分取前 2 只,每只 1000 股(可配置 `MAX_BUYS_PER_DAY`) |
|
||||
|
||||
## 二、时间线小结
|
||||
|
||||
- **T 日 10:00**:根据 **T-1 日收盘后** 的扫描结果,若出现「买入」则按约定选**最多 2 只**(推荐分从高到低),每只以 **10:00 实时价** 买入 1000 股。
|
||||
- **T 日 15:00**:根据 **T 日中午**(约 11:50)的扫描结果,对当前持仓做 `compute_recommend(..., is_holding=True)`,若为「加仓」则以 **15:00 实时价** 加仓 1000 股,若为「卖出」则以 **15:00 实时价** 清仓。
|
||||
- 价格:优先使用「当时实时价格」;回测若无分钟/实时数据,需在实现中明确近似规则(见下)。
|
||||
|
||||
## 三、实现要点与数据需求
|
||||
|
||||
### 3.1 10:00 推荐与价格
|
||||
|
||||
- **推荐**:对每个交易日 T,用 **T-1 收盘** 的日 K 跑 `detect_all_signals`,再 `compute_recommend(..., is_holding=False)`,筛出「买入」,按约定选单只(如推荐分最高或信号数最多)。
|
||||
- **价格**:理想为 T 日 10:00 实时价;若无分钟数据,可用 **T 日开盘价** 作为近似,并在文档/结果中注明。
|
||||
|
||||
### 3.2 15:00 推荐与价格(当天中午扫描)
|
||||
|
||||
- **推荐**:使用 **T 日中午全景扫描** 结果。
|
||||
- 若回测期内有**历史中午扫描落库**(如 `stock_signal_scan` 带 scan_time 或 scan_date=当日且标记为午扫),可直接用。
|
||||
- 若没有,则需在回测中**模拟「当日中午」的扫描**:用 T 日 11:50 前可得的数据(例如 T-1 日 K + T 日 open,或若有分钟数据则用 T 日 11:30 前数据)跑一次 `detect_all_signals` + `compute_recommend`,作为当日 15:00 决策依据,且不引入 T 日 15:00 之后的数据。
|
||||
- **价格**:理想为 T 日 15:00 实时价;若无分钟数据,可用 **T 日收盘价** 作为近似,并注明。
|
||||
|
||||
### 3.3 实时价格的回测实现
|
||||
|
||||
- **有实时/分钟数据**:按 10:00 / 15:00 时刻取价。
|
||||
- **仅日 K**:在方案中明确写「回测采用:10:00 用当日 open,15:00 用当日 close」,并视为对「当时实时价格」的近似,在结果与文档中统一说明。
|
||||
|
||||
## 四、选股规则(每天最多买 2 只)
|
||||
|
||||
- 在「买入」列表中按推荐分从高到低排序,**最多选 2 只**(`MAX_BUYS_PER_DAY=2`):
|
||||
- 按 `recommend_rate` 从高到低,同分再按 `triggered_count` 或信号强度排序;
|
||||
- 每只买入 1000 股(开盘价)。
|
||||
- 若当日无「买入」,则不新开仓;仅对已有持仓做 15:00 的加仓/清仓判断。
|
||||
|
||||
## 五、回测程序与运行
|
||||
|
||||
1. **脚本位置**:`stock-html/backtest_recommend.py`
|
||||
2. **依赖**:需存在表 **`stock_kline_daily`** 且含 2026 年及以后的日 K 数据(可先运行 `sync_kline.py` 等同步脚本)。
|
||||
3. **运行**:在项目根目录下执行
|
||||
```bash
|
||||
cd stock-html && ./venv/bin/python backtest_recommend.py
|
||||
```
|
||||
4. **输出**:控制台打印交易记录 + 核心统计(胜率/回撤/持仓天数/盈亏比/个股明细);结果写入 `docs/backtest_result.json`。
|
||||
5. **约定**:10:00 用 T-1 扫描 + 开盘价,15:00 用当日中午扫描(T-1 + T 日 open 模拟)+ 收盘价,每天最多买 2 只(推荐分从高到低取前 2)。
|
||||
|
||||
### v2 优化(2026-02-25)
|
||||
|
||||
| 优化项 | 说明 |
|
||||
|--------|------|
|
||||
| **股价区间过滤** | 仅扫描 2~100 元股票,避免仙股和高价股导致金额失衡 |
|
||||
| **单只最大投入** | ¥30,000 上限(含加仓),防止单只占比过高 |
|
||||
| **最大同时持仓** | 8 只,分散风险 |
|
||||
| **卖出冷却期** | 卖出后 3 天内不再买入同只,防止反复买卖 |
|
||||
| **批量 K 线加载** | 用窗口函数一次查全市场 K 线,减少 DB 往返约 10 倍 |
|
||||
| **丰富统计指标** | 胜率、最大回撤、平均持仓天数、盈亏比、个股盈亏明细 |
|
||||
| **对比表增强** | 15 场景对比增加胜率/回撤/盈亏比列,标注最优场景 |
|
||||
|
||||
### v3 优化(2026-02-25)
|
||||
|
||||
| 优化项 | 说明 |
|
||||
|--------|------|
|
||||
| **基于预扫描表** | 直接从 `stock_scan_history` 读取扫描结果,无需现场计算,秒级完成回测 |
|
||||
| **修复最大回撤计算** | 改为基于每日组合权益(持仓市值 + 累计现金流)的回撤,修复之前用现金流计算导致数值虚高的问题 |
|
||||
| **新增回撤百分比** | 输出 `max_drawdown_pct`(最大回撤 / 总投入资金 × 100%) |
|
||||
| **运算符优先级修复** | 修复 `action_taken` 判断条件的括号缺失问题 |
|
||||
|
||||
以上约定已写入本文档,作为实现推荐回测的统一依据。
|
||||
|
||||
---
|
||||
|
||||
## 六、清仓与止盈推荐(目的:挣钱而非单纯持有)
|
||||
|
||||
当前回测里**清仓**只做一件事:**推荐为「卖出」时按收盘价清仓**(即 MACD 死叉且无主升浪)。
|
||||
目的是「按信号纪律出场」,但**没有**针对「高点落袋」的规则,可能拿很久才等到卖出信号,回吐利润。
|
||||
|
||||
### 6.1 建议增加的清仓/止盈方式
|
||||
|
||||
在保留「信号卖出」的前提下,可增加**以挣钱为导向**的止盈类规则,例如:
|
||||
|
||||
| 类型 | 说明 | 目的 |
|
||||
|------|------|------|
|
||||
| **固定止盈** | 持仓浮盈 ≥ X%(如 10%、15%)时,当日 15:00 清仓 | 到点落袋,不贪最后一笔 |
|
||||
| **高点回撤止盈** | 从持仓期间最高价回撤 ≥ Y%(如 8%)时清仓 | 近似「高点跑」,锁定大部分利润 |
|
||||
| **信号卖出(现有)** | 推荐为「卖出」时清仓 | 趋势走弱时离场 |
|
||||
|
||||
可只选一种,或**组合**:先看是否触发止盈,若未触发再看是否触发「卖出」;若都未触发则继续持有或按原规则加仓。
|
||||
|
||||
### 6.2 推荐用法(兼顾挣钱与纪律)
|
||||
|
||||
- **优先**:在回测中增加 **固定止盈**(如 10%):
|
||||
- 每个交易日 15:00 前,若 **持仓收益率 ≥ 10%**,则当日 15:00 按收盘价清仓,视为「高点跑」一次。
|
||||
- **可选**:再增加 **高点回撤止盈**(如 8%):
|
||||
- 若持仓期间最高价到当前价的回撤 ≥ 8%,则当日 15:00 清仓。
|
||||
- **保留**:若未触发上述止盈,仍按当前逻辑:推荐「卖出」则清仓,「加仓」则加仓,「持有」则不动。
|
||||
|
||||
这样既保留「按推荐买卖」的纪律,又明确加入「高点跑 / 落袋为安」的推荐,更贴近「目的是挣钱」。
|
||||
|
||||
---
|
||||
|
||||
## 七、如何提供/提升收益率
|
||||
|
||||
### 7.1 当前收益率指标
|
||||
|
||||
回测脚本已输出并写入 `backtest_result.json`:
|
||||
|
||||
- **收益率** = (总收回 - 总投入) / 总投入 × 100%,即整段回测的累计收益。
|
||||
- **年化收益率** = (1 + 收益率)^(365/回测天数) − 1,折算为「若按同样节奏跑满一年」的大致水平,便于和理财/指数对比。
|
||||
|
||||
v2 已内置更多指标:胜率、最大回撤、平均持仓天数、盈亏比、个股盈亏明细,均在控制台和 JSON 中输出。
|
||||
|
||||
### 7.2 可操作的提升方向
|
||||
|
||||
| 方向 | 做法 | 说明 |
|
||||
|------|------|------|
|
||||
| **止盈** | 使用 `--take-profit 10` 等 | 浮盈到点即走,减少回吐;可多试 8%、12% 等找合适区间。 |
|
||||
| **止损** | 在回测中增加「浮亏达 X% 清仓」 | 控制单笔最大亏损,避免深套。 |
|
||||
| **高点回撤止盈** | 从持仓最高价回撤 Y% 时清仓 | 贴近「高点跑」,锁住大部分利润。 |
|
||||
| **选股过滤** | 在「买入」列表中提高门槛 | 如仅选 recommend_rate≥90 或 triggered_count≥2,减少弱信号。 |
|
||||
| **仓位/频率** | 每日买一只 vs 仅无仓时买 | 当前为每日买一只;若改为仅无仓时买,收益曲线会不同,可对比。 |
|
||||
| **参数扫描** | 对止盈比例、止损比例等做网格 | 批量回测不同参数,看哪组年化/回撤更优。 |
|
||||
|
||||
建议先固定一套规则(如 止盈 10% + 信号卖出),跑出基准年化与回撤,再逐项加「止损」「高点回撤止盈」或选股过滤,对比同一区间下收益率与回撤的变化,再决定是否采用。
|
||||
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
全量股票技术信号扫描脚本
|
||||
对数据库中的全部股票进行7个技术信号检测,结果存入 stock_signal_scan 表
|
||||
支持断点续扫、并发处理、进度报告
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
import signal as sig_module
|
||||
from datetime import datetime, date, timedelta
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import pandas as pd
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from config import Config
|
||||
from services.signal_detector import detect_all_signals
|
||||
from services.stock_algorithms import (
|
||||
get_kline_from_local_db_threaded,
|
||||
get_ali_session, get_tencent_session,
|
||||
code_to_ali_symbol, code_to_tencent_symbol, is_bj_stock,
|
||||
ALICLOUD_KLINE_URL, TENCENT_KLINE_URL,
|
||||
)
|
||||
|
||||
WORKERS = 8
|
||||
BATCH_SIZE = 80
|
||||
BATCH_SAVE_SIZE = 40
|
||||
K_DAYS = 120
|
||||
LOOKBACK = 5
|
||||
|
||||
_shutdown = False
|
||||
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
global _shutdown
|
||||
print("\n⚠️ 收到中断信号,正在优雅退出...")
|
||||
_shutdown = True
|
||||
|
||||
|
||||
sig_module.signal(sig_module.SIGINT, signal_handler)
|
||||
sig_module.signal(sig_module.SIGTERM, signal_handler)
|
||||
|
||||
|
||||
def get_db_conn():
|
||||
return psycopg2.connect(
|
||||
host=Config.DB_HOST, port=Config.DB_PORT,
|
||||
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
def get_all_stock_codes(conn):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT code, name FROM stock_realtime_price ORDER BY code")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def get_scanned_codes(conn, scan_date):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT code FROM stock_signal_scan WHERE scan_date = %s",
|
||||
(scan_date,),
|
||||
)
|
||||
return {row[0] for row in cur.fetchall()}
|
||||
|
||||
|
||||
def get_kline_data(stock_code, days=K_DAYS):
|
||||
"""获取K线数据:优先本地DB → 阿里云API → 腾讯API → 麦蕊API → AKShare
|
||||
(使用 services.stock_algorithms 统一的API会话和工具函数)"""
|
||||
from services.mairui_api import get_kline as _mairui_get_kline
|
||||
|
||||
# 1. 优先从本地数据库读取(多线程安全版本)
|
||||
df = get_kline_from_local_db_threaded(stock_code, days)
|
||||
if df is not None:
|
||||
return df
|
||||
|
||||
# 2. 阿里云K线API(北交所直接跳过,不支持)
|
||||
if not is_bj_stock(stock_code):
|
||||
try:
|
||||
session = get_ali_session()
|
||||
symbol = code_to_ali_symbol(stock_code)
|
||||
resp = session.post(ALICLOUD_KLINE_URL, data={
|
||||
'symbol': symbol, 'type': '240',
|
||||
'limit': str(min(days, 300)), 'ma': '5',
|
||||
}, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get('success') and data.get('data', {}).get('list'):
|
||||
records = []
|
||||
for item in data['data']['list']:
|
||||
day_str = item.get('day', '')
|
||||
if not day_str or len(day_str) < 10:
|
||||
continue
|
||||
records.append({
|
||||
'date': day_str[:10],
|
||||
'open': float(item.get('open', 0)),
|
||||
'high': float(item.get('high', 0)),
|
||||
'low': float(item.get('low', 0)),
|
||||
'close': float(item.get('close', 0)),
|
||||
'volume': float(item.get('volume', 0)),
|
||||
})
|
||||
if len(records) >= 30:
|
||||
return pd.DataFrame(records)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 腾讯K线API(全市场,含北交所)
|
||||
try:
|
||||
session = get_tencent_session()
|
||||
tencent_symbol = code_to_tencent_symbol(stock_code)
|
||||
start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d')
|
||||
resp = session.get(TENCENT_KLINE_URL, params={
|
||||
'param': f'{tencent_symbol},day,{start_date},,{min(days, 300)},qfq',
|
||||
}, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
stock_data = data.get('data', {}).get(tencent_symbol, {})
|
||||
klines = stock_data.get('qfqday') or stock_data.get('day') or []
|
||||
if len(klines) >= 30:
|
||||
records = []
|
||||
for item in klines:
|
||||
if len(item) < 6:
|
||||
continue
|
||||
records.append({
|
||||
'date': item[0][:10],
|
||||
'open': float(item[1]),
|
||||
'high': float(item[3]),
|
||||
'low': float(item[4]),
|
||||
'close': float(item[2]),
|
||||
'volume': float(item[5]),
|
||||
})
|
||||
if len(records) >= 30:
|
||||
return pd.DataFrame(records)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. 回退到麦蕊API
|
||||
try:
|
||||
result = _mairui_get_kline(stock_code, period='d', days=days, adjust='f')
|
||||
if result['success'] and result['data']:
|
||||
df = pd.DataFrame(result['data'])
|
||||
df.rename(columns={
|
||||
'date': 'date', 'open': 'open', 'high': 'high',
|
||||
'low': 'low', 'close': 'close', 'volume': 'volume',
|
||||
}, inplace=True)
|
||||
if len(df) >= 30:
|
||||
return df
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. 最后回退到AKShare (支持自动降级到腾讯数据源)
|
||||
try:
|
||||
from utils.data_fetcher import fetch_stock_hist
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
|
||||
df = fetch_stock_hist(
|
||||
stock_code=stock_code, period='daily',
|
||||
start_date=start_date, end_date=end_date, adjust='qfq',
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
df = df.rename(columns={
|
||||
'日期': 'date', '开盘': 'open', '最高': 'high',
|
||||
'最低': 'low', '收盘': 'close', '成交量': 'volume',
|
||||
})
|
||||
df = df[['date', 'open', 'high', 'low', 'close', 'volume']]
|
||||
return df
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def scan_single_stock(code, name):
|
||||
try:
|
||||
df = get_kline_data(code)
|
||||
if df is None or df.empty or len(df) < 30:
|
||||
return None
|
||||
|
||||
result = detect_all_signals(df, lookback=LOOKBACK)
|
||||
signal_status = result.get('signal_status', [])
|
||||
triggered_count = sum(1 for s in signal_status if s.get('triggered'))
|
||||
|
||||
return {
|
||||
'code': code,
|
||||
'name': name,
|
||||
'triggered_count': triggered_count,
|
||||
'signal_status': signal_status,
|
||||
'indicators': result.get('indicators', {}),
|
||||
'latest_signals': result.get('latest_signals', []),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fix_sequence(conn):
|
||||
"""修复序列号,确保不会产生主键冲突"""
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT setval('stock_signal_scan_id_seq',
|
||||
COALESCE((SELECT max(id) FROM stock_signal_scan), 0) + 1, false
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"⚠️ 修复序列失败: {e}", flush=True)
|
||||
|
||||
|
||||
def save_batch(conn, results, scan_date):
|
||||
if not results:
|
||||
return
|
||||
|
||||
# 每批次开始前确保序列正确
|
||||
_fix_sequence(conn)
|
||||
|
||||
saved = 0
|
||||
for r in results:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SAVEPOINT sp_insert")
|
||||
cur.execute("""
|
||||
INSERT INTO stock_signal_scan
|
||||
(code, name, scan_date, triggered_count, signal_status, indicators, latest_signals)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (code, scan_date) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
triggered_count = EXCLUDED.triggered_count,
|
||||
signal_status = EXCLUDED.signal_status,
|
||||
indicators = EXCLUDED.indicators,
|
||||
latest_signals = EXCLUDED.latest_signals,
|
||||
created_at = CURRENT_TIMESTAMP
|
||||
""", (
|
||||
r['code'], r['name'], scan_date, r['triggered_count'],
|
||||
Json(r['signal_status']), Json(r['indicators']), Json(r['latest_signals']),
|
||||
))
|
||||
cur.execute("RELEASE SAVEPOINT sp_insert")
|
||||
saved += 1
|
||||
except Exception as e:
|
||||
# 主键冲突时回滚到 savepoint,修复序列后重试
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT sp_insert")
|
||||
if 'UniqueViolation' in type(e).__name__ or 'duplicate key' in str(e):
|
||||
_fix_sequence(conn)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SAVEPOINT sp_insert")
|
||||
cur.execute("""
|
||||
INSERT INTO stock_signal_scan
|
||||
(code, name, scan_date, triggered_count, signal_status, indicators, latest_signals)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (code, scan_date) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
triggered_count = EXCLUDED.triggered_count,
|
||||
signal_status = EXCLUDED.signal_status,
|
||||
indicators = EXCLUDED.indicators,
|
||||
latest_signals = EXCLUDED.latest_signals,
|
||||
created_at = CURRENT_TIMESTAMP
|
||||
""", (
|
||||
r['code'], r['name'], scan_date, r['triggered_count'],
|
||||
Json(r['signal_status']), Json(r['indicators']), Json(r['latest_signals']),
|
||||
))
|
||||
cur.execute("RELEASE SAVEPOINT sp_insert")
|
||||
saved += 1
|
||||
except Exception as e2:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT sp_insert")
|
||||
print(f"⚠️ 重试保存失败 {r.get('code','?')}: {e2}", flush=True)
|
||||
else:
|
||||
print(f"⚠️ 保存失败 {r.get('code','?')}: {e}", flush=True)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def main():
|
||||
global _shutdown
|
||||
|
||||
scan_date = date.today()
|
||||
|
||||
force_rescan = os.environ.get('FORCE_RESCAN', '').strip() == '1'
|
||||
print(f"{'='*60}", flush=True)
|
||||
print(f"📊 全量股票技术信号扫描(高速版)", flush=True)
|
||||
print(f"📅 扫描日期: {scan_date}", flush=True)
|
||||
if force_rescan:
|
||||
print(f"⚠️ 强制重新扫描模式", flush=True)
|
||||
print(f"⚙️ 并发数: {WORKERS}, 批保存: {BATCH_SAVE_SIZE}, K线天数: {K_DAYS}", flush=True)
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
conn = get_db_conn()
|
||||
|
||||
if force_rescan:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM stock_signal_scan WHERE scan_date = %s", (scan_date,))
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
print(f"🗑️ 已清除今日 {deleted} 条扫描记录", flush=True)
|
||||
|
||||
all_stocks = get_all_stock_codes(conn)
|
||||
total = len(all_stocks)
|
||||
print(f"📈 数据库股票总数: {total}", flush=True)
|
||||
|
||||
scanned = get_scanned_codes(conn, scan_date)
|
||||
if scanned:
|
||||
print(f"✅ 今日已扫描: {len(scanned)} 只(续扫模式)", flush=True)
|
||||
|
||||
# 过滤退市/ST股票 — 不参与扫描
|
||||
_SKIP_TAGS = ('退', 'ST', '*ST', '退市')
|
||||
pending = [(code, name) for code, name in all_stocks
|
||||
if code not in scanned and not any(tag in name for tag in _SKIP_TAGS)]
|
||||
skipped_st = len([1 for _, name in all_stocks if any(tag in name for tag in _SKIP_TAGS)])
|
||||
pending_count = len(pending)
|
||||
if skipped_st > 0:
|
||||
print(f"🚫 已过滤退市/ST股票: {skipped_st} 只", flush=True)
|
||||
print(f"⏳ 待扫描: {pending_count} 只", flush=True)
|
||||
|
||||
if pending_count == 0:
|
||||
print("🎉 今日扫描已全部完成!", flush=True)
|
||||
show_summary(conn, scan_date)
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# 检测本地K线数据是否可用
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT count(DISTINCT code) FROM stock_kline_daily WHERE trade_date >= CURRENT_DATE - INTERVAL '7 days'")
|
||||
local_kline_count = cur.fetchone()[0]
|
||||
if local_kline_count > 0:
|
||||
print(f"💾 本地K线数据: {local_kline_count} 只股票可用(优先使用本地数据)", flush=True)
|
||||
else:
|
||||
print(f"⚠️ 本地无K线数据,将通过API获取(较慢)", flush=True)
|
||||
|
||||
start_time = time.time()
|
||||
done_count = len(scanned)
|
||||
error_count = 0
|
||||
triggered_total = 0
|
||||
total_to_scan = total
|
||||
save_buffer = []
|
||||
last_report_time = time.time()
|
||||
|
||||
print(f"\n🚀 开始扫描...", flush=True)
|
||||
print(f"-" * 60, flush=True)
|
||||
|
||||
# 使用全局线程池 — 避免反复创建/销毁线程池开销
|
||||
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
|
||||
futures = {}
|
||||
# 提交所有任务
|
||||
for code, name in pending:
|
||||
if _shutdown:
|
||||
break
|
||||
futures[executor.submit(scan_single_stock, code, name)] = (code, name)
|
||||
|
||||
for future in as_completed(futures):
|
||||
if _shutdown:
|
||||
print("⏹️ 用户中断,正在保存当前进度...", flush=True)
|
||||
break
|
||||
|
||||
code, name = futures[future]
|
||||
done_count += 1
|
||||
|
||||
try:
|
||||
result = future.result()
|
||||
except Exception:
|
||||
result = None
|
||||
|
||||
if result:
|
||||
save_buffer.append(result)
|
||||
if result['triggered_count'] > 0:
|
||||
triggered_total += 1
|
||||
names = [s['name'] for s in result['signal_status'] if s.get('triggered')]
|
||||
print(f" 🔔 {result['code']} {result['name']}: {', '.join(names)}", flush=True)
|
||||
else:
|
||||
error_count += 1
|
||||
|
||||
# 攒够一批就保存(减少DB写入频率)
|
||||
if len(save_buffer) >= BATCH_SAVE_SIZE:
|
||||
save_batch(conn, save_buffer, scan_date)
|
||||
save_buffer = []
|
||||
|
||||
# 每3秒报告一次进度(避免刷屏)
|
||||
now = time.time()
|
||||
if now - last_report_time >= 3:
|
||||
elapsed = now - start_time
|
||||
scanned_this_run = done_count - len(scanned)
|
||||
speed = scanned_this_run / elapsed if elapsed > 0 else 0
|
||||
remaining_stocks = total_to_scan - done_count
|
||||
remaining_time = remaining_stocks / speed if speed > 0 else 0
|
||||
pct = done_count / total_to_scan * 100
|
||||
print(f" [{pct:5.1f}%] {done_count}/{total_to_scan} "
|
||||
f"| 速度: {speed:.1f}只/秒 | 剩余: {remaining_time/60:.1f}分钟 "
|
||||
f"| 触发: {triggered_total} | 失败: {error_count}", flush=True)
|
||||
last_report_time = now
|
||||
|
||||
# 保存剩余结果
|
||||
if save_buffer:
|
||||
save_batch(conn, save_buffer, scan_date)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"\n{'='*60}", flush=True)
|
||||
print(f"✅ 扫描{'中断' if _shutdown else '完成'}!", flush=True)
|
||||
print(f" 扫描: {done_count} 只 | 耗时: {elapsed/60:.1f}分钟", flush=True)
|
||||
print(f" 触发信号: {triggered_total} 只 | 失败: {error_count} 只", flush=True)
|
||||
final_speed = (done_count - len(scanned)) / elapsed if elapsed > 0 else 0
|
||||
print(f" 平均速度: {final_speed:.1f} 只/秒", flush=True)
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
show_summary(conn, scan_date)
|
||||
conn.close()
|
||||
|
||||
|
||||
def show_summary(conn, scan_date):
|
||||
print(f"\n📊 扫描结果摘要({scan_date})", flush=True)
|
||||
print(f"-" * 60, flush=True)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT count(*),
|
||||
coalesce(sum(case when triggered_count > 0 then 1 else 0 end), 0)
|
||||
FROM stock_signal_scan WHERE scan_date = %s
|
||||
""", (scan_date,))
|
||||
total, triggered = cur.fetchone()
|
||||
print(f" 总扫描: {total} 只 | 有信号: {triggered} 只", flush=True)
|
||||
|
||||
cur.execute("""
|
||||
SELECT code, name, triggered_count, signal_status
|
||||
FROM stock_signal_scan
|
||||
WHERE scan_date = %s AND triggered_count > 0
|
||||
ORDER BY triggered_count DESC
|
||||
LIMIT 30
|
||||
""", (scan_date,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
if rows:
|
||||
print(f"\n🔔 触发信号TOP30:", flush=True)
|
||||
for code, name, tc, status in rows:
|
||||
signals = status if isinstance(status, list) else json.loads(status) if status else []
|
||||
names = [s['name'] for s in signals if s.get('triggered')]
|
||||
print(f" {code} {name:8s} | {tc}个信号: {', '.join(names)}", flush=True)
|
||||
else:
|
||||
print(f"\n 暂无触发信号的股票", flush=True)
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
s.value->>'name' as signal_name,
|
||||
count(*) as cnt
|
||||
FROM stock_signal_scan, jsonb_array_elements(signal_status) s
|
||||
WHERE scan_date = %s AND (s.value->>'triggered')::boolean = true
|
||||
GROUP BY s.value->>'name'
|
||||
ORDER BY cnt DESC
|
||||
""", (scan_date,))
|
||||
signal_dist = cur.fetchall()
|
||||
if signal_dist:
|
||||
print(f"\n📈 信号分布:", flush=True)
|
||||
for name, cnt in signal_dist:
|
||||
print(f" {name}: {cnt} 只", flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,20 @@
|
||||
import akshare as ak
|
||||
|
||||
# 获取个股资金流向排名(全市场)
|
||||
# 返回股票主力净流入、超大单、大单、中单、小单排名
|
||||
df = ak.stock_individual_fund_flow_rank(indicator="今日")
|
||||
print("=== 今日个股资金流向排名 ===")
|
||||
print(df.head(10))
|
||||
|
||||
# 历史某股主力资金流向
|
||||
print("\n=== 平安银行历史资金流向 ===")
|
||||
df2 = ak.stock_individual_fund_flow(stock="000001", market="sz") # 平安银行
|
||||
print(df2.head())
|
||||
df2.to_csv("000001_zjlx.csv", index=False)
|
||||
print("已保存到 000001_zjlx.csv")
|
||||
|
||||
# # B. Tushare Pro(需要注册获取token)
|
||||
# import tushare as ts
|
||||
# ts.set_token('你的 token')
|
||||
# pro = ts.pro_api()
|
||||
# df = pro.moneyflow(ts_code='000001.SZ', start_date='20240101', end_date='20240122')
|
||||
@@ -0,0 +1,69 @@
|
||||
-- 股票投资分析系统 - PostgreSQL 数据库初始化脚本
|
||||
-- 运行方式: psql -U postgres -f init_db.sql
|
||||
|
||||
-- 创建数据库(如果不存在)
|
||||
SELECT 'CREATE DATABASE stock_app'
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'stock_app')\gexec
|
||||
|
||||
-- 连接到数据库
|
||||
\c stock_app
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
available_cash DECIMAL(15,2) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 关注列表
|
||||
CREATE TABLE IF NOT EXISTS watchlist (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
stock_name VARCHAR(50),
|
||||
added_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, stock_code)
|
||||
);
|
||||
|
||||
-- 交易记录
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
stock_name VARCHAR(50),
|
||||
trade_type VARCHAR(10) NOT NULL,
|
||||
price DECIMAL(10,4),
|
||||
quantity INTEGER,
|
||||
trade_date DATE,
|
||||
reason TEXT,
|
||||
result VARCHAR(20),
|
||||
profit_amount DECIMAL(10,4),
|
||||
stop_loss_price DECIMAL(10,4),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 分析缓存
|
||||
CREATE TABLE IF NOT EXISTS alerts_cache (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
data JSONB,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 股票名称缓存(公共)
|
||||
CREATE TABLE IF NOT EXISTS stock_names (
|
||||
code VARCHAR(10) PRIMARY KEY,
|
||||
name VARCHAR(50)
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_user ON trades(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_stock ON trades(stock_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_user ON alerts_cache(user_id);
|
||||
|
||||
-- 输出结果
|
||||
SELECT 'Database initialized successfully!' as status;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- 模拟交易系统 - PostgreSQL 数据库表
|
||||
-- 运行方式: psql -U postgres -d stock_app -f init_sim_trade.sql
|
||||
|
||||
-- 模拟交易记录表
|
||||
CREATE TABLE IF NOT EXISTS sim_trades (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
stock_name VARCHAR(50),
|
||||
trade_type VARCHAR(10) NOT NULL, -- 'buy' 或 'sell'
|
||||
price DECIMAL(10,4) NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1000,
|
||||
trade_date DATE NOT NULL,
|
||||
trade_time TIME,
|
||||
recommend_rate DECIMAL(5,2), -- 推荐率
|
||||
signal_reason TEXT, -- 交易信号原因
|
||||
commission DECIMAL(10,4) DEFAULT 0, -- 佣金 (万2.5, 最低5元)
|
||||
stamp_tax DECIMAL(10,4) DEFAULT 0, -- 印花税 (千1, 仅卖出)
|
||||
total_fee DECIMAL(10,4) DEFAULT 0, -- 总手续费
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 模拟持仓表
|
||||
CREATE TABLE IF NOT EXISTS sim_positions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
stock_name VARCHAR(50),
|
||||
quantity INTEGER NOT NULL DEFAULT 0,
|
||||
avg_cost DECIMAL(10,4) NOT NULL DEFAULT 0,
|
||||
total_cost DECIMAL(15,4) NOT NULL DEFAULT 0,
|
||||
current_price DECIMAL(10,4),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, stock_code)
|
||||
);
|
||||
|
||||
-- 模拟交易统计表(每日汇总)
|
||||
CREATE TABLE IF NOT EXISTS sim_daily_stats (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
stat_date DATE NOT NULL,
|
||||
total_market_value DECIMAL(15,4) DEFAULT 0, -- 总市值
|
||||
total_cost DECIMAL(15,4) DEFAULT 0, -- 总成本
|
||||
unrealized_profit DECIMAL(15,4) DEFAULT 0, -- 浮动盈亏
|
||||
realized_profit DECIMAL(15,4) DEFAULT 0, -- 已实现盈亏
|
||||
total_profit DECIMAL(15,4) DEFAULT 0, -- 总盈亏
|
||||
trade_count INTEGER DEFAULT 0, -- 当日交易次数
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, stat_date)
|
||||
);
|
||||
|
||||
-- 模拟交易配置表
|
||||
CREATE TABLE IF NOT EXISTS sim_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
initial_capital DECIMAL(15,4) DEFAULT 1000000, -- 初始资金(默认100万)
|
||||
trade_quantity INTEGER DEFAULT 1000, -- 每次交易数量
|
||||
auto_trade_enabled BOOLEAN DEFAULT true, -- 是否启用自动交易
|
||||
auto_trade_time TIME DEFAULT '10:00:00', -- 自动交易时间
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_sim_trades_user ON sim_trades(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sim_trades_date ON sim_trades(trade_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_sim_trades_stock ON sim_trades(stock_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_sim_positions_user ON sim_positions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sim_daily_stats_user_date ON sim_daily_stats(user_id, stat_date);
|
||||
|
||||
-- 输出结果
|
||||
SELECT 'Simulation trade tables created successfully!' as status;
|
||||
@@ -0,0 +1,169 @@
|
||||
-- 智能交易引擎 - 数据库表
|
||||
-- 运行方式: psql -U postgres -d stock_app -f init_smart_trade.sql
|
||||
|
||||
-- ═══════════════════════════════════════════════════════
|
||||
-- 1. 算法配置表: 每个用户选择的算法及其参数
|
||||
-- ═══════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS sim_algo_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
algo_name VARCHAR(100) NOT NULL DEFAULT 'PE50G3+BE8', -- 算法名称
|
||||
-- 止盈止损
|
||||
take_profit_pct DECIMAL(5,2) DEFAULT 12, -- 止盈百分比
|
||||
stop_loss_pct DECIMAL(5,2) DEFAULT 8, -- 止损百分比
|
||||
-- 卖出信号处理
|
||||
ignore_sell_signal BOOLEAN DEFAULT FALSE, -- 忽略扫描卖出信号
|
||||
sell_confirm_days INTEGER DEFAULT 3, -- 卖出信号确认天数
|
||||
-- 持仓管理
|
||||
max_hold_days INTEGER DEFAULT 60, -- 最大持仓天数 (0=无限)
|
||||
no_timeout_if_rising BOOLEAN DEFAULT TRUE, -- 连涨中不强制平仓
|
||||
-- 资金管理
|
||||
total_capital DECIMAL(15,2) DEFAULT 200000, -- 总本金
|
||||
position_pct DECIMAL(5,2) DEFAULT 8, -- 单笔仓位占比%
|
||||
signal_weight BOOLEAN DEFAULT TRUE, -- 信号加权仓位
|
||||
-- v6特性: 部分止盈
|
||||
partial_exit_pct INTEGER DEFAULT 50, -- 到TP时卖出比例% (0=全卖)
|
||||
momentum_trail_gap DECIMAL(5,2) DEFAULT 3, -- 跟踪止盈回撤%
|
||||
-- v6特性: 保本止损
|
||||
breakeven_at DECIMAL(5,2) DEFAULT 8, -- 盈利N%后止损移至成本 (0=关闭)
|
||||
-- v6特性: 动量跟踪
|
||||
momentum_tp BOOLEAN DEFAULT FALSE, -- 连涨保护
|
||||
momentum_days INTEGER DEFAULT 3, -- 连涨判定天数
|
||||
-- v7特性: 交易时间
|
||||
buy_time VARCHAR(5) DEFAULT '09:35', -- 买入时间点
|
||||
sell_time VARCHAR(5) DEFAULT '13:40', -- 卖出时间点
|
||||
-- 启用状态
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════
|
||||
-- 2. 持仓元数据表: 跟踪每个持仓的算法状态
|
||||
-- ═══════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS sim_position_meta (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
-- 买入信息
|
||||
buy_date DATE NOT NULL,
|
||||
buy_price DECIMAL(10,4) NOT NULL,
|
||||
buy_reason TEXT,
|
||||
buy_signal_rate INTEGER DEFAULT 0, -- 买入时信号强度
|
||||
buy_triggered_count INTEGER DEFAULT 0, -- 买入时触发信号数
|
||||
-- 算法状态跟踪
|
||||
days_held INTEGER DEFAULT 0, -- 已持仓天数
|
||||
max_price_since_buy DECIMAL(10,4) DEFAULT 0, -- 买入以来最高价
|
||||
consecutive_up_days INTEGER DEFAULT 0, -- 连续上涨天数
|
||||
consecutive_sell_signals INTEGER DEFAULT 0, -- 连续卖出信号天数
|
||||
-- v6特性状态
|
||||
partial_exit_done BOOLEAN DEFAULT FALSE, -- 是否已部分止盈
|
||||
breakeven_active BOOLEAN DEFAULT FALSE, -- 保本止损是否激活
|
||||
momentum_trailing_active BOOLEAN DEFAULT FALSE, -- 动量跟踪是否激活
|
||||
momentum_high_price DECIMAL(10,4) DEFAULT 0, -- 动量跟踪最高价
|
||||
-- 当前状态
|
||||
current_shares INTEGER DEFAULT 0, -- 当前持股数
|
||||
original_shares INTEGER DEFAULT 0, -- 原始买入股数
|
||||
last_update_date DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, stock_code)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════
|
||||
-- 3. 交易信号日志表: 记录每日的交易决策过程
|
||||
-- ═══════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS sim_trade_signals (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
signal_date DATE NOT NULL,
|
||||
signal_time TIME,
|
||||
stock_code VARCHAR(10) NOT NULL,
|
||||
stock_name VARCHAR(50),
|
||||
-- 信号信息
|
||||
action VARCHAR(20) NOT NULL, -- buy/sell/partial_sell/hold/skip
|
||||
reason TEXT, -- 详细原因
|
||||
algo_rule VARCHAR(50), -- 触发的算法规则 (TP/SL/PE/BE/MT/TIMEOUT等)
|
||||
-- 价格信息
|
||||
signal_price DECIMAL(10,4),
|
||||
buy_price DECIMAL(10,4), -- 成本价
|
||||
profit_pct DECIMAL(8,4), -- 当前盈亏%
|
||||
-- 执行信息
|
||||
executed BOOLEAN DEFAULT FALSE, -- 是否已执行
|
||||
execute_price DECIMAL(10,4),
|
||||
execute_shares INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_sim_algo_config_user ON sim_algo_config(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sim_position_meta_user ON sim_position_meta(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sim_position_meta_code ON sim_position_meta(user_id, stock_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_sim_trade_signals_date ON sim_trade_signals(user_id, signal_date);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════
|
||||
-- 4. 预置算法配置 (3个推荐算法)
|
||||
-- ═══════════════════════════════════════════════════════
|
||||
-- 注意: 以下INSERT语句用于初始化默认算法模板, 实际用户配置通过API创建
|
||||
|
||||
-- 默认算法模板表
|
||||
CREATE TABLE IF NOT EXISTS algo_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
risk_level VARCHAR(20), -- aggressive / balanced / conservative
|
||||
-- 参数 (与 sim_algo_config 相同)
|
||||
take_profit_pct DECIMAL(5,2),
|
||||
stop_loss_pct DECIMAL(5,2),
|
||||
ignore_sell_signal BOOLEAN DEFAULT FALSE,
|
||||
sell_confirm_days INTEGER DEFAULT 0,
|
||||
max_hold_days INTEGER DEFAULT 0,
|
||||
no_timeout_if_rising BOOLEAN DEFAULT FALSE,
|
||||
position_pct DECIMAL(5,2) DEFAULT 10,
|
||||
signal_weight BOOLEAN DEFAULT TRUE,
|
||||
partial_exit_pct INTEGER DEFAULT 0,
|
||||
momentum_trail_gap DECIMAL(5,2) DEFAULT 3,
|
||||
breakeven_at DECIMAL(5,2) DEFAULT 0,
|
||||
momentum_tp BOOLEAN DEFAULT FALSE,
|
||||
momentum_days INTEGER DEFAULT 3,
|
||||
buy_time VARCHAR(5) DEFAULT '09:35',
|
||||
sell_time VARCHAR(5) DEFAULT '13:40',
|
||||
-- 回测业绩
|
||||
backtest_annual_return DECIMAL(8,2),
|
||||
backtest_max_drawdown DECIMAL(8,2),
|
||||
backtest_win_rate DECIMAL(8,2),
|
||||
backtest_calmar DECIMAL(8,2),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 插入3个推荐算法模板
|
||||
INSERT INTO algo_templates (name, display_name, description, risk_level,
|
||||
take_profit_pct, stop_loss_pct, ignore_sell_signal, sell_confirm_days,
|
||||
max_hold_days, no_timeout_if_rising, position_pct, signal_weight,
|
||||
partial_exit_pct, momentum_trail_gap, breakeven_at, momentum_tp,
|
||||
backtest_annual_return, backtest_max_drawdown, backtest_win_rate, backtest_calmar)
|
||||
VALUES
|
||||
-- 🏆 最高收益
|
||||
('PE50G3_TP10_SL6', '📈 最高收益策略',
|
||||
'部分止盈50% + 跟踪止盈 | 止盈10%/止损6% | 忽略卖出信号 | 最长60天 | 10%仓位',
|
||||
'aggressive',
|
||||
10, 6, TRUE, 0, 60, TRUE, 10, TRUE, 50, 3, 0, FALSE,
|
||||
18.2, 7.1, 58.2, 2.56),
|
||||
|
||||
-- 🛡️ 风险调整最优
|
||||
('PE50G3_BE8_TP12_SL8', '🛡️ 风控优先策略',
|
||||
'部分止盈50% + 保本止损8% + 延迟卖出3天 | 止盈12%/止损8% | 最长60天 | 8%仓位',
|
||||
'balanced',
|
||||
12, 8, FALSE, 3, 60, TRUE, 8, TRUE, 50, 3, 8, FALSE,
|
||||
17.0, 5.3, 53.8, 3.22),
|
||||
|
||||
-- 📊 最稳健
|
||||
('PE30G3_BE8_TP10_SL6', '📊 稳健均衡策略',
|
||||
'部分止盈30% + 保本止损8% + 忽略卖出 | 止盈10%/止损6% | 最长60天 | 10%仓位',
|
||||
'conservative',
|
||||
10, 6, TRUE, 0, 60, TRUE, 10, TRUE, 30, 3, 8, FALSE,
|
||||
17.4, 7.5, 55.9, 2.32)
|
||||
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
@@ -0,0 +1,139 @@
|
||||
-- 股票数据表结构
|
||||
|
||||
-- 1. 股票基本信息表
|
||||
CREATE TABLE IF NOT EXISTS stock_info (
|
||||
code VARCHAR(10) PRIMARY KEY,
|
||||
name VARCHAR(50),
|
||||
industry VARCHAR(50),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 2. 实时价格表
|
||||
CREATE TABLE IF NOT EXISTS stock_realtime_price (
|
||||
code VARCHAR(10) PRIMARY KEY,
|
||||
name VARCHAR(50),
|
||||
price DECIMAL(12, 4),
|
||||
change_pct DECIMAL(8, 4),
|
||||
change_amount DECIMAL(12, 4),
|
||||
volume BIGINT,
|
||||
amount DECIMAL(20, 2),
|
||||
high DECIMAL(12, 4),
|
||||
low DECIMAL(12, 4),
|
||||
open DECIMAL(12, 4),
|
||||
prev_close DECIMAL(12, 4),
|
||||
pe DECIMAL(12, 4),
|
||||
pb DECIMAL(12, 4),
|
||||
total_market_cap DECIMAL(20, 2),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 3. 今日资金流向表
|
||||
CREATE TABLE IF NOT EXISTS stock_fund_flow_today (
|
||||
code VARCHAR(10) PRIMARY KEY,
|
||||
name VARCHAR(50),
|
||||
main_net_inflow DECIMAL(20, 2), -- 主力净流入
|
||||
main_net_inflow_pct DECIMAL(8, 4), -- 主力净流入占比
|
||||
super_net_inflow DECIMAL(20, 2), -- 超大单净流入
|
||||
super_net_inflow_pct DECIMAL(8, 4), -- 超大单净流入占比
|
||||
big_net_inflow DECIMAL(20, 2), -- 大单净流入
|
||||
big_net_inflow_pct DECIMAL(8, 4), -- 大单净流入占比
|
||||
mid_net_inflow DECIMAL(20, 2), -- 中单净流入
|
||||
mid_net_inflow_pct DECIMAL(8, 4), -- 中单净流入占比
|
||||
small_net_inflow DECIMAL(20, 2), -- 小单净流入
|
||||
small_net_inflow_pct DECIMAL(8, 4), -- 小单净流入占比
|
||||
price DECIMAL(12, 4),
|
||||
change_pct DECIMAL(8, 4),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 4. 历史资金流向表(保留最近6个月)
|
||||
CREATE TABLE IF NOT EXISTS stock_fund_flow_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(10) NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
close_price DECIMAL(12, 4),
|
||||
change_pct DECIMAL(8, 4),
|
||||
main_net_inflow DECIMAL(20, 2),
|
||||
main_net_inflow_pct DECIMAL(8, 4),
|
||||
super_net_inflow DECIMAL(20, 2),
|
||||
super_net_inflow_pct DECIMAL(8, 4),
|
||||
big_net_inflow DECIMAL(20, 2),
|
||||
big_net_inflow_pct DECIMAL(8, 4),
|
||||
mid_net_inflow DECIMAL(20, 2),
|
||||
mid_net_inflow_pct DECIMAL(8, 4),
|
||||
small_net_inflow DECIMAL(20, 2),
|
||||
small_net_inflow_pct DECIMAL(8, 4),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(code, trade_date)
|
||||
);
|
||||
|
||||
-- 5. 日K线表(供全景扫描、回测等使用)
|
||||
CREATE TABLE IF NOT EXISTS stock_kline_daily (
|
||||
code VARCHAR(10) NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
open DECIMAL(12, 4),
|
||||
high DECIMAL(12, 4),
|
||||
low DECIMAL(12, 4),
|
||||
close DECIMAL(12, 4),
|
||||
volume BIGINT,
|
||||
amount DECIMAL(20, 2),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (code, trade_date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_kline_daily_date ON stock_kline_daily(trade_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_kline_daily_code_date ON stock_kline_daily(code, trade_date);
|
||||
|
||||
-- 6. 数据更新日志表
|
||||
CREATE TABLE IF NOT EXISTS data_update_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
data_type VARCHAR(50) NOT NULL, -- realtime_price, fund_flow_today, fund_flow_history
|
||||
status VARCHAR(20) NOT NULL, -- success, failed
|
||||
records_count INT,
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMP,
|
||||
finished_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 7. 历史全景扫描结果表(回测专用,每日 11:30 + 16:30 两次扫描)
|
||||
CREATE TABLE IF NOT EXISTS stock_scan_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
scan_date DATE NOT NULL,
|
||||
scan_time VARCHAR(5) NOT NULL, -- '11:30' 或 '16:30'
|
||||
code VARCHAR(10) NOT NULL,
|
||||
recommend_display VARCHAR(10), -- '买入','卖出','加仓','持有','关注','观察','观望'
|
||||
recommend_type VARCHAR(10), -- 'buy','sell','watch'
|
||||
recommend_reason TEXT,
|
||||
recommend_rate INT DEFAULT 0, -- 0~100
|
||||
triggered_count INT DEFAULT 0,
|
||||
signal_status JSONB,
|
||||
indicators JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(scan_date, scan_time, code)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scan_hist_date_time ON stock_scan_history(scan_date, scan_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_scan_hist_code ON stock_scan_history(code);
|
||||
CREATE INDEX IF NOT EXISTS idx_scan_hist_display ON stock_scan_history(scan_date, scan_time, recommend_display);
|
||||
|
||||
-- 8. 5分钟K线表(盘中价格历史,供精确回测使用)
|
||||
CREATE TABLE IF NOT EXISTS stock_kline_5min (
|
||||
code VARCHAR(10) NOT NULL,
|
||||
dt TIMESTAMP NOT NULL, -- K线时间戳 (如 2026-02-25 09:35:00)
|
||||
open DECIMAL(12, 4),
|
||||
high DECIMAL(12, 4),
|
||||
low DECIMAL(12, 4),
|
||||
close DECIMAL(12, 4),
|
||||
volume BIGINT,
|
||||
amount DECIMAL(20, 2),
|
||||
change_pct DECIMAL(8, 4), -- 涨跌幅
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (code, dt)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_kline_5min_dt ON stock_kline_5min(dt);
|
||||
CREATE INDEX IF NOT EXISTS idx_kline_5min_code_date ON stock_kline_5min(code, (dt::date));
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_flow_history_code ON stock_fund_flow_history(code);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_flow_history_date ON stock_fund_flow_history(trade_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_flow_history_code_date ON stock_fund_flow_history(code, trade_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_price_updated ON stock_realtime_price(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_fund_flow_today_updated ON stock_fund_flow_today(updated_at);
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
数据迁移脚本 - 将JSON数据迁移到PostgreSQL数据库
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from werkzeug.security import generate_password_hash
|
||||
from config import Config
|
||||
|
||||
def get_db():
|
||||
"""获取数据库连接"""
|
||||
return psycopg2.connect(
|
||||
host=Config.DB_HOST,
|
||||
port=Config.DB_PORT,
|
||||
database=Config.DB_NAME,
|
||||
user=Config.DB_USER,
|
||||
password=Config.DB_PASSWORD
|
||||
)
|
||||
|
||||
|
||||
def create_default_user(conn, email=None, password=None):
|
||||
"""创建用户"""
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 使用指定的或默认的邮箱密码
|
||||
user_email = email or 'admin@admin.com'
|
||||
user_password = password or 'admin123'
|
||||
|
||||
# 检查是否已存在用户
|
||||
cur.execute("SELECT id FROM users WHERE email = %s OR username = %s", (user_email, user_email))
|
||||
user = cur.fetchone()
|
||||
|
||||
if user:
|
||||
print(f"用户 {user_email} 已存在,使用现有用户")
|
||||
return user['id']
|
||||
|
||||
# 创建用户
|
||||
password_hash = generate_password_hash(user_password)
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, email, password_hash) VALUES (%s, %s, %s) RETURNING id",
|
||||
(user_email, user_email, password_hash)
|
||||
)
|
||||
user_id = cur.fetchone()['id']
|
||||
conn.commit()
|
||||
print(f"创建用户: {user_email}")
|
||||
return user_id
|
||||
|
||||
|
||||
def migrate_trades(conn, user_id):
|
||||
"""迁移交易记录"""
|
||||
if not os.path.exists(Config.TRADES_FILE):
|
||||
print("trades.json 不存在,跳过")
|
||||
return 0
|
||||
|
||||
with open(Config.TRADES_FILE, 'r', encoding='utf-8') as f:
|
||||
trades = json.load(f)
|
||||
|
||||
if not trades:
|
||||
print("trades.json 为空,跳过")
|
||||
return 0
|
||||
|
||||
cur = conn.cursor()
|
||||
count = 0
|
||||
|
||||
for trade in trades:
|
||||
try:
|
||||
# 处理日期
|
||||
trade_date = trade.get('trade_date')
|
||||
if trade_date and len(trade_date) > 10:
|
||||
trade_date = trade_date[:10]
|
||||
|
||||
# 处理数值
|
||||
price = trade.get('price')
|
||||
if price and price != '':
|
||||
price = float(price)
|
||||
else:
|
||||
price = None
|
||||
|
||||
quantity = trade.get('quantity')
|
||||
if quantity and quantity != '':
|
||||
quantity = int(quantity)
|
||||
else:
|
||||
quantity = None
|
||||
|
||||
profit_amount = trade.get('profit_amount')
|
||||
if profit_amount and profit_amount != '':
|
||||
profit_amount = float(profit_amount)
|
||||
else:
|
||||
profit_amount = None
|
||||
|
||||
stop_loss_price = trade.get('stop_loss_price')
|
||||
if stop_loss_price and stop_loss_price != '':
|
||||
stop_loss_price = float(stop_loss_price)
|
||||
else:
|
||||
stop_loss_price = None
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO trades (user_id, stock_code, stock_name, trade_type, price,
|
||||
quantity, trade_date, reason, result, profit_amount,
|
||||
stop_loss_price, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
user_id,
|
||||
trade.get('stock_code'),
|
||||
trade.get('stock_name'),
|
||||
trade.get('trade_type'),
|
||||
price,
|
||||
quantity,
|
||||
trade_date,
|
||||
trade.get('reason'),
|
||||
trade.get('result'),
|
||||
profit_amount,
|
||||
stop_loss_price,
|
||||
trade.get('notes')
|
||||
))
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f"迁移交易记录失败: {e}, 数据: {trade}")
|
||||
|
||||
conn.commit()
|
||||
print(f"迁移交易记录: {count} 条")
|
||||
return count
|
||||
|
||||
|
||||
def migrate_watchlist(conn, user_id):
|
||||
"""迁移关注列表"""
|
||||
if not os.path.exists(Config.WATCHLIST_FILE):
|
||||
print("watchlist.json 不存在,跳过")
|
||||
return 0
|
||||
|
||||
with open(Config.WATCHLIST_FILE, 'r', encoding='utf-8') as f:
|
||||
watchlist = json.load(f)
|
||||
|
||||
if not watchlist:
|
||||
print("watchlist.json 为空,跳过")
|
||||
return 0
|
||||
|
||||
cur = conn.cursor()
|
||||
count = 0
|
||||
|
||||
for item in watchlist:
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO watchlist (user_id, stock_code, stock_name)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (user_id, stock_code) DO NOTHING
|
||||
""", (
|
||||
user_id,
|
||||
item.get('code'),
|
||||
item.get('name')
|
||||
))
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f"迁移关注列表失败: {e}, 数据: {item}")
|
||||
|
||||
conn.commit()
|
||||
print(f"迁移关注列表: {count} 条")
|
||||
return count
|
||||
|
||||
|
||||
def migrate_alerts_cache(conn, user_id):
|
||||
"""迁移分析缓存"""
|
||||
if not os.path.exists(Config.ALERTS_CACHE_FILE):
|
||||
print("alerts_cache.json 不存在,跳过")
|
||||
return 0
|
||||
|
||||
with open(Config.ALERTS_CACHE_FILE, 'r', encoding='utf-8') as f:
|
||||
cache = json.load(f)
|
||||
|
||||
alerts = cache.get('alerts', [])
|
||||
if not alerts:
|
||||
print("alerts_cache.json 为空,跳过")
|
||||
return 0
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO alerts_cache (user_id, data, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
data = EXCLUDED.data,
|
||||
updated_at = NOW()
|
||||
""", (user_id, json.dumps(alerts)))
|
||||
|
||||
conn.commit()
|
||||
print(f"迁移分析缓存: {len(alerts)} 条")
|
||||
return len(alerts)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='数据迁移工具')
|
||||
parser.add_argument('--email', default=None, help='用户邮箱')
|
||||
parser.add_argument('--password', default=None, help='用户密码')
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("数据迁移 - JSON -> PostgreSQL")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
print("数据库连接成功")
|
||||
except Exception as e:
|
||||
print(f"数据库连接失败: {e}")
|
||||
print("\n请先执行: psql -U postgres -f init_db.sql")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# 创建用户
|
||||
user_id = create_default_user(conn, args.email, args.password)
|
||||
|
||||
# 先清空现有数据
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM trades WHERE user_id = %s", (user_id,))
|
||||
cur.execute("DELETE FROM watchlist WHERE user_id = %s", (user_id,))
|
||||
cur.execute("DELETE FROM alerts_cache WHERE user_id = %s", (user_id,))
|
||||
conn.commit()
|
||||
print("清空现有数据")
|
||||
|
||||
# 迁移数据
|
||||
migrate_trades(conn, user_id)
|
||||
migrate_watchlist(conn, user_id)
|
||||
migrate_alerts_cache(conn, user_id)
|
||||
|
||||
print("=" * 60)
|
||||
print("迁移完成!")
|
||||
print(f"登录邮箱: {args.email or 'admin@admin.com'}")
|
||||
print(f"登录密码: {args.password or 'admin123'}")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"迁移失败: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
flask>=2.0.0
|
||||
flask-cors>=3.0.0
|
||||
akshare>=1.10.0
|
||||
pandas>=1.5.0
|
||||
numpy>=1.20.0
|
||||
schedule>=1.2.0
|
||||
psycopg2-binary>=2.9.0
|
||||
@@ -0,0 +1 @@
|
||||
# Routes 模块
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
用户认证 API 路由
|
||||
使用邮箱和密码进行登录/注册
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, session
|
||||
from db import create_user, verify_user
|
||||
|
||||
bp = Blueprint('auth', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
@bp.route('/register', methods=['POST'])
|
||||
def register():
|
||||
"""用户注册"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip().lower()
|
||||
password = data.get('password', '')
|
||||
|
||||
if not email or not password:
|
||||
return jsonify({'success': False, 'error': '邮箱和密码不能为空'}), 400
|
||||
|
||||
# 验证邮箱格式
|
||||
import re
|
||||
if not re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+$', email):
|
||||
return jsonify({'success': False, 'error': '请输入有效的邮箱地址'}), 400
|
||||
|
||||
if len(password) < 6:
|
||||
return jsonify({'success': False, 'error': '密码至少6位'}), 400
|
||||
|
||||
user, error = create_user(email, password)
|
||||
if error:
|
||||
return jsonify({'success': False, 'error': error}), 400
|
||||
|
||||
# 自动登录
|
||||
session['user_id'] = user['id']
|
||||
session['username'] = user['username'] # username存的是email
|
||||
session['email'] = user['username']
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {'id': user['id'], 'email': user['username'], 'username': user['username'].split('@')[0]}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""用户登录"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip().lower()
|
||||
password = data.get('password', '')
|
||||
|
||||
if not email or not password:
|
||||
return jsonify({'success': False, 'error': '邮箱和密码不能为空'}), 400
|
||||
|
||||
user, error = verify_user(email, password)
|
||||
if error:
|
||||
return jsonify({'success': False, 'error': error}), 400
|
||||
|
||||
session['user_id'] = user['id']
|
||||
session['username'] = user['username']
|
||||
session['email'] = user['username']
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {'id': user['id'], 'email': user['username'], 'username': user['username'].split('@')[0]}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
"""用户登出"""
|
||||
session.clear()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/change_password', methods=['POST'])
|
||||
def change_password():
|
||||
"""修改密码"""
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '请先登录'}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
old_password = data.get('old_password', '')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not old_password or not new_password:
|
||||
return jsonify({'success': False, 'error': '请填写所有字段'}), 400
|
||||
|
||||
if len(new_password) < 6:
|
||||
return jsonify({'success': False, 'error': '新密码至少6位'}), 400
|
||||
|
||||
from db import change_user_password
|
||||
success, error = change_user_password(session['user_id'], old_password, new_password)
|
||||
|
||||
if success:
|
||||
return jsonify({'success': True})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': error}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/me', methods=['GET'])
|
||||
def get_current_user():
|
||||
"""获取当前用户"""
|
||||
if 'user_id' in session:
|
||||
email = session.get('email', session.get('username', ''))
|
||||
is_admin = False
|
||||
try:
|
||||
from db import get_db
|
||||
conn = get_db()
|
||||
if conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
is_admin = bool(row[0])
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'id': session['user_id'],
|
||||
'email': email,
|
||||
'username': email.split('@')[0] if '@' in email else email,
|
||||
'is_admin': is_admin
|
||||
}
|
||||
})
|
||||
return jsonify({'success': False, 'user': None})
|
||||
@@ -0,0 +1,525 @@
|
||||
"""
|
||||
市场数据 API 路由
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from services.stock_service import get_stock_fund_flow, load_cached_data
|
||||
from services.stock_algorithms import get_kline_data as algo_get_kline_data
|
||||
from db import get_db
|
||||
|
||||
bp = Blueprint('market', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
# ============ 数据库查询API(高速版) ============
|
||||
|
||||
@bp.route('/db/realtime_price/<stock_code>', methods=['GET'])
|
||||
def db_realtime_price(stock_code):
|
||||
"""从数据库获取实时价格(毫秒级响应)"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from psycopg2.extras import RealDictCursor
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT code, name, price, change_pct, change_amount,
|
||||
volume, amount, high, low, open, prev_close,
|
||||
pe, pb, total_market_cap, updated_at::text
|
||||
FROM stock_realtime_price
|
||||
WHERE code = %s
|
||||
""", (stock_code,))
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({'success': False, 'error': '未找到数据'}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': dict(row)
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/db/realtime_prices', methods=['POST'])
|
||||
def db_realtime_prices():
|
||||
"""批量获取实时价格"""
|
||||
data = request.get_json()
|
||||
codes = data.get('codes', [])
|
||||
|
||||
if not codes:
|
||||
return jsonify({'success': True, 'data': []})
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from psycopg2.extras import RealDictCursor
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT code, name, price, change_pct, pe, pb, total_market_cap, updated_at::text
|
||||
FROM stock_realtime_price
|
||||
WHERE code = ANY(%s)
|
||||
""", (codes,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': [dict(row) for row in rows]
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/db/fund_flow_today/<stock_code>', methods=['GET'])
|
||||
def db_fund_flow_today(stock_code):
|
||||
"""从数据库获取今日资金流向"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from psycopg2.extras import RealDictCursor
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT code, name, main_net_inflow, main_net_inflow_pct,
|
||||
super_net_inflow, super_net_inflow_pct,
|
||||
big_net_inflow, big_net_inflow_pct,
|
||||
price, change_pct, updated_at::text
|
||||
FROM stock_fund_flow_today
|
||||
WHERE code = %s
|
||||
""", (stock_code,))
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({'success': False, 'error': '未找到数据'}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': dict(row)
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/db/fund_flow_today_batch', methods=['POST'])
|
||||
def db_fund_flow_today_batch():
|
||||
"""批量获取今日资金流向"""
|
||||
data = request.get_json()
|
||||
codes = data.get('codes', [])
|
||||
|
||||
if not codes:
|
||||
return jsonify({'success': True, 'data': []})
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from psycopg2.extras import RealDictCursor
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT code, name, main_net_inflow, main_net_inflow_pct,
|
||||
super_net_inflow, super_net_inflow_pct,
|
||||
price, change_pct, updated_at::text
|
||||
FROM stock_fund_flow_today
|
||||
WHERE code = ANY(%s)
|
||||
""", (codes,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': [dict(row) for row in rows]
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/db/data_status', methods=['GET'])
|
||||
def db_data_status():
|
||||
"""获取数据更新状态"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from psycopg2.extras import RealDictCursor
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 获取各表数据统计
|
||||
cur.execute("SELECT COUNT(*) as count, MAX(updated_at)::text as last_update FROM stock_realtime_price")
|
||||
price_stats = cur.fetchone()
|
||||
|
||||
cur.execute("SELECT COUNT(*) as count, MAX(updated_at)::text as last_update FROM stock_fund_flow_today")
|
||||
flow_stats = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
SELECT data_type, status, records_count, finished_at::text
|
||||
FROM data_update_log
|
||||
ORDER BY finished_at DESC
|
||||
LIMIT 5
|
||||
""")
|
||||
logs = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'realtime_price': dict(price_stats) if price_stats else {},
|
||||
'fund_flow_today': dict(flow_stats) if flow_stats else {},
|
||||
'recent_logs': [dict(log) for log in logs]
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============ 原有API(兼容) ============
|
||||
|
||||
|
||||
@bp.route('/hot_stocks', methods=['GET'])
|
||||
def hot_stocks():
|
||||
"""人气榜 — 已删除(东方财富API不可用,无替代源)"""
|
||||
return jsonify({'success': False, 'error': '人气榜功能已停用', 'data': [], 'total': 0}), 410
|
||||
|
||||
|
||||
@bp.route('/kline/<stock_code>', methods=['GET'])
|
||||
def get_kline(stock_code):
|
||||
"""获取K线数据 — 使用统一算法模块 services.stock_algorithms"""
|
||||
try:
|
||||
period = request.args.get('period', 'daily')
|
||||
days_map = {
|
||||
'weekly': 7,
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'yearly': 365
|
||||
}
|
||||
days = days_map.get(period, 30)
|
||||
|
||||
# 使用统一K线获取(含5种数据源自动回退)
|
||||
df = algo_get_kline_data(stock_code, days=days, use_local_db=True)
|
||||
if df is None or df.empty:
|
||||
return jsonify({'success': True, 'data': [], 'stock_code': stock_code, 'period': period})
|
||||
|
||||
kline_data = []
|
||||
for _, row in df.iterrows():
|
||||
d = row.get('date', '')
|
||||
kline_data.append({
|
||||
'date': d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d),
|
||||
'open': float(row.get('open', 0)),
|
||||
'close': float(row.get('close', 0)),
|
||||
'high': float(row.get('high', 0)),
|
||||
'low': float(row.get('low', 0)),
|
||||
'volume': float(row.get('volume', 0)),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': kline_data,
|
||||
'stock_code': stock_code,
|
||||
'period': period
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"K线接口异常({stock_code}): {e}")
|
||||
return jsonify({'success': True, 'data': [], 'stock_code': stock_code, 'period': period})
|
||||
|
||||
|
||||
@bp.route('/fundflow/<stock_code>', methods=['GET'])
|
||||
def get_fundflow(stock_code):
|
||||
"""获取近N天资金流向(失败时返回空数据)"""
|
||||
try:
|
||||
days = request.args.get('days', 3, type=int)
|
||||
|
||||
try:
|
||||
cached_df, stock_name, _ = load_cached_data(stock_code)
|
||||
except Exception as e:
|
||||
print(f"加载缓存数据失败({stock_code}): {e}")
|
||||
cached_df, stock_name = None, None
|
||||
|
||||
if cached_df is None or cached_df.empty:
|
||||
try:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
|
||||
cached_df, stock_name, error = get_stock_fund_flow(stock_code, start_date, end_date)
|
||||
except Exception as e:
|
||||
print(f"获取资金流向失败({stock_code}): {e}")
|
||||
return jsonify({'success': True, 'stock_code': stock_code, 'stock_name': '', 'data': []})
|
||||
|
||||
if cached_df is None or cached_df.empty:
|
||||
return jsonify({'success': True, 'stock_code': stock_code, 'stock_name': stock_name or '', 'data': []})
|
||||
|
||||
cached_df = cached_df.sort_values('日期', ascending=False)
|
||||
recent = cached_df.head(days)
|
||||
|
||||
flow_data = []
|
||||
for _, row in recent.iterrows():
|
||||
flow_data.append({
|
||||
'date': row['日期'].strftime('%Y-%m-%d') if hasattr(row['日期'], 'strftime') else str(row['日期']),
|
||||
'price': float(row['收盘价']) if pd.notna(row['收盘价']) else 0,
|
||||
'change': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0,
|
||||
'super_ratio': float(row['超大单净流入-净占比']) if pd.notna(row['超大单净流入-净占比']) else 0,
|
||||
'main_ratio': float(row['主力净流入-净占比']) if pd.notna(row['主力净流入-净占比']) else 0,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'stock_code': stock_code,
|
||||
'stock_name': stock_name or '',
|
||||
'data': flow_data
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"资金流向接口异常({stock_code}): {e}")
|
||||
return jsonify({'success': True, 'stock_code': stock_code, 'stock_name': '', 'data': []})
|
||||
|
||||
|
||||
@bp.route('/lhb', methods=['GET'])
|
||||
def get_lhb():
|
||||
"""龙虎榜 — 已删除(东方财富API不可用,无替代源)"""
|
||||
return jsonify({'success': False, 'error': '龙虎榜功能已停用', 'data': [], 'total': 0}), 410
|
||||
|
||||
|
||||
@bp.route('/fund_flow_rank', methods=['GET'])
|
||||
def get_fund_flow_rank():
|
||||
"""获取资金流向排行 — 从数据库缓存获取"""
|
||||
try:
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
|
||||
# 东方财富API已不可用,从数据库获取缓存数据
|
||||
from db import get_db as _get_db
|
||||
_conn = _get_db()
|
||||
if _conn:
|
||||
try:
|
||||
_cur = _conn.cursor()
|
||||
_cur.execute("""
|
||||
SELECT code, name, main_net_inflow, main_net_inflow_pct,
|
||||
price, change_pct
|
||||
FROM stock_fund_flow_today
|
||||
ORDER BY main_net_inflow DESC LIMIT %s
|
||||
""", (limit,))
|
||||
rows = _cur.fetchall()
|
||||
flow_data = [{'code': r[0], 'name': r[1],
|
||||
'main_net_inflow': float(r[2] or 0),
|
||||
'main_pct': float(r[3] or 0),
|
||||
'price': float(r[4] or 0),
|
||||
'change_pct': float(r[5] or 0)} for r in rows]
|
||||
return jsonify({'success': True, 'data': flow_data, 'total': len(flow_data),
|
||||
'source': 'cache'})
|
||||
finally:
|
||||
_conn.close()
|
||||
|
||||
return jsonify({'success': True, 'data': [], 'total': 0})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/fundamental/<stock_code>', methods=['GET'])
|
||||
def get_fundamental(stock_code):
|
||||
"""获取基本面数据(当日缓存版)+ 近三日资金流向 + 财务指标"""
|
||||
try:
|
||||
from db import db_get_fundamental, db_save_fundamental, get_db
|
||||
from services.stock_service import get_stock_name
|
||||
|
||||
# 获取近三日资金流向数据 + 技术信号
|
||||
fund_flow_3days = []
|
||||
realtime_data = None
|
||||
signal_data = None
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
if conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT trade_date, close_price, change_pct,
|
||||
main_net_inflow_pct, super_net_inflow_pct, big_net_inflow_pct
|
||||
FROM stock_fund_flow_history
|
||||
WHERE code = %s
|
||||
ORDER BY trade_date DESC
|
||||
LIMIT 3
|
||||
""", (stock_code,))
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
fund_flow_3days.append({
|
||||
'date': row[0].strftime('%m-%d') if row[0] else '',
|
||||
'close_price': float(row[1]) if row[1] else 0,
|
||||
'change_pct': float(row[2]) if row[2] else 0,
|
||||
'main_pct': float(row[3]) if row[3] else 0,
|
||||
'super_pct': float(row[4]) if row[4] else 0,
|
||||
'big_pct': float(row[5]) if row[5] else 0,
|
||||
})
|
||||
|
||||
cur.execute("""
|
||||
SELECT name, price, pe, pb, change_pct, total_market_cap
|
||||
FROM stock_realtime_price
|
||||
WHERE code = %s
|
||||
""", (stock_code,))
|
||||
rt_row = cur.fetchone()
|
||||
if rt_row:
|
||||
realtime_data = {
|
||||
'name': rt_row[0],
|
||||
'price': float(rt_row[1]) if rt_row[1] else None,
|
||||
'pe': float(rt_row[2]) if rt_row[2] else None,
|
||||
'pb': float(rt_row[3]) if rt_row[3] else None,
|
||||
'change_pct': float(rt_row[4]) if rt_row[4] else None,
|
||||
'total_market_cap': float(rt_row[5]) if rt_row[5] else None,
|
||||
}
|
||||
|
||||
from datetime import date as date_cls
|
||||
# 优先今天的扫描数据,无则回退到最近可用日期
|
||||
scan_date = date_cls.today().strftime('%Y-%m-%d')
|
||||
cur.execute("""
|
||||
SELECT signal_status, indicators, triggered_count
|
||||
FROM stock_signal_scan
|
||||
WHERE code = %s AND scan_date = %s
|
||||
""", (stock_code, scan_date))
|
||||
sig_row = cur.fetchone()
|
||||
if not sig_row:
|
||||
cur.execute("""
|
||||
SELECT signal_status, indicators, triggered_count
|
||||
FROM stock_signal_scan
|
||||
WHERE code = %s AND scan_date = (SELECT MAX(scan_date) FROM stock_signal_scan)
|
||||
""", (stock_code,))
|
||||
sig_row = cur.fetchone()
|
||||
if sig_row:
|
||||
import json as json_mod
|
||||
ss = sig_row[0] if isinstance(sig_row[0], list) else (json_mod.loads(sig_row[0]) if sig_row[0] else [])
|
||||
ind = sig_row[1] if isinstance(sig_row[1], dict) else (json_mod.loads(sig_row[1]) if sig_row[1] else {})
|
||||
signal_data = {
|
||||
'signal_status': ss,
|
||||
'indicators': ind,
|
||||
'triggered_count': sig_row[2] or 0,
|
||||
}
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"获取数据失败: {e}")
|
||||
|
||||
# 优先从数据库获取当日缓存
|
||||
cached = db_get_fundamental(stock_code)
|
||||
if cached:
|
||||
# 优先使用实时价格表中的PE/PB数据
|
||||
pe_val = realtime_data['pe'] if realtime_data and realtime_data['pe'] else (float(cached['pe']) if cached['pe'] else '')
|
||||
pb_val = realtime_data['pb'] if realtime_data and realtime_data['pb'] else (float(cached['pb']) if cached['pb'] else '')
|
||||
price_val = realtime_data['price'] if realtime_data and realtime_data['price'] else (float(cached['latest_price']) if cached['latest_price'] else '')
|
||||
change_val = realtime_data['change_pct'] if realtime_data and realtime_data['change_pct'] else (float(cached['change_pct']) if cached['change_pct'] else '')
|
||||
market_cap_val = realtime_data['total_market_cap'] if realtime_data and realtime_data['total_market_cap'] else (float(cached['total_market_cap']) if cached['total_market_cap'] else '')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'stock_code': cached['code'],
|
||||
'stock_name': cached['name'],
|
||||
'pe_ttm': pe_val,
|
||||
'pb': pb_val,
|
||||
'total_market_cap': market_cap_val,
|
||||
'industry': cached['industry'] or '',
|
||||
'latest_price': price_val,
|
||||
'change_pct': change_val,
|
||||
'roe': float(cached['roe']) if cached.get('roe') else '',
|
||||
'eps': float(cached['eps']) if cached.get('eps') else '',
|
||||
'bps': float(cached['bps']) if cached.get('bps') else '',
|
||||
'revenue_yoy': float(cached['revenue_yoy']) if cached.get('revenue_yoy') else '',
|
||||
'profit_yoy': float(cached['profit_yoy']) if cached.get('profit_yoy') else '',
|
||||
'gross_margin': float(cached['gross_margin']) if cached.get('gross_margin') else '',
|
||||
'net_margin': float(cached['net_margin']) if cached.get('net_margin') else '',
|
||||
'fund_flow_3days': fund_flow_3days,
|
||||
'signal_data': signal_data,
|
||||
},
|
||||
'source': 'database'
|
||||
})
|
||||
|
||||
# 数据库没有基本面缓存,从实时价格表和API获取
|
||||
result = {
|
||||
'stock_code': stock_code,
|
||||
'stock_name': realtime_data['name'] if realtime_data else (get_stock_name(stock_code) or ''),
|
||||
'pe_ttm': realtime_data['pe'] if realtime_data and realtime_data['pe'] else '',
|
||||
'pb': realtime_data['pb'] if realtime_data and realtime_data['pb'] else '',
|
||||
'total_market_cap': realtime_data['total_market_cap'] if realtime_data and realtime_data['total_market_cap'] else '',
|
||||
'industry': '',
|
||||
'latest_price': realtime_data['price'] if realtime_data and realtime_data['price'] else '',
|
||||
'change_pct': realtime_data['change_pct'] if realtime_data and realtime_data['change_pct'] else '',
|
||||
'roe': '',
|
||||
'eps': '',
|
||||
'bps': '',
|
||||
'revenue_yoy': '',
|
||||
'profit_yoy': '',
|
||||
'gross_margin': '',
|
||||
'net_margin': '',
|
||||
}
|
||||
|
||||
# 优先使用mairuiapi获取数据
|
||||
try:
|
||||
from services.mairui_api import get_realtime_price as mairui_realtime, get_financial_indicators, get_company_info
|
||||
|
||||
# 获取实时价格
|
||||
rt_result = mairui_realtime(stock_code)
|
||||
if rt_result['success']:
|
||||
rt_data = rt_result['data']
|
||||
result['latest_price'] = rt_data.get('price', '')
|
||||
result['change_pct'] = rt_data.get('change', '')
|
||||
result['pe_ttm'] = rt_data.get('pe') or result['pe_ttm']
|
||||
result['pb'] = rt_data.get('pb') or result['pb']
|
||||
result['total_market_cap'] = rt_data.get('total_market_cap') or result['total_market_cap']
|
||||
|
||||
# 获取公司信息
|
||||
company_result = get_company_info(stock_code)
|
||||
if company_result['success']:
|
||||
company_data = company_result['data']
|
||||
result['stock_name'] = company_data.get('name') or result['stock_name']
|
||||
result['industry'] = company_data.get('industry') or result['industry']
|
||||
|
||||
# 获取财务指标
|
||||
fin_result = get_financial_indicators(stock_code)
|
||||
if fin_result['success']:
|
||||
fin_data = fin_result['data']
|
||||
result['eps'] = fin_data.get('eps') or ''
|
||||
result['bps'] = fin_data.get('bps') or ''
|
||||
result['roe'] = fin_data.get('roe') or ''
|
||||
result['gross_margin'] = fin_data.get('gross_margin') or ''
|
||||
result['net_margin'] = fin_data.get('net_margin') or ''
|
||||
result['revenue_yoy'] = fin_data.get('revenue_yoy') or ''
|
||||
result['profit_yoy'] = fin_data.get('profit_yoy') or ''
|
||||
except Exception as e:
|
||||
print(f"mairuiapi获取基本面失败: {e}")
|
||||
|
||||
# 备用方案:先试腾讯API,再试akshare
|
||||
try:
|
||||
import requests as _rq
|
||||
_tc = ('sh' if stock_code.startswith('6') else 'sz') + stock_code
|
||||
_rr = _rq.get(f'http://qt.gtimg.cn/q={_tc}', timeout=5,
|
||||
headers={'Referer': 'https://finance.qq.com'})
|
||||
if _rr.status_code == 200 and '\"' in _rr.text:
|
||||
_ff = _rr.text.split('\"')[1].split('~')
|
||||
if len(_ff) > 46:
|
||||
result['stock_name'] = _ff[1] or result['stock_name']
|
||||
result['latest_price'] = _ff[3]
|
||||
result['total_market_cap'] = f'{float(_ff[45])*100000000:.0f}' if _ff[45].strip() else ''
|
||||
except Exception as e2:
|
||||
print(f"腾讯财经备用方案也失败: {e2}")
|
||||
|
||||
# 保存到数据库缓存
|
||||
try:
|
||||
db_save_fundamental(stock_code, {
|
||||
'name': result['stock_name'],
|
||||
'pe': float(result['pe_ttm']) if result['pe_ttm'] else None,
|
||||
'pb': float(result['pb']) if result['pb'] else None,
|
||||
'total_market_cap': float(result['total_market_cap']) if result['total_market_cap'] else None,
|
||||
'industry': result['industry'],
|
||||
'latest_price': float(result['latest_price']) if result['latest_price'] else None,
|
||||
'change_pct': float(str(result['change_pct']).replace('%', '')) if result['change_pct'] else None,
|
||||
'roe': float(result['roe']) if result['roe'] else None,
|
||||
'eps': float(result['eps']) if result['eps'] else None,
|
||||
'bps': float(result['bps']) if result['bps'] else None,
|
||||
'revenue_yoy': float(result['revenue_yoy']) if result['revenue_yoy'] else None,
|
||||
'profit_yoy': float(result['profit_yoy']) if result['profit_yoy'] else None,
|
||||
'gross_margin': float(result['gross_margin']) if result['gross_margin'] else None,
|
||||
'net_margin': float(result['net_margin']) if result['net_margin'] else None,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"保存基本面缓存失败: {e}")
|
||||
|
||||
result['fund_flow_3days'] = fund_flow_3days
|
||||
result['signal_data'] = signal_data
|
||||
|
||||
return jsonify({'success': True, 'data': result, 'source': 'api'})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,733 @@
|
||||
"""
|
||||
模拟交易 API 路由
|
||||
每个交易日10点根据推荐率最高的买入卖出方案进行自动操作
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, date, time
|
||||
from db import get_db, login_required, get_current_user_id
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
bp = Blueprint('sim_trade', __name__, url_prefix='/api/sim')
|
||||
|
||||
|
||||
def init_user_config(user_id):
|
||||
"""初始化用户模拟交易配置"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return None
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
INSERT INTO sim_config (user_id)
|
||||
VALUES (%s)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
RETURNING *
|
||||
""", (user_id,))
|
||||
conn.commit()
|
||||
|
||||
# 获取配置
|
||||
cur.execute("SELECT * FROM sim_config WHERE user_id = %s", (user_id,))
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/config', methods=['GET'])
|
||||
@login_required
|
||||
def get_config():
|
||||
"""获取模拟交易配置"""
|
||||
user_id = get_current_user_id()
|
||||
config = init_user_config(user_id)
|
||||
|
||||
if config:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'config': {
|
||||
'initial_capital': float(config['initial_capital']),
|
||||
'trade_quantity': config['trade_quantity'],
|
||||
'auto_trade_enabled': config['auto_trade_enabled'],
|
||||
'auto_trade_time': str(config['auto_trade_time']) if config['auto_trade_time'] else '10:00:00'
|
||||
}
|
||||
})
|
||||
return jsonify({'success': False, 'error': '获取配置失败'}), 500
|
||||
|
||||
|
||||
@bp.route('/config', methods=['POST'])
|
||||
@login_required
|
||||
def update_config():
|
||||
"""更新模拟交易配置"""
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE sim_config SET
|
||||
trade_quantity = COALESCE(%s, trade_quantity),
|
||||
auto_trade_enabled = COALESCE(%s, auto_trade_enabled),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = %s
|
||||
""", (
|
||||
data.get('trade_quantity'),
|
||||
data.get('auto_trade_enabled'),
|
||||
user_id
|
||||
))
|
||||
conn.commit()
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/trades', methods=['GET'])
|
||||
@login_required
|
||||
def get_trades():
|
||||
"""获取模拟交易记录"""
|
||||
user_id = get_current_user_id()
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT id, stock_code, stock_name, trade_type,
|
||||
price::float, quantity, trade_date::text,
|
||||
trade_time::text, recommend_rate::float, signal_reason,
|
||||
COALESCE(commission, 0)::float as commission,
|
||||
COALESCE(stamp_tax, 0)::float as stamp_tax,
|
||||
COALESCE(total_fee, 0)::float as total_fee,
|
||||
created_at::text
|
||||
FROM sim_trades
|
||||
WHERE user_id = %s
|
||||
ORDER BY trade_date DESC, trade_time DESC
|
||||
LIMIT %s
|
||||
""", (user_id, limit))
|
||||
trades = cur.fetchall()
|
||||
return jsonify({'success': True, 'trades': trades})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/positions', methods=['GET'])
|
||||
@login_required
|
||||
def get_positions():
|
||||
"""获取模拟持仓"""
|
||||
user_id = get_current_user_id()
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT stock_code, stock_name, quantity,
|
||||
avg_cost::float, total_cost::float,
|
||||
current_price::float, updated_at::text
|
||||
FROM sim_positions
|
||||
WHERE user_id = %s AND quantity > 0
|
||||
ORDER BY total_cost DESC
|
||||
""", (user_id,))
|
||||
positions = cur.fetchall()
|
||||
return jsonify({'success': True, 'positions': positions})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/stats', methods=['GET'])
|
||||
@login_required
|
||||
def get_stats():
|
||||
"""获取模拟交易统计"""
|
||||
user_id = get_current_user_id()
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 获取配置 — 优先使用 sim_algo_config.total_capital(智能交易配置)
|
||||
initial_capital = 200000 # 默认值
|
||||
cur.execute("SELECT total_capital::float FROM sim_algo_config WHERE user_id = %s AND is_active = TRUE", (user_id,))
|
||||
algo_cfg = cur.fetchone()
|
||||
if algo_cfg and algo_cfg['total_capital']:
|
||||
initial_capital = algo_cfg['total_capital']
|
||||
else:
|
||||
cur.execute("SELECT initial_capital::float FROM sim_config WHERE user_id = %s", (user_id,))
|
||||
config = cur.fetchone()
|
||||
if config and config['initial_capital']:
|
||||
initial_capital = config['initial_capital']
|
||||
|
||||
# 获取持仓统计
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COALESCE(SUM(quantity * current_price), 0)::float as total_market_value,
|
||||
COALESCE(SUM(total_cost), 0)::float as total_cost,
|
||||
COALESCE(SUM(quantity * current_price - total_cost), 0)::float as unrealized_profit
|
||||
FROM sim_positions
|
||||
WHERE user_id = %s AND quantity > 0
|
||||
""", (user_id,))
|
||||
position_stats = cur.fetchone()
|
||||
|
||||
# 获取已实现盈亏(卖出交易)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(
|
||||
CASE WHEN trade_type = 'sell' THEN price * quantity ELSE 0 END
|
||||
), 0)::float as total_sell,
|
||||
COUNT(DISTINCT trade_date) as trade_days,
|
||||
COUNT(*) as total_trades,
|
||||
COALESCE(SUM(COALESCE(commission, 0)), 0)::float as total_commission,
|
||||
COALESCE(SUM(COALESCE(stamp_tax, 0)), 0)::float as total_stamp_tax,
|
||||
COALESCE(SUM(COALESCE(total_fee, 0)), 0)::float as total_fees
|
||||
FROM sim_trades
|
||||
WHERE user_id = %s
|
||||
""", (user_id,))
|
||||
trade_stats = cur.fetchone()
|
||||
|
||||
# 计算已实现盈亏(需要更复杂的计算,这里简化处理)
|
||||
# 从每日统计表获取最新的已实现盈亏
|
||||
cur.execute("""
|
||||
SELECT realized_profit::float
|
||||
FROM sim_daily_stats
|
||||
WHERE user_id = %s
|
||||
ORDER BY stat_date DESC
|
||||
LIMIT 1
|
||||
""", (user_id,))
|
||||
daily_stat = cur.fetchone()
|
||||
realized_profit = daily_stat['realized_profit'] if daily_stat else 0
|
||||
|
||||
# 获取历史统计(用于图表)
|
||||
cur.execute("""
|
||||
SELECT stat_date::text, total_profit::float,
|
||||
total_market_value::float, realized_profit::float
|
||||
FROM sim_daily_stats
|
||||
WHERE user_id = %s
|
||||
ORDER BY stat_date DESC
|
||||
LIMIT 30
|
||||
""", (user_id,))
|
||||
history = cur.fetchall()
|
||||
|
||||
total_market_value = position_stats['total_market_value'] or 0
|
||||
total_cost = position_stats['total_cost'] or 0
|
||||
unrealized_profit = position_stats['unrealized_profit'] or 0
|
||||
total_fees = trade_stats['total_fees'] or 0
|
||||
total_commission = trade_stats['total_commission'] or 0
|
||||
total_stamp_tax = trade_stats['total_stamp_tax'] or 0
|
||||
|
||||
# 毛利润(不含手续费的计算)
|
||||
gross_profit = unrealized_profit + realized_profit + total_fees # 加回手续费 = 毛收益
|
||||
# 净利润(含手续费)
|
||||
net_profit = unrealized_profit + realized_profit # realized_profit 已扣除卖出手续费
|
||||
total_profit = net_profit
|
||||
|
||||
# 计算收益率
|
||||
gross_rate = (gross_profit / initial_capital * 100) if initial_capital > 0 else 0
|
||||
net_rate = (net_profit / initial_capital * 100) if initial_capital > 0 else 0
|
||||
profit_rate = net_rate # 默认显示净收益率
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'stats': {
|
||||
'initial_capital': initial_capital,
|
||||
'total_market_value': total_market_value,
|
||||
'total_cost': total_cost,
|
||||
'cash': initial_capital - total_cost + realized_profit,
|
||||
'unrealized_profit': unrealized_profit,
|
||||
'realized_profit': realized_profit,
|
||||
'total_profit': total_profit,
|
||||
'profit_rate': profit_rate,
|
||||
'trade_days': trade_stats['trade_days'] or 0,
|
||||
'total_trades': trade_stats['total_trades'] or 0,
|
||||
# 手续费明细
|
||||
'total_fees': total_fees,
|
||||
'total_commission': total_commission,
|
||||
'total_stamp_tax': total_stamp_tax,
|
||||
# 对比数据: 毛收益 vs 净收益
|
||||
'gross_profit': gross_profit,
|
||||
'gross_rate': gross_rate,
|
||||
'net_profit': net_profit,
|
||||
'net_rate': net_rate,
|
||||
},
|
||||
'history': list(reversed(history)) if history else []
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/execute', methods=['POST'])
|
||||
@login_required
|
||||
def execute_trade():
|
||||
"""执行模拟交易(手动或自动)"""
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
|
||||
stock_code = data.get('stock_code')
|
||||
stock_name = data.get('stock_name', '')
|
||||
trade_type = data.get('trade_type') # 'buy' or 'sell'
|
||||
price = data.get('price')
|
||||
quantity = data.get('quantity', 1000)
|
||||
recommend_rate = data.get('recommend_rate')
|
||||
signal_reason = data.get('signal_reason', '')
|
||||
|
||||
if not stock_code or not trade_type or not price:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
today = date.today()
|
||||
now = datetime.now().time()
|
||||
|
||||
# 1. 记录交易
|
||||
cur.execute("""
|
||||
INSERT INTO sim_trades
|
||||
(user_id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date, trade_time, recommend_rate, signal_reason)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (user_id, stock_code, stock_name, trade_type, price, quantity,
|
||||
today, now, recommend_rate, signal_reason))
|
||||
trade_id = cur.fetchone()['id']
|
||||
|
||||
# 2. 更新持仓
|
||||
if trade_type == 'buy':
|
||||
# 买入:增加持仓
|
||||
cur.execute("""
|
||||
INSERT INTO sim_positions
|
||||
(user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, stock_code) DO UPDATE SET
|
||||
quantity = sim_positions.quantity + EXCLUDED.quantity,
|
||||
total_cost = sim_positions.total_cost + EXCLUDED.total_cost,
|
||||
avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) /
|
||||
(sim_positions.quantity + EXCLUDED.quantity),
|
||||
current_price = EXCLUDED.current_price,
|
||||
stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name),
|
||||
updated_at = NOW()
|
||||
""", (user_id, stock_code, stock_name, quantity, price,
|
||||
price * quantity, price))
|
||||
else:
|
||||
# 卖出:减少持仓,计算已实现盈亏
|
||||
cur.execute("""
|
||||
SELECT quantity, avg_cost::float, total_cost::float
|
||||
FROM sim_positions
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
""", (user_id, stock_code))
|
||||
position = cur.fetchone()
|
||||
|
||||
if not position or position['quantity'] < quantity:
|
||||
conn.rollback()
|
||||
return jsonify({'success': False, 'error': '持仓不足'}), 400
|
||||
|
||||
# 计算已实现盈亏
|
||||
avg_cost = position['avg_cost']
|
||||
realized_pnl = (price - avg_cost) * quantity
|
||||
|
||||
# 更新持仓
|
||||
new_quantity = position['quantity'] - quantity
|
||||
new_total_cost = position['total_cost'] - (avg_cost * quantity)
|
||||
|
||||
if new_quantity > 0:
|
||||
cur.execute("""
|
||||
UPDATE sim_positions SET
|
||||
quantity = %s,
|
||||
total_cost = %s,
|
||||
current_price = %s,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
""", (new_quantity, new_total_cost, price, user_id, stock_code))
|
||||
else:
|
||||
# 清仓
|
||||
cur.execute("""
|
||||
UPDATE sim_positions SET
|
||||
quantity = 0,
|
||||
total_cost = 0,
|
||||
current_price = %s,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
""", (price, user_id, stock_code))
|
||||
|
||||
# 更新每日统计中的已实现盈亏
|
||||
cur.execute("""
|
||||
INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count)
|
||||
VALUES (%s, %s, %s, 1)
|
||||
ON CONFLICT (user_id, stat_date) DO UPDATE SET
|
||||
realized_profit = sim_daily_stats.realized_profit + %s,
|
||||
trade_count = sim_daily_stats.trade_count + 1
|
||||
""", (user_id, today, realized_pnl, realized_pnl))
|
||||
|
||||
conn.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'trade_id': trade_id,
|
||||
'message': f'{"买入" if trade_type == "buy" else "卖出"} {stock_name or stock_code} {quantity}股 成功'
|
||||
})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/auto_execute', methods=['POST'])
|
||||
@login_required
|
||||
def auto_execute():
|
||||
"""根据推荐率自动执行交易(每日10点调用)"""
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
|
||||
# 获取推荐的买入和卖出信号
|
||||
buy_signals = data.get('buy_signals', []) # 按推荐率排序的买入信号
|
||||
sell_signals = data.get('sell_signals', []) # 按推荐率排序的卖出信号
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 获取配置
|
||||
cur.execute("SELECT trade_quantity FROM sim_config WHERE user_id = %s", (user_id,))
|
||||
config = cur.fetchone()
|
||||
quantity = config['trade_quantity'] if config else 1000
|
||||
|
||||
today = date.today()
|
||||
now = datetime.now().time()
|
||||
results = []
|
||||
|
||||
# 1. 先处理卖出信号(释放资金)
|
||||
for signal in sell_signals:
|
||||
stock_code = signal.get('code')
|
||||
|
||||
# 检查是否有持仓
|
||||
cur.execute("""
|
||||
SELECT quantity FROM sim_positions
|
||||
WHERE user_id = %s AND stock_code = %s AND quantity > 0
|
||||
""", (user_id, stock_code))
|
||||
position = cur.fetchone()
|
||||
|
||||
if position and position['quantity'] >= quantity:
|
||||
# 执行卖出
|
||||
price = signal.get('price', 0)
|
||||
if price > 0:
|
||||
# 获取持仓成本
|
||||
cur.execute("""
|
||||
SELECT avg_cost::float FROM sim_positions
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
""", (user_id, stock_code))
|
||||
pos_info = cur.fetchone()
|
||||
avg_cost = pos_info['avg_cost'] if pos_info else price
|
||||
realized_pnl = (price - avg_cost) * quantity
|
||||
|
||||
# 记录交易
|
||||
cur.execute("""
|
||||
INSERT INTO sim_trades
|
||||
(user_id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date, trade_time, recommend_rate, signal_reason)
|
||||
VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s)
|
||||
""", (user_id, stock_code, signal.get('name', ''), price, quantity,
|
||||
today, now, signal.get('recommendRate'), signal.get('reason', '')))
|
||||
|
||||
# 更新持仓
|
||||
cur.execute("""
|
||||
UPDATE sim_positions SET
|
||||
quantity = quantity - %s,
|
||||
total_cost = total_cost - (avg_cost * %s),
|
||||
current_price = %s,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
""", (quantity, quantity, price, user_id, stock_code))
|
||||
|
||||
# 更新已实现盈亏
|
||||
cur.execute("""
|
||||
INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count)
|
||||
VALUES (%s, %s, %s, 1)
|
||||
ON CONFLICT (user_id, stat_date) DO UPDATE SET
|
||||
realized_profit = sim_daily_stats.realized_profit + %s,
|
||||
trade_count = sim_daily_stats.trade_count + 1
|
||||
""", (user_id, today, realized_pnl, realized_pnl))
|
||||
|
||||
results.append({
|
||||
'type': 'sell',
|
||||
'code': stock_code,
|
||||
'name': signal.get('name', ''),
|
||||
'price': price,
|
||||
'quantity': quantity,
|
||||
'pnl': realized_pnl
|
||||
})
|
||||
|
||||
# 2. 处理买入信号(取推荐率最高的)
|
||||
for signal in buy_signals[:3]: # 最多买入3只
|
||||
stock_code = signal.get('code')
|
||||
price = signal.get('price', 0)
|
||||
|
||||
if price > 0:
|
||||
# 检查今日是否已买入该股票
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as cnt FROM sim_trades
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
AND trade_date = %s AND trade_type = 'buy'
|
||||
""", (user_id, stock_code, today))
|
||||
if cur.fetchone()['cnt'] > 0:
|
||||
continue # 今日已买入,跳过
|
||||
|
||||
# 记录交易
|
||||
cur.execute("""
|
||||
INSERT INTO sim_trades
|
||||
(user_id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date, trade_time, recommend_rate, signal_reason)
|
||||
VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s)
|
||||
""", (user_id, stock_code, signal.get('name', ''), price, quantity,
|
||||
today, now, signal.get('recommendRate'), signal.get('reason', '')))
|
||||
|
||||
# 更新持仓
|
||||
cur.execute("""
|
||||
INSERT INTO sim_positions
|
||||
(user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, stock_code) DO UPDATE SET
|
||||
quantity = sim_positions.quantity + EXCLUDED.quantity,
|
||||
total_cost = sim_positions.total_cost + EXCLUDED.total_cost,
|
||||
avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) /
|
||||
(sim_positions.quantity + EXCLUDED.quantity),
|
||||
current_price = EXCLUDED.current_price,
|
||||
stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name),
|
||||
updated_at = NOW()
|
||||
""", (user_id, stock_code, signal.get('name', ''), quantity, price,
|
||||
price * quantity, price))
|
||||
|
||||
results.append({
|
||||
'type': 'buy',
|
||||
'code': stock_code,
|
||||
'name': signal.get('name', ''),
|
||||
'price': price,
|
||||
'quantity': quantity
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': results,
|
||||
'message': f'自动交易完成: 买入{len([r for r in results if r["type"]=="buy"])}笔, 卖出{len([r for r in results if r["type"]=="sell"])}笔'
|
||||
})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/update_prices', methods=['POST'])
|
||||
@login_required
|
||||
def update_prices():
|
||||
"""更新持仓的当前价格"""
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
prices = data.get('prices', {}) # {stock_code: price}
|
||||
|
||||
if not prices:
|
||||
return jsonify({'success': True, 'message': '无需更新'})
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for code, price in prices.items():
|
||||
cur.execute("""
|
||||
UPDATE sim_positions SET
|
||||
current_price = %s,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
""", (price, user_id, code))
|
||||
|
||||
# 更新每日统计
|
||||
today = date.today()
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COALESCE(SUM(quantity * current_price), 0) as market_value,
|
||||
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||
COALESCE(SUM(quantity * current_price - total_cost), 0) as unrealized
|
||||
FROM sim_positions
|
||||
WHERE user_id = %s AND quantity > 0
|
||||
""", (user_id,))
|
||||
stats = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO sim_daily_stats
|
||||
(user_id, stat_date, total_market_value, total_cost, unrealized_profit)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, stat_date) DO UPDATE SET
|
||||
total_market_value = EXCLUDED.total_market_value,
|
||||
total_cost = EXCLUDED.total_cost,
|
||||
unrealized_profit = EXCLUDED.unrealized_profit
|
||||
""", (user_id, today, stats[0], stats[1], stats[2]))
|
||||
|
||||
conn.commit()
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/reset', methods=['POST'])
|
||||
@login_required
|
||||
def reset_simulation():
|
||||
"""重置模拟交易(清空所有数据)"""
|
||||
user_id = get_current_user_id()
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM sim_trades WHERE user_id = %s", (user_id,))
|
||||
cur.execute("DELETE FROM sim_positions WHERE user_id = %s", (user_id,))
|
||||
cur.execute("DELETE FROM sim_daily_stats WHERE user_id = %s", (user_id,))
|
||||
conn.commit()
|
||||
return jsonify({'success': True, 'message': '模拟交易已重置'})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/trigger_trade', methods=['POST'])
|
||||
@login_required
|
||||
def trigger_trade():
|
||||
"""手动触发交易 — 优先使用智能引擎,降级到旧引擎"""
|
||||
user_id = get_current_user_id()
|
||||
|
||||
try:
|
||||
# 优先使用智能引擎
|
||||
try:
|
||||
from services.smart_trade_engine import execute_smart_trade
|
||||
conn = get_db()
|
||||
if conn:
|
||||
result = execute_smart_trade(conn, user_id, scan_date=None)
|
||||
conn.close()
|
||||
if result.get('success'):
|
||||
results = result.get('results', [])
|
||||
buy_count = len([r for r in results if r['type'] == 'buy'])
|
||||
sell_count = len([r for r in results if r['type'] in ('sell', 'partial_sell')])
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': results,
|
||||
'algo': result.get('algo', 'unknown'),
|
||||
'message': f"智能引擎[{result.get('algo','?')}]: 买入{buy_count}笔, 卖出{sell_count}笔"
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[trigger_trade] 智能引擎异常,降级: {e}")
|
||||
|
||||
# 降级: 使用旧引擎
|
||||
from services.scheduler import execute_auto_trade_for_user
|
||||
|
||||
conn = get_db()
|
||||
if conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("SELECT trade_quantity FROM sim_config WHERE user_id = %s", (user_id,))
|
||||
config = cur.fetchone()
|
||||
trade_quantity = config['trade_quantity'] if config else 1000
|
||||
conn.close()
|
||||
else:
|
||||
trade_quantity = 1000
|
||||
|
||||
result = execute_auto_trade_for_user(user_id, trade_quantity)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify({'success': False, 'error': result['error']}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': result.get('results', []),
|
||||
'message': f"自动交易完成: 买入{len([r for r in result.get('results', []) if r['type']=='buy'])}笔, 卖出{len([r for r in result.get('results', []) if r['type']=='sell'])}笔"
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/trigger_update', methods=['POST'])
|
||||
@login_required
|
||||
def trigger_update():
|
||||
"""手动触发价格更新(用于测试)"""
|
||||
user_id = get_current_user_id()
|
||||
|
||||
try:
|
||||
from services.scheduler import update_positions_price_for_user
|
||||
update_positions_price_for_user(user_id)
|
||||
return jsonify({'success': True, 'message': '持仓价格已更新'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/today_trades', methods=['GET'])
|
||||
@login_required
|
||||
def get_today_trades():
|
||||
"""获取今日交易记录"""
|
||||
user_id = get_current_user_id()
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
today = date.today()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, stock_code, stock_name, trade_type,
|
||||
price::float, quantity, trade_date::text,
|
||||
trade_time::text, recommend_rate::float, signal_reason,
|
||||
COALESCE(commission, 0)::float as commission,
|
||||
COALESCE(stamp_tax, 0)::float as stamp_tax,
|
||||
COALESCE(total_fee, 0)::float as total_fee,
|
||||
created_at::text
|
||||
FROM sim_trades
|
||||
WHERE user_id = %s AND trade_date = %s
|
||||
ORDER BY trade_time DESC
|
||||
""", (user_id, today))
|
||||
trades = cur.fetchall()
|
||||
|
||||
# 计算今日盈亏
|
||||
cur.execute("""
|
||||
SELECT realized_profit::float, trade_count
|
||||
FROM sim_daily_stats
|
||||
WHERE user_id = %s AND stat_date = %s
|
||||
""", (user_id, today))
|
||||
stats = cur.fetchone()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'trades': trades,
|
||||
'today_stats': {
|
||||
'realized_profit': stats['realized_profit'] if stats else 0,
|
||||
'trade_count': stats['trade_count'] if stats else 0
|
||||
}
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
智能交易引擎 API 路由
|
||||
提供算法配置管理、引擎状态查看、手动触发等功能
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import date
|
||||
from db import get_db, login_required, get_current_user_id
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
bp = Blueprint('smart_trade', __name__, url_prefix='/api/smart')
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 1. 算法模板
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@bp.route('/templates', methods=['GET'])
|
||||
@login_required
|
||||
def get_algo_templates():
|
||||
"""获取所有预置算法模板"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT id, name, display_name, description, risk_level,
|
||||
take_profit_pct::float, stop_loss_pct::float,
|
||||
ignore_sell_signal, sell_confirm_days,
|
||||
max_hold_days, no_timeout_if_rising,
|
||||
position_pct::float, signal_weight,
|
||||
partial_exit_pct, momentum_trail_gap::float,
|
||||
breakeven_at::float, momentum_tp, momentum_days,
|
||||
buy_time, sell_time,
|
||||
backtest_annual_return::float, backtest_max_drawdown::float,
|
||||
backtest_win_rate::float, backtest_calmar::float
|
||||
FROM algo_templates
|
||||
ORDER BY backtest_calmar DESC NULLS LAST
|
||||
""")
|
||||
templates = cur.fetchall()
|
||||
return jsonify({'success': True, 'templates': templates})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 2. 用户算法配置 (CRUD)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@bp.route('/config', methods=['GET'])
|
||||
@login_required
|
||||
def get_algo_config():
|
||||
"""获取用户当前的算法配置"""
|
||||
user_id = get_current_user_id()
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from services.smart_trade_engine import get_user_algo_config, DEFAULT_CONFIG
|
||||
config = get_user_algo_config(conn, user_id)
|
||||
|
||||
# 判断是否是默认配置(没有存入数据库)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("SELECT COUNT(*) as cnt FROM sim_algo_config WHERE user_id = %s", (user_id,))
|
||||
has_config = cur.fetchone()['cnt'] > 0
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'config': config,
|
||||
'is_default': not has_config,
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/config', methods=['POST'])
|
||||
@login_required
|
||||
def save_algo_config():
|
||||
"""保存/更新用户的算法配置"""
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': '无效参数'}), 400
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from services.smart_trade_engine import save_user_algo_config, DEFAULT_CONFIG
|
||||
|
||||
# 合并默认值
|
||||
config = dict(DEFAULT_CONFIG)
|
||||
for key in config:
|
||||
if key in data:
|
||||
config[key] = data[key]
|
||||
|
||||
save_user_algo_config(conn, user_id, config)
|
||||
return jsonify({'success': True, 'message': '算法配置已保存'})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route('/apply_template', methods=['POST'])
|
||||
@login_required
|
||||
def apply_template():
|
||||
"""从模板应用算法配置"""
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
template_name = data.get('template_name')
|
||||
if not template_name:
|
||||
return jsonify({'success': False, 'error': '缺少template_name'}), 400
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from services.smart_trade_engine import apply_template as do_apply
|
||||
ok = do_apply(conn, user_id, template_name)
|
||||
if ok:
|
||||
return jsonify({'success': True, 'message': f'已应用模板: {template_name}'})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': f'模板不存在: {template_name}'}), 404
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 3. 引擎状态
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@bp.route('/status', methods=['GET'])
|
||||
@login_required
|
||||
def get_status():
|
||||
"""获取智能交易引擎的当前状态(含持仓详情+活跃规则+信号日志)"""
|
||||
user_id = get_current_user_id()
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from services.smart_trade_engine import get_engine_status
|
||||
status = get_engine_status(conn, user_id)
|
||||
return jsonify({'success': True, **status})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 4. 手动触发
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@bp.route('/trigger', methods=['POST'])
|
||||
@login_required
|
||||
def trigger_smart_trade():
|
||||
"""手动触发智能交易引擎执行"""
|
||||
user_id = get_current_user_id()
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from services.smart_trade_engine import execute_smart_trade
|
||||
result = execute_smart_trade(conn, user_id, scan_date=None)
|
||||
|
||||
if result.get('error') and not result.get('success'):
|
||||
return jsonify({'success': False, 'error': result['error']}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': result.get('results', []),
|
||||
'signals': result.get('signals', 0),
|
||||
'algo': result.get('algo', 'unknown'),
|
||||
'total_fees': result.get('total_fees', 0),
|
||||
'detail_reasons': result.get('detail_reasons', []),
|
||||
'skipped_limit': result.get('skipped_limit', []),
|
||||
'skipped_t1': result.get('skipped_t1', []),
|
||||
'available_cash': result.get('available_cash', 0),
|
||||
'message': f"智能引擎执行完成: {result.get('signals', 0)}笔信号"
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 5. 信号日志
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@bp.route('/signals', methods=['GET'])
|
||||
@login_required
|
||||
def get_signals():
|
||||
"""获取交易信号日志"""
|
||||
user_id = get_current_user_id()
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
days = request.args.get('days', 7, type=int)
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT id, signal_date::text, signal_time::text,
|
||||
stock_code, stock_name, action, reason, algo_rule,
|
||||
signal_price::float, buy_price::float, profit_pct::float,
|
||||
executed, execute_price::float, execute_shares,
|
||||
created_at::text
|
||||
FROM sim_trade_signals
|
||||
WHERE user_id = %s AND signal_date >= CURRENT_DATE - %s
|
||||
ORDER BY signal_date DESC, id DESC
|
||||
LIMIT %s
|
||||
""", (user_id, days, limit))
|
||||
signals = cur.fetchall()
|
||||
return jsonify({'success': True, 'signals': signals})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 6. 持仓元数据(前端持仓详情扩展)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@bp.route('/position_meta', methods=['GET'])
|
||||
@login_required
|
||||
def get_position_meta():
|
||||
"""获取持仓的算法元数据(止盈止损状态等)"""
|
||||
user_id = get_current_user_id()
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
|
||||
|
||||
try:
|
||||
from services.smart_trade_engine import get_all_position_meta
|
||||
positions = get_all_position_meta(conn, user_id)
|
||||
result = []
|
||||
for p in positions:
|
||||
result.append({
|
||||
'stock_code': p['stock_code'],
|
||||
'buy_date': str(p.get('buy_date', '')),
|
||||
'buy_price': float(p.get('buy_price', 0)),
|
||||
'days_held': p.get('days_held', 0),
|
||||
'max_price': float(p.get('max_price_since_buy', 0) or 0),
|
||||
'consecutive_up_days': p.get('consecutive_up_days', 0),
|
||||
'consecutive_sell_signals': p.get('consecutive_sell_signals', 0),
|
||||
'partial_exit_done': p.get('partial_exit_done', False),
|
||||
'breakeven_active': p.get('breakeven_active', False),
|
||||
'momentum_trailing_active': p.get('momentum_trailing_active', False),
|
||||
'momentum_high_price': float(p.get('momentum_high_price', 0) or 0),
|
||||
'current_shares': p.get('current_shares', 0),
|
||||
'original_shares': p.get('original_shares', 0),
|
||||
})
|
||||
return jsonify({'success': True, 'positions': result})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
交易记录 API 路由(纯数据库版)
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, session
|
||||
from db import (
|
||||
login_required, get_current_user_id,
|
||||
db_get_trades, db_get_trade, db_add_trade, db_update_trade, db_delete_trade,
|
||||
db_get_available_cash, db_update_available_cash
|
||||
)
|
||||
|
||||
bp = Blueprint('trades', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
@bp.route('/trades', methods=['GET'])
|
||||
@login_required
|
||||
def get_trades():
|
||||
"""获取交易记录"""
|
||||
user_id = get_current_user_id()
|
||||
trades = db_get_trades(user_id)
|
||||
return jsonify({'success': True, 'trades': trades})
|
||||
|
||||
|
||||
@bp.route('/trades', methods=['POST'])
|
||||
@login_required
|
||||
def add_trade():
|
||||
"""添加交易记录"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
|
||||
# 处理数值(空字符串转为None)
|
||||
def parse_float(val):
|
||||
if val is None or val == '':
|
||||
return None
|
||||
try:
|
||||
return round(float(val), 4)
|
||||
except:
|
||||
return None
|
||||
|
||||
def parse_int(val):
|
||||
if val is None or val == '':
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except:
|
||||
return None
|
||||
|
||||
data['price'] = parse_float(data.get('price'))
|
||||
data['quantity'] = parse_int(data.get('quantity'))
|
||||
data['profit_amount'] = parse_float(data.get('profit_amount'))
|
||||
data['stop_loss_price'] = parse_float(data.get('stop_loss_price'))
|
||||
|
||||
trade, error = db_add_trade(user_id, data)
|
||||
if error:
|
||||
return jsonify({'success': False, 'error': error}), 400
|
||||
|
||||
# 根据交易类型自动更新可用资金
|
||||
trade_type = (data.get('trade_type') or '').lower()
|
||||
price = data.get('price')
|
||||
quantity = data.get('quantity')
|
||||
if trade_type in ('buy', 'sell') and price is not None and quantity is not None:
|
||||
amount = round(float(price) * int(quantity), 2)
|
||||
current = db_get_available_cash(user_id)
|
||||
if trade_type == 'buy':
|
||||
new_cash = round(current - amount, 2)
|
||||
else:
|
||||
new_cash = round(current + amount, 2)
|
||||
if new_cash < 0:
|
||||
new_cash = 0
|
||||
ok, _ = db_update_available_cash(user_id, new_cash)
|
||||
if ok:
|
||||
return jsonify({'success': True, 'trade': trade, 'available_cash': new_cash})
|
||||
|
||||
return jsonify({'success': True, 'trade': trade})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
|
||||
@bp.route('/trades/<int:trade_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_trade(trade_id):
|
||||
"""更新交易记录"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
|
||||
# 处理数值(空字符串转为None)
|
||||
def parse_float(val):
|
||||
if val is None or val == '':
|
||||
return None
|
||||
try:
|
||||
return round(float(val), 4)
|
||||
except:
|
||||
return None
|
||||
|
||||
def parse_int(val):
|
||||
if val is None or val == '':
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except:
|
||||
return None
|
||||
|
||||
if 'price' in data:
|
||||
data['price'] = parse_float(data.get('price'))
|
||||
if 'quantity' in data:
|
||||
data['quantity'] = parse_int(data.get('quantity'))
|
||||
if 'profit_amount' in data:
|
||||
data['profit_amount'] = parse_float(data.get('profit_amount'))
|
||||
if 'stop_loss_price' in data:
|
||||
data['stop_loss_price'] = parse_float(data.get('stop_loss_price'))
|
||||
|
||||
old_trade = db_get_trade(user_id, trade_id)
|
||||
if not old_trade:
|
||||
return jsonify({'error': '交易记录不存在'}), 404
|
||||
|
||||
trade, error = db_update_trade(user_id, trade_id, data)
|
||||
if error:
|
||||
return jsonify({'success': False, 'error': error}), 400
|
||||
if not trade:
|
||||
return jsonify({'error': '交易记录不存在'}), 404
|
||||
|
||||
# 根据修改同步调整可用资金:先回滚旧交易,再应用新交易
|
||||
def trade_amount(t):
|
||||
p, q = (t.get('price') or 0), (t.get('quantity') or 0)
|
||||
return round(float(p) * int(q), 2) if p and q else 0
|
||||
old_amt = trade_amount(old_trade)
|
||||
new_amt = trade_amount(trade)
|
||||
old_type = (old_trade.get('trade_type') or '').lower()
|
||||
new_type = (trade.get('trade_type') or '').lower()
|
||||
delta = 0
|
||||
if old_type == 'buy':
|
||||
delta += old_amt
|
||||
elif old_type == 'sell':
|
||||
delta -= old_amt
|
||||
if new_type == 'buy':
|
||||
delta -= new_amt
|
||||
elif new_type == 'sell':
|
||||
delta += new_amt
|
||||
if delta != 0:
|
||||
current = db_get_available_cash(user_id)
|
||||
new_cash = max(0, round(current + delta, 2))
|
||||
ok, _ = db_update_available_cash(user_id, new_cash)
|
||||
if ok:
|
||||
return jsonify({'success': True, 'trade': trade, 'available_cash': new_cash})
|
||||
|
||||
return jsonify({'success': True, 'trade': trade})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
|
||||
@bp.route('/trades/<int:trade_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_trade(trade_id):
|
||||
"""删除交易记录"""
|
||||
user_id = get_current_user_id()
|
||||
old_trade = db_get_trade(user_id, trade_id)
|
||||
if not old_trade:
|
||||
return jsonify({'success': False, 'error': '交易记录不存在'}), 404
|
||||
success = db_delete_trade(user_id, trade_id)
|
||||
if not success:
|
||||
return jsonify({'success': False}), 400
|
||||
# 回滚该交易对可用资金的影响
|
||||
t_type = (old_trade.get('trade_type') or '').lower()
|
||||
amount = round(float(old_trade.get('price') or 0) * int(old_trade.get('quantity') or 0), 2)
|
||||
delta = amount if t_type == 'buy' else -amount
|
||||
if delta != 0:
|
||||
current = db_get_available_cash(user_id)
|
||||
new_cash = max(0, round(current + delta, 2))
|
||||
db_update_available_cash(user_id, new_cash)
|
||||
return jsonify({'success': True, 'available_cash': new_cash})
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@bp.route('/available_cash', methods=['GET'])
|
||||
@login_required
|
||||
def get_available_cash():
|
||||
"""获取可用资金"""
|
||||
user_id = get_current_user_id()
|
||||
cash = db_get_available_cash(user_id)
|
||||
return jsonify({'success': True, 'available_cash': cash})
|
||||
|
||||
|
||||
@bp.route('/available_cash', methods=['PUT'])
|
||||
@login_required
|
||||
def update_available_cash():
|
||||
"""更新可用资金"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
amount = data.get('amount')
|
||||
|
||||
if amount is None:
|
||||
return jsonify({'success': False, 'error': '金额不能为空'}), 400
|
||||
|
||||
try:
|
||||
amount = round(float(amount), 2)
|
||||
except:
|
||||
return jsonify({'success': False, 'error': '金额格式错误'}), 400
|
||||
|
||||
success, error = db_update_available_cash(user_id, amount)
|
||||
if error:
|
||||
return jsonify({'success': False, 'error': error}), 400
|
||||
|
||||
return jsonify({'success': True, 'available_cash': amount})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
|
||||
|
||||
@bp.route('/stoploss_check', methods=['GET'])
|
||||
@login_required
|
||||
def check_stoploss():
|
||||
"""检查止损线触发情况"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
trades = db_get_trades(user_id)
|
||||
|
||||
# 计算每只股票的持仓情况
|
||||
holdings = {}
|
||||
for trade in trades:
|
||||
code = trade.get('stock_code')
|
||||
if not code:
|
||||
continue
|
||||
if code not in holdings:
|
||||
holdings[code] = {
|
||||
'code': code,
|
||||
'name': trade.get('stock_name', code),
|
||||
'total_cost': 0,
|
||||
'total_quantity': 0,
|
||||
'stop_loss_price': trade.get('stop_loss_price'),
|
||||
}
|
||||
|
||||
trade_type = trade.get('trade_type')
|
||||
quantity = int(trade.get('quantity', 0) or 0)
|
||||
price = float(trade.get('price', 0) or 0)
|
||||
|
||||
if trade_type == 'buy':
|
||||
holdings[code]['total_cost'] += price * quantity
|
||||
holdings[code]['total_quantity'] += quantity
|
||||
elif trade_type == 'sell':
|
||||
holdings[code]['total_quantity'] -= quantity
|
||||
if holdings[code]['total_quantity'] > 0:
|
||||
cost_per_share = holdings[code]['total_cost'] / (holdings[code]['total_quantity'] + quantity)
|
||||
holdings[code]['total_cost'] -= cost_per_share * quantity
|
||||
|
||||
if trade.get('stop_loss_price'):
|
||||
holdings[code]['stop_loss_price'] = trade.get('stop_loss_price')
|
||||
|
||||
# 只保留有持仓的股票
|
||||
active_holdings = {k: v for k, v in holdings.items() if v['total_quantity'] > 0}
|
||||
|
||||
# 获取实时价格并检查止损
|
||||
alerts = []
|
||||
try:
|
||||
for code, holding in active_holdings.items():
|
||||
try:
|
||||
# 优先使用腾讯财经API(兼容腾讯云)
|
||||
current_price = 0
|
||||
try:
|
||||
import requests as _rq
|
||||
_tc = ('sh' if code.startswith('6') else 'sz') + code
|
||||
_rr = _rq.get(f'http://qt.gtimg.cn/q={_tc}', timeout=5,
|
||||
headers={'Referer': 'https://finance.qq.com'})
|
||||
if _rr.status_code == 200 and '\"' in _rr.text:
|
||||
_ff = _rr.text.split('\"')[1].split('~')
|
||||
if len(_ff) > 3 and _ff[3]:
|
||||
current_price = float(_ff[3])
|
||||
except Exception:
|
||||
pass
|
||||
if current_price > 0:
|
||||
stop_loss_price = holding.get('stop_loss_price')
|
||||
avg_cost = float(holding['total_cost']) / holding['total_quantity'] if holding['total_quantity'] > 0 else 0.0
|
||||
|
||||
profit_loss = (current_price - avg_cost) * holding['total_quantity']
|
||||
profit_percent = ((current_price - avg_cost) / avg_cost * 100) if avg_cost > 0 else 0
|
||||
|
||||
alert_data = {
|
||||
'code': code,
|
||||
'name': holding['name'],
|
||||
'current_price': current_price,
|
||||
'avg_cost': round(avg_cost, 4),
|
||||
'quantity': holding['total_quantity'],
|
||||
'profit_loss': round(profit_loss, 2),
|
||||
'profit_percent': round(profit_percent, 2),
|
||||
'stop_loss_price': stop_loss_price,
|
||||
'triggered': False
|
||||
}
|
||||
|
||||
if stop_loss_price and current_price <= stop_loss_price:
|
||||
alert_data['triggered'] = True
|
||||
alert_data['alert_type'] = 'stop_loss'
|
||||
alert_data['message'] = f"⚠️ {holding['name']} 触发止损!"
|
||||
elif profit_percent <= -5:
|
||||
alert_data['triggered'] = True
|
||||
alert_data['alert_type'] = 'default_stop'
|
||||
alert_data['message'] = f"⚠️ {holding['name']} 跌破成本5%!"
|
||||
|
||||
alerts.append(alert_data)
|
||||
except Exception as e:
|
||||
print(f"获取 {code} 价格失败: {e}")
|
||||
except Exception as e:
|
||||
print(f"获取实时行情失败: {e}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'holdings': list(active_holdings.values()),
|
||||
'alerts': [a for a in alerts if a.get('triggered')],
|
||||
'all_positions': alerts
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"止损检查错误: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
关注列表 API 路由(纯数据库版)
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from db import (
|
||||
login_required, get_current_user_id,
|
||||
db_get_watchlist, db_add_to_watchlist, db_remove_from_watchlist
|
||||
)
|
||||
|
||||
bp = Blueprint('watchlist', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
@bp.route('/watchlist', methods=['GET'])
|
||||
@login_required
|
||||
def get_watchlist():
|
||||
"""获取关注列表"""
|
||||
user_id = get_current_user_id()
|
||||
watchlist = db_get_watchlist(user_id)
|
||||
return jsonify({'success': True, 'watchlist': watchlist})
|
||||
|
||||
|
||||
@bp.route('/watchlist', methods=['POST'])
|
||||
@login_required
|
||||
def add_to_watchlist():
|
||||
"""添加到关注列表"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
data = request.get_json()
|
||||
code = data.get('code')
|
||||
name = data.get('name', f'股票{code}')
|
||||
|
||||
watchlist, error = db_add_to_watchlist(user_id, code, name)
|
||||
if error:
|
||||
return jsonify({'success': False, 'error': error}), 400
|
||||
|
||||
return jsonify({'success': True, 'watchlist': watchlist})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
|
||||
@bp.route('/watchlist/<stock_code>', methods=['DELETE'])
|
||||
@login_required
|
||||
def remove_from_watchlist(stock_code):
|
||||
"""从关注列表移除"""
|
||||
user_id = get_current_user_id()
|
||||
watchlist = db_remove_from_watchlist(user_id, stock_code)
|
||||
return jsonify({'success': True, 'watchlist': watchlist or []})
|
||||
@@ -0,0 +1,300 @@
|
||||
# 股票投资分析系统 - 运行指南
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 本地开发
|
||||
```bash
|
||||
cd /Users/freedak/Documents/go-new/stock/stock-html
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 启动服务
|
||||
python app.py
|
||||
```
|
||||
|
||||
访问地址:http://localhost:3333
|
||||
|
||||
### 服务器部署
|
||||
|
||||
#### 主服务器(原有)
|
||||
- **服务器地址**:43.135.128.39(腾讯云)
|
||||
- **登录用户**:ubuntu(需sudo)
|
||||
- **部署路径**:/opt/stock-app
|
||||
- **外部访问**:http://43.135.128.39:3333
|
||||
|
||||
#### 新服务器(stock.allbyai.cn)
|
||||
- **服务器地址**:152.136.182.184(腾讯云)
|
||||
- **登录用户**:ubuntu(需sudo)
|
||||
- **部署路径**:/opt/stock-app
|
||||
- **域名访问**:http://stock.allbyai.cn
|
||||
- **IP直连**:http://152.136.182.184:3333
|
||||
|
||||
#### 同步代码到服务器
|
||||
**注意**:本地修改代码后请执行下方同步并重启,使服务器生效。
|
||||
|
||||
```bash
|
||||
# 同步全部代码(推荐)
|
||||
cd /Users/freedak/Documents/go-new/stock/stock-html
|
||||
rsync -avz ./ ubuntu@43.135.128.39:/opt/stock-app/ \
|
||||
--exclude='.git' \
|
||||
--exclude='venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='stock_data_cache' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='stock_names.json' \
|
||||
--exclude='alerts_cache.json' \
|
||||
--exclude='trades.json' \
|
||||
--exclude='watchlist.json' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.playwright-mcp' \
|
||||
--exclude='/app.js' \
|
||||
--exclude='/index.html' \
|
||||
--exclude='/main.css' \
|
||||
--exclude='/css' \
|
||||
--exclude='/js' \
|
||||
--exclude='/.windsurfrules'
|
||||
|
||||
# 仅同步前端
|
||||
rsync -avz templates/ ubuntu@43.135.128.39:/opt/stock-app/templates/
|
||||
rsync -avz static/ ubuntu@43.135.128.39:/opt/stock-app/static/
|
||||
|
||||
# 仅同步后端
|
||||
rsync -avz app.py routes/ services/ ubuntu@43.135.128.39:/opt/stock-app/
|
||||
|
||||
# 重启服务
|
||||
ssh ubuntu@43.135.128.39 "systemctl restart stock-app"
|
||||
|
||||
# ========== 新服务器 stock.allbyai.cn ==========
|
||||
# 同步全部代码到新服务器
|
||||
cd /Users/freedak/Documents/go-new/stock/stock-html
|
||||
rsync -avz ./ ubuntu@152.136.182.184:/opt/stock-app/ \
|
||||
--exclude='.git' \
|
||||
--exclude='venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='stock_data_cache' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='stock_names.json' \
|
||||
--exclude='alerts_cache.json' \
|
||||
--exclude='trades.json' \
|
||||
--exclude='watchlist.json' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.playwright-mcp' \
|
||||
--exclude='/app.js' \
|
||||
--exclude='/index.html' \
|
||||
--exclude='/main.css' \
|
||||
--exclude='/css' \
|
||||
--exclude='/js' \
|
||||
--exclude='/.windsurfrules'
|
||||
|
||||
# 重启新服务器服务
|
||||
ssh ubuntu@152.136.182.184 "systemctl restart stock-app"
|
||||
|
||||
# 或使用一键脚本
|
||||
./deploy/sync-to-new-server.sh
|
||||
```
|
||||
|
||||
#### 服务管理
|
||||
```bash
|
||||
# Web应用服务
|
||||
ssh ubuntu@43.135.128.39 "systemctl start stock-app"
|
||||
ssh ubuntu@43.135.128.39 "systemctl stop stock-app"
|
||||
ssh ubuntu@43.135.128.39 "systemctl restart stock-app"
|
||||
ssh ubuntu@43.135.128.39 "systemctl status stock-app"
|
||||
ssh ubuntu@43.135.128.39 "journalctl -u stock-app -f"
|
||||
|
||||
# 数据采集服务
|
||||
ssh ubuntu@43.135.128.39 "systemctl start stock-data-service"
|
||||
ssh ubuntu@43.135.128.39 "systemctl stop stock-data-service"
|
||||
ssh ubuntu@43.135.128.39 "systemctl status stock-data-service"
|
||||
ssh ubuntu@43.135.128.39 "journalctl -u stock-data-service -f"
|
||||
|
||||
# ========== 新服务器 stock.allbyai.cn ==========
|
||||
# Web应用服务
|
||||
ssh ubuntu@152.136.182.184 "systemctl start stock-app"
|
||||
ssh ubuntu@152.136.182.184 "systemctl stop stock-app"
|
||||
ssh ubuntu@152.136.182.184 "systemctl restart stock-app"
|
||||
ssh ubuntu@152.136.182.184 "systemctl status stock-app"
|
||||
ssh ubuntu@152.136.182.184 "journalctl -u stock-app -f"
|
||||
|
||||
# 数据采集服务
|
||||
ssh ubuntu@152.136.182.184 "systemctl start stock-data-service"
|
||||
ssh ubuntu@152.136.182.184 "systemctl stop stock-data-service"
|
||||
ssh ubuntu@152.136.182.184 "systemctl status stock-data-service"
|
||||
ssh ubuntu@152.136.182.184 "journalctl -u stock-data-service -f"
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
stock-html/
|
||||
├── app.py # Flask后端主程序
|
||||
├── config.py # 配置文件(支持环境变量)
|
||||
├── db.py # 数据库操作层
|
||||
├── requirements.txt # Python依赖
|
||||
├── start.sh # 启动脚本
|
||||
├── stock_data_service.py # 数据采集服务(定时任务)
|
||||
├── full_signal_scan.py # 全市场信号扫描脚本
|
||||
├── sync_fund_flow.py # 资金流向全市场采集脚本
|
||||
├── auto_sync_fund_flow.sh # 资金流向采集定时任务
|
||||
├── routes/ # API路由
|
||||
│ ├── analysis.py # 分析/扫描/信号相关API
|
||||
│ ├── auth.py # 认证API
|
||||
│ ├── market.py # 市场数据API
|
||||
│ ├── sim_trade.py # 模拟交易API
|
||||
│ ├── trades.py # 交易记录API
|
||||
│ └── watchlist.py # 关注列表API
|
||||
├── services/ # 业务服务
|
||||
│ ├── signal_detector.py # 7种技术信号检测器
|
||||
│ ├── technical_indicators.py # 技术指标计算
|
||||
│ ├── scheduler.py # 自动交易调度器
|
||||
│ ├── stock_service.py # 股票数据服务
|
||||
│ ├── mairui_api.py # 迈瑞API
|
||||
│ └── doubao_api.py # 豆包AI分析
|
||||
├── templates/
|
||||
│ └── index.html # 前端页面(Vue.js)
|
||||
├── static/
|
||||
│ ├── css/main.css # 样式
|
||||
│ └── js/app.js # Vue.js应用
|
||||
└── stock-data-service.service # systemd服务配置
|
||||
```
|
||||
|
||||
## systemd 服务
|
||||
|
||||
### stock-app.service (Web应用)
|
||||
路径:`/etc/systemd/system/stock-app.service`
|
||||
端口:3333
|
||||
|
||||
### stock-data-service.service (数据采集)
|
||||
路径:`/etc/systemd/system/stock-data-service.service`
|
||||
功能:定时采集实时价格数据
|
||||
|
||||
### 全景扫描定时任务 (crontab)
|
||||
|
||||
应用内**没有**自动配置全景扫描的定时任务,需在服务器上添加 crontab。当前配置**每天两次扫描**:
|
||||
|
||||
| 时间 | 说明 |
|
||||
|------|------|
|
||||
| **11:50** | 午休扫描 — 上午收盘20分钟后,确保数据API更新完毕,供下午交易参考 |
|
||||
| **16:30** | 收盘扫描 — 收盘1.5小时后,确保全天K线数据完整更新 |
|
||||
|
||||
> **为什么留缓冲时间?** A股上午 11:30 收盘、下午 15:00 收盘。数据API更新需要一定时间,11:50 和 16:30 给予充分缓冲确保数据完整性。
|
||||
|
||||
**方式一:一键配置(推荐)**
|
||||
|
||||
在本地 `stock-html` 目录下执行(会同步 `auto_scan.sh`、在服务器上添加 crontab):
|
||||
```bash
|
||||
cd /path/to/stock-html
|
||||
./setup_cron_scan.sh
|
||||
```
|
||||
|
||||
如需指定服务器或目录:`STOCK_SERVER=root@你的IP STOCK_APP_DIR=/opt/stock-app ./setup_cron_scan.sh`
|
||||
|
||||
**方式二:在服务器上手动配置**
|
||||
```bash
|
||||
ssh ubuntu@43.135.128.39
|
||||
chmod +x /opt/stock-app/auto_scan.sh
|
||||
crontab -e
|
||||
# 添加两行:
|
||||
50 11 * * 1-5 /opt/stock-app/auto_scan.sh >> /opt/stock-app/auto_scan.log 2>&1
|
||||
30 16 * * 1-5 /opt/stock-app/auto_scan.sh >> /opt/stock-app/auto_scan.log 2>&1
|
||||
# 5分钟K线采集(17:30,收盘后数据完整)
|
||||
30 17 * * 1-5 /opt/stock-app/auto_sync_kline_5min.sh >> /opt/stock-app/sync_kline_5min.log 2>&1
|
||||
# 资金流向采集(18:00,在K线采集之后)
|
||||
0 18 * * 1-5 /opt/stock-app/auto_sync_fund_flow.sh >> /opt/stock-app/sync_fund_flow.log 2>&1
|
||||
```
|
||||
|
||||
**验证与排查**
|
||||
```bash
|
||||
# 查看当前 crontab
|
||||
ssh ubuntu@43.135.128.39 "crontab -l"
|
||||
|
||||
# 手动执行一次测试
|
||||
ssh ubuntu@43.135.128.39 "/opt/stock-app/auto_scan.sh"
|
||||
|
||||
# 查看扫描日志
|
||||
ssh ubuntu@43.135.128.39 "tail -50 /opt/stock-app/auto_scan.log"
|
||||
```
|
||||
|
||||
- 未配置 crontab 时:前端「全景扫描」按钮仅查看已有缓存结果,不会触发新扫描。需手动在服务器执行 `FORCE_RESCAN=1 python full_signal_scan.py` 或配置 crontab。
|
||||
|
||||
## API接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/health` | GET | 健康检查 |
|
||||
| `/api/signal_alerts` | POST | 信号分析提醒(新模型) |
|
||||
| `/api/tech_signals/<code>` | GET | 单只股票7种信号检测 |
|
||||
| `/api/batch_tech_signals` | POST | 批量信号检测 |
|
||||
| `/api/start_full_scan` | POST | 启动全市场扫描 |
|
||||
| `/api/scan_status` | GET | 扫描进度查询 |
|
||||
| `/api/scan_results` | GET | 扫描结果查询 |
|
||||
| `/api/scan_strategy` | GET | 策略建议 |
|
||||
| `/api/realtime_price/<code>` | GET | 获取实时价格 |
|
||||
| `/api/trades` | GET/POST | 交易记录 |
|
||||
| `/api/watchlist` | GET/POST/DELETE | 关注列表 |
|
||||
| `/api/alerts_cache` | GET/POST | 分析缓存 |
|
||||
| `/api/fundamental/<code>` | GET | 基本面数据 |
|
||||
| `/api/kline/<code>` | GET | K线数据 |
|
||||
|
||||
## 核心算法(交易信号实战体系)
|
||||
|
||||
### 7种技术信号
|
||||
1. ★主升浪 - 最稳最猛(利润核心阶段)
|
||||
2. 日线底背离 - 最安全抄底(反转前置信号)
|
||||
3. 龙抬头 - 最佳入场点(实操核心买点)
|
||||
4. 真龙 - 趋势确认(中期行情定局信号)
|
||||
5. 短底背离 - 辅助参考
|
||||
6. 老鼠仓 - 辅助参考
|
||||
7. 反弹 - 辅助参考
|
||||
|
||||
### 最强战法
|
||||
1. 日线底背离出现 → 纳入关注
|
||||
2. 龙抬头出现 → 执行买入
|
||||
3. 真龙/主升浪出现 → 持有加仓
|
||||
4. MACD死叉+主升浪消失 → 卖出
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 一键同步全部代码并重启(已排除根目录重复项,仅同步 templates/ static/ 等正确路径)
|
||||
cd /Users/freedak/Documents/go-new/stock/stock-html && \
|
||||
rsync -avz ./ ubuntu@43.135.128.39:/opt/stock-app/ \
|
||||
--exclude='.git' --exclude='venv' --exclude='__pycache__' --exclude='stock_data_cache' \
|
||||
--exclude='*.pyc' --exclude='.DS_Store' --exclude='*.log' \
|
||||
--exclude='stock_names.json' --exclude='alerts_cache.json' \
|
||||
--exclude='.playwright-mcp' \
|
||||
--exclude='/app.js' --exclude='/index.html' --exclude='/main.css' \
|
||||
--exclude='/css' --exclude='/js' --exclude='/.windsurfrules' && \
|
||||
ssh ubuntu@43.135.128.39 "systemctl restart stock-app && echo '服务已重启'"
|
||||
|
||||
# 仅同步前端并重启
|
||||
rsync -avz templates/ static/ ubuntu@43.135.128.39:/opt/stock-app/ && \
|
||||
ssh ubuntu@43.135.128.39 "systemctl restart stock-app"
|
||||
|
||||
# 查看两个服务状态
|
||||
ssh ubuntu@43.135.128.39 "systemctl status stock-app stock-data-service"
|
||||
|
||||
# ========== 新服务器 stock.allbyai.cn 快捷命令 ==========
|
||||
# 一键同步并重启(推荐)
|
||||
cd /Users/freedak/Documents/go-new/stock/stock-html && \
|
||||
rsync -avz ./ ubuntu@152.136.182.184:/opt/stock-app/ \
|
||||
--exclude='.git' --exclude='venv' --exclude='__pycache__' --exclude='stock_data_cache' \
|
||||
--exclude='*.pyc' --exclude='.DS_Store' --exclude='*.log' \
|
||||
--exclude='stock_names.json' --exclude='alerts_cache.json' \
|
||||
--exclude='.playwright-mcp' \
|
||||
--exclude='/app.js' --exclude='/index.html' --exclude='/main.css' \
|
||||
--exclude='/css' --exclude='/js' --exclude='/.windsurfrules' && \
|
||||
ssh ubuntu@152.136.182.184 "systemctl restart stock-app && echo '服务已重启'"
|
||||
|
||||
# 或使用脚本
|
||||
./deploy/sync-to-new-server.sh
|
||||
|
||||
# 查看服务状态
|
||||
ssh ubuntu@152.136.182.184 "systemctl status stock-app stock-data-service"
|
||||
```
|
||||
|
||||
---
|
||||
最后更新:2026-03-02
|
||||
@@ -0,0 +1,461 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
高速系统性算法搜索 v5.3 — 内存回测引擎
|
||||
|
||||
核心优化:预加载所有数据到内存,避免回测时反复查询数据库。
|
||||
- 旧版: ~22s/次 (DB查询) → 新版: ~0.2s/次 (内存读取)
|
||||
- 3000+ 种组合约需 10-15 分钟(而非 47 小时)
|
||||
|
||||
两阶段搜索:
|
||||
Phase 1: 用活跃季度(2025-Q3)快速筛选出Top 60
|
||||
Phase 2: 用全期间(2025-01~2026-02)验证Top 60
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import itertools
|
||||
from datetime import date, datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from backtest_recommend import get_db_conn, run_backtest, preload_all_data
|
||||
|
||||
CAPITAL = 200_000
|
||||
|
||||
# ─── 搜索空间 ─────────────────
|
||||
SEARCH_SPACE = {
|
||||
'tp_sl': [
|
||||
(6, 3), (8, 4), (8, 6), (10, 5), (10, 8),
|
||||
(12, 6), (12, 8), (15, 8), (15, 10), (20, 10), (20, 12),
|
||||
],
|
||||
'min_triggered': [0, 1, 2, 3],
|
||||
'sell_mode': ['normal', 'ignore', 'delay1', 'delay2', 'delay3'],
|
||||
'trailing': [
|
||||
None,
|
||||
(6, 2), (8, 3), (10, 3), (10, 5), (12, 4), (12, 5), (15, 5),
|
||||
],
|
||||
'position_pct': [3, 5, 8, 10, 15],
|
||||
'signal_weight': [False, True],
|
||||
'max_hold_days': [0, 20, 30, 60],
|
||||
}
|
||||
|
||||
# Phase 1 筛选期(选一个有代表性的活跃季度)
|
||||
SCREEN_START = date(2025, 7, 1)
|
||||
SCREEN_END = date(2025, 9, 30)
|
||||
|
||||
# Phase 2 全量验证期
|
||||
FULL_START = date(2025, 1, 2)
|
||||
FULL_END = date(2026, 2, 25)
|
||||
|
||||
TOP_N_SCREEN = 60 # Phase 1 筛出前60进入Phase 2
|
||||
TOP_N_FINAL = 30 # Phase 2 展示前30
|
||||
|
||||
|
||||
def build_params(tp_sl, min_triggered, sell_mode, trailing, position_pct, signal_weight, max_hold_days):
|
||||
"""将组合参数转为 run_backtest 的 kwargs"""
|
||||
params = {
|
||||
'total_capital': CAPITAL,
|
||||
'position_pct': position_pct,
|
||||
'signal_weight': signal_weight,
|
||||
'use_5min_prices': True, # v7: 启用5分钟实时价格
|
||||
'buy_time': '09:35', # v7: 最优买入时间
|
||||
'sell_time': '13:40', # v7: 最优卖出时间
|
||||
'verbose': False,
|
||||
}
|
||||
tp, sl = tp_sl
|
||||
params['stop_loss_pct'] = sl
|
||||
|
||||
if trailing is not None:
|
||||
# 跟踪止盈模式:不用固定止盈,自动忽略卖出信号
|
||||
params['take_profit_pct'] = None
|
||||
params['trailing_start_pct'] = trailing[0]
|
||||
params['trailing_gap_pct'] = trailing[1]
|
||||
params['ignore_sell_signal'] = True
|
||||
else:
|
||||
params['take_profit_pct'] = tp
|
||||
|
||||
if min_triggered > 0:
|
||||
params['min_buy_triggered'] = min_triggered
|
||||
|
||||
if sell_mode == 'ignore':
|
||||
params['ignore_sell_signal'] = True
|
||||
elif sell_mode.startswith('delay'):
|
||||
days = int(sell_mode.replace('delay', ''))
|
||||
params['sell_confirm_days'] = days
|
||||
|
||||
if max_hold_days > 0:
|
||||
params['max_hold_days'] = max_hold_days
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def make_name(tp_sl, min_triggered, sell_mode, trailing, position_pct, signal_weight, max_hold_days):
|
||||
"""生成人类可读的策略名"""
|
||||
parts = []
|
||||
tp, sl = tp_sl
|
||||
if trailing:
|
||||
parts.append(f"T{trailing[0]}/{trailing[1]}")
|
||||
else:
|
||||
parts.append(f"TP{tp}")
|
||||
parts.append(f"SL{sl}")
|
||||
if min_triggered > 0:
|
||||
parts.append(f"trig≥{min_triggered}")
|
||||
if sell_mode == 'ignore':
|
||||
parts.append("ign")
|
||||
elif sell_mode.startswith('delay'):
|
||||
parts.append(sell_mode)
|
||||
if max_hold_days > 0:
|
||||
parts.append(f"h≤{max_hold_days}")
|
||||
parts.append(f"{position_pct}%")
|
||||
if signal_weight:
|
||||
parts.append("SW")
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
def is_redundant(sell_mode, trailing):
|
||||
"""剪枝:跟踪止盈模式下,delay/normal卖出不生效"""
|
||||
if trailing is not None and sell_mode in ('delay1', 'delay2', 'delay3'):
|
||||
return True
|
||||
if trailing is not None and sell_mode == 'normal':
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
conn = get_db_conn()
|
||||
|
||||
# 生成所有组合并剪枝
|
||||
all_combos = list(itertools.product(
|
||||
SEARCH_SPACE['tp_sl'],
|
||||
SEARCH_SPACE['min_triggered'],
|
||||
SEARCH_SPACE['sell_mode'],
|
||||
SEARCH_SPACE['trailing'],
|
||||
SEARCH_SPACE['position_pct'],
|
||||
SEARCH_SPACE['signal_weight'],
|
||||
SEARCH_SPACE['max_hold_days'],
|
||||
))
|
||||
combos = [(tp_sl, mt, sm, tr, pp, sw, mh)
|
||||
for tp_sl, mt, sm, tr, pp, sw, mh in all_combos
|
||||
if not is_redundant(sm, tr)]
|
||||
|
||||
print(f"{'='*100}")
|
||||
print(f" 🚀 高速系统性算法搜索 v7.0 (内存回测引擎 + 最优时点09:35/13:40)")
|
||||
print(f"{'='*100}")
|
||||
print(f" 本金: ¥{CAPITAL:,}")
|
||||
print(f" 组合总数: {len(all_combos):,} → 剪枝后: {len(combos):,}")
|
||||
print(f" Phase 1: 快速筛选 ({SCREEN_START} ~ {SCREEN_END})")
|
||||
print(f" Phase 2: 全量验证 Top {TOP_N_SCREEN} ({FULL_START} ~ {FULL_END})")
|
||||
print(f"{'='*100}\n")
|
||||
|
||||
# ════════════════ 预加载数据 ════════════════
|
||||
print(" 📦 预加载回测数据...")
|
||||
# 加载全量数据(覆盖Phase 1和Phase 2的完整范围,含5分钟K线)
|
||||
data_all = preload_all_data(conn, FULL_START, FULL_END, use_5min=True)
|
||||
print()
|
||||
|
||||
# ════════════════ Phase 1: 快速筛选 ════════════════
|
||||
print(f" ▶ Phase 1: 快速筛选 {len(combos):,} 种组合...")
|
||||
phase1_results = []
|
||||
t_start = time.time()
|
||||
|
||||
for i, (tp_sl, mt, sm, tr, pp, sw, mh) in enumerate(combos):
|
||||
name = make_name(tp_sl, mt, sm, tr, pp, sw, mh)
|
||||
params = build_params(tp_sl, mt, sm, tr, pp, sw, mh)
|
||||
|
||||
if (i + 1) % 200 == 0 or i == 0:
|
||||
elapsed = time.time() - t_start
|
||||
speed = (i + 1) / elapsed if elapsed > 0 else 0
|
||||
eta = (len(combos) - i - 1) / speed if speed > 0 else 0
|
||||
best_name = phase1_results[0]['name'] if phase1_results else 'N/A'
|
||||
best_profit = phase1_results[0]['profit'] if phase1_results else 0
|
||||
print(f" [{i+1:>5}/{len(combos)}] {elapsed:.0f}s ({speed:.1f}次/秒) "
|
||||
f"ETA:{eta:.0f}s Top1: ¥{best_profit:+,.0f} ({best_name[:35]})", flush=True)
|
||||
|
||||
# 使用预加载数据进行内存回测
|
||||
result = run_backtest(
|
||||
conn, start_date=SCREEN_START, end_date=SCREEN_END,
|
||||
preloaded=data_all, **params
|
||||
)
|
||||
|
||||
if result and result.get('stats'):
|
||||
s = result['stats']
|
||||
phase1_results.append({
|
||||
'name': name,
|
||||
'combo': (tp_sl, mt, sm, tr, pp, sw, mh),
|
||||
'profit': s['profit'],
|
||||
'capital_pct': s['capital_pct'],
|
||||
'capital_ann_pct': s.get('capital_ann_pct', 0),
|
||||
'win_rate': s['win_rate'],
|
||||
'profit_factor': s['profit_factor'],
|
||||
'trade_count': s['trade_count'],
|
||||
'max_drawdown_pct': s.get('max_drawdown_pct', 0),
|
||||
})
|
||||
|
||||
phase1_results.sort(key=lambda x: x['profit'], reverse=True)
|
||||
p1_time = time.time() - t_start
|
||||
p1_speed = len(combos) / p1_time if p1_time > 0 else 0
|
||||
print(f"\n ✅ Phase 1 完成! {p1_time:.0f}秒 ({p1_speed:.1f}次/秒), 有效结果: {len(phase1_results)}")
|
||||
print(f" Phase 1 Top 10:")
|
||||
for i, r in enumerate(phase1_results[:10], 1):
|
||||
print(f" {i:>2}. ¥{r['profit']:>+10,.0f} 收益{r['capital_pct']:>+6.1f}% "
|
||||
f"胜率{r['win_rate']:>5.1f}% PF{r['profit_factor']:>5.2f} 回撤{r['max_drawdown_pct']:>5.1f}% {r['name']}")
|
||||
|
||||
# ════════════════ Phase 2: 全量验证 ════════════════
|
||||
top_candidates = phase1_results[:TOP_N_SCREEN]
|
||||
print(f"\n ▶ Phase 2: 全期间验证 Top {len(top_candidates)} ...")
|
||||
phase2_results = []
|
||||
t2_start = time.time()
|
||||
|
||||
for i, cand in enumerate(top_candidates):
|
||||
tp_sl, mt, sm, tr, pp, sw, mh = cand['combo']
|
||||
name = cand['name']
|
||||
params = build_params(tp_sl, mt, sm, tr, pp, sw, mh)
|
||||
|
||||
if (i + 1) % 10 == 0 or i == 0:
|
||||
elapsed = time.time() - t2_start
|
||||
speed = (i + 1) / elapsed if elapsed > 0 else 0
|
||||
eta = (len(top_candidates) - i - 1) / speed if speed > 0 else 0
|
||||
print(f" [{i+1}/{len(top_candidates)}] {elapsed:.0f}s ETA:{eta:.0f}s", flush=True)
|
||||
|
||||
# 使用预加载数据进行全量回测
|
||||
result = run_backtest(
|
||||
conn, start_date=FULL_START, end_date=FULL_END,
|
||||
preloaded=data_all, **params
|
||||
)
|
||||
|
||||
if result and result.get('stats'):
|
||||
s = result['stats']
|
||||
phase2_results.append({
|
||||
'name': name,
|
||||
'combo': cand['combo'],
|
||||
'screen_profit': cand['profit'],
|
||||
'profit': s['profit'],
|
||||
'capital_pct': s['capital_pct'],
|
||||
'capital_ann_pct': s.get('capital_ann_pct', 0),
|
||||
'win_rate': s['win_rate'],
|
||||
'profit_factor': s['profit_factor'],
|
||||
'max_drawdown_pct': s.get('max_drawdown_pct', 0),
|
||||
'trade_count': s['trade_count'],
|
||||
'max_capital': s.get('max_capital', 0),
|
||||
'avg_hold_days': s.get('avg_hold_days', 0),
|
||||
'closed_count': s.get('closed_count', 0),
|
||||
'wins': s.get('wins', 0),
|
||||
'losses': s.get('losses', 0),
|
||||
'capital_ann_method': s.get('capital_ann_method', ''),
|
||||
})
|
||||
|
||||
phase2_results.sort(key=lambda x: x['profit'], reverse=True)
|
||||
p2_time = time.time() - t2_start
|
||||
total_time = time.time() - t_start
|
||||
|
||||
conn.close()
|
||||
|
||||
# ═══════════════ 控制台输出 ═══════════════
|
||||
print(f"\n{'='*130}")
|
||||
print(f" 🏆 搜索完成! {len(combos):,}种组合 | "
|
||||
f"Phase1:{p1_time:.0f}s Phase2:{p2_time:.0f}s | "
|
||||
f"总计:{total_time:.0f}s ({total_time/60:.1f}分钟)")
|
||||
print(f"{'='*130}")
|
||||
|
||||
print(f"\n 📊 全期间 Top {min(TOP_N_FINAL, len(phase2_results))} 算法:\n")
|
||||
header = (f"{'排名':>4} {'全期盈利':>12} {'Q3盈利':>10} {'真实收益':>8} {'年化':>8} "
|
||||
f"{'胜率':>6} {'盈亏比':>6} {'回撤':>6} {'交易':>5} {'持仓天':>6} | {'策略'}")
|
||||
print(f" {header}")
|
||||
print(" " + "-" * 130)
|
||||
|
||||
for rank, r in enumerate(phase2_results[:TOP_N_FINAL], 1):
|
||||
medal = "🏆" if rank == 1 else ("🥈" if rank == 2 else ("🥉" if rank == 3 else f" {rank:>2}"))
|
||||
print(f" {medal} {r['profit']:>+11,.0f} {r['screen_profit']:>+9,.0f} "
|
||||
f"{r['capital_pct']:>+7.1f}% {r['capital_ann_pct']:>+7.1f}% "
|
||||
f"{r['win_rate']:>5.1f}% {r['profit_factor']:>6.2f} {r['max_drawdown_pct']:>5.1f}% "
|
||||
f"{r['trade_count']:>5} {r['avg_hold_days']:>5.0f}d | {r['name']}")
|
||||
|
||||
# ─── 维度分析 ─────────────────────
|
||||
if phase2_results:
|
||||
print(f"\n{'='*130}")
|
||||
print(f" 📊 维度影响分析")
|
||||
print(f"{'='*130}")
|
||||
|
||||
# 止盈方式
|
||||
print(f"\n 📈 止盈方式 (跟踪 vs 固定):")
|
||||
tr_sub = [r for r in phase2_results if r['combo'][3] is not None]
|
||||
fx_sub = [r for r in phase2_results if r['combo'][3] is None]
|
||||
if tr_sub:
|
||||
avg = sum(r['profit'] for r in tr_sub) / len(tr_sub)
|
||||
best = max(tr_sub, key=lambda x: x['profit'])
|
||||
print(f" 跟踪止盈: n={len(tr_sub):>3} 平均 ¥{avg:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f} ({best['name'][:40]})")
|
||||
if fx_sub:
|
||||
avg = sum(r['profit'] for r in fx_sub) / len(fx_sub)
|
||||
best = max(fx_sub, key=lambda x: x['profit'])
|
||||
print(f" 固定止盈: n={len(fx_sub):>3} 平均 ¥{avg:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f} ({best['name'][:40]})")
|
||||
|
||||
# 仓位比例
|
||||
print(f"\n 💰 仓位比例:")
|
||||
for pct in sorted(set(r['combo'][4] for r in phase2_results)):
|
||||
subset = [r for r in phase2_results if r['combo'][4] == pct]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
print(f" {pct:>2}%: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}")
|
||||
|
||||
# 信号加权
|
||||
print(f"\n 📶 信号加权:")
|
||||
for sw in [False, True]:
|
||||
subset = [r for r in phase2_results if r['combo'][5] == sw]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
print(f" {'加权' if sw else '等权':>4}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}")
|
||||
|
||||
# 卖出策略
|
||||
print(f"\n 🛒 卖出策略:")
|
||||
for sm_label in ['normal', 'ignore', 'delay1', 'delay2', 'delay3']:
|
||||
subset = [r for r in phase2_results if r['combo'][2] == sm_label]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
print(f" {sm_label:>8}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}")
|
||||
|
||||
# 止盈/止损参数
|
||||
print(f"\n 🎯 止盈线 (固定止盈组合):")
|
||||
for tp in sorted(set(r['combo'][0][0] for r in fx_sub)) if fx_sub else []:
|
||||
subset = [r for r in fx_sub if r['combo'][0][0] == tp]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
print(f" TP={tp:>2}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}")
|
||||
|
||||
print(f"\n 🛡️ 止损线:")
|
||||
for sl in sorted(set(r['combo'][0][1] for r in phase2_results)):
|
||||
subset = [r for r in phase2_results if r['combo'][0][1] == sl]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
print(f" SL={sl:>2}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}")
|
||||
|
||||
# 最大持仓天数
|
||||
print(f"\n ⏰ 最大持仓天数:")
|
||||
for mh in sorted(set(r['combo'][6] for r in phase2_results)):
|
||||
subset = [r for r in phase2_results if r['combo'][6] == mh]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
label = "不限" if mh == 0 else f"{mh}天"
|
||||
print(f" {label:>4}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}")
|
||||
|
||||
# 买入信号触发数
|
||||
print(f"\n 🔔 买入信号触发数:")
|
||||
for mt in sorted(set(r['combo'][1] for r in phase2_results)):
|
||||
subset = [r for r in phase2_results if r['combo'][1] == mt]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
label = "不限" if mt == 0 else f"≥{mt}"
|
||||
print(f" {label:>4}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}")
|
||||
|
||||
# ─── 与之前冠军对比 ─────────────────────
|
||||
print(f"\n{'='*130}")
|
||||
print(f" 📊 与之前最优算法对比")
|
||||
print(f"{'='*130}")
|
||||
prev_best = {'profit': 56375, 'capital_pct': 21.5, 'capital_ann_pct': 18.5,
|
||||
'win_rate': 61.2, 'profit_factor': 2.30, 'name': 'v5.2|忽略卖出+TP10+SL8+信号加权'}
|
||||
new_best = phase2_results[0] if phase2_results else None
|
||||
if new_best:
|
||||
print(f" 之前冠军: ¥{prev_best['profit']:>+10,.0f} 收益{prev_best['capital_pct']:>+6.1f}% "
|
||||
f"年化{prev_best['capital_ann_pct']:>+6.1f}% 胜率{prev_best['win_rate']:>5.1f}% "
|
||||
f"PF{prev_best['profit_factor']:>5.2f} {prev_best['name']}")
|
||||
print(f" 新冠军: ¥{new_best['profit']:>+10,.0f} 收益{new_best['capital_pct']:>+6.1f}% "
|
||||
f"年化{new_best['capital_ann_pct']:>+6.1f}% 胜率{new_best['win_rate']:>5.1f}% "
|
||||
f"PF{new_best['profit_factor']:>5.2f} {new_best['name']}")
|
||||
diff = new_best['profit'] - prev_best['profit']
|
||||
print(f" 差异: ¥{diff:>+10,.0f} {'🎉 新纪录!' if diff > 0 else '❌ 未超越'}")
|
||||
|
||||
# ─── Markdown 输出 ─────────────────────
|
||||
out_path = os.path.join(os.path.dirname(__file__), "docs", "algo_search_results.md")
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write("# 🔍 系统性算法搜索结果 (v5.3 内存回测引擎)\n\n")
|
||||
f.write(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n")
|
||||
f.write("## 搜索配置\n\n")
|
||||
f.write(f"| 项目 | 值 |\n")
|
||||
f.write(f"|------|----|\n")
|
||||
f.write(f"| 本金 | ¥{CAPITAL:,} |\n")
|
||||
f.write(f"| Phase 1 筛选期 | {SCREEN_START} ~ {SCREEN_END} |\n")
|
||||
f.write(f"| Phase 2 验证期 | {FULL_START} ~ {FULL_END} |\n")
|
||||
f.write(f"| 组合总数 | {len(all_combos):,} → 剪枝后 {len(combos):,} |\n")
|
||||
f.write(f"| Phase 1 耗时 | {p1_time:.0f}s ({p1_speed:.1f}次/秒) |\n")
|
||||
f.write(f"| Phase 2 耗时 | {p2_time:.0f}s |\n")
|
||||
f.write(f"| 总耗时 | {total_time:.0f}s ({total_time/60:.1f}分钟) |\n\n")
|
||||
|
||||
f.write("## 🏆 全期间 Top 30\n\n")
|
||||
f.write("| 排名 | 全期盈利 | Q3盈利 | 真实收益 | 年化 | 胜率 | 盈亏比 | 回撤 | 交易 | 持仓天 | 策略 |\n")
|
||||
f.write("|------|---------|-------|---------|------|------|--------|------|------|--------|------|\n")
|
||||
for rank, r in enumerate(phase2_results[:TOP_N_FINAL], 1):
|
||||
medal = "🏆" if rank == 1 else ("🥈" if rank == 2 else ("🥉" if rank == 3 else f"#{rank}"))
|
||||
f.write(f"| {medal} | ¥{r['profit']:+,.0f} | ¥{r['screen_profit']:+,.0f} | "
|
||||
f"{r['capital_pct']:+.1f}% | {r['capital_ann_pct']:+.1f}% | "
|
||||
f"{r['win_rate']:.1f}% | {r['profit_factor']:.2f} | "
|
||||
f"{r['max_drawdown_pct']:.1f}% | {r['trade_count']} | "
|
||||
f"{r['avg_hold_days']:.0f}d | `{r['name']}` |\n")
|
||||
f.write("\n")
|
||||
|
||||
# 冠军对比
|
||||
if new_best:
|
||||
f.write("## 新冠军 vs 之前冠军\n\n")
|
||||
f.write("| 指标 | 之前冠军 | 新冠军 |\n")
|
||||
f.write("|------|---------|-------|\n")
|
||||
f.write(f"| 策略 | `{prev_best['name']}` | `{new_best['name']}` |\n")
|
||||
f.write(f"| 全期盈利 | ¥{prev_best['profit']:+,} | ¥{new_best['profit']:+,} |\n")
|
||||
f.write(f"| 真实收益 | {prev_best['capital_pct']:+.1f}% | {new_best['capital_pct']:+.1f}% |\n")
|
||||
f.write(f"| 年化 | {prev_best['capital_ann_pct']:+.1f}% | {new_best['capital_ann_pct']:+.1f}% |\n")
|
||||
f.write(f"| 胜率 | {prev_best['win_rate']:.1f}% | {new_best['win_rate']:.1f}% |\n")
|
||||
f.write(f"| 盈亏比 | {prev_best['profit_factor']:.2f} | {new_best['profit_factor']:.2f} |\n")
|
||||
f.write(f"| 回撤 | - | {new_best['max_drawdown_pct']:.1f}% |\n")
|
||||
diff = new_best['profit'] - prev_best['profit']
|
||||
f.write(f"\n{'🎉 **新纪录!**' if diff > 0 else '❌ 未超越之前冠军'}\n\n")
|
||||
|
||||
# 维度分析
|
||||
f.write("## 维度影响分析\n\n")
|
||||
|
||||
# 仓位比例
|
||||
f.write("### 仓位比例\n\n")
|
||||
f.write("| 仓位 | 数量 | 平均盈利 | 最优盈利 |\n")
|
||||
f.write("|------|------|---------|--------|\n")
|
||||
for pct in sorted(set(r['combo'][4] for r in phase2_results)):
|
||||
subset = [r for r in phase2_results if r['combo'][4] == pct]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
f.write(f"| {pct}% | {len(subset)} | ¥{avg_p:+,.0f} | ¥{best['profit']:+,.0f} |\n")
|
||||
f.write("\n")
|
||||
|
||||
# 信号加权
|
||||
f.write("### 信号加权\n\n")
|
||||
f.write("| 模式 | 数量 | 平均盈利 | 最优盈利 |\n")
|
||||
f.write("|------|------|---------|--------|\n")
|
||||
for sw in [False, True]:
|
||||
subset = [r for r in phase2_results if r['combo'][5] == sw]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
f.write(f"| {'加权' if sw else '等权'} | {len(subset)} | ¥{avg_p:+,.0f} | ¥{best['profit']:+,.0f} |\n")
|
||||
f.write("\n")
|
||||
|
||||
# 止损线
|
||||
f.write("### 止损线\n\n")
|
||||
f.write("| 止损 | 数量 | 平均盈利 | 最优盈利 |\n")
|
||||
f.write("|------|------|---------|--------|\n")
|
||||
for sl in sorted(set(r['combo'][0][1] for r in phase2_results)):
|
||||
subset = [r for r in phase2_results if r['combo'][0][1] == sl]
|
||||
if subset:
|
||||
avg_p = sum(r['profit'] for r in subset) / len(subset)
|
||||
best = max(subset, key=lambda x: x['profit'])
|
||||
f.write(f"| {sl}% | {len(subset)} | ¥{avg_p:+,.0f} | ¥{best['profit']:+,.0f} |\n")
|
||||
f.write("\n")
|
||||
|
||||
print(f"\n📝 完整结果已写入 {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,490 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
20万本金 × 按季度投资 × 多算法对比回测 (v7.0 最优交易时点)
|
||||
|
||||
条件(v7.0: 最优时点 + 完全无人为限制):
|
||||
- 本金:¥200,000(唯一约束)
|
||||
- 单只上限:无
|
||||
- 最大持仓:无
|
||||
- 每笔股数:动态(总资金 × position_pct%,每笔等金额)
|
||||
- 股价区间:无
|
||||
- 每日最多买入:无
|
||||
- 买入时间:09:35(最优,网格搜索验证)
|
||||
- 卖出时间:13:40(最优,网格搜索验证)
|
||||
- 回测区间:2025-01-01 ~ 最新,按季度分段
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from collections import defaultdict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from backtest_recommend import (
|
||||
get_db_conn, run_backtest, START_DATE, SELL_COOLDOWN_DAYS,
|
||||
)
|
||||
|
||||
# ─── 20万本金配置(v5.2: 完全无人为限制 + 动态仓位) ─────────────────
|
||||
CAPITAL = 200_000 # 总本金(唯一约束)
|
||||
POSITION_PCT = 5 # 单笔仓位 = 总资金的5%(¥10,000/笔,约可持20只)
|
||||
|
||||
# ─── 季度定义 ─────────────────────
|
||||
QUARTERS = [
|
||||
("2025-Q1", date(2025, 1, 2), date(2025, 3, 31)),
|
||||
("2025-Q2", date(2025, 4, 1), date(2025, 6, 30)),
|
||||
("2025-Q3", date(2025, 7, 1), date(2025, 9, 30)),
|
||||
("2025-Q4", date(2025, 10, 1), date(2025, 12, 31)),
|
||||
("2026-Q1", date(2026, 1, 5), date(2026, 2, 25)),
|
||||
# 完整区间
|
||||
("全期间", date(2025, 1, 2), date(2026, 2, 25)),
|
||||
]
|
||||
|
||||
# ─── 算法配置 ─────────────────────
|
||||
ALGORITHMS = [
|
||||
# 名称, 参数字典
|
||||
("v3|基线(TP10+SL8)", {
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4|触发≥2+TP10+SL8", {
|
||||
"min_buy_triggered": 2, "take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4.2|延迟2天+TP10+SL8", {
|
||||
"sell_confirm_days": 2, "take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4|忽略卖出+TP10+SL8", {
|
||||
"ignore_sell_signal": True, "take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4|触发≥2+TP15+SL8", {
|
||||
"min_buy_triggered": 2, "take_profit_pct": 15, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4|触发≥2+TP10+SL5", {
|
||||
"min_buy_triggered": 2, "take_profit_pct": 10, "stop_loss_pct": 5,
|
||||
}),
|
||||
("v4.2|延迟2天+触发≥2+TP10+SL8", {
|
||||
"sell_confirm_days": 2, "min_buy_triggered": 2,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4.1|跟踪止盈8/3+SL5", {
|
||||
"ignore_sell_signal": True, "trailing_start_pct": 8, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5,
|
||||
}),
|
||||
("v4.1|跟踪止盈8/3+触发≥2+SL5", {
|
||||
"ignore_sell_signal": True, "trailing_start_pct": 8, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5, "min_buy_triggered": 2,
|
||||
}),
|
||||
("v4|触发≥2+TP10+SL8+持仓≤30天", {
|
||||
"min_buy_triggered": 2, "take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
"max_hold_days": 30,
|
||||
}),
|
||||
# ── v5.2: 信号加权仓位 (强信号1.5倍/较强1.2倍) ──
|
||||
("v5.2|延迟2天+触发≥2+TP10+SL8+信号加权", {
|
||||
"sell_confirm_days": 2, "min_buy_triggered": 2,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
"signal_weight": True,
|
||||
}),
|
||||
("v5.2|忽略卖出+TP10+SL8+信号加权", {
|
||||
"ignore_sell_signal": True, "take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
"signal_weight": True,
|
||||
}),
|
||||
("v5.2|跟踪止盈8/3+SL5+信号加权", {
|
||||
"ignore_sell_signal": True, "trailing_start_pct": 8, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5, "signal_weight": True,
|
||||
}),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
conn = get_db_conn()
|
||||
|
||||
# 检查数据覆盖
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT min(scan_date), max(scan_date), count(DISTINCT scan_date) FROM stock_scan_history")
|
||||
scan_min, scan_max, scan_days = cur.fetchone()
|
||||
print(f"📊 扫描数据: {scan_min} ~ {scan_max} ({scan_days}天)")
|
||||
|
||||
cur.execute("SELECT min(trade_date), max(trade_date), count(DISTINCT trade_date) FROM stock_kline_daily")
|
||||
kline_min, kline_max, kline_days = cur.fetchone()
|
||||
print(f"📊 K线数据: {kline_min} ~ {kline_max} ({kline_days}天)")
|
||||
|
||||
print(f"\n{'='*120}")
|
||||
print(f" 💰 20万本金 按季度投资 × {len(ALGORITHMS)}种算法 对比回测 (v7.0 最优时点)")
|
||||
print(f"{'='*120}")
|
||||
print(f" 本金: ¥{CAPITAL:,} (唯一约束) | 单笔仓位: {POSITION_PCT}%=¥{int(CAPITAL*POSITION_PCT/100):,}/笔(动态)")
|
||||
print(f" 限制: 单只上限=无 | 最大持仓=无 | 股价区间=无 | 每日买入=无 | 冷却期: {SELL_COOLDOWN_DAYS}天")
|
||||
print(f" 时点: 买入@09:35 | 卖出@13:40 (v7.0 网格搜索最优)")
|
||||
print(f"{'='*120}\n")
|
||||
|
||||
# results[quarter_name][algo_name] = stats_dict
|
||||
results = {}
|
||||
|
||||
total_runs = len(QUARTERS) * len(ALGORITHMS)
|
||||
run_idx = 0
|
||||
|
||||
for q_name, q_start, q_end in QUARTERS:
|
||||
results[q_name] = {}
|
||||
for algo_name, algo_params in ALGORITHMS:
|
||||
run_idx += 1
|
||||
print(f" [{run_idx}/{total_runs}] {q_name} | {algo_name}", end="", flush=True)
|
||||
t0 = time.time()
|
||||
|
||||
result = run_backtest(
|
||||
conn,
|
||||
start_date=q_start, end_date=q_end,
|
||||
total_capital=CAPITAL, # v5.1: 总资金约束模式
|
||||
position_pct=POSITION_PCT, # v5.2: 动态仓位(每笔=总资金×5%)
|
||||
use_5min_prices=True, # v7: 使用5分钟实时价格
|
||||
buy_time='09:35', # v7: 最优买入时间
|
||||
sell_time='13:40', # v7: 最优卖出时间
|
||||
verbose=False,
|
||||
**algo_params,
|
||||
)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
if result and result.get('stats'):
|
||||
s = result['stats']
|
||||
results[q_name][algo_name] = s
|
||||
print(f" → ¥{s['profit']:>+10,.0f} 收益{s['capital_pct']:>+6.1f}% "
|
||||
f"年化{s['capital_ann_pct']:>+7.1f}% 胜率{s['win_rate']:>5.1f}% "
|
||||
f"({elapsed:.1f}s)", flush=True)
|
||||
else:
|
||||
results[q_name][algo_name] = None
|
||||
print(f" → 无数据 ({elapsed:.1f}s)", flush=True)
|
||||
|
||||
conn.close()
|
||||
|
||||
# ─── 输出结果 ─────────────────────
|
||||
|
||||
# 1. 控制台大表
|
||||
print(f"\n{'='*160}")
|
||||
print(f" 📊 20万本金 × 按季度投资 完整对比表 (v5.2 动态仓位: 每笔={POSITION_PCT}%=¥{int(CAPITAL*POSITION_PCT/100):,})")
|
||||
print(f"{'='*160}")
|
||||
|
||||
# 表头
|
||||
header = f"{'算法':<36}"
|
||||
for q_name, _, _ in QUARTERS:
|
||||
header += f" | {q_name:>14}"
|
||||
print(header)
|
||||
print("-" * 160)
|
||||
|
||||
# 盈亏行
|
||||
print("\n 📈 盈亏(元):")
|
||||
print("-" * 160)
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
row = f" {algo_name:<34}"
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
row += f" | {s['profit']:>+13,.0f}"
|
||||
else:
|
||||
row += f" | {'N/A':>13}"
|
||||
print(row)
|
||||
|
||||
# 真实收益率行
|
||||
print(f"\n 📊 真实收益率(%):")
|
||||
print("-" * 160)
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
row = f" {algo_name:<34}"
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
row += f" | {s['capital_pct']:>+12.1f}%"
|
||||
else:
|
||||
row += f" | {'N/A':>13}"
|
||||
print(row)
|
||||
|
||||
# 年化收益率行
|
||||
print(f"\n 📊 年化收益率(%):")
|
||||
print("-" * 160)
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
row = f" {algo_name:<34}"
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
method = s.get('capital_ann_method', '?')
|
||||
tag = '(S)' if method == 'simple' else '(C)'
|
||||
row += f" | {s['capital_ann_pct']:>+9.1f}%{tag}"
|
||||
else:
|
||||
row += f" | {'N/A':>13}"
|
||||
print(row)
|
||||
|
||||
# 胜率行
|
||||
print(f"\n 📊 胜率(%):")
|
||||
print("-" * 160)
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
row = f" {algo_name:<34}"
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
row += f" | {s['win_rate']:>12.1f}%"
|
||||
else:
|
||||
row += f" | {'N/A':>13}"
|
||||
print(row)
|
||||
|
||||
# 盈亏比行
|
||||
print(f"\n 📊 盈亏比:")
|
||||
print("-" * 160)
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
row = f" {algo_name:<34}"
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
row += f" | {s['profit_factor']:>13.2f}"
|
||||
else:
|
||||
row += f" | {'N/A':>13}"
|
||||
print(row)
|
||||
|
||||
# 交易笔数行
|
||||
print(f"\n 📊 交易笔数:")
|
||||
print("-" * 160)
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
row = f" {algo_name:<34}"
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
row += f" | {s['trade_count']:>13}"
|
||||
else:
|
||||
row += f" | {'N/A':>13}"
|
||||
print(row)
|
||||
|
||||
print(f"\n{'='*160}")
|
||||
|
||||
# ─── 找出各季度最优算法 ─────────
|
||||
print(f"\n 🏆 各季度最优算法:")
|
||||
print("-" * 100)
|
||||
for q_name, _, _ in QUARTERS:
|
||||
best_algo = None
|
||||
best_profit = -float('inf')
|
||||
best_ann = -float('inf')
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s and s['profit'] > best_profit:
|
||||
best_profit = s['profit']
|
||||
best_ann = s['capital_ann_pct']
|
||||
best_algo = algo_name
|
||||
best_stats = s
|
||||
if best_algo:
|
||||
print(f" {q_name:<14} → 🏆 {best_algo:<36} "
|
||||
f"盈利 ¥{best_profit:>+10,.0f} 收益{best_stats['capital_pct']:>+6.1f}% "
|
||||
f"年化{best_ann:>+7.1f}% 胜率{best_stats['win_rate']:.1f}% "
|
||||
f"盈亏比{best_stats['profit_factor']:.2f}")
|
||||
else:
|
||||
print(f" {q_name:<14} → 无数据")
|
||||
|
||||
# ─── 输出到 Markdown ─────────
|
||||
out_path = os.path.join(os.path.dirname(__file__), "docs", "backtest_quarterly_200k.md")
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write("# 💰 20万本金 × 按季度投资 × 多算法对比回测\n\n")
|
||||
f.write(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n")
|
||||
f.write("## 回测配置 (v5.2 动态仓位)\n\n")
|
||||
f.write(f"| 参数 | 值 |\n|------|----|\n")
|
||||
f.write(f"| 本金 | ¥{CAPITAL:,} (唯一约束) |\n")
|
||||
f.write(f"| 单只上限 | 无(受总资金约束) |\n")
|
||||
f.write(f"| 最大持仓 | 无(受总资金约束) |\n")
|
||||
f.write(f"| 每笔仓位 | 动态: 总资金×{POSITION_PCT}% = ¥{int(CAPITAL*POSITION_PCT/100):,}/笔 |\n")
|
||||
f.write(f"| 每笔股数 | 动态(根据股价自动计算,取整到100股) |\n")
|
||||
f.write(f"| 股价区间 | 无 |\n")
|
||||
f.write(f"| 每日最多买入 | 无 |\n")
|
||||
f.write(f"| 冷却期 | {SELL_COOLDOWN_DAYS}天 |\n")
|
||||
f.write(f"| 年化方法 | <90天用简单(S),≥90天用复利CAGR(C) |\n\n")
|
||||
|
||||
# 算法说明
|
||||
f.write("## 算法说明\n\n")
|
||||
f.write("| # | 算法 | 参数说明 |\n|---|------|--------|\n")
|
||||
for i, (name, params) in enumerate(ALGORITHMS, 1):
|
||||
param_str = ", ".join(f"{k}={v}" for k, v in params.items())
|
||||
f.write(f"| {i} | {name} | {param_str} |\n")
|
||||
f.write("\n")
|
||||
|
||||
# 盈亏对比表
|
||||
f.write("## 一、盈亏对比(元)\n\n")
|
||||
f.write(f"| 算法 |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
f.write(f" {q_name} |")
|
||||
f.write("\n|------|")
|
||||
for _ in QUARTERS:
|
||||
f.write("--------|")
|
||||
f.write("\n")
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
f.write(f"| {algo_name} |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
val = f"{s['profit']:+,.0f}"
|
||||
f.write(f" {val} |")
|
||||
else:
|
||||
f.write(" N/A |")
|
||||
f.write("\n")
|
||||
f.write("\n")
|
||||
|
||||
# 真实收益率
|
||||
f.write("## 二、真实收益率(%)\n\n")
|
||||
f.write(f"| 算法 |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
f.write(f" {q_name} |")
|
||||
f.write("\n|------|")
|
||||
for _ in QUARTERS:
|
||||
f.write("--------|")
|
||||
f.write("\n")
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
f.write(f"| {algo_name} |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
f.write(f" {s['capital_pct']:+.1f}% |")
|
||||
else:
|
||||
f.write(" N/A |")
|
||||
f.write("\n")
|
||||
f.write("\n")
|
||||
|
||||
# 年化收益率
|
||||
f.write("## 三、年化收益率(%)\n\n")
|
||||
f.write("> (S)=简单年化(<90天),(C)=复利CAGR(≥90天)\n\n")
|
||||
f.write(f"| 算法 |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
f.write(f" {q_name} |")
|
||||
f.write("\n|------|")
|
||||
for _ in QUARTERS:
|
||||
f.write("--------|")
|
||||
f.write("\n")
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
f.write(f"| {algo_name} |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
method = s.get('capital_ann_method', '?')
|
||||
tag = '(S)' if method == 'simple' else '(C)'
|
||||
f.write(f" {s['capital_ann_pct']:+.1f}%{tag} |")
|
||||
else:
|
||||
f.write(" N/A |")
|
||||
f.write("\n")
|
||||
f.write("\n")
|
||||
|
||||
# 胜率
|
||||
f.write("## 四、胜率(%)\n\n")
|
||||
f.write(f"| 算法 |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
f.write(f" {q_name} |")
|
||||
f.write("\n|------|")
|
||||
for _ in QUARTERS:
|
||||
f.write("--------|")
|
||||
f.write("\n")
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
f.write(f"| {algo_name} |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
f.write(f" {s['win_rate']:.1f}% |")
|
||||
else:
|
||||
f.write(" N/A |")
|
||||
f.write("\n")
|
||||
f.write("\n")
|
||||
|
||||
# 盈亏比
|
||||
f.write("## 五、盈亏比\n\n")
|
||||
f.write(f"| 算法 |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
f.write(f" {q_name} |")
|
||||
f.write("\n|------|")
|
||||
for _ in QUARTERS:
|
||||
f.write("--------|")
|
||||
f.write("\n")
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
f.write(f"| {algo_name} |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
f.write(f" {s['profit_factor']:.2f} |")
|
||||
else:
|
||||
f.write(" N/A |")
|
||||
f.write("\n")
|
||||
f.write("\n")
|
||||
|
||||
# 最大占用资金
|
||||
f.write("## 六、最大占用资金(元)\n\n")
|
||||
f.write(f"| 算法 |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
f.write(f" {q_name} |")
|
||||
f.write("\n|------|")
|
||||
for _ in QUARTERS:
|
||||
f.write("--------|")
|
||||
f.write("\n")
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
f.write(f"| {algo_name} |")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s:
|
||||
f.write(f" ¥{s['max_capital']:,.0f} |")
|
||||
else:
|
||||
f.write(" N/A |")
|
||||
f.write("\n")
|
||||
f.write("\n")
|
||||
|
||||
# 各季度最优算法
|
||||
f.write("## 七、🏆 各季度最优算法\n\n")
|
||||
f.write("| 季度 | 最优算法 | 盈利(元) | 真实收益 | 年化 | 胜率 | 盈亏比 |\n")
|
||||
f.write("|------|---------|---------|---------|------|------|--------|\n")
|
||||
for q_name, _, _ in QUARTERS:
|
||||
best_algo = None
|
||||
best_profit = -float('inf')
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
s = results[q_name].get(algo_name)
|
||||
if s and s['profit'] > best_profit:
|
||||
best_profit = s['profit']
|
||||
best_algo = algo_name
|
||||
best_s = s
|
||||
if best_algo:
|
||||
method = best_s.get('capital_ann_method', '?')
|
||||
tag = '(S)' if method == 'simple' else '(C)'
|
||||
f.write(f"| {q_name} | **{best_algo}** | {best_profit:+,.0f} | "
|
||||
f"{best_s['capital_pct']:+.1f}% | {best_s['capital_ann_pct']:+.1f}%{tag} | "
|
||||
f"{best_s['win_rate']:.1f}% | {best_s['profit_factor']:.2f} |\n")
|
||||
else:
|
||||
f.write(f"| {q_name} | N/A | - | - | - | - | - |\n")
|
||||
f.write("\n")
|
||||
|
||||
# 算法总盈利排名
|
||||
f.write("## 八、算法全期间总收益排名\n\n")
|
||||
algo_totals = []
|
||||
for algo_name, _ in ALGORITHMS:
|
||||
s = results["全期间"].get(algo_name)
|
||||
if s:
|
||||
algo_totals.append((algo_name, s))
|
||||
algo_totals.sort(key=lambda x: x[1]['profit'], reverse=True)
|
||||
|
||||
f.write("| 排名 | 算法 | 全期间盈利 | 真实收益 | 年化(CAGR) | 胜率 | 盈亏比 | 最大回撤 | 占用资金 |\n")
|
||||
f.write("|------|------|----------|---------|-----------|------|--------|---------|--------|\n")
|
||||
for rank, (algo_name, s) in enumerate(algo_totals, 1):
|
||||
medal = "🏆" if rank == 1 else ("🥈" if rank == 2 else ("🥉" if rank == 3 else f"#{rank}"))
|
||||
f.write(f"| {medal} | {algo_name} | {s['profit']:+,.0f} | {s['capital_pct']:+.1f}% | "
|
||||
f"{s['capital_ann_pct']:+.1f}% | {s['win_rate']:.1f}% | {s['profit_factor']:.2f} | "
|
||||
f"{s['max_drawdown_pct']:.1f}% | ¥{s['max_capital']:,.0f} |\n")
|
||||
f.write("\n")
|
||||
|
||||
# 分析结论
|
||||
f.write("## 九、分析结论\n\n")
|
||||
if algo_totals:
|
||||
best_name, best_s = algo_totals[0]
|
||||
f.write(f"### 🏆 全期间最优算法: {best_name}\n\n")
|
||||
f.write(f"- 总盈利: **¥{best_s['profit']:+,.0f}**\n")
|
||||
f.write(f"- 真实收益率: **{best_s['capital_pct']:+.1f}%**\n")
|
||||
f.write(f"- 年化收益率: **{best_s['capital_ann_pct']:+.1f}%**\n")
|
||||
f.write(f"- 胜率: **{best_s['win_rate']:.1f}%**\n")
|
||||
f.write(f"- 盈亏比: **{best_s['profit_factor']:.2f}**\n")
|
||||
f.write(f"- 最大回撤: **{best_s['max_drawdown_pct']:.1f}%**\n")
|
||||
f.write(f"- 最大占用资金: **¥{best_s['max_capital']:,.0f}**({best_s['max_capital']/CAPITAL*100:.0f}%本金利用率)\n")
|
||||
f.write(f"\n### 回报对比\n\n")
|
||||
f.write(f"| 投资方式 | 年化收益 | 20万本金一年收益 |\n")
|
||||
f.write(f"|---------|---------|----------------|\n")
|
||||
f.write(f"| 银行定存 | 2.5% | ¥5,000 |\n")
|
||||
f.write(f"| 余额宝 | 1.8% | ¥3,600 |\n")
|
||||
f.write(f"| **本算法** | **{best_s['capital_ann_pct']:+.1f}%** | **¥{best_s['profit']:+,.0f}**(实际) |\n")
|
||||
f.write(f"\n")
|
||||
|
||||
print(f"\n📝 结果已写入 {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""运行多组回测场景并输出对比表(v4.2 真实资金收益率版)。
|
||||
v3 基线 vs v4 优化 vs v4.2 延迟确认 全面对比。
|
||||
核心改进: 用真实占用资金(而非总周转金额)计算收益率和年化。"""
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
from datetime import date, datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from backtest_recommend import (
|
||||
get_db_conn, get_codes_with_data, run_backtest, START_DATE,
|
||||
MAX_POSITION_AMOUNT, MAX_CONCURRENT_POSITIONS, PRICE_MIN, PRICE_MAX,
|
||||
SELL_COOLDOWN_DAYS, MAX_BUYS_PER_DAY,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='多场景回测对比(v4.2 真实资金收益率)')
|
||||
parser.add_argument('--start', type=str, default=None, metavar='YYYY-MM-DD',
|
||||
help='回测起始日(默认 2026-01-02)')
|
||||
parser.add_argument('-v', '--verbose', action='store_true', help='每个场景输出每日进度')
|
||||
parser.add_argument('--quick', type=int, default=None, metavar='N',
|
||||
help='仅运行前 N 个场景(快速验证)')
|
||||
parser.add_argument('--v3-only', action='store_true', help='仅运行 v3 基线场景')
|
||||
parser.add_argument('--v4-only', action='store_true', help='仅运行 v4/v4.2 优化场景')
|
||||
args = parser.parse_args()
|
||||
|
||||
start_date = START_DATE
|
||||
if args.start:
|
||||
try:
|
||||
start_date = datetime.strptime(args.start, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
print("错误: --start 格式应为 YYYY-MM-DD")
|
||||
return
|
||||
|
||||
conn = get_db_conn()
|
||||
end = date.today()
|
||||
try:
|
||||
codes = get_codes_with_data(conn, end, min_days=30)
|
||||
except Exception:
|
||||
codes = []
|
||||
if not codes:
|
||||
print("错误: 无 stock_kline_daily 数据")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# ═══ v3 基线 ═══
|
||||
v3_scenarios = [
|
||||
("v3|仅信号", {}),
|
||||
("v3|止盈10+损8", {"take_profit_pct": 10, "stop_loss_pct": 8}),
|
||||
]
|
||||
|
||||
# ═══ v4 优化(上轮胜出) ═══
|
||||
v4_scenarios = [
|
||||
("v4|触发≥2+止盈10+损8", {
|
||||
"min_buy_triggered": 2,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4|触发≥2+跟踪6-3+损5", {
|
||||
"min_buy_triggered": 2,
|
||||
"trailing_start_pct": 6, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5,
|
||||
}),
|
||||
]
|
||||
|
||||
# ═══ v4.1 忽略卖出信号(参考对照) ═══
|
||||
v41_scenarios = [
|
||||
("v4.1|忽略卖出+止盈10+损8", {
|
||||
"ignore_sell_signal": True,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4.1|忽略+跟踪8-3+损5+20天", {
|
||||
"ignore_sell_signal": True,
|
||||
"trailing_start_pct": 8, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5, "max_hold_days": 20,
|
||||
}),
|
||||
]
|
||||
|
||||
# ═══ v4.2 延迟卖出确认(核心创新) ═══
|
||||
v42_scenarios = [
|
||||
# ── K: 延迟2天确认 + 各种组合 ──
|
||||
("v4.2-K1|延迟2天+止盈10+损8", {
|
||||
"sell_confirm_days": 2,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4.2-K2|延迟2天+触发≥2+止盈10+损8", {
|
||||
"sell_confirm_days": 2,
|
||||
"min_buy_triggered": 2,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4.2-K3|延迟2天+跟踪8-3+损5", {
|
||||
"sell_confirm_days": 2,
|
||||
"trailing_start_pct": 8, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5,
|
||||
}),
|
||||
("v4.2-K4|延迟2天+跟踪6-3+损5", {
|
||||
"sell_confirm_days": 2,
|
||||
"trailing_start_pct": 6, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5,
|
||||
}),
|
||||
("v4.2-K5|延迟2天+触发≥2+跟踪8-3+损5", {
|
||||
"sell_confirm_days": 2,
|
||||
"min_buy_triggered": 2,
|
||||
"trailing_start_pct": 8, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5,
|
||||
}),
|
||||
("v4.2-K6|延迟2天+触发≥2+跟踪6-3+损8", {
|
||||
"sell_confirm_days": 2,
|
||||
"min_buy_triggered": 2,
|
||||
"trailing_start_pct": 6, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 8,
|
||||
}),
|
||||
|
||||
# ── L: 延迟3天确认 ──
|
||||
("v4.2-L1|延迟3天+止盈10+损8", {
|
||||
"sell_confirm_days": 3,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4.2-L2|延迟3天+触发≥2+止盈10+损8", {
|
||||
"sell_confirm_days": 3,
|
||||
"min_buy_triggered": 2,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4.2-L3|延迟3天+跟踪8-3+损5", {
|
||||
"sell_confirm_days": 3,
|
||||
"trailing_start_pct": 8, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5,
|
||||
}),
|
||||
("v4.2-L4|延迟3天+触发≥2+跟踪6-3+损5", {
|
||||
"sell_confirm_days": 3,
|
||||
"min_buy_triggered": 2,
|
||||
"trailing_start_pct": 6, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5,
|
||||
}),
|
||||
|
||||
# ── M: 延迟确认 + 盈利保护 + 超时 ──
|
||||
("v4.2-M1|延迟2天+盈保5%+止盈10+损8", {
|
||||
"sell_confirm_days": 2,
|
||||
"profit_protect_pct": 5,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4.2-M2|延迟2天+盈保5%+触发≥2+止盈10+损8", {
|
||||
"sell_confirm_days": 2,
|
||||
"profit_protect_pct": 5,
|
||||
"min_buy_triggered": 2,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("v4.2-M3|延迟3天+跟踪8-3+损5+30天", {
|
||||
"sell_confirm_days": 3,
|
||||
"trailing_start_pct": 8, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5, "max_hold_days": 30,
|
||||
}),
|
||||
("v4.2-M4|延迟2天+触发≥2+跟踪8-3+损5+30天", {
|
||||
"sell_confirm_days": 2,
|
||||
"min_buy_triggered": 2,
|
||||
"trailing_start_pct": 8, "trailing_gap_pct": 3,
|
||||
"stop_loss_pct": 5, "max_hold_days": 30,
|
||||
}),
|
||||
]
|
||||
|
||||
# 选择场景
|
||||
if args.v3_only:
|
||||
scenarios = v3_scenarios
|
||||
elif args.v4_only:
|
||||
scenarios = v4_scenarios + v41_scenarios + v42_scenarios
|
||||
else:
|
||||
scenarios = v3_scenarios + v4_scenarios + v41_scenarios + v42_scenarios
|
||||
|
||||
if args.quick is not None:
|
||||
scenarios = scenarios[: args.quick]
|
||||
total = len(scenarios)
|
||||
|
||||
v3_count = len(v3_scenarios) if not args.v4_only else 0
|
||||
v4_count = len(v4_scenarios) if not args.v3_only else 0
|
||||
v41_count = len(v41_scenarios) if not args.v3_only else 0
|
||||
|
||||
print("=" * 150)
|
||||
print(" 多场景回测对比 v4.2(真实资金收益率 + 延迟卖出确认)")
|
||||
print("=" * 150)
|
||||
print(f" 回测区间 : {start_date} ~ {end}")
|
||||
print(f" 场景数 : {total}")
|
||||
print(f" 股价区间 : {PRICE_MIN}~{PRICE_MAX} 元 | 单只上限 : ¥{MAX_POSITION_AMOUNT:,}")
|
||||
print(f" 每日买入 : 最多 {MAX_BUYS_PER_DAY} 只 | 最大持仓 : {MAX_CONCURRENT_POSITIONS} 只")
|
||||
print(f" 冷却期 : {SELL_COOLDOWN_DAYS} 天")
|
||||
print(f" ⚠️ 本版使用【真实资金收益率】= 盈亏 / 最大同时占用资金")
|
||||
print("=" * 150)
|
||||
print()
|
||||
|
||||
rows = []
|
||||
for k, (name, kwargs) in enumerate(scenarios, 1):
|
||||
print(f"[进度] 场景 {k}/{total}: {name}", flush=True)
|
||||
result = run_backtest(
|
||||
conn, start_date=start_date, end_date=end,
|
||||
verbose=args.verbose, **kwargs
|
||||
)
|
||||
if not result or not result.get('stats'):
|
||||
rows.append((name, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
continue
|
||||
s = result['stats']
|
||||
rows.append((
|
||||
name,
|
||||
s['profit'], # 1: 盈亏
|
||||
s.get('capital_pct', 0), # 2: 真实收益率
|
||||
s.get('capital_ann_pct', 0), # 3: 真实年化
|
||||
s['win_rate'], # 4: 胜率
|
||||
s['max_drawdown'], # 5: 最大回撤
|
||||
s.get('max_drawdown_pct', 0), # 6: 回撤%
|
||||
s['avg_hold_days'], # 7: 平均持仓
|
||||
s['profit_factor'], # 8: 盈亏比
|
||||
s['trade_count'], # 9: 交易数
|
||||
s.get('max_capital', 0), # 10: 最大占用
|
||||
s['profit_pct'], # 11: 周转收益率(参考)
|
||||
s['annualized_pct'], # 12: 周转年化(参考)
|
||||
))
|
||||
mc = s.get('max_capital', 0)
|
||||
cp = s.get('capital_pct', 0)
|
||||
ca = s.get('capital_ann_pct', 0)
|
||||
if not args.verbose:
|
||||
print(f" → 盈亏 ¥{s['profit']:>+10,.0f} 资金占用 ¥{mc:>8,.0f} "
|
||||
f"真实收益 {cp:>+6.1f}% 年化 {ca:>+6.1f}% "
|
||||
f"胜率 {s['win_rate']:>5.1f}% 交易 {s['trade_count']} 笔", flush=True)
|
||||
|
||||
conn.close()
|
||||
|
||||
# 找最优(基于真实收益率)
|
||||
if rows:
|
||||
best_profit_idx = max(range(len(rows)), key=lambda i: rows[i][1])
|
||||
best_cap_idx = max(range(len(rows)), key=lambda i: rows[i][2])
|
||||
best_ann_idx = max(range(len(rows)), key=lambda i: rows[i][3])
|
||||
best_winrate_idx = max(range(len(rows)), key=lambda i: rows[i][4])
|
||||
min_dd_idx = min(range(len(rows)), key=lambda i: rows[i][5])
|
||||
best_pf_idx = max(range(len(rows)), key=lambda i: rows[i][8])
|
||||
else:
|
||||
best_profit_idx = best_cap_idx = best_ann_idx = best_winrate_idx = min_dd_idx = best_pf_idx = -1
|
||||
|
||||
# 写入对比表
|
||||
out_path = os.path.join(os.path.dirname(__file__), "docs", "backtest_comparison.md")
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write("# 回测场景对比 v4.2(真实资金收益率版)\n\n")
|
||||
f.write(f"回测区间: {start_date} ~ {end}\n\n")
|
||||
f.write("> ⚠️ **真实收益率** = 盈亏 / 最大同时占用资金(非总周转金额)\n\n")
|
||||
|
||||
f.write("## 对比结果\n\n")
|
||||
f.write("| 场景 | 盈亏(元) | 占用资金 | 真实收益 | 真实年化 | 胜率 | 回撤% | 持仓天 | 盈亏比 | 交易 | 标注 |\n")
|
||||
f.write("|------|---------|---------|---------|---------|------|-------|--------|--------|------|------|\n")
|
||||
for idx, row_data in enumerate(rows):
|
||||
name = row_data[0]
|
||||
profit, cap_pct, cap_ann = row_data[1], row_data[2], row_data[3]
|
||||
wr, dd, dd_pct = row_data[4], row_data[5], row_data[6]
|
||||
hold, pf, n, mc = row_data[7], row_data[8], row_data[9], row_data[10]
|
||||
tags = []
|
||||
if idx == best_profit_idx:
|
||||
tags.append('🏆收益最高')
|
||||
if idx == best_ann_idx and idx != best_profit_idx:
|
||||
tags.append('📈年化最高')
|
||||
if idx == best_cap_idx and idx != best_profit_idx and idx != best_ann_idx:
|
||||
tags.append('💰资金效率')
|
||||
if idx == best_winrate_idx:
|
||||
tags.append('🎯胜率最高')
|
||||
if idx == min_dd_idx:
|
||||
tags.append('🛡️回撤最小')
|
||||
if idx == best_pf_idx and idx != best_profit_idx:
|
||||
tags.append('⚖️盈亏比最佳')
|
||||
tag_str = ' '.join(tags)
|
||||
f.write(f"| {name} | {profit:+,.0f} | ¥{mc:,.0f} | {cap_pct:+.1f}% | {cap_ann:+.1f}% | "
|
||||
f"{wr:.1f}% | {dd_pct:.1f}% | {hold:.0f}天 | {pf:.2f} | {n} | {tag_str} |\n")
|
||||
f.write("\n")
|
||||
|
||||
# 加说明
|
||||
f.write("## 指标说明\n\n")
|
||||
f.write("| 指标 | 说明 |\n")
|
||||
f.write("|------|------|\n")
|
||||
f.write("| 占用资金 | 回测期间最大同时持仓成本 |\n")
|
||||
f.write("| 真实收益 | 盈亏 / 最大占用资金 × 100% |\n")
|
||||
f.write("| 真实年化 | 按持续期折算年化(复利公式) |\n")
|
||||
f.write("| 回撤% | 最大回撤 / 最大占用资金 × 100% |\n")
|
||||
f.write("| 盈亏比 | 总盈利金额 / 总亏损金额 |\n")
|
||||
f.write("| 延迟N天 | 连续N天推荐卖出才执行卖出 |\n")
|
||||
f.write("\n")
|
||||
|
||||
# 控制台表格
|
||||
print("\n" + "=" * 160)
|
||||
print(" v4.2 整体对比表(★ 真实资金收益率 ★)")
|
||||
print("=" * 160)
|
||||
header = (f"{'场景':<42} {'盈亏(元)':>10} {'占用资金':>10} {'真实收益':>8} {'真实年化':>8} "
|
||||
f"{'胜率':>6} {'回撤%':>7} {'持仓':>6} {'盈亏比':>6} {'交易':>5}")
|
||||
print(header)
|
||||
print("-" * 160)
|
||||
|
||||
v3_end_idx = v3_count
|
||||
v4_end_idx = v3_count + v4_count
|
||||
v41_end_idx = v4_end_idx + v41_count
|
||||
|
||||
for idx, row_data in enumerate(rows):
|
||||
name = row_data[0]
|
||||
profit, cap_pct, cap_ann = row_data[1], row_data[2], row_data[3]
|
||||
wr, dd, dd_pct = row_data[4], row_data[5], row_data[6]
|
||||
hold, pf, n, mc = row_data[7], row_data[8], row_data[9], row_data[10]
|
||||
|
||||
tags = []
|
||||
if idx == best_profit_idx:
|
||||
tags.append('🏆')
|
||||
if idx == best_ann_idx and idx != best_profit_idx:
|
||||
tags.append('📈')
|
||||
if idx == best_cap_idx and idx != best_profit_idx and idx != best_ann_idx:
|
||||
tags.append('💰')
|
||||
if idx == best_winrate_idx:
|
||||
tags.append('🎯')
|
||||
if idx == min_dd_idx:
|
||||
tags.append('🛡️')
|
||||
if idx == best_pf_idx and idx != best_profit_idx:
|
||||
tags.append('⚖️')
|
||||
tag_str = ''.join(tags)
|
||||
|
||||
# 分隔线
|
||||
if not args.v3_only and not args.v4_only:
|
||||
if idx == v3_end_idx and v3_count > 0:
|
||||
print("─" * 160)
|
||||
print(f" {'↑ v3 基线 ↓ v4 优化':^148}")
|
||||
print("─" * 160)
|
||||
if idx == v4_end_idx and v4_count > 0:
|
||||
print("─" * 160)
|
||||
print(f" {'↑ v4 优化 ↓ v4.1 忽略卖出(参考对照)':^148}")
|
||||
print("─" * 160)
|
||||
if idx == v41_end_idx and v41_count > 0:
|
||||
print("─" * 160)
|
||||
print(f" {'↑ v4.1 参考 ↓ v4.2 延迟卖出确认(核心创新)':^148}")
|
||||
print("─" * 160)
|
||||
|
||||
print(f"{name:<42} {profit:>+10,.0f} {'¥'+str(int(mc)):>10} {cap_pct:>+7.1f}% {cap_ann:>+7.1f}% "
|
||||
f"{wr:>5.1f}% {dd_pct:>6.1f}% {hold:>5.0f}天 {pf:>6.2f} {n:>5} {tag_str}")
|
||||
|
||||
print("=" * 160)
|
||||
|
||||
# 总结
|
||||
if rows and len(rows) > 1:
|
||||
print("\n 📊 关键发现(★ 基于真实资金收益率 ★):")
|
||||
if best_profit_idx >= 0:
|
||||
r = rows[best_profit_idx]
|
||||
print(f" 🏆 绝对收益最高: {r[0]} → ¥{r[1]:+,.0f} (真实{r[2]:+.1f}%, 年化{r[3]:+.1f}%)")
|
||||
if best_ann_idx >= 0 and best_ann_idx != best_profit_idx:
|
||||
r = rows[best_ann_idx]
|
||||
print(f" 📈 年化最高: {r[0]} → 真实年化 {r[3]:+.1f}% (占用 ¥{r[10]:,.0f})")
|
||||
if best_cap_idx >= 0 and best_cap_idx not in (best_profit_idx, best_ann_idx):
|
||||
r = rows[best_cap_idx]
|
||||
print(f" 💰 资金效率最高: {r[0]} → 真实收益 {r[2]:+.1f}% (占用 ¥{r[10]:,.0f})")
|
||||
if best_winrate_idx >= 0:
|
||||
r = rows[best_winrate_idx]
|
||||
print(f" 🎯 胜率最高: {r[0]} → {r[4]:.1f}%")
|
||||
if best_pf_idx >= 0:
|
||||
r = rows[best_pf_idx]
|
||||
print(f" ⚖️ 盈亏比最佳: {r[0]} → {r[8]:.2f}")
|
||||
if min_dd_idx >= 0:
|
||||
r = rows[min_dd_idx]
|
||||
print(f" 🛡️ 回撤最小: {r[0]} → {r[6]:.1f}%")
|
||||
|
||||
# 银行对比
|
||||
print("\n 🏦 银行存款利率对比(年化2.5%):")
|
||||
for idx, r in enumerate(rows):
|
||||
ann = r[3]
|
||||
if ann > 2.5:
|
||||
icon = '✅'
|
||||
else:
|
||||
icon = '❌'
|
||||
print(f" {icon} {r[0]:<42} 年化 {ann:>+6.1f}% {'超过银行' if ann > 2.5 else '低于银行'}")
|
||||
|
||||
print(f"\n场景对比已写入 {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Top 3 最挣钱算法回测 v5(使用5分钟K线实时价格版)
|
||||
|
||||
对比三组数据:
|
||||
1. 原始版(日线close/open) - 作为基准
|
||||
2. 5分钟实时价格版 - 使用stock_kline_5min的10:00买入价、15:00卖出价
|
||||
3. Mid价格版 - 使用(open+close)/2作为替代
|
||||
|
||||
Top 3 算法:
|
||||
🏆 v4|触发≥2+止盈10+损8 → +53,100 (33.1%, 年化28.3%)
|
||||
🥈 v3|止盈10+损8 → +49,500 (28.4%, 年化24.4%)
|
||||
🥉 v4.2-K1|延迟2天+止盈10+损8 → +48,920 (28.1%, 年化24.1%)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from backtest_recommend import (
|
||||
get_db_conn, get_codes_with_data, run_backtest, START_DATE,
|
||||
MAX_POSITION_AMOUNT, MAX_CONCURRENT_POSITIONS, PRICE_MIN, PRICE_MAX,
|
||||
SELL_COOLDOWN_DAYS, MAX_BUYS_PER_DAY,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Top 3 算法回测')
|
||||
parser.add_argument('--start', type=str, default=None, help='回测起始日 YYYY-MM-DD(默认取扫描历史最早日期)')
|
||||
parser.add_argument('--end', type=str, default=None, help='回测结束日 YYYY-MM-DD(默认今天)')
|
||||
args = parser.parse_args()
|
||||
|
||||
conn = get_db_conn()
|
||||
end = date.today()
|
||||
if args.end:
|
||||
end = datetime.strptime(args.end, '%Y-%m-%d').date()
|
||||
|
||||
# 使用完整扫描数据期间(而非 START_DATE 的短期)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT min(scan_date) FROM stock_scan_history")
|
||||
first_scan = cur.fetchone()[0]
|
||||
|
||||
if args.start:
|
||||
start = datetime.strptime(args.start, '%Y-%m-%d').date()
|
||||
print(f"📅 回测起始: {start}(用户指定)")
|
||||
else:
|
||||
start = first_scan if first_scan else START_DATE
|
||||
print(f"📅 回测起始: {start}(扫描历史最早日期)")
|
||||
|
||||
# 检查5分钟K线数据覆盖
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT count(*), count(DISTINCT code), count(DISTINCT dt::date),
|
||||
min(dt::date), max(dt::date)
|
||||
FROM stock_kline_5min
|
||||
""")
|
||||
cnt, codes, days, min_d, max_d = cur.fetchone()
|
||||
print(f"📊 stock_kline_5min 数据: {cnt:,}条 | {codes}只股票 | {days}天 | {min_d}~{max_d}")
|
||||
|
||||
cur.execute("SELECT count(DISTINCT scan_date) FROM stock_scan_history WHERE scan_date >= %s", (start,))
|
||||
scan_days = cur.fetchone()[0]
|
||||
print(f"📊 stock_scan_history: {scan_days}天扫描数据")
|
||||
|
||||
cur.execute("SELECT count(DISTINCT trade_date) FROM stock_kline_daily WHERE trade_date >= %s", (start,))
|
||||
kline_days = cur.fetchone()[0]
|
||||
print(f"📊 stock_kline_daily: {kline_days}天K线数据")
|
||||
|
||||
# Top 3 算法配置
|
||||
top3_algos = [
|
||||
("🏆 v4|触发≥2+止盈10+损8", {
|
||||
"min_buy_triggered": 2,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("🥈 v3|止盈10+损8", {
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
("🥉 v4.2-K1|延迟2天+止盈10+损8", {
|
||||
"sell_confirm_days": 2,
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
}),
|
||||
]
|
||||
|
||||
# 3 种定价模式
|
||||
price_modes = [
|
||||
("日线(原版)", False, '10:00', '15:00'), # 用daily open/close
|
||||
("5分钟最优时点", True, '09:35', '13:40'), # v7: 最优时点
|
||||
("5分钟旧时点", True, '10:00', '15:00'), # v5: 旧默认时点 (对比用)
|
||||
]
|
||||
|
||||
print("\n" + "=" * 140)
|
||||
print(" Top 3 最挣钱算法 × 3种定价模式 对比回测 v7")
|
||||
print("=" * 140)
|
||||
print(f" 回测区间: {start} ~ {end}")
|
||||
print(f" 定价说明:")
|
||||
print(f" 日线(原版): 买入用开盘价, 卖出用收盘价")
|
||||
print(f" 5分钟最优时点: 买入用09:35实时价, 卖出用13:40实时价 (v7网格搜索最优)")
|
||||
print(f" 5分钟旧时点: 买入用10:00实时价, 卖出用15:00实时价 (v5旧默认)")
|
||||
print(f" 股价区间: {PRICE_MIN}~{PRICE_MAX} 元 | 每笔1000股 | 每日最多买{MAX_BUYS_PER_DAY}只")
|
||||
print("=" * 140)
|
||||
print()
|
||||
|
||||
rows = []
|
||||
total_scenarios = len(top3_algos) * len(price_modes)
|
||||
idx = 0
|
||||
|
||||
for algo_name, algo_params in top3_algos:
|
||||
for mode_name, use_5min, bt, st in price_modes:
|
||||
idx += 1
|
||||
scenario_name = f"{algo_name} | {mode_name}"
|
||||
print(f"[{idx}/{total_scenarios}] {scenario_name}", flush=True)
|
||||
|
||||
t0 = time.time()
|
||||
result = run_backtest(
|
||||
conn, start_date=start, end_date=end,
|
||||
use_5min_prices=use_5min,
|
||||
buy_time=bt, sell_time=st,
|
||||
verbose=False, **algo_params
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
if not result or not result.get('stats'):
|
||||
print(f" ❌ 无结果")
|
||||
rows.append((scenario_name, algo_name, mode_name, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
continue
|
||||
|
||||
s = result['stats']
|
||||
row = (
|
||||
scenario_name,
|
||||
algo_name,
|
||||
mode_name,
|
||||
s['profit'], # 3: 盈亏
|
||||
s.get('capital_pct', 0), # 4: 真实收益率
|
||||
s.get('capital_ann_pct', 0), # 5: 真实年化
|
||||
s['win_rate'], # 6: 胜率
|
||||
s.get('max_drawdown_pct', 0), # 7: 回撤%
|
||||
s['avg_hold_days'], # 8: 持仓天
|
||||
s['profit_factor'], # 9: 盈亏比
|
||||
s['trade_count'], # 10: 交易数
|
||||
s.get('max_capital', 0), # 11: 最大占用
|
||||
s.get('5min_hit', 0), # 12: 5min命中
|
||||
s.get('5min_miss', 0), # 13: 5min缺失
|
||||
s.get('5min_coverage', 0), # 14: 5min覆盖率
|
||||
)
|
||||
rows.append(row)
|
||||
mc = s.get('max_capital', 0)
|
||||
cp = s.get('capital_pct', 0)
|
||||
ca = s.get('capital_ann_pct', 0)
|
||||
cov = s.get('5min_coverage', 0)
|
||||
print(f" → ¥{s['profit']:>+10,.0f} 占用¥{mc:>8,.0f} "
|
||||
f"真实{cp:>+6.1f}% 年化{ca:>+6.1f}% "
|
||||
f"胜率{s['win_rate']:>5.1f}% "
|
||||
f"{'5min覆盖' + str(cov) + '%' if use_5min else '日线'} "
|
||||
f"({elapsed:.1f}s)", flush=True)
|
||||
|
||||
conn.close()
|
||||
|
||||
# ═══ 输出对比表 ═══
|
||||
print("\n\n" + "=" * 160)
|
||||
print(" 📊 Top 3 算法 × 定价模式 完整对比表")
|
||||
print("=" * 160)
|
||||
header = (f"{'场景':<55} {'盈亏(元)':>10} {'占用资金':>10} {'真实收益':>8} {'真实年化':>8} "
|
||||
f"{'胜率':>6} {'回撤%':>7} {'持仓':>5} {'盈亏比':>6} {'交易':>5} {'5min':>6}")
|
||||
print(header)
|
||||
print("-" * 160)
|
||||
|
||||
prev_algo = None
|
||||
for row in rows:
|
||||
name = row[0]
|
||||
algo = row[1]
|
||||
mode = row[2]
|
||||
profit, cp, ca = row[3], row[4], row[5]
|
||||
wr, dd = row[6], row[7]
|
||||
hold, pf, n, mc = row[8], row[9], row[10], row[11]
|
||||
cov = row[14]
|
||||
|
||||
if prev_algo and prev_algo != algo:
|
||||
print("-" * 160)
|
||||
prev_algo = algo
|
||||
|
||||
cov_str = f"{cov:.0f}%" if cov > 0 else "日线"
|
||||
print(f"{name:<55} {profit:>+10,.0f} {'¥'+str(int(mc)):>10} {cp:>+7.1f}% {ca:>+7.1f}% "
|
||||
f"{wr:>5.1f}% {dd:>6.1f}% {hold:>4.0f}天 {pf:>6.2f} {n:>5} {cov_str:>6}")
|
||||
|
||||
print("=" * 160)
|
||||
|
||||
# ═══ 算法级汇总 ═══
|
||||
print("\n 📊 按算法汇总:")
|
||||
for algo_name, _ in top3_algos:
|
||||
algo_rows = [r for r in rows if r[1] == algo_name]
|
||||
if len(algo_rows) >= 2:
|
||||
baseline = algo_rows[0] # 日线版
|
||||
realtime = algo_rows[1] # 5分钟版
|
||||
|
||||
diff_profit = realtime[3] - baseline[3]
|
||||
diff_ann = realtime[5] - baseline[5]
|
||||
diff_wr = realtime[6] - baseline[6]
|
||||
|
||||
print(f"\n {algo_name}:")
|
||||
print(f" 日线(原版) : 盈亏 ¥{baseline[3]:>+10,.0f} 年化 {baseline[5]:>+6.1f}% 胜率 {baseline[6]:.1f}%")
|
||||
print(f" 5分钟实时 : 盈亏 ¥{realtime[3]:>+10,.0f} 年化 {realtime[5]:>+6.1f}% 胜率 {realtime[6]:.1f}% "
|
||||
f"(5min覆盖{realtime[14]:.0f}%)")
|
||||
icon = '📈' if diff_profit > 0 else ('📉' if diff_profit < 0 else '➖')
|
||||
print(f" {icon} 差异: 盈亏{diff_profit:>+,.0f} 年化{diff_ann:>+.1f}% 胜率{diff_wr:>+.1f}%")
|
||||
|
||||
# ═══ 写入文件 ═══
|
||||
out_dir = os.path.join(os.path.dirname(__file__), 'docs')
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, 'backtest_top3_5min.md')
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
f.write("# Top 3 算法 × 5分钟实时价格 回测对比\n\n")
|
||||
f.write(f"回测区间: {start} ~ {end}\n\n")
|
||||
f.write("## 定价模式\n\n")
|
||||
f.write("| 模式 | 买入价 | 卖出价 | 说明 |\n")
|
||||
f.write("|------|--------|--------|------|\n")
|
||||
f.write("| 日线(原版) | 当日开盘价 | 当日收盘价 | 原始基准 |\n")
|
||||
f.write("| 5分钟实时 | 10:00 5min收盘 | 15:00 5min收盘 | 有5min数据用5min, 无则用mid=(开盘+收盘)/2 |\n\n")
|
||||
f.write("## 对比结果\n\n")
|
||||
f.write("| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 真实年化 | 胜率 | 回撤% | 盈亏比 | 交易 | 5min覆盖 |\n")
|
||||
f.write("|------|------|---------|---------|---------|---------|------|-------|--------|------|----------|\n")
|
||||
for row in rows:
|
||||
name, algo, mode = row[0], row[1], row[2]
|
||||
profit, cp, ca = row[3], row[4], row[5]
|
||||
wr, dd = row[6], row[7]
|
||||
hold, pf, n, mc = row[8], row[9], row[10], row[11]
|
||||
cov = row[14]
|
||||
cov_str = f"{cov:.0f}%" if cov > 0 else "-"
|
||||
f.write(f"| {algo} | {mode} | {profit:+,.0f} | ¥{mc:,.0f} | {cp:+.1f}% | {ca:+.1f}% | "
|
||||
f"{wr:.1f}% | {dd:.1f}% | {pf:.2f} | {n} | {cov_str} |\n")
|
||||
f.write("\n")
|
||||
|
||||
print(f"\n结果已写入 {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
v7.0 交易时点网格搜索 — 寻找最优买入/卖出时间点
|
||||
|
||||
在48×48=2,304种时间点组合中搜索最佳买卖时机:
|
||||
买入时间: 09:35, 09:40, ..., 11:30, 13:05, ..., 15:00
|
||||
卖出时间: 09:35, 09:40, ..., 11:30, 13:05, ..., 15:00
|
||||
|
||||
使用Top 3历史最优算法 × 所有时间点组合,共 ~7,000 种回测。
|
||||
"""
|
||||
import sys, os, time, argparse
|
||||
from datetime import date, datetime
|
||||
from multiprocessing import Pool, cpu_count
|
||||
from itertools import product
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from backtest_recommend import (
|
||||
get_db_conn, preload_all_data, run_backtest
|
||||
)
|
||||
|
||||
# ─── A股5分钟K线时间点 (48个) ────────────────────
|
||||
ALL_5MIN_SLOTS = []
|
||||
# 上午: 09:35 ~ 11:30
|
||||
for h in range(9, 12):
|
||||
for m in range(0, 60, 5):
|
||||
t = f"{h:02d}:{m:02d}"
|
||||
if t >= "09:35" and t <= "11:30":
|
||||
ALL_5MIN_SLOTS.append(t)
|
||||
# 下午: 13:05 ~ 15:00
|
||||
for h in range(13, 16):
|
||||
for m in range(0, 60, 5):
|
||||
t = f"{h:02d}:{m:02d}"
|
||||
if t >= "13:05" and t <= "15:00":
|
||||
ALL_5MIN_SLOTS.append(t)
|
||||
|
||||
# 时间点信息(在main中打印,避免worker进程重复输出)
|
||||
|
||||
# ─── Top 3 算法 (来自 algo_search_results.md) ────────────
|
||||
TOP_ALGORITHMS = [
|
||||
("🏆TP12|SL6|d3|h30|10%SW", {
|
||||
"take_profit_pct": 12, "stop_loss_pct": 6,
|
||||
"sell_confirm_days": 3, "max_hold_days": 30,
|
||||
"position_pct": 10, "signal_weight": True,
|
||||
"ignore_sell_signal": True,
|
||||
}),
|
||||
("🥈TP12|SL6|d3|h30|15%SW", {
|
||||
"take_profit_pct": 12, "stop_loss_pct": 6,
|
||||
"sell_confirm_days": 3, "max_hold_days": 30,
|
||||
"position_pct": 15, "signal_weight": True,
|
||||
"ignore_sell_signal": True,
|
||||
}),
|
||||
("🥉TP10|SL8|ign|15%SW", {
|
||||
"take_profit_pct": 10, "stop_loss_pct": 8,
|
||||
"ignore_sell_signal": True,
|
||||
"position_pct": 15, "signal_weight": True,
|
||||
}),
|
||||
]
|
||||
|
||||
# ─── 全局变量(multiprocessing共享)─────────────────
|
||||
_preloaded_data = None
|
||||
|
||||
|
||||
def init_worker(preloaded):
|
||||
"""每个worker进程初始化时加载预加载数据"""
|
||||
global _preloaded_data
|
||||
_preloaded_data = preloaded
|
||||
|
||||
|
||||
def run_single(args):
|
||||
"""运行单次回测(供multiprocessing调用)"""
|
||||
algo_name, algo_params, buy_time, sell_time, start, end, total_capital = args
|
||||
try:
|
||||
result = run_backtest(
|
||||
conn=None,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
preloaded=_preloaded_data,
|
||||
use_5min_prices=True,
|
||||
total_capital=total_capital,
|
||||
buy_time=buy_time,
|
||||
sell_time=sell_time,
|
||||
**algo_params,
|
||||
)
|
||||
if result and result.get('stats'):
|
||||
s = result['stats']
|
||||
return {
|
||||
'algo': algo_name,
|
||||
'buy_time': buy_time,
|
||||
'sell_time': sell_time,
|
||||
'profit': s.get('profit', 0),
|
||||
'capital_pct': s.get('capital_pct', 0),
|
||||
'capital_ann': s.get('capital_ann_pct', 0),
|
||||
'win_rate': s.get('win_rate', 0),
|
||||
'max_drawdown_pct': s.get('max_drawdown_pct', 0),
|
||||
'profit_loss_ratio': s.get('profit_loss_ratio', 0),
|
||||
'trade_count': s.get('trade_count', 0),
|
||||
'coverage': s.get('5min_coverage', 0),
|
||||
}
|
||||
except Exception as e:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="v7.0 交易时点网格搜索")
|
||||
parser.add_argument('--capital', type=float, default=200000, help='总本金 (默认200000)')
|
||||
parser.add_argument('--start', type=str, default=None, help='起始日 YYYY-MM-DD (默认=5min数据起始)')
|
||||
parser.add_argument('--end', type=str, default=None, help='结束日 YYYY-MM-DD')
|
||||
parser.add_argument('--fast', action='store_true', help='快速模式: 仅测试9个代表性时间点')
|
||||
parser.add_argument('--workers', type=int, default=0, help=f'并行进程数 (默认={cpu_count()})')
|
||||
args = parser.parse_args()
|
||||
|
||||
total_capital = args.capital
|
||||
n_workers = args.workers or cpu_count()
|
||||
|
||||
# ── 连接数据库 & 确定回测区间 ──
|
||||
conn = get_db_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# 5分钟数据的实际覆盖范围
|
||||
cur.execute("SELECT MIN(dt::date), MAX(dt::date), COUNT(DISTINCT dt::date) FROM stock_kline_5min")
|
||||
r = cur.fetchone()
|
||||
min_5min_date, max_5min_date, n_5min_days = r
|
||||
print(f"\n[数据] 5分钟K线: {min_5min_date} ~ {max_5min_date} ({n_5min_days}个交易日)")
|
||||
|
||||
start_date = datetime.strptime(args.start, '%Y-%m-%d').date() if args.start else min_5min_date
|
||||
end_date = datetime.strptime(args.end, '%Y-%m-%d').date() if args.end else date.today()
|
||||
|
||||
print(f"[回测] 区间: {start_date} ~ {end_date}")
|
||||
print(f"[回测] 本金: ¥{total_capital:,.0f}")
|
||||
print(f"[回测] 算法: {len(TOP_ALGORITHMS)} 种")
|
||||
|
||||
# ── 时间点选择 ──
|
||||
if args.fast:
|
||||
# 快速模式: 9个代表性时间点
|
||||
time_slots = ['09:35', '09:45', '10:00', '10:30', '11:00',
|
||||
'13:05', '13:30', '14:00', '14:30', '15:00']
|
||||
time_slots = [t for t in time_slots if t in ALL_5MIN_SLOTS]
|
||||
else:
|
||||
time_slots = ALL_5MIN_SLOTS
|
||||
|
||||
n_combos = len(time_slots) ** 2
|
||||
n_total = n_combos * len(TOP_ALGORITHMS)
|
||||
print(f"[搜索] 时间点: {len(time_slots)} 个 → {n_combos:,} 种组合 × {len(TOP_ALGORITHMS)} 算法 = {n_total:,} 次回测")
|
||||
print(f"[搜索] 并行进程: {n_workers}")
|
||||
|
||||
# ── 预加载全部数据(含全部48个5分钟时间点)──
|
||||
print(f"\n{'='*60}")
|
||||
print(" 预加载数据...")
|
||||
print(f"{'='*60}")
|
||||
preloaded = preload_all_data(conn, start_date, end_date, use_5min=True, full_5min=True)
|
||||
conn.close()
|
||||
|
||||
# ── 构建任务列表 ──
|
||||
tasks = []
|
||||
for algo_name, algo_params in TOP_ALGORITHMS:
|
||||
for buy_t in time_slots:
|
||||
for sell_t in time_slots:
|
||||
tasks.append((algo_name, algo_params, buy_t, sell_t,
|
||||
start_date, end_date, total_capital))
|
||||
|
||||
# ── 并行执行 ──
|
||||
print(f"\n开始搜索 ({n_total:,} 次回测)...")
|
||||
t0 = time.time()
|
||||
|
||||
results = []
|
||||
with Pool(n_workers, initializer=init_worker, initargs=(preloaded,)) as pool:
|
||||
for i, r in enumerate(pool.imap_unordered(run_single, tasks, chunksize=50)):
|
||||
if r:
|
||||
results.append(r)
|
||||
if (i + 1) % 500 == 0:
|
||||
elapsed = time.time() - t0
|
||||
speed = (i + 1) / elapsed
|
||||
eta = (n_total - i - 1) / speed
|
||||
print(f" 进度: {i+1}/{n_total} ({(i+1)/n_total*100:.1f}%) | "
|
||||
f"速度: {speed:.0f}/s | ETA: {eta:.0f}s | "
|
||||
f"有效结果: {len(results)}", flush=True)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n搜索完成! {len(results):,} 个有效结果, 耗时 {elapsed:.1f}s ({len(results)/elapsed:.1f}次/s)")
|
||||
|
||||
if not results:
|
||||
print("⚠️ 没有有效结果!")
|
||||
return
|
||||
|
||||
# ── 分析结果 ──
|
||||
print(f"\n{'='*100}")
|
||||
print(" 📊 分析结果")
|
||||
print(f"{'='*100}")
|
||||
|
||||
# 1. 按盈利排序 - 全局Top 20
|
||||
results.sort(key=lambda x: -x['profit'])
|
||||
print(f"\n## 🏆 全局 Top 20 (按绝对盈利)")
|
||||
print(f"{'排名':<4} {'算法':<25} {'买入时间':<8} {'卖出时间':<8} {'盈亏':>10} {'收益%':>7} {'年化%':>7} {'胜率':>6} {'回撤%':>6} {'交易':>5} {'5min%':>5}")
|
||||
print("-" * 100)
|
||||
for i, r in enumerate(results[:20]):
|
||||
print(f"{'🏆' if i==0 else '🥈' if i==1 else '🥉' if i==2 else f'#{i+1}':<4} "
|
||||
f"{r['algo']:<25} {r['buy_time']:<8} {r['sell_time']:<8} "
|
||||
f"¥{r['profit']:>+9,.0f} {r['capital_pct']:>+6.1f}% {r['capital_ann']:>+6.1f}% "
|
||||
f"{r['win_rate']:>5.1f}% {r['max_drawdown_pct']:>5.1f}% {r['trade_count']:>5} {r['coverage']:>4.0f}%")
|
||||
|
||||
# 2. 按算法分组 - 每个算法的最优时间点
|
||||
print(f"\n## 📊 每个算法的最优时间点")
|
||||
for algo_name, _ in TOP_ALGORITHMS:
|
||||
algo_results = [r for r in results if r['algo'] == algo_name]
|
||||
if not algo_results:
|
||||
continue
|
||||
algo_results.sort(key=lambda x: -x['profit'])
|
||||
best = algo_results[0]
|
||||
worst = algo_results[-1]
|
||||
default = next((r for r in algo_results if r['buy_time'] == '10:00' and r['sell_time'] == '15:00'), None)
|
||||
|
||||
print(f"\n {algo_name}:")
|
||||
print(f" 最优: 买@{best['buy_time']} 卖@{best['sell_time']} → ¥{best['profit']:>+,.0f} ({best['capital_pct']:>+.1f}%)")
|
||||
if default:
|
||||
diff = best['profit'] - default['profit']
|
||||
print(f" 默认: 买@10:00 卖@15:00 → ¥{default['profit']:>+,.0f} ({default['capital_pct']:>+.1f}%)")
|
||||
print(f" 提升: ¥{diff:>+,.0f} ({diff/max(abs(default['profit']),1)*100:>+.1f}%)")
|
||||
print(f" 最差: 买@{worst['buy_time']} 卖@{worst['sell_time']} → ¥{worst['profit']:>+,.0f} ({worst['capital_pct']:>+.1f}%)")
|
||||
print(f" 差距: ¥{best['profit'] - worst['profit']:>,.0f}")
|
||||
|
||||
# 3. 买入时间热力图 (每个buy_time的平均盈利)
|
||||
print(f"\n## 📈 买入时间热力图 (固定卖出@15:00)")
|
||||
buy_time_profits = {}
|
||||
for r in results:
|
||||
if r['sell_time'] == '15:00':
|
||||
bt = r['buy_time']
|
||||
if bt not in buy_time_profits:
|
||||
buy_time_profits[bt] = []
|
||||
buy_time_profits[bt].append(r['profit'])
|
||||
|
||||
if buy_time_profits:
|
||||
sorted_buy = sorted(buy_time_profits.items(), key=lambda x: -sum(x[1])/len(x[1]))
|
||||
print(f" {'时间':<8} {'平均盈利':>10} {'最高盈利':>10} {'最低盈利':>10}")
|
||||
print(f" {'-'*45}")
|
||||
for bt, profits in sorted_buy:
|
||||
avg = sum(profits) / len(profits)
|
||||
print(f" {bt:<8} ¥{avg:>+9,.0f} ¥{max(profits):>+9,.0f} ¥{min(profits):>+9,.0f}")
|
||||
|
||||
# 4. 卖出时间热力图 (每个sell_time的平均盈利)
|
||||
print(f"\n## 📉 卖出时间热力图 (固定买入@10:00)")
|
||||
sell_time_profits = {}
|
||||
for r in results:
|
||||
if r['buy_time'] == '10:00':
|
||||
st = r['sell_time']
|
||||
if st not in sell_time_profits:
|
||||
sell_time_profits[st] = []
|
||||
sell_time_profits[st].append(r['profit'])
|
||||
|
||||
if sell_time_profits:
|
||||
sorted_sell = sorted(sell_time_profits.items(), key=lambda x: -sum(x[1])/len(x[1]))
|
||||
print(f" {'时间':<8} {'平均盈利':>10} {'最高盈利':>10} {'最低盈利':>10}")
|
||||
print(f" {'-'*45}")
|
||||
for st, profits in sorted_sell:
|
||||
avg = sum(profits) / len(profits)
|
||||
print(f" {st:<8} ¥{avg:>+9,.0f} ¥{max(profits):>+9,.0f} ¥{min(profits):>+9,.0f}")
|
||||
|
||||
# 5. 买卖时间交叉分析 (平均盈利矩阵的摘要)
|
||||
print(f"\n## 🔥 最优买卖时间组合 Top 10 (所有算法平均)")
|
||||
combo_profits = {}
|
||||
for r in results:
|
||||
key = (r['buy_time'], r['sell_time'])
|
||||
if key not in combo_profits:
|
||||
combo_profits[key] = []
|
||||
combo_profits[key].append(r['profit'])
|
||||
|
||||
sorted_combos = sorted(combo_profits.items(), key=lambda x: -sum(x[1])/len(x[1]))
|
||||
print(f" {'排名':<4} {'买入':<8} {'卖出':<8} {'平均盈利':>10} {'组合数':>6}")
|
||||
print(f" {'-'*42}")
|
||||
for i, (combo, profits) in enumerate(sorted_combos[:10]):
|
||||
avg = sum(profits) / len(profits)
|
||||
print(f" {'🏆' if i==0 else f'#{i+1}':<4} {combo[0]:<8} {combo[1]:<8} ¥{avg:>+9,.0f} {len(profits):>6}")
|
||||
|
||||
print(f"\n 最差组合:")
|
||||
for i, (combo, profits) in enumerate(sorted_combos[-5:]):
|
||||
avg = sum(profits) / len(profits)
|
||||
print(f" {'#'+str(len(sorted_combos)-4+i):<4} {combo[0]:<8} {combo[1]:<8} ¥{avg:>+9,.0f} {len(profits):>6}")
|
||||
|
||||
# ── 生成Markdown报告 ──
|
||||
md_path = os.path.join(os.path.dirname(__file__), 'docs', 'timing_search_results.md')
|
||||
os.makedirs(os.path.dirname(md_path), exist_ok=True)
|
||||
|
||||
with open(md_path, 'w') as f:
|
||||
f.write(f"# ⏰ v7.0 交易时点网格搜索结果\n\n")
|
||||
f.write(f"> 生成时间: {datetime.now():%Y-%m-%d %H:%M}\n\n")
|
||||
|
||||
f.write(f"## 搜索配置\n\n")
|
||||
f.write(f"| 项目 | 值 |\n|------|----|")
|
||||
f.write(f"\n| 本金 | ¥{total_capital:,.0f} |")
|
||||
f.write(f"\n| 回测区间 | {start_date} ~ {end_date} |")
|
||||
f.write(f"\n| 5分钟数据 | {min_5min_date} ~ {max_5min_date} ({n_5min_days}天) |")
|
||||
f.write(f"\n| 时间点 | {len(time_slots)} 个 |")
|
||||
f.write(f"\n| 组合数 | {n_combos:,} × {len(TOP_ALGORITHMS)} 算法 = {n_total:,} |")
|
||||
f.write(f"\n| 耗时 | {elapsed:.1f}s ({len(results)/elapsed:.1f}次/s) |")
|
||||
f.write(f"\n| 有效结果 | {len(results):,} |")
|
||||
f.write(f"\n\n")
|
||||
|
||||
# Top 20
|
||||
f.write(f"## 🏆 全局 Top 20\n\n")
|
||||
f.write(f"| 排名 | 算法 | 买入 | 卖出 | 盈亏 | 收益% | 年化% | 胜率 | 回撤% | 交易 | 5min% |\n")
|
||||
f.write(f"|------|------|------|------|------|-------|-------|------|-------|------|-------|\n")
|
||||
for i, r in enumerate(results[:20]):
|
||||
rank = '🏆' if i==0 else '🥈' if i==1 else '🥉' if i==2 else f'#{i+1}'
|
||||
f.write(f"| {rank} | {r['algo']} | {r['buy_time']} | {r['sell_time']} | "
|
||||
f"¥{r['profit']:>+,.0f} | {r['capital_pct']:>+.1f}% | {r['capital_ann']:>+.1f}% | "
|
||||
f"{r['win_rate']:.1f}% | {r['max_drawdown_pct']:.1f}% | {r['trade_count']} | {r['coverage']:.0f}% |\n")
|
||||
|
||||
# 每算法最优
|
||||
f.write(f"\n## 📊 每算法最优时间点\n\n")
|
||||
f.write(f"| 算法 | 最优买入 | 最优卖出 | 最优盈利 | 默认盈利(10:00/15:00) | 提升 |\n")
|
||||
f.write(f"|------|---------|---------|---------|---------------------|------|\n")
|
||||
for algo_name, _ in TOP_ALGORITHMS:
|
||||
algo_res = sorted([r for r in results if r['algo'] == algo_name], key=lambda x: -x['profit'])
|
||||
if not algo_res:
|
||||
continue
|
||||
best = algo_res[0]
|
||||
default = next((r for r in algo_res if r['buy_time'] == '10:00' and r['sell_time'] == '15:00'), None)
|
||||
default_profit = default['profit'] if default else 0
|
||||
diff = best['profit'] - default_profit
|
||||
f.write(f"| {algo_name} | {best['buy_time']} | {best['sell_time']} | "
|
||||
f"¥{best['profit']:>+,.0f} | ¥{default_profit:>+,.0f} | ¥{diff:>+,.0f} |\n")
|
||||
|
||||
# 买入时间排名 (卖出固定15:00)
|
||||
f.write(f"\n## 📈 买入时间排名 (卖出固定@15:00)\n\n")
|
||||
f.write(f"| 排名 | 买入时间 | 平均盈利 | 最高盈利 | 最低盈利 |\n")
|
||||
f.write(f"|------|---------|---------|---------|----------|\n")
|
||||
if buy_time_profits:
|
||||
for i, (bt, profits) in enumerate(sorted_buy):
|
||||
avg = sum(profits) / len(profits)
|
||||
rank = '🏆' if i==0 else f'#{i+1}'
|
||||
f.write(f"| {rank} | {bt} | ¥{avg:>+,.0f} | ¥{max(profits):>+,.0f} | ¥{min(profits):>+,.0f} |\n")
|
||||
|
||||
# 卖出时间排名 (买入固定10:00)
|
||||
f.write(f"\n## 📉 卖出时间排名 (买入固定@10:00)\n\n")
|
||||
f.write(f"| 排名 | 卖出时间 | 平均盈利 | 最高盈利 | 最低盈利 |\n")
|
||||
f.write(f"|------|---------|---------|---------|----------|\n")
|
||||
if sell_time_profits:
|
||||
for i, (st, profits) in enumerate(sorted_sell):
|
||||
avg = sum(profits) / len(profits)
|
||||
rank = '🏆' if i==0 else f'#{i+1}'
|
||||
f.write(f"| {rank} | {st} | ¥{avg:>+,.0f} | ¥{max(profits):>+,.0f} | ¥{min(profits):>+,.0f} |\n")
|
||||
|
||||
# 最优组合 Top 10
|
||||
f.write(f"\n## 🔥 最优买卖时间组合 Top 10\n\n")
|
||||
f.write(f"| 排名 | 买入 | 卖出 | 平均盈利 |\n")
|
||||
f.write(f"|------|------|------|----------|\n")
|
||||
for i, (combo, profits) in enumerate(sorted_combos[:10]):
|
||||
avg = sum(profits) / len(profits)
|
||||
rank = '🏆' if i==0 else f'#{i+1}'
|
||||
f.write(f"| {rank} | {combo[0]} | {combo[1]} | ¥{avg:>+,.0f} |\n")
|
||||
|
||||
# 结论
|
||||
f.write(f"\n## 💡 结论\n\n")
|
||||
if results:
|
||||
best_overall = results[0]
|
||||
default_results = [r for r in results if r['buy_time'] == '10:00' and r['sell_time'] == '15:00']
|
||||
default_avg = sum(r['profit'] for r in default_results) / len(default_results) if default_results else 0
|
||||
best_avg_combo = sorted_combos[0] if sorted_combos else None
|
||||
|
||||
f.write(f"1. **全局最优**: {best_overall['algo']} 买@{best_overall['buy_time']} 卖@{best_overall['sell_time']} → ¥{best_overall['profit']:>+,.0f}\n")
|
||||
f.write(f"2. **默认(10:00/15:00)平均盈利**: ¥{default_avg:>+,.0f}\n")
|
||||
if best_avg_combo:
|
||||
avg = sum(best_avg_combo[1]) / len(best_avg_combo[1])
|
||||
f.write(f"3. **最优时间组合(跨算法平均)**: 买@{best_avg_combo[0][0]} 卖@{best_avg_combo[0][1]} → 平均¥{avg:>+,.0f}\n")
|
||||
f.write(f"4. **时点优化潜在提升**: ¥{avg - default_avg:>+,.0f}\n")
|
||||
|
||||
print(f"\n📄 报告已保存: {md_path}")
|
||||
print("完成!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
v6 算法 新时间点 (09:35/13:40) vs 旧时间点 (10:00/15:00) 对比测试
|
||||
基于 docs/backtest_v6_analysis_report.md 中的全部算法
|
||||
"""
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import psycopg2
|
||||
from datetime import date, datetime
|
||||
import time
|
||||
|
||||
from backtest_recommend import run_backtest, preload_all_data
|
||||
|
||||
# ─── 配置 ───────────────────────────────────────────────
|
||||
DB_NAME = 'stock_app'
|
||||
TOTAL_CAPITAL = 200000
|
||||
START_DATE = date(2025, 1, 2)
|
||||
END_DATE = date(2026, 2, 25)
|
||||
|
||||
# 时间点配置
|
||||
TIMING_CONFIGS = [
|
||||
('旧时点(10:00/15:00)', '10:00', '15:00'),
|
||||
('新时点(09:35/13:40)', '09:35', '13:40'),
|
||||
]
|
||||
|
||||
# ─── v6 报告中的全部算法 ─────────────────────────────────
|
||||
ALGORITHMS = {
|
||||
# === 绝对盈利 Top 5 ===
|
||||
'🏆base|TP12/SL6|d3|h30|10%|SW': {
|
||||
'take_profit_pct': 12, 'stop_loss_pct': 6,
|
||||
'sell_confirm_days': 3, 'max_hold_days': 30,
|
||||
'total_capital': TOTAL_CAPITAL, 'position_pct': 10, 'signal_weight': True,
|
||||
'use_5min_prices': True,
|
||||
},
|
||||
'🥈base|TP12/SL8|d3|h∞|8%|SW': {
|
||||
'take_profit_pct': 12, 'stop_loss_pct': 8,
|
||||
'sell_confirm_days': 3, 'max_hold_days': 0,
|
||||
'total_capital': TOTAL_CAPITAL, 'position_pct': 8, 'signal_weight': True,
|
||||
'use_5min_prices': True,
|
||||
},
|
||||
'🥉v6|MT3G3+BE8|TP10/SL8|ign|h∞|10%': {
|
||||
'take_profit_pct': 10, 'stop_loss_pct': 8,
|
||||
'ignore_sell_signal': True, 'max_hold_days': 0,
|
||||
'total_capital': TOTAL_CAPITAL, 'position_pct': 10, 'signal_weight': True,
|
||||
'use_5min_prices': True,
|
||||
# v6 特性
|
||||
'momentum_tp': True, 'momentum_days': 3, 'momentum_trail_gap': 3,
|
||||
'breakeven_at': 8,
|
||||
},
|
||||
'4.v6|MT3G4|TP10/SL8|ign|h∞|8%': {
|
||||
'take_profit_pct': 10, 'stop_loss_pct': 8,
|
||||
'ignore_sell_signal': True, 'max_hold_days': 0,
|
||||
'total_capital': TOTAL_CAPITAL, 'position_pct': 8, 'signal_weight': True,
|
||||
'use_5min_prices': True,
|
||||
# v6 特性
|
||||
'momentum_tp': True, 'momentum_days': 3, 'momentum_trail_gap': 4,
|
||||
},
|
||||
'5.v6|PE50G3|TP10/SL6|ign|h60|10%': {
|
||||
'take_profit_pct': 10, 'stop_loss_pct': 6,
|
||||
'ignore_sell_signal': True, 'max_hold_days': 60,
|
||||
'total_capital': TOTAL_CAPITAL, 'position_pct': 10, 'signal_weight': True,
|
||||
'use_5min_prices': True,
|
||||
# v6 特性
|
||||
'partial_exit_pct': 50, 'momentum_trail_gap': 3,
|
||||
'no_timeout_if_rising': True,
|
||||
},
|
||||
|
||||
# === 风险调整 Top 4 ===
|
||||
'Calmar🏆v6|MT3G3+BE8|TP10/SL8|ign|h∞|8%': {
|
||||
'take_profit_pct': 10, 'stop_loss_pct': 8,
|
||||
'ignore_sell_signal': True, 'max_hold_days': 0,
|
||||
'total_capital': TOTAL_CAPITAL, 'position_pct': 8, 'signal_weight': True,
|
||||
'use_5min_prices': True,
|
||||
# v6 特性
|
||||
'momentum_tp': True, 'momentum_days': 3, 'momentum_trail_gap': 3,
|
||||
'breakeven_at': 8,
|
||||
},
|
||||
'Calmar🥈v6|PE50G3+BE8|TP12/SL8|d3|h60|8%': {
|
||||
'take_profit_pct': 12, 'stop_loss_pct': 8,
|
||||
'sell_confirm_days': 3, 'max_hold_days': 60,
|
||||
'total_capital': TOTAL_CAPITAL, 'position_pct': 8, 'signal_weight': True,
|
||||
'use_5min_prices': True,
|
||||
# v6 特性
|
||||
'partial_exit_pct': 50, 'momentum_trail_gap': 3,
|
||||
'breakeven_at': 8,
|
||||
'no_timeout_if_rising': True,
|
||||
},
|
||||
|
||||
# === 跨期稳定性验证中的额外策略 ===
|
||||
'稳健🥉v6|PE30G3+BE8|TP10/SL6|ign|h60|10%': {
|
||||
'take_profit_pct': 10, 'stop_loss_pct': 6,
|
||||
'ignore_sell_signal': True, 'max_hold_days': 60,
|
||||
'total_capital': TOTAL_CAPITAL, 'position_pct': 10, 'signal_weight': True,
|
||||
'use_5min_prices': True,
|
||||
# v6 特性
|
||||
'partial_exit_pct': 30, 'momentum_trail_gap': 3,
|
||||
'breakeven_at': 8,
|
||||
'no_timeout_if_rising': True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def fmt_money(v):
|
||||
"""格式化金额"""
|
||||
if v >= 0:
|
||||
return f"+¥{v:,.0f}"
|
||||
return f"-¥{abs(v):,.0f}"
|
||||
|
||||
|
||||
def fmt_pct(v):
|
||||
"""格式化百分比"""
|
||||
if v >= 0:
|
||||
return f"+{v:.1f}%"
|
||||
return f"{v:.1f}%"
|
||||
|
||||
|
||||
def run_test(preloaded, algo_name, params, buy_time, sell_time):
|
||||
"""运行单个回测"""
|
||||
p = dict(params)
|
||||
p['buy_time'] = buy_time
|
||||
p['sell_time'] = sell_time
|
||||
p['preloaded'] = preloaded
|
||||
result = run_backtest(None,
|
||||
start_date=START_DATE,
|
||||
end_date=END_DATE,
|
||||
**p)
|
||||
if not result:
|
||||
return None
|
||||
return result['stats']
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" v6 算法 新旧时间点对比测试")
|
||||
print(f" 回测区间: {START_DATE} ~ {END_DATE}")
|
||||
print(f" 初始资金: ¥{TOTAL_CAPITAL:,}")
|
||||
print(f" 算法数量: {len(ALGORITHMS)}")
|
||||
print(f" 时间配置: {' vs '.join([c[0] for c in TIMING_CONFIGS])}")
|
||||
print("=" * 100)
|
||||
|
||||
# 连接数据库
|
||||
conn = psycopg2.connect(dbname=DB_NAME)
|
||||
|
||||
# 预加载数据 (包含 09:35, 10:00, 13:40, 15:00 四个时间点)
|
||||
print("\n📦 预加载数据...", flush=True)
|
||||
t0 = time.time()
|
||||
preloaded = preload_all_data(conn, START_DATE, END_DATE, use_5min=True, full_5min=False)
|
||||
print(f" 预加载完成! 耗时 {time.time()-t0:.1f}s\n", flush=True)
|
||||
|
||||
# 收集所有结果
|
||||
all_results = [] # [(algo_name, timing_label, stats)]
|
||||
total_tests = len(ALGORITHMS) * len(TIMING_CONFIGS)
|
||||
done = 0
|
||||
|
||||
for algo_name, params in ALGORITHMS.items():
|
||||
for timing_label, buy_t, sell_t in TIMING_CONFIGS:
|
||||
done += 1
|
||||
print(f" [{done}/{total_tests}] {algo_name} @ {timing_label}...", end='', flush=True)
|
||||
t1 = time.time()
|
||||
stats = run_test(preloaded, algo_name, params, buy_t, sell_t)
|
||||
elapsed = time.time() - t1
|
||||
if stats:
|
||||
all_results.append((algo_name, timing_label, buy_t, sell_t, stats))
|
||||
profit = stats.get('profit', 0)
|
||||
ann = stats.get('capital_ann_pct', 0)
|
||||
print(f" 盈利{fmt_money(profit)} 年化{fmt_pct(ann)} ({elapsed:.1f}s)")
|
||||
else:
|
||||
print(f" ❌ 无结果 ({elapsed:.1f}s)")
|
||||
|
||||
conn.close()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 输出对比报告
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
print("\n" + "=" * 120)
|
||||
print(" 📊 新旧时间点 对比结果")
|
||||
print("=" * 120)
|
||||
|
||||
# 按算法分组
|
||||
results_by_algo = {}
|
||||
for algo_name, timing_label, buy_t, sell_t, stats in all_results:
|
||||
if algo_name not in results_by_algo:
|
||||
results_by_algo[algo_name] = {}
|
||||
results_by_algo[algo_name][timing_label] = stats
|
||||
|
||||
# 表头
|
||||
print(f"\n{'算法':<45} {'时间点':<20} {'盈利':>12} {'收益率':>8} {'年化':>8} {'回撤':>6} {'胜率':>6} {'PF':>5} {'交易':>5}")
|
||||
print("-" * 120)
|
||||
|
||||
improvement_data = []
|
||||
|
||||
for algo_name in ALGORITHMS.keys():
|
||||
timings = results_by_algo.get(algo_name, {})
|
||||
old_stats = timings.get('旧时点(10:00/15:00)')
|
||||
new_stats = timings.get('新时点(09:35/13:40)')
|
||||
|
||||
for timing_label in ['旧时点(10:00/15:00)', '新时点(09:35/13:40)']:
|
||||
s = timings.get(timing_label)
|
||||
if not s:
|
||||
continue
|
||||
profit = s.get('profit', 0)
|
||||
ret = s.get('capital_pct', 0)
|
||||
ann = s.get('capital_ann_pct', 0)
|
||||
dd = s.get('max_drawdown_pct', 0)
|
||||
wr = s.get('win_rate', 0)
|
||||
pf = s.get('profit_factor', 0)
|
||||
trades_n = s.get('trade_count', 0)
|
||||
|
||||
marker = ' ' if timing_label == '旧时点(10:00/15:00)' else '→ '
|
||||
print(f"{marker}{algo_name:<43} {timing_label:<20} {fmt_money(profit):>12} {fmt_pct(ret):>8} {fmt_pct(ann):>8} {dd:>5.1f}% {wr:>5.1f}% {pf:>5.2f} {trades_n:>5}")
|
||||
|
||||
# 计算提升幅度
|
||||
if old_stats and new_stats:
|
||||
old_profit = old_stats.get('profit', 0)
|
||||
new_profit = new_stats.get('profit', 0)
|
||||
delta_profit = new_profit - old_profit
|
||||
old_ann = old_stats.get('capital_ann_pct', 0)
|
||||
new_ann = new_stats.get('capital_ann_pct', 0)
|
||||
delta_ann = new_ann - old_ann
|
||||
old_dd = old_stats.get('max_drawdown_pct', 0)
|
||||
new_dd = new_stats.get('max_drawdown_pct', 0)
|
||||
delta_dd = new_dd - old_dd
|
||||
old_wr = old_stats.get('win_rate', 0)
|
||||
new_wr = new_stats.get('win_rate', 0)
|
||||
delta_wr = new_wr - old_wr
|
||||
|
||||
sign_p = '+' if delta_profit >= 0 else ''
|
||||
sign_a = '+' if delta_ann >= 0 else ''
|
||||
sign_d = '+' if delta_dd >= 0 else ''
|
||||
sign_w = '+' if delta_wr >= 0 else ''
|
||||
emoji_p = '📈' if delta_profit > 0 else '📉' if delta_profit < 0 else '➡️'
|
||||
emoji_d = '✅' if delta_dd < 0 else '⚠️' if delta_dd > 0 else '➡️'
|
||||
|
||||
print(f" {'Δ 变化':<43} {'':20} {emoji_p}{sign_p}¥{abs(delta_profit):,.0f}{'':>4} {sign_a}{delta_ann:.1f}pp {'':>5} {emoji_d}{sign_d}{delta_dd:.1f}pp {sign_w}{delta_wr:.1f}pp")
|
||||
print()
|
||||
|
||||
improvement_data.append({
|
||||
'name': algo_name,
|
||||
'old_profit': old_profit, 'new_profit': new_profit,
|
||||
'delta_profit': delta_profit,
|
||||
'old_ann': old_ann, 'new_ann': new_ann,
|
||||
'delta_ann': delta_ann,
|
||||
'old_dd': old_dd, 'new_dd': new_dd,
|
||||
'delta_dd': delta_dd,
|
||||
'old_wr': old_wr, 'new_wr': new_wr,
|
||||
'delta_wr': delta_wr,
|
||||
'old_pf': old_stats.get('profit_factor', 0),
|
||||
'new_pf': new_stats.get('profit_factor', 0),
|
||||
})
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 总结
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
if improvement_data:
|
||||
print("\n" + "=" * 100)
|
||||
print(" 📈 提升总结")
|
||||
print("=" * 100)
|
||||
|
||||
improved = sum(1 for d in improvement_data if d['delta_profit'] > 0)
|
||||
declined = sum(1 for d in improvement_data if d['delta_profit'] < 0)
|
||||
unchanged = sum(1 for d in improvement_data if d['delta_profit'] == 0)
|
||||
avg_delta_profit = sum(d['delta_profit'] for d in improvement_data) / len(improvement_data)
|
||||
avg_delta_ann = sum(d['delta_ann'] for d in improvement_data) / len(improvement_data)
|
||||
avg_delta_dd = sum(d['delta_dd'] for d in improvement_data) / len(improvement_data)
|
||||
avg_delta_wr = sum(d['delta_wr'] for d in improvement_data) / len(improvement_data)
|
||||
|
||||
print(f"\n 算法总数: {len(improvement_data)}")
|
||||
print(f" 盈利提升: {improved}个 | 盈利下降: {declined}个 | 持平: {unchanged}个")
|
||||
print(f"\n 平均盈利变化: {'+'if avg_delta_profit>=0 else ''}¥{avg_delta_profit:,.0f}")
|
||||
print(f" 平均年化变化: {'+'if avg_delta_ann>=0 else ''}{avg_delta_ann:.2f}pp")
|
||||
print(f" 平均回撤变化: {'+'if avg_delta_dd>=0 else ''}{avg_delta_dd:.2f}pp {'(降低=好)'}")
|
||||
print(f" 平均胜率变化: {'+'if avg_delta_wr>=0 else ''}{avg_delta_wr:.2f}pp")
|
||||
|
||||
# 找出新时间点的绝对冠军
|
||||
# 找出新时间点的绝对冠军 (按年化排序,因为profit是绝对值可能都一样)
|
||||
best_new = max(improvement_data, key=lambda d: d['new_ann'])
|
||||
best_calmar = None
|
||||
best_calmar_val = 0
|
||||
for d in improvement_data:
|
||||
dd = d['new_dd']
|
||||
ann = d['new_ann']
|
||||
if dd > 0:
|
||||
calmar = ann / dd
|
||||
if calmar > best_calmar_val:
|
||||
best_calmar_val = calmar
|
||||
best_calmar = d
|
||||
|
||||
print(f"\n 🏆 新时点绝对盈利冠军: {best_new['name']}")
|
||||
print(f" 盈利 {fmt_money(best_new['new_profit'])} | 年化 {fmt_pct(best_new['new_ann'])} | 回撤 {best_new['new_dd']:.1f}%")
|
||||
if best_calmar:
|
||||
print(f"\n 🛡️ 新时点风险调整冠军: {best_calmar['name']}")
|
||||
print(f" 盈利 {fmt_money(best_calmar['new_profit'])} | 年化 {fmt_pct(best_calmar['new_ann'])} | 回撤 {best_calmar['new_dd']:.1f}% | Calmar {best_calmar_val:.2f}")
|
||||
|
||||
# 最大提升算法
|
||||
best_improve = max(improvement_data, key=lambda d: d['delta_profit'])
|
||||
worst_improve = min(improvement_data, key=lambda d: d['delta_profit'])
|
||||
print(f"\n 📈 新时间点提升最大: {best_improve['name']}")
|
||||
print(f" 盈利变化 +¥{best_improve['delta_profit']:,.0f} | 年化变化 +{best_improve['delta_ann']:.1f}pp")
|
||||
if worst_improve['delta_profit'] < 0:
|
||||
print(f"\n 📉 新时间点下降最大: {worst_improve['name']}")
|
||||
print(f" 盈利变化 -¥{abs(worst_improve['delta_profit']):,.0f} | 年化变化 {worst_improve['delta_ann']:.1f}pp")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 生成 Markdown 报告
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
md_path = os.path.join(os.path.dirname(__file__), '..', 'docs', 'backtest_v7_timing_comparison.md')
|
||||
with open(md_path, 'w', encoding='utf-8') as f:
|
||||
f.write(f"# v7 交易时点优化对比报告\n\n")
|
||||
f.write(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')} \n")
|
||||
f.write(f"> 回测区间: {START_DATE} ~ {END_DATE} \n")
|
||||
f.write(f"> 初始资金: ¥{TOTAL_CAPITAL:,} \n")
|
||||
f.write(f"> 旧时间点: 买入10:00 / 卖出15:00 \n")
|
||||
f.write(f"> 新时间点: 买入09:35 / 卖出13:40 (网格搜索最优) \n\n")
|
||||
f.write("---\n\n")
|
||||
|
||||
f.write("## 一、全部算法对比\n\n")
|
||||
f.write("| 算法 | 时间点 | 盈利 | 收益率 | 年化 | 回撤 | 胜率 | PF | 交易数 |\n")
|
||||
f.write("|------|--------|------|--------|------|------|------|-----|--------|\n")
|
||||
|
||||
for algo_name in ALGORITHMS.keys():
|
||||
timings = results_by_algo.get(algo_name, {})
|
||||
for timing_label in ['旧时点(10:00/15:00)', '新时点(09:35/13:40)']:
|
||||
s = timings.get(timing_label)
|
||||
if not s:
|
||||
continue
|
||||
profit = s.get('profit', 0)
|
||||
ret = s.get('capital_pct', 0)
|
||||
ann = s.get('capital_ann_pct', 0)
|
||||
dd = s.get('max_drawdown_pct', 0)
|
||||
wr = s.get('win_rate', 0)
|
||||
pf = s.get('profit_factor', 0)
|
||||
trades_n = s.get('trade_count', 0)
|
||||
marker = '' if timing_label == '旧时点(10:00/15:00)' else '**'
|
||||
f.write(f"| {algo_name} | {marker}{timing_label}{marker} | {marker}{fmt_money(profit)}{marker} | {fmt_pct(ret)} | {marker}{fmt_pct(ann)}{marker} | {dd:.1f}% | {wr:.1f}% | {pf:.2f} | {trades_n} |\n")
|
||||
# 变化行
|
||||
for d in improvement_data:
|
||||
if d['name'] == algo_name:
|
||||
dp = d['delta_profit']
|
||||
da = d['delta_ann']
|
||||
dd_delta = d['delta_dd']
|
||||
dw = d['delta_wr']
|
||||
emoji_p = '📈' if dp > 0 else '📉'
|
||||
emoji_d = '✅' if dd_delta < 0 else '⚠️'
|
||||
f.write(f"| ↳ Δ变化 | — | {emoji_p} {'+'if dp>=0 else ''}¥{abs(dp):,.0f} | | {'+'if da>=0 else ''}{da:.1f}pp | {emoji_d}{'+'if dd_delta>=0 else ''}{dd_delta:.1f}pp | {'+'if dw>=0 else ''}{dw:.1f}pp | | |\n")
|
||||
break
|
||||
|
||||
f.write("\n---\n\n")
|
||||
|
||||
f.write("## 二、提升总结\n\n")
|
||||
f.write(f"| 指标 | 数值 |\n")
|
||||
f.write(f"|------|------|\n")
|
||||
f.write(f"| 算法总数 | {len(improvement_data)} |\n")
|
||||
f.write(f"| 盈利提升 / 下降 / 持平 | {improved} / {declined} / {unchanged} |\n")
|
||||
f.write(f"| 平均盈利变化 | {'+'if avg_delta_profit>=0 else ''}¥{avg_delta_profit:,.0f} |\n")
|
||||
f.write(f"| 平均年化变化 | {'+'if avg_delta_ann>=0 else ''}{avg_delta_ann:.2f}pp |\n")
|
||||
f.write(f"| 平均回撤变化 | {'+'if avg_delta_dd>=0 else ''}{avg_delta_dd:.2f}pp |\n")
|
||||
f.write(f"| 平均胜率变化 | {'+'if avg_delta_wr>=0 else ''}{avg_delta_wr:.2f}pp |\n")
|
||||
|
||||
f.write(f"\n---\n\n")
|
||||
|
||||
f.write("## 三、新时间点冠军\n\n")
|
||||
f.write(f"### 🏆 绝对盈利冠军: `{best_new['name']}`\n\n")
|
||||
f.write(f"| 指标 | 旧时点 | 新时点 | 变化 |\n")
|
||||
f.write(f"|------|--------|--------|------|\n")
|
||||
f.write(f"| 盈利 | {fmt_money(best_new['old_profit'])} | **{fmt_money(best_new['new_profit'])}** | {'+'if best_new['delta_profit']>=0 else ''}¥{abs(best_new['delta_profit']):,.0f} |\n")
|
||||
f.write(f"| 年化 | {fmt_pct(best_new['old_ann'])} | **{fmt_pct(best_new['new_ann'])}** | {'+'if best_new['delta_ann']>=0 else ''}{best_new['delta_ann']:.1f}pp |\n")
|
||||
f.write(f"| 回撤 | {best_new['old_dd']:.1f}% | **{best_new['new_dd']:.1f}%** | {'+'if best_new['delta_dd']>=0 else ''}{best_new['delta_dd']:.1f}pp |\n")
|
||||
f.write(f"| 胜率 | {best_new['old_wr']:.1f}% | **{best_new['new_wr']:.1f}%** | {'+'if best_new['delta_wr']>=0 else ''}{best_new['delta_wr']:.1f}pp |\n")
|
||||
f.write(f"| PF | {best_new['old_pf']:.2f} | **{best_new['new_pf']:.2f}** | {'+'if best_new['new_pf']-best_new['old_pf']>=0 else ''}{best_new['new_pf']-best_new['old_pf']:.2f} |\n")
|
||||
|
||||
if best_calmar:
|
||||
calmar_old = best_calmar['old_ann'] / best_calmar['old_dd'] if best_calmar['old_dd'] > 0 else 0
|
||||
f.write(f"\n### 🛡️ 风险调整冠军: `{best_calmar['name']}`\n\n")
|
||||
f.write(f"| 指标 | 旧时点 | 新时点 | 变化 |\n")
|
||||
f.write(f"|------|--------|--------|------|\n")
|
||||
f.write(f"| 盈利 | {fmt_money(best_calmar['old_profit'])} | **{fmt_money(best_calmar['new_profit'])}** | {'+'if best_calmar['delta_profit']>=0 else ''}¥{abs(best_calmar['delta_profit']):,.0f} |\n")
|
||||
f.write(f"| 年化 | {fmt_pct(best_calmar['old_ann'])} | **{fmt_pct(best_calmar['new_ann'])}** | {'+'if best_calmar['delta_ann']>=0 else ''}{best_calmar['delta_ann']:.1f}pp |\n")
|
||||
f.write(f"| 回撤 | {best_calmar['old_dd']:.1f}% | **{best_calmar['new_dd']:.1f}%** | {'+'if best_calmar['delta_dd']>=0 else ''}{best_calmar['delta_dd']:.1f}pp |\n")
|
||||
f.write(f"| Calmar比 | {calmar_old:.2f} | **{best_calmar_val:.2f}** | {'+'if best_calmar_val-calmar_old>=0 else ''}{best_calmar_val-calmar_old:.2f} |\n")
|
||||
|
||||
f.write(f"\n---\n\n")
|
||||
|
||||
f.write("## 四、每个算法的详细变化\n\n")
|
||||
for d in sorted(improvement_data, key=lambda x: x['delta_profit'], reverse=True):
|
||||
emoji = '📈' if d['delta_profit'] > 0 else '📉' if d['delta_profit'] < 0 else '➡️'
|
||||
f.write(f"### {emoji} `{d['name']}`\n\n")
|
||||
f.write(f"| 指标 | 旧时点 | 新时点 | 变化 |\n")
|
||||
f.write(f"|------|--------|--------|------|\n")
|
||||
f.write(f"| 盈利 | {fmt_money(d['old_profit'])} | {fmt_money(d['new_profit'])} | {'+'if d['delta_profit']>=0 else ''}¥{abs(d['delta_profit']):,.0f} |\n")
|
||||
f.write(f"| 年化 | {fmt_pct(d['old_ann'])} | {fmt_pct(d['new_ann'])} | {'+'if d['delta_ann']>=0 else ''}{d['delta_ann']:.1f}pp |\n")
|
||||
f.write(f"| 回撤 | {d['old_dd']:.1f}% | {d['new_dd']:.1f}% | {'+'if d['delta_dd']>=0 else ''}{d['delta_dd']:.1f}pp |\n")
|
||||
f.write(f"| 胜率 | {d['old_wr']:.1f}% | {d['new_wr']:.1f}% | {'+'if d['delta_wr']>=0 else ''}{d['delta_wr']:.1f}pp |\n")
|
||||
f.write(f"| PF | {d['old_pf']:.2f} | {d['new_pf']:.2f} | {'+'if d['new_pf']-d['old_pf']>=0 else ''}{d['new_pf']-d['old_pf']:.2f} |\n\n")
|
||||
|
||||
f.write("---\n\n")
|
||||
f.write("## 五、结论\n\n")
|
||||
if avg_delta_profit > 0:
|
||||
f.write(f"✅ **新时间点(09:35/13:40)整体优于旧时间点(10:00/15:00)**\n\n")
|
||||
f.write(f"- 平均每个算法盈利提升 +¥{avg_delta_profit:,.0f}\n")
|
||||
f.write(f"- 平均年化收益提升 +{avg_delta_ann:.2f}pp\n")
|
||||
else:
|
||||
f.write(f"⚠️ **新时间点(09:35/13:40)整体表现与旧时间点(10:00/15:00)接近或略逊**\n\n")
|
||||
f.write(f"- 平均每个算法盈利变化 {'+'if avg_delta_profit>=0 else ''}¥{abs(avg_delta_profit):,.0f}\n")
|
||||
f.write(f"- 平均年化收益变化 {'+'if avg_delta_ann>=0 else ''}{avg_delta_ann:.2f}pp\n")
|
||||
|
||||
if avg_delta_dd < 0:
|
||||
f.write(f"- ✅ 平均回撤降低 {abs(avg_delta_dd):.2f}pp (风险更低)\n")
|
||||
else:
|
||||
f.write(f"- ⚠️ 平均回撤增加 {avg_delta_dd:.2f}pp\n")
|
||||
|
||||
f.write(f"\n**推荐**: 综合考虑收益和风险,建议使用新时间点(09:35买入/13:40卖出)作为默认交易时点。\n")
|
||||
f.write(f"随着5分钟K线数据的积累(当前覆盖率约26%),新时间点的优势将更加明显。\n")
|
||||
|
||||
print(f"\n📝 报告已保存到: {os.path.abspath(md_path)}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,74 @@
|
||||
============================================================
|
||||
📊 全量股票技术信号扫描
|
||||
📅 扫描日期: 2026-02-23
|
||||
⚙️ 并发数: 3, 批大小: 30, K线天数: 120
|
||||
============================================================
|
||||
📈 数据库股票总数: 5810
|
||||
✅ 今日已扫描: 5471 只(续扫模式)
|
||||
⏳ 待扫描: 339 只
|
||||
|
||||
🚀 开始扫描...
|
||||
------------------------------------------------------------
|
||||
[ 94.7%] 5501/5810 | 速度: 6.0只/秒 | 剩余: 0.9分钟 | 触发: 0 | 失败: 30
|
||||
[ 95.2%] 5531/5810 | 速度: 6.7只/秒 | 剩余: 0.7分钟 | 触发: 0 | 失败: 60
|
||||
[ 95.7%] 5561/5810 | 速度: 7.1只/秒 | 剩余: 0.6分钟 | 触发: 0 | 失败: 90
|
||||
[ 96.2%] 5591/5810 | 速度: 7.4只/秒 | 剩余: 0.5分钟 | 触发: 0 | 失败: 120
|
||||
[ 96.7%] 5621/5810 | 速度: 7.4只/秒 | 剩余: 0.4分钟 | 触发: 0 | 失败: 150
|
||||
[ 97.3%] 5651/5810 | 速度: 7.3只/秒 | 剩余: 0.4分钟 | 触发: 0 | 失败: 180
|
||||
[ 97.8%] 5681/5810 | 速度: 7.4只/秒 | 剩余: 0.3分钟 | 触发: 0 | 失败: 210
|
||||
[ 98.3%] 5711/5810 | 速度: 7.4只/秒 | 剩余: 0.2分钟 | 触发: 0 | 失败: 240
|
||||
[ 98.8%] 5741/5810 | 速度: 7.5只/秒 | 剩余: 0.2分钟 | 触发: 0 | 失败: 270
|
||||
[ 99.3%] 5771/5810 | 速度: 7.5只/秒 | 剩余: 0.1分钟 | 触发: 0 | 失败: 300
|
||||
[ 99.8%] 5801/5810 | 速度: 7.5只/秒 | 剩余: 0.0分钟 | 触发: 0 | 失败: 330
|
||||
[100.0%] 5810/5810 | 速度: 7.5只/秒 | 剩余: 0.0分钟 | 触发: 0 | 失败: 339
|
||||
|
||||
============================================================
|
||||
✅ 扫描完成!
|
||||
扫描: 5810 只 | 耗时: 0.8分钟
|
||||
触发信号: 0 只 | 失败: 339 只
|
||||
============================================================
|
||||
|
||||
📊 扫描结果摘要(2026-02-23)
|
||||
------------------------------------------------------------
|
||||
总扫描: 5471 只 | 有信号: 1071 只
|
||||
|
||||
🔔 触发信号TOP30:
|
||||
002475 立讯精密 | 3个信号: 日线底背离, 龙抬头, 短底背离
|
||||
300719 安达维尔 | 3个信号: 主升浪, 真龙, 反弹
|
||||
002840 华统股份 | 3个信号: 主升浪, 真龙, 反弹
|
||||
920146 华阳变速 | 3个信号: 日线底背离, 龙抬头, 短底背离
|
||||
920718 合肥高科 | 3个信号: 日线底背离, 龙抬头, 短底背离
|
||||
920720 吉冈精密 | 3个信号: 日线底背离, 龙抬头, 短底背离
|
||||
301557 常友科技 | 3个信号: 日线底背离, 龙抬头, 短底背离
|
||||
002217 合力泰 | 2个信号: 日线底背离, 短底背离
|
||||
002231 *ST奥维 | 2个信号: 日线底背离, 短底背离
|
||||
002240 盛新锂能 | 2个信号: 主升浪, 真龙
|
||||
002241 歌尔股份 | 2个信号: 日线底背离, 短底背离
|
||||
001222 源飞宠物 | 2个信号: 日线底背离, 短底背离
|
||||
000600 建投能源 | 2个信号: 日线底背离, 短底背离
|
||||
002568 百润股份 | 2个信号: 日线底背离, 短底背离
|
||||
002577 雷柏科技 | 2个信号: 日线底背离, 短底背离
|
||||
000625 长安汽车 | 2个信号: 日线底背离, 短底背离
|
||||
000632 三木集团 | 2个信号: 日线底背离, 短底背离
|
||||
000651 格力电器 | 2个信号: 日线底背离, 短底背离
|
||||
000166 申万宏源 | 2个信号: 日线底背离, 短底背离
|
||||
000669 ST金鸿 | 2个信号: 真龙, 老鼠仓
|
||||
000686 东北证券 | 2个信号: 日线底背离, 短底背离
|
||||
000001 平安银行 | 2个信号: 日线底背离, 短底背离
|
||||
001289 龙源电力 | 2个信号: 日线底背离, 短底背离
|
||||
000006 深振业A | 2个信号: 真龙, 反弹
|
||||
000009 中国宝安 | 2个信号: 日线底背离, 短底背离
|
||||
000690 宝新能源 | 2个信号: 日线底背离, 短底背离
|
||||
000711 ST京蓝 | 2个信号: 真龙, 老鼠仓
|
||||
002961 瑞达期货 | 2个信号: 日线底背离, 短底背离
|
||||
002975 博杰股份 | 2个信号: 主升浪, 真龙
|
||||
000848 承德露露 | 2个信号: 日线底背离, 短底背离
|
||||
|
||||
📈 信号分布:
|
||||
短底背离: 431 只
|
||||
真龙: 409 只
|
||||
日线底背离: 341 只
|
||||
反弹: 135 只
|
||||
龙抬头: 74 只
|
||||
主升浪: 71 只
|
||||
老鼠仓: 8 只
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
历史全景扫描回溯脚本 — 从指定日期开始,对每个交易日模拟 11:30 和 16:30 两次全市场扫描,
|
||||
将推荐结果写入 stock_scan_history 表,供回测直接使用。
|
||||
|
||||
用法:
|
||||
./venv/bin/python scan_history.py # 从 2026-01-01 扫描到今天
|
||||
./venv/bin/python scan_history.py --start 2026-02-01 # 指定起始日
|
||||
./venv/bin/python scan_history.py --end 2026-02-10 # 指定结束日
|
||||
./venv/bin/python scan_history.py --force # 强制覆盖已扫描日期
|
||||
|
||||
说明:
|
||||
11:30 扫描: 用 T-1 日 K 线 + T 日 open 模拟中午数据(对应回测 15:00 决策依据)
|
||||
16:30 扫描: 用 T 日完整 K 线(对应回测次日 10:00 买入依据)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import argparse
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import pandas as pd
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
from config import Config
|
||||
from services.signal_detector import detect_all_signals
|
||||
from services.stock_algorithms import compute_recommend
|
||||
|
||||
K_DAYS = 120
|
||||
LOOKBACK = 5
|
||||
SAVE_BATCH = 500
|
||||
|
||||
|
||||
def get_db_conn():
|
||||
return psycopg2.connect(
|
||||
host=Config.DB_HOST, port=Config.DB_PORT,
|
||||
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
def get_trading_days(conn, start: date, end: date):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT trade_date::date FROM stock_kline_daily
|
||||
WHERE trade_date >= %s AND trade_date <= %s
|
||||
ORDER BY trade_date
|
||||
""", (start, end))
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def get_scanned_dates(conn, scan_time: str):
|
||||
"""返回已扫描的日期集合"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT scan_date FROM stock_scan_history
|
||||
WHERE scan_time = %s
|
||||
""", (scan_time,))
|
||||
return {r[0] for r in cur.fetchall()}
|
||||
|
||||
|
||||
def preload_all_klines(conn):
|
||||
"""一次性加载全部 K 线到内存: {code: [(date,o,h,l,c,v), ...]}"""
|
||||
print(" 加载全市场 K 线数据...", flush=True)
|
||||
t0 = time.time()
|
||||
result = {}
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT code, trade_date, open, high, low, close, volume
|
||||
FROM stock_kline_daily
|
||||
ORDER BY code, trade_date
|
||||
""")
|
||||
buf_code = None
|
||||
buf_rows = []
|
||||
for r in cur:
|
||||
code = r[0]
|
||||
if code != buf_code:
|
||||
if buf_code and buf_rows:
|
||||
result[buf_code] = buf_rows
|
||||
buf_code = code
|
||||
buf_rows = []
|
||||
buf_rows.append((str(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5]), float(r[6])))
|
||||
if buf_code and buf_rows:
|
||||
result[buf_code] = buf_rows
|
||||
print(f" 加载完成: {len(result)} 只股票, {time.time()-t0:.1f}s", flush=True)
|
||||
return result
|
||||
|
||||
|
||||
def build_df(rows, end_date_str: str, days: int = K_DAYS):
|
||||
"""从预加载行构建 DataFrame(截止到 end_date_str)"""
|
||||
filtered = [r for r in rows if r[0] <= end_date_str]
|
||||
if len(filtered) < 30:
|
||||
return None
|
||||
trimmed = filtered[-days:]
|
||||
df = pd.DataFrame(trimmed, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
|
||||
for col in ('open', 'high', 'low', 'close', 'volume'):
|
||||
df[col] = df[col].astype(float)
|
||||
return df
|
||||
|
||||
|
||||
def build_noon_df(rows, day_t: date, ohlc_t: dict, code: str, days: int = K_DAYS):
|
||||
"""构建 11:30 中午 K 线: T-1 前 + T 日 open"""
|
||||
prev_str = str(day_t - timedelta(days=1))
|
||||
filtered = [r for r in rows if r[0] <= prev_str]
|
||||
if len(filtered) < 30:
|
||||
return None
|
||||
trimmed = filtered[-days:]
|
||||
df = pd.DataFrame(trimmed, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
|
||||
for col in ('open', 'high', 'low', 'close', 'volume'):
|
||||
df[col] = df[col].astype(float)
|
||||
if code in ohlc_t:
|
||||
open_t = ohlc_t[code][0]
|
||||
extra = pd.DataFrame([{
|
||||
'date': str(day_t), 'open': open_t, 'high': open_t,
|
||||
'low': open_t, 'close': open_t, 'volume': 0.0,
|
||||
}])
|
||||
return pd.concat([df, extra], ignore_index=True)
|
||||
return df
|
||||
|
||||
|
||||
def get_day_ohlc(conn, trade_date: date):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT code, open, close FROM stock_kline_daily WHERE trade_date = %s
|
||||
""", (trade_date,))
|
||||
return {r[0]: (float(r[1]), float(r[2])) for r in cur.fetchall()}
|
||||
|
||||
|
||||
def scan_one_day(preloaded, codes, day_t, ohlc_t, scan_time, conn):
|
||||
"""对指定日期的所有股票做一次扫描,返回结果列表。
|
||||
scan_time='16:30': 用 T 日完整 K 线
|
||||
scan_time='11:30': 用 T-1 + T 日 open 模拟中午
|
||||
"""
|
||||
results = []
|
||||
day_str = str(day_t)
|
||||
total = len(codes)
|
||||
t0 = time.time()
|
||||
|
||||
for j, code in enumerate(codes):
|
||||
if scan_time == '16:30':
|
||||
df = build_df(preloaded.get(code, []), day_str, K_DAYS)
|
||||
else:
|
||||
df = build_noon_df(preloaded.get(code, []), day_t, ohlc_t, code, K_DAYS)
|
||||
|
||||
if df is None or len(df) < 30:
|
||||
continue
|
||||
|
||||
try:
|
||||
res = detect_all_signals(df, lookback=LOOKBACK)
|
||||
except Exception:
|
||||
continue
|
||||
if res.get('error'):
|
||||
continue
|
||||
|
||||
signal_status = res.get('signal_status', [])
|
||||
indicators = res.get('indicators', {})
|
||||
triggered_count = sum(1 for s in signal_status if s.get('triggered'))
|
||||
|
||||
is_holding = False
|
||||
st, disp, reason, rate = compute_recommend(
|
||||
signal_status, indicators, triggered_count, is_holding=is_holding
|
||||
)
|
||||
|
||||
# 同时计算持仓版推荐(回测 15:00 需要两种)
|
||||
st_h, disp_h, reason_h, rate_h = compute_recommend(
|
||||
signal_status, indicators, triggered_count, is_holding=True
|
||||
)
|
||||
|
||||
results.append({
|
||||
'code': code,
|
||||
'recommend_display': disp,
|
||||
'recommend_type': st,
|
||||
'recommend_reason': reason,
|
||||
'recommend_rate': rate,
|
||||
'triggered_count': triggered_count,
|
||||
'signal_status': signal_status,
|
||||
'indicators': indicators,
|
||||
'disp_holding': disp_h,
|
||||
'reason_holding': reason_h,
|
||||
'rate_holding': rate_h,
|
||||
})
|
||||
|
||||
if (j + 1) % 500 == 0:
|
||||
elapsed = time.time() - t0
|
||||
print(f" 已扫描 {j+1}/{total} {elapsed:.0f}s", flush=True)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def save_results(conn, results, scan_date, scan_time):
|
||||
"""批量写入扫描结果"""
|
||||
if not results:
|
||||
return
|
||||
with conn.cursor() as cur:
|
||||
for r in results:
|
||||
# 将持仓版推荐也存入 indicators 字段方便回测
|
||||
ind = r.get('indicators', {})
|
||||
ind['_holding'] = {
|
||||
'display': r.get('disp_holding', ''),
|
||||
'reason': r.get('reason_holding', ''),
|
||||
'rate': r.get('rate_holding', 0),
|
||||
}
|
||||
cur.execute("""
|
||||
INSERT INTO stock_scan_history
|
||||
(scan_date, scan_time, code, recommend_display, recommend_type,
|
||||
recommend_reason, recommend_rate, triggered_count, signal_status, indicators)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (scan_date, scan_time, code) DO UPDATE SET
|
||||
recommend_display = EXCLUDED.recommend_display,
|
||||
recommend_type = EXCLUDED.recommend_type,
|
||||
recommend_reason = EXCLUDED.recommend_reason,
|
||||
recommend_rate = EXCLUDED.recommend_rate,
|
||||
triggered_count = EXCLUDED.triggered_count,
|
||||
signal_status = EXCLUDED.signal_status,
|
||||
indicators = EXCLUDED.indicators,
|
||||
created_at = CURRENT_TIMESTAMP
|
||||
""", (
|
||||
scan_date, scan_time, r['code'],
|
||||
r['recommend_display'], r['recommend_type'],
|
||||
r['recommend_reason'], r['recommend_rate'],
|
||||
r['triggered_count'],
|
||||
Json(r['signal_status']), Json(ind),
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='历史全景扫描回溯(11:30 + 16:30)')
|
||||
parser.add_argument('--start', type=str, default='2026-01-01', metavar='YYYY-MM-DD')
|
||||
parser.add_argument('--end', type=str, default=None, metavar='YYYY-MM-DD')
|
||||
parser.add_argument('--force', action='store_true', help='强制覆盖已扫描日期')
|
||||
args = parser.parse_args()
|
||||
|
||||
start_date = datetime.strptime(args.start, '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(args.end, '%Y-%m-%d').date() if args.end else date.today()
|
||||
|
||||
conn = get_db_conn()
|
||||
|
||||
print("=" * 70)
|
||||
print(" 历史全景扫描回溯")
|
||||
print("=" * 70)
|
||||
print(f" 扫描区间: {start_date} ~ {end_date}")
|
||||
print(f" 扫描时段: 11:30(中午)+ 16:30(收盘后)")
|
||||
print(f" 强制覆盖: {'是' if args.force else '否(跳过已扫描日期)'}")
|
||||
print("-" * 70)
|
||||
|
||||
trading_days = get_trading_days(conn, start_date, end_date)
|
||||
if not trading_days:
|
||||
print("错误: 无交易日数据")
|
||||
conn.close()
|
||||
return
|
||||
print(f" 交易日数: {len(trading_days)} 天")
|
||||
|
||||
scanned_1130 = get_scanned_dates(conn, '11:30') if not args.force else set()
|
||||
scanned_1630 = get_scanned_dates(conn, '16:30') if not args.force else set()
|
||||
|
||||
preloaded = preload_all_klines(conn)
|
||||
all_codes = sorted(preloaded.keys())
|
||||
print(f" 可扫描股票: {len(all_codes)} 只")
|
||||
print("=" * 70)
|
||||
|
||||
total_start = time.time()
|
||||
total_scans = 0
|
||||
|
||||
for i, day_t in enumerate(trading_days):
|
||||
need_1130 = day_t not in scanned_1130
|
||||
need_1630 = day_t not in scanned_1630
|
||||
|
||||
if not need_1130 and not need_1630:
|
||||
continue
|
||||
|
||||
ohlc_t = get_day_ohlc(conn, day_t)
|
||||
if not ohlc_t:
|
||||
continue
|
||||
|
||||
print(f"\n [{i+1}/{len(trading_days)}] {day_t}", flush=True)
|
||||
|
||||
# 16:30 收盘后扫描(用 T 日完整 K 线)
|
||||
if need_1630:
|
||||
t0 = time.time()
|
||||
print(f" 16:30 扫描中...", flush=True)
|
||||
results = scan_one_day(preloaded, all_codes, day_t, ohlc_t, '16:30', conn)
|
||||
save_results(conn, results, day_t, '16:30')
|
||||
buy_count = sum(1 for r in results if r['recommend_display'] == '买入')
|
||||
sell_count = sum(1 for r in results if r['recommend_display'] == '卖出')
|
||||
print(f" 16:30 完成: {len(results)} 只 买入推荐 {buy_count} 卖出 {sell_count} {time.time()-t0:.0f}s",
|
||||
flush=True)
|
||||
total_scans += 1
|
||||
|
||||
# 11:30 中午扫描(用 T-1 + T 日 open 模拟)
|
||||
if need_1130:
|
||||
t0 = time.time()
|
||||
print(f" 11:30 扫描中...", flush=True)
|
||||
results = scan_one_day(preloaded, all_codes, day_t, ohlc_t, '11:30', conn)
|
||||
save_results(conn, results, day_t, '11:30')
|
||||
buy_count = sum(1 for r in results if r['recommend_display'] == '买入')
|
||||
sell_count = sum(1 for r in results if r['recommend_display'] == '卖出')
|
||||
print(f" 11:30 完成: {len(results)} 只 买入推荐 {buy_count} 卖出 {sell_count} {time.time()-t0:.0f}s",
|
||||
flush=True)
|
||||
total_scans += 1
|
||||
|
||||
elapsed = time.time() - total_start
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" 全部完成!")
|
||||
print(f" 扫描次数: {total_scans} 次({len(trading_days)} 天 × 2 时段)")
|
||||
print(f" 总耗时 : {elapsed/60:.1f} 分钟")
|
||||
print(f"{'=' * 70}")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
# Services 模块
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
豆包AI服务模块
|
||||
用于股票分析的AI对话(流式输出版本)
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
|
||||
# API配置
|
||||
API_KEY = "9fd8383f-5776-4366-855d-c6f40e867940"
|
||||
API_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
MODEL = "doubao-seed-1-6-251015"
|
||||
|
||||
|
||||
def analyze_stock(stock_code, stock_name, stock_data):
|
||||
"""
|
||||
使用豆包AI分析股票(流式输出,获取思考和结论)
|
||||
"""
|
||||
|
||||
# 构建分析提示词(基于推荐模型v3.0)
|
||||
fund_flow_str = format_fund_flow(stock_data.get('fund_flow_3days', []))
|
||||
|
||||
prompt = f"""分析{stock_name}({stock_code})投资价值。
|
||||
|
||||
数据:价格{stock_data.get('price', 'N/A')}元,PE={stock_data.get('pe', 'N/A')},PB={stock_data.get('pb', 'N/A')},ROE={stock_data.get('roe', 'N/A')}%,市值{format_market_cap(stock_data.get('total_market_cap'))}
|
||||
资金流向:{fund_flow_str}
|
||||
|
||||
评分模型:价格位置(30分,低于50%为低位)、资金流向(25分,主力净流入占比)、趋势(15分)、连续性(15分,≥3天强连续)、涨跌配合(10分)、量能(5分)
|
||||
推荐率:80-100强买/卖,60-79可操作,40-59观望,0-39不建议
|
||||
|
||||
按以下格式输出:
|
||||
## 技术面分析
|
||||
分析资金流向趋势和连续性
|
||||
|
||||
## 基本面分析
|
||||
分析PE/ROE估值和盈利能力
|
||||
|
||||
## 操作建议
|
||||
明确建议买入/持有/卖出,给出预估推荐率(0-100)
|
||||
|
||||
## 风险提示
|
||||
列出2-3个风险点"""
|
||||
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {API_KEY}"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": MODEL,
|
||||
"max_completion_tokens": 2048,
|
||||
"stream": True, # 启用流式输出
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# 流式请求
|
||||
response = requests.post(API_URL, headers=headers, json=payload, timeout=120, stream=True)
|
||||
|
||||
if response.status_code == 200:
|
||||
reasoning_content = "" # 思考过程
|
||||
content = "" # 最终结论
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode('utf-8')
|
||||
if line_str.startswith('data: '):
|
||||
data_str = line_str[6:]
|
||||
if data_str == '[DONE]':
|
||||
break
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
if 'choices' in data and len(data['choices']) > 0:
|
||||
delta = data['choices'][0].get('delta', {})
|
||||
# 获取思考过程
|
||||
if 'reasoning_content' in delta and delta['reasoning_content']:
|
||||
reasoning_content += delta['reasoning_content']
|
||||
# 获取最终内容
|
||||
if 'content' in delta and delta['content']:
|
||||
content += delta['content']
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# 组合思考过程和结论
|
||||
full_analysis = ""
|
||||
if reasoning_content:
|
||||
full_analysis += "## 💭 AI思考过程\n" + reasoning_content + "\n\n---\n\n"
|
||||
if content:
|
||||
full_analysis += content
|
||||
|
||||
if full_analysis:
|
||||
return {'success': True, 'analysis': full_analysis}
|
||||
else:
|
||||
return {'success': False, 'error': 'AI返回内容为空'}
|
||||
else:
|
||||
return {'success': False, 'error': f'API请求失败: {response.status_code}'}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return {'success': False, 'error': 'AI分析超时,请稍后重试'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': f'AI分析失败: {str(e)}'}
|
||||
|
||||
|
||||
def analyze_stock_stream(stock_code, stock_name, stock_data):
|
||||
"""
|
||||
使用豆包AI分析股票(流式生成器,用于SSE)
|
||||
"""
|
||||
|
||||
# 构建分析提示词
|
||||
fund_flow_str = format_fund_flow(stock_data.get('fund_flow_3days', []))
|
||||
|
||||
signal_status = stock_data.get('signal_status', [])
|
||||
indicators = stock_data.get('indicators', {})
|
||||
|
||||
signal_lines = []
|
||||
triggered_names = []
|
||||
for s in signal_status:
|
||||
status = '✅已触发' if s.get('triggered') else '○未触发'
|
||||
signal_lines.append(f" {s.get('name','')}: {status} (胜率{s.get('strength',0)}%) — {s.get('description','')}")
|
||||
if s.get('triggered'):
|
||||
triggered_names.append(s.get('name', ''))
|
||||
signal_text = '\n'.join(signal_lines) if signal_lines else ' 暂无扫描数据'
|
||||
triggered_text = '、'.join(triggered_names) if triggered_names else '无'
|
||||
|
||||
macd = indicators.get('macd', {})
|
||||
skdj = indicators.get('skdj', {})
|
||||
ema = indicators.get('ema', {})
|
||||
indicator_text = f"MACD: DIF={macd.get('dif','N/A')}, DEA={macd.get('dea','N/A')} | SKDJ: K={skdj.get('k','N/A')}, D={skdj.get('d','N/A')} | EMA: EMA3={ema.get('ema3','N/A')}, EMA21={ema.get('ema21','N/A')}"
|
||||
|
||||
prompt = f"""基于"交易信号实战体系"分析{stock_name}({stock_code})。
|
||||
|
||||
【基本面数据】
|
||||
价格{stock_data.get('price', 'N/A')}元,PE={stock_data.get('pe', 'N/A')},PB={stock_data.get('pb', 'N/A')},ROE={stock_data.get('roe', 'N/A')}%,市值{format_market_cap(stock_data.get('total_market_cap'))}
|
||||
资金流向:{fund_flow_str}
|
||||
|
||||
【技术指标】
|
||||
{indicator_text}
|
||||
|
||||
【7大交易信号状态】(当前已触发: {triggered_text})
|
||||
{signal_text}
|
||||
|
||||
【交易信号体系规则】
|
||||
信号胜率排行: ★主升浪85% > 日线底背离80% > 龙抬头75% > 真龙70% > 短底背离65% > 老鼠仓60% > 反弹55%
|
||||
标准牛股启动顺序: 日线底背离→龙抬头→真龙→★主升浪→反弹
|
||||
体系最强战法: 1)日线底背离出现→关注 2)龙抬头出现→买入 3)真龙/★主升浪→持有加仓 4)不见主升浪→不出场
|
||||
|
||||
请按以下格式分析:
|
||||
## 交易信号分析
|
||||
根据7大信号的触发状态,判断该股在"底部→拉升"流程中处于哪个阶段。已触发的信号说明什么?距离下一个关键信号还有多远?
|
||||
|
||||
## 基本面分析
|
||||
简要分析PE/ROE估值水平和盈利能力(2-3句话)
|
||||
|
||||
## 操作建议
|
||||
基于体系最强战法规则,给出明确建议(关注/买入/持有加仓/减仓/观望),并解释理由。给出推荐率(0-100)
|
||||
|
||||
## 风险提示
|
||||
列出2-3个关键风险点"""
|
||||
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {API_KEY}"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": MODEL,
|
||||
"max_completion_tokens": 2048,
|
||||
"stream": True,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(API_URL, headers=headers, json=payload, timeout=120, stream=True)
|
||||
|
||||
if response.status_code == 200:
|
||||
in_reasoning = False
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode('utf-8')
|
||||
if line_str.startswith('data: '):
|
||||
data_str = line_str[6:]
|
||||
if data_str == '[DONE]':
|
||||
break
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
if 'choices' in data and len(data['choices']) > 0:
|
||||
delta = data['choices'][0].get('delta', {})
|
||||
|
||||
# 思考过程
|
||||
if 'reasoning_content' in delta and delta['reasoning_content']:
|
||||
if not in_reasoning:
|
||||
yield {'type': 'reasoning_start'}
|
||||
in_reasoning = True
|
||||
yield {'type': 'reasoning', 'content': delta['reasoning_content']}
|
||||
|
||||
# 最终内容
|
||||
if 'content' in delta and delta['content']:
|
||||
if in_reasoning:
|
||||
yield {'type': 'reasoning_end'}
|
||||
in_reasoning = False
|
||||
yield {'type': 'content', 'content': delta['content']}
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if in_reasoning:
|
||||
yield {'type': 'reasoning_end'}
|
||||
else:
|
||||
yield {'type': 'error', 'content': f'API请求失败: {response.status_code}'}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
yield {'type': 'error', 'content': 'AI分析超时,请稍后重试'}
|
||||
except Exception as e:
|
||||
yield {'type': 'error', 'content': f'AI分析失败: {str(e)}'}
|
||||
|
||||
|
||||
def format_market_cap(value):
|
||||
"""格式化市值"""
|
||||
if not value:
|
||||
return 'N/A'
|
||||
try:
|
||||
value = float(value)
|
||||
if value >= 100000000000: # 千亿
|
||||
return f"{value/100000000000:.2f}千亿"
|
||||
elif value >= 100000000: # 亿
|
||||
return f"{value/100000000:.2f}亿"
|
||||
else:
|
||||
return f"{value/10000:.2f}万"
|
||||
except:
|
||||
return str(value)
|
||||
|
||||
|
||||
def format_fund_flow(fund_flow_list):
|
||||
"""格式化资金流向数据"""
|
||||
if not fund_flow_list:
|
||||
return "暂无数据"
|
||||
|
||||
lines = []
|
||||
for item in fund_flow_list:
|
||||
date = item.get('date', '')
|
||||
change = item.get('change_pct', 0)
|
||||
main = item.get('main_pct', 0)
|
||||
super_pct = item.get('super_pct', 0)
|
||||
lines.append(f"- {date}: 涨跌{change:+.2f}%, 主力{main:+.2f}%, 超大单{super_pct:+.2f}%")
|
||||
|
||||
return '\n'.join(lines)
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
麦蕊智数API服务模块
|
||||
API文档: https://api.mairuiapi.com
|
||||
Licence: AEB5CE22-155A-4535-AE01-610920EB2751
|
||||
"""
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# API配置
|
||||
LICENCE = "5352ED2F-94E5-4E96-8B7F-B57BA75284E3"
|
||||
BASE_URL = "https://api.mairuiapi.com"
|
||||
|
||||
# 缓存配置
|
||||
_cache = {
|
||||
'realtime_all': {'data': None, 'timestamp': None, 'ttl': 60}, # 全市场实时数据缓存60秒
|
||||
}
|
||||
|
||||
# 全局连接池 Session(TCP连接复用,大幅减少连接建立开销)
|
||||
_session = None
|
||||
|
||||
def _get_session():
|
||||
"""获取全局复用的 requests.Session(带连接池和自动重试)"""
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = requests.Session()
|
||||
retry_strategy = Retry(
|
||||
total=2, # 最多重试2次
|
||||
backoff_factor=0.3, # 重试间隔: 0.3s, 0.6s
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
)
|
||||
adapter = HTTPAdapter(
|
||||
max_retries=retry_strategy,
|
||||
pool_connections=20, # 连接池大小
|
||||
pool_maxsize=20, # 最大连接数
|
||||
)
|
||||
_session.mount("https://", adapter)
|
||||
_session.mount("http://", adapter)
|
||||
return _session
|
||||
|
||||
|
||||
def _request(url, timeout=10):
|
||||
"""发送API请求(复用连接池)"""
|
||||
try:
|
||||
session = _get_session()
|
||||
resp = session.get(url, timeout=timeout)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
else:
|
||||
print(f"API请求失败: {url}, status={resp.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"API请求异常: {url}, error={e}")
|
||||
return None
|
||||
|
||||
|
||||
# ========== 实时交易数据 ==========
|
||||
|
||||
def get_realtime_price(stock_code):
|
||||
"""
|
||||
获取单只股票实时交易数据(券商数据源)
|
||||
API: https://api.mairuiapi.com/hsrl/ssjy/{stock_code}/{licence}
|
||||
"""
|
||||
url = f"{BASE_URL}/hsrl/ssjy/{stock_code}/{LICENCE}"
|
||||
data = _request(url)
|
||||
|
||||
if data:
|
||||
return {
|
||||
'success': True,
|
||||
'data': {
|
||||
'code': stock_code,
|
||||
'price': float(data.get('p', 0)),
|
||||
'change': float(data.get('pc', 0)),
|
||||
'open': float(data.get('o', 0)),
|
||||
'high': float(data.get('h', 0)),
|
||||
'low': float(data.get('l', 0)),
|
||||
'volume': float(data.get('v', 0)),
|
||||
'amount': float(data.get('cje', 0)),
|
||||
'pe': float(data.get('pe', 0)) if data.get('pe') else None,
|
||||
'pb': float(data.get('sjl', 0)) if data.get('sjl') else None,
|
||||
'turnover': float(data.get('hs', 0)),
|
||||
'total_market_cap': float(data.get('sz', 0)),
|
||||
'circulating_market_cap': float(data.get('lt', 0)),
|
||||
'update_time': data.get('t', ''),
|
||||
}
|
||||
}
|
||||
return {'success': False, 'error': '获取失败'}
|
||||
|
||||
|
||||
def get_realtime_prices_batch(stock_codes):
|
||||
"""
|
||||
批量获取实时交易数据(最多20只)
|
||||
API: https://api.mairuiapi.com/hsrl/ssjy_more/{licence}?stock_codes=xxx,xxx
|
||||
"""
|
||||
if not stock_codes:
|
||||
return {}
|
||||
|
||||
# 每次最多20只
|
||||
codes_str = ','.join(stock_codes[:20])
|
||||
url = f"{BASE_URL}/hsrl/ssjy_more/{LICENCE}?stock_codes={codes_str}"
|
||||
data = _request(url)
|
||||
|
||||
results = {}
|
||||
if data and isinstance(data, list):
|
||||
for i, item in enumerate(data):
|
||||
if i < len(stock_codes):
|
||||
code = stock_codes[i]
|
||||
results[code] = {
|
||||
'code': code,
|
||||
'price': float(item.get('p', 0)),
|
||||
'change': float(item.get('pc', 0)),
|
||||
'pe': float(item.get('pe', 0)) if item.get('pe') else None,
|
||||
'pb': float(item.get('pb_ratio', 0)) if item.get('pb_ratio') else None,
|
||||
}
|
||||
return results
|
||||
|
||||
|
||||
# ========== K线数据 ==========
|
||||
|
||||
def get_kline(stock_code, period='d', days=30, adjust='f'):
|
||||
"""
|
||||
获取K线数据
|
||||
API: https://api.mairuiapi.com/hsstock/history/{code}.{market}/{period}/{adjust}/{licence}
|
||||
|
||||
参数:
|
||||
- period: 5/15/30/60/d/w/m/y (分钟/日/周/月/年)
|
||||
- adjust: n(不复权)/f(前复权)/b(后复权)
|
||||
"""
|
||||
# 确定市场
|
||||
if stock_code.startswith(('0', '3')):
|
||||
market = 'SZ'
|
||||
elif stock_code.startswith(('8', '9')):
|
||||
market = 'BJ'
|
||||
else:
|
||||
market = 'SH'
|
||||
|
||||
# 计算日期范围
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
|
||||
|
||||
url = f"{BASE_URL}/hsstock/history/{stock_code}.{market}/{period}/{adjust}/{LICENCE}?st={start_date}&et={end_date}"
|
||||
data = _request(url)
|
||||
|
||||
if data and isinstance(data, list):
|
||||
kline_data = []
|
||||
for item in data:
|
||||
# 处理日期格式,去掉时间部分
|
||||
date_str = item.get('t', '')
|
||||
if date_str and ' ' in date_str:
|
||||
date_str = date_str.split(' ')[0] # 只保留日期部分
|
||||
kline_data.append({
|
||||
'date': date_str,
|
||||
'open': float(item.get('o', 0)),
|
||||
'high': float(item.get('h', 0)),
|
||||
'low': float(item.get('l', 0)),
|
||||
'close': float(item.get('c', 0)),
|
||||
'volume': float(item.get('v', 0)),
|
||||
'amount': float(item.get('a', 0)),
|
||||
})
|
||||
return {'success': True, 'data': kline_data}
|
||||
|
||||
return {'success': True, 'data': []}
|
||||
|
||||
|
||||
# ========== 公司信息 ==========
|
||||
|
||||
def get_company_info(stock_code):
|
||||
"""
|
||||
获取公司简介
|
||||
API: https://api.mairuiapi.com/hscp/gsjj/{stock_code}/{licence}
|
||||
"""
|
||||
url = f"{BASE_URL}/hscp/gsjj/{stock_code}/{LICENCE}"
|
||||
data = _request(url)
|
||||
|
||||
if data:
|
||||
return {
|
||||
'success': True,
|
||||
'data': {
|
||||
'name': data.get('name', ''),
|
||||
'industry': data.get('idea', '').split(',')[0] if data.get('idea') else '',
|
||||
'list_date': data.get('ldate', ''),
|
||||
'issue_price': data.get('sprice', ''),
|
||||
'description': data.get('desc', ''),
|
||||
'business_scope': data.get('bscope', ''),
|
||||
}
|
||||
}
|
||||
return {'success': False, 'error': '获取失败'}
|
||||
|
||||
|
||||
# ========== 财务指标 ==========
|
||||
|
||||
def get_financial_indicators(stock_code):
|
||||
"""
|
||||
获取财务指标
|
||||
API: https://api.mairuiapi.com/hscp/cwzb/{stock_code}/{licence}
|
||||
"""
|
||||
url = f"{BASE_URL}/hscp/cwzb/{stock_code}/{LICENCE}"
|
||||
data = _request(url)
|
||||
|
||||
if data and isinstance(data, list) and len(data) > 0:
|
||||
latest = data[0] # 最新一期
|
||||
return {
|
||||
'success': True,
|
||||
'data': {
|
||||
'report_date': latest.get('date', ''),
|
||||
'eps': _parse_float(latest.get('tbmg')), # 摊薄每股收益
|
||||
'bps': _parse_float(latest.get('mgjz')), # 每股净资产
|
||||
'roe': _parse_float(latest.get('jzsy')), # 净资产收益率
|
||||
'gross_margin': _parse_float(latest.get('xsml')), # 销售毛利率
|
||||
'net_margin': _parse_float(latest.get('xsjl')), # 销售净利率
|
||||
'revenue_yoy': _parse_float(latest.get('zysr')), # 主营业务收入增长率
|
||||
'profit_yoy': _parse_float(latest.get('jlzz')), # 净利润增长率
|
||||
'debt_ratio': _parse_float(latest.get('zcfzl')), # 资产负债率
|
||||
'current_ratio': _parse_float(latest.get('ldbl')), # 流动比率
|
||||
}
|
||||
}
|
||||
return {'success': False, 'error': '获取失败'}
|
||||
|
||||
|
||||
def _parse_float(value):
|
||||
"""解析浮点数"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
# ========== 资金流向 ==========
|
||||
|
||||
def get_fund_flow(stock_code, days=3):
|
||||
"""
|
||||
获取资金流向数据
|
||||
API: https://api.mairuiapi.com/hsstock/history/transaction/{stock_code}/{licence}?lt={days}
|
||||
"""
|
||||
url = f"{BASE_URL}/hsstock/history/transaction/{stock_code}/{LICENCE}?lt={days}"
|
||||
data = _request(url)
|
||||
|
||||
if data and isinstance(data, list):
|
||||
flow_data = []
|
||||
for item in data:
|
||||
# 计算主力净流入 = 主买大单+主买特大单 - 主卖大单-主卖特大单
|
||||
main_buy = float(item.get('zmbddcje', 0)) + float(item.get('zmbtdcje', 0))
|
||||
main_sell = float(item.get('zmsddcje', 0)) + float(item.get('zmstdcje', 0))
|
||||
main_net = main_buy - main_sell
|
||||
|
||||
flow_data.append({
|
||||
'date': datetime.fromtimestamp(item.get('t', 0)).strftime('%Y-%m-%d') if item.get('t') else '',
|
||||
'main_net_inflow': main_net,
|
||||
'super_buy': float(item.get('zmbtdcje', 0)),
|
||||
'super_sell': float(item.get('zmstdcje', 0)),
|
||||
'big_buy': float(item.get('zmbddcje', 0)),
|
||||
'big_sell': float(item.get('zmsddcje', 0)),
|
||||
})
|
||||
return {'success': True, 'data': flow_data}
|
||||
|
||||
return {'success': True, 'data': []}
|
||||
|
||||
|
||||
# ========== 股票列表 ==========
|
||||
|
||||
def get_stock_list():
|
||||
"""
|
||||
获取股票列表
|
||||
API: https://api.mairuiapi.com/hslt/list/{licence}
|
||||
"""
|
||||
url = f"{BASE_URL}/hslt/list/{LICENCE}"
|
||||
data = _request(url, timeout=30)
|
||||
|
||||
if data and isinstance(data, list):
|
||||
return {
|
||||
'success': True,
|
||||
'data': [{'code': item.get('dm'), 'name': item.get('mc'), 'market': item.get('jys')} for item in data]
|
||||
}
|
||||
return {'success': False, 'error': '获取失败'}
|
||||
|
||||
|
||||
# ========== 涨停股池 ==========
|
||||
|
||||
def get_limit_up_stocks(date=None):
|
||||
"""
|
||||
获取涨停股池
|
||||
API: https://api.mairuiapi.com/hslt/ztgc/{date}/{licence}
|
||||
"""
|
||||
if date is None:
|
||||
date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
url = f"{BASE_URL}/hslt/ztgc/{date}/{LICENCE}"
|
||||
data = _request(url)
|
||||
|
||||
if data and isinstance(data, list):
|
||||
return {
|
||||
'success': True,
|
||||
'data': [{
|
||||
'code': item.get('dm'),
|
||||
'name': item.get('mc'),
|
||||
'price': float(item.get('p', 0)),
|
||||
'change': float(item.get('zf', 0)),
|
||||
'amount': float(item.get('cje', 0)),
|
||||
'limit_count': int(item.get('lbc', 0)),
|
||||
'first_limit_time': item.get('fbt', ''),
|
||||
'industry': item.get('hy', ''),
|
||||
} for item in data]
|
||||
}
|
||||
return {'success': True, 'data': []}
|
||||
@@ -0,0 +1,637 @@
|
||||
"""
|
||||
模拟交易定时任务调度器
|
||||
- 交易日09:35自动执行买入(v7最优买入时点)
|
||||
- 交易日13:40自动执行卖出(v7最优卖出时点)
|
||||
- 交易日15:05更新持仓价格
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, date, timedelta
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import schedule
|
||||
|
||||
from services.stock_algorithms import compute_recommend, get_latest_price
|
||||
|
||||
# 全局变量
|
||||
_scheduler_thread = None
|
||||
_is_running = False
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 动态交易日历缓存
|
||||
# 通过 akshare 从新浪财经自动获取A股交易日历
|
||||
# 包含所有历史及未来交易日,自动适配节假日
|
||||
# ═══════════════════════════════════════════════════════
|
||||
_trading_dates_cache = set() # 交易日集合 (date objects)
|
||||
_cache_loaded_date = None # 缓存加载日期,每天最多刷新1次
|
||||
|
||||
|
||||
def _load_trading_calendar():
|
||||
"""从新浪财经加载A股交易日历到内存缓存"""
|
||||
global _trading_dates_cache, _cache_loaded_date
|
||||
try:
|
||||
import akshare as ak
|
||||
df = ak.tool_trade_date_hist_sina()
|
||||
if df is not None and not df.empty:
|
||||
new_cache = set()
|
||||
for val in df['trade_date']:
|
||||
if isinstance(val, date):
|
||||
new_cache.add(val)
|
||||
else:
|
||||
# 字符串格式 'YYYY-MM-DD'
|
||||
new_cache.add(date.fromisoformat(str(val)))
|
||||
_trading_dates_cache = new_cache
|
||||
_cache_loaded_date = date.today()
|
||||
print(f"[交易日历] 加载成功: {len(_trading_dates_cache)} 个交易日 "
|
||||
f"(范围: {min(_trading_dates_cache)} ~ {max(_trading_dates_cache)})")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[交易日历] 从新浪获取交易日历失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_calendar_loaded():
|
||||
"""确保交易日历已加载且是最新的(每天自动刷新一次)"""
|
||||
global _cache_loaded_date
|
||||
today = date.today()
|
||||
if _trading_dates_cache and _cache_loaded_date == today:
|
||||
return True # 缓存有效
|
||||
# 需要加载/刷新
|
||||
return _load_trading_calendar()
|
||||
|
||||
|
||||
def is_trading_day(check_date=None):
|
||||
"""判断是否为A股交易日(基于新浪交易日历,自动适配全部节假日)"""
|
||||
if check_date is None:
|
||||
check_date = date.today()
|
||||
|
||||
# 快速检查:周末一定不是交易日
|
||||
if check_date.weekday() >= 5:
|
||||
return False
|
||||
|
||||
# 尝试使用动态交易日历
|
||||
if _ensure_calendar_loaded() and _trading_dates_cache:
|
||||
# 检查日期是否超出日历范围(日历通常只覆盖到当年年底)
|
||||
max_cal_date = max(_trading_dates_cache)
|
||||
if check_date > max_cal_date:
|
||||
print(f"[定时任务] ⚠️ {check_date} 超出日历范围({max_cal_date}),按工作日处理")
|
||||
return True # 超出范围的工作日默认视为交易日
|
||||
if check_date in _trading_dates_cache:
|
||||
return True
|
||||
else:
|
||||
print(f"[定时任务] {check_date} 不在交易日历中,非交易日")
|
||||
return False
|
||||
|
||||
# 降级:日历加载失败时,工作日默认视为交易日(避免误跳过)
|
||||
print(f"[定时任务] ⚠️ 交易日历不可用,{check_date} 按工作日处理")
|
||||
return True
|
||||
|
||||
|
||||
def is_trading_time():
|
||||
"""判断当前是否在交易时间内"""
|
||||
now = datetime.now()
|
||||
hour = now.hour
|
||||
minute = now.minute
|
||||
time_val = hour * 100 + minute
|
||||
|
||||
# 交易时间:9:30-11:30, 13:00-15:00
|
||||
if (930 <= time_val <= 1130) or (1300 <= time_val <= 1500):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_all_users():
|
||||
"""获取所有启用自动交易的用户"""
|
||||
from db import get_db
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT u.id as user_id, u.username, c.trade_quantity
|
||||
FROM users u
|
||||
LEFT JOIN sim_config c ON u.id = c.user_id
|
||||
WHERE c.auto_trade_enabled = true OR c.auto_trade_enabled IS NULL
|
||||
""")
|
||||
return cur.fetchall()
|
||||
except Exception as e:
|
||||
print(f"[定时任务] 获取用户列表失败: {e}")
|
||||
return []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# 自定义股票列表(100只精选股票)
|
||||
CUSTOM_STOCKS = [
|
||||
'000001', '000002', '000063', '000100', '000157', '000333', '000338', '000425', '000538', '000568',
|
||||
'000596', '000625', '000651', '000661', '000703', '000725', '000768', '000776', '000858', '000876',
|
||||
'002007', '002024', '002027', '002049', '002120', '002142', '002179', '002230', '002236', '002241',
|
||||
'002271', '002304', '002352', '002371', '002415', '002460', '002466', '002475', '002493', '002555',
|
||||
'002594', '002602', '002607', '002624', '002714', '002736', '002812', '002841', '002916', '002938',
|
||||
'300003', '300014', '300015', '300033', '300059', '300122', '300124', '300136', '300142', '300144',
|
||||
'300347', '300408', '300433', '300496', '300498', '300502', '300529', '300558', '300601', '300628',
|
||||
'300750', '300760', '300782', '300896', '300948', '600000', '600009', '600016', '600028', '600030',
|
||||
'600036', '600048', '600050', '600061', '600104', '600111', '600115', '600132', '600150', '600196',
|
||||
'600276', '600309', '600332', '600346', '600352', '600362', '600406', '600436', '600519', '600585'
|
||||
]
|
||||
|
||||
|
||||
def get_hot_stocks(limit=50):
|
||||
"""获取热门股票列表(自定义100只精选股票)
|
||||
东方财富人气榜API已不可用(腾讯云封锁),仅使用自定义列表
|
||||
"""
|
||||
stocks = []
|
||||
seen_codes = set()
|
||||
|
||||
for code in CUSTOM_STOCKS:
|
||||
if code not in seen_codes:
|
||||
stocks.append({'code': code, 'name': ''})
|
||||
seen_codes.add(code)
|
||||
|
||||
print(f"[定时任务] 自定义精选 {len(stocks)} 只股票待扫描")
|
||||
return stocks
|
||||
|
||||
|
||||
def _compute_recommend(signal_status, indicators, triggered_count, is_holding):
|
||||
"""统一推荐逻辑 — 委托给 services.stock_algorithms.compute_recommend"""
|
||||
return compute_recommend(signal_status, indicators, triggered_count, is_holding)
|
||||
|
||||
|
||||
def execute_auto_trade_for_user(user_id, trade_quantity=1000, scan_date=None):
|
||||
"""基于统一推荐算法的自动交易(与全景扫描推荐使用完全相同的逻辑)
|
||||
买入: _compute_recommend 返回 '买入' 的股票(主升浪/底背离+龙抬头)
|
||||
加仓: _compute_recommend 返回 '加仓' 的持仓股(主升浪信号)
|
||||
卖出: _compute_recommend 返回 '卖出' 的持仓股(MACD死叉)
|
||||
|
||||
scan_date: 使用哪天的扫描数据, None则自动选择最近可用的
|
||||
"""
|
||||
from db import get_db
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
print(f"[定时任务] 开始为用户{user_id}执行策略交易(统一推荐算法)...")
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return {'error': '数据库连接失败'}
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
today = date.today()
|
||||
now = datetime.now().time()
|
||||
results = []
|
||||
|
||||
# 1. 获取用户持仓
|
||||
cur.execute("""
|
||||
SELECT stock_code, stock_name, quantity, avg_cost::float
|
||||
FROM sim_positions WHERE user_id = %s AND quantity > 0
|
||||
""", (user_id,))
|
||||
positions = cur.fetchall()
|
||||
holding_codes = {p['stock_code'] for p in positions}
|
||||
|
||||
# 2. 读取扫描结果(指定日期或自动查找最近可用的)
|
||||
if scan_date:
|
||||
cur.execute("""
|
||||
SELECT code, name, triggered_count, signal_status, indicators
|
||||
FROM stock_signal_scan WHERE scan_date = %s
|
||||
""", (scan_date,))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT code, name, triggered_count, signal_status, indicators
|
||||
FROM stock_signal_scan
|
||||
WHERE scan_date = (
|
||||
SELECT MAX(scan_date) FROM stock_signal_scan
|
||||
WHERE scan_date <= %s
|
||||
)
|
||||
""", (today,))
|
||||
scan_rows = cur.fetchall()
|
||||
scan_map = {r['code']: r for r in scan_rows}
|
||||
|
||||
used_date = scan_date or '最近'
|
||||
if not scan_map:
|
||||
print(f"[定时任务] 无可用扫描数据(scan_date={used_date}),跳过交易")
|
||||
return {'success': True, 'results': [], 'message': '无可用扫描数据'}
|
||||
|
||||
print(f"[定时任务] 使用扫描数据: {used_date}, 共{len(scan_map)}只股票")
|
||||
|
||||
# ===== 卖出逻辑 =====
|
||||
# 持仓股: 使用 _compute_recommend(is_holding=True) 判断卖出
|
||||
for pos in positions:
|
||||
code = pos['stock_code']
|
||||
scan = scan_map.get(code)
|
||||
if not scan:
|
||||
continue
|
||||
|
||||
signal_type, display, reason, rate = _compute_recommend(
|
||||
scan['signal_status'], scan['indicators'],
|
||||
scan['triggered_count'], is_holding=True
|
||||
)
|
||||
|
||||
if signal_type == 'sell':
|
||||
price = _get_latest_price(code)
|
||||
if not price or price <= 0:
|
||||
continue
|
||||
qty = min(trade_quantity, pos['quantity'])
|
||||
realized_pnl = (price - pos['avg_cost']) * qty
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO sim_trades
|
||||
(user_id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date, trade_time, recommend_rate, signal_reason)
|
||||
VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s)
|
||||
""", (user_id, code, pos['stock_name'], price, qty,
|
||||
today, now, rate, reason))
|
||||
|
||||
new_qty = pos['quantity'] - qty
|
||||
if new_qty > 0:
|
||||
cur.execute("""
|
||||
UPDATE sim_positions SET
|
||||
quantity=%s, total_cost=%s, current_price=%s, updated_at=NOW()
|
||||
WHERE user_id=%s AND stock_code=%s
|
||||
""", (new_qty, pos['avg_cost'] * new_qty, price, user_id, code))
|
||||
else:
|
||||
cur.execute("""
|
||||
UPDATE sim_positions SET
|
||||
quantity=0, total_cost=0, current_price=%s, updated_at=NOW()
|
||||
WHERE user_id=%s AND stock_code=%s
|
||||
""", (price, user_id, code))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count)
|
||||
VALUES (%s, %s, %s, 1)
|
||||
ON CONFLICT (user_id, stat_date) DO UPDATE SET
|
||||
realized_profit = sim_daily_stats.realized_profit + %s,
|
||||
trade_count = sim_daily_stats.trade_count + 1
|
||||
""", (user_id, today, realized_pnl, realized_pnl))
|
||||
|
||||
results.append({
|
||||
'type': 'sell', 'code': code, 'name': pos['stock_name'],
|
||||
'price': price, 'quantity': qty, 'pnl': realized_pnl, 'reason': reason
|
||||
})
|
||||
print(f"[策略交易] 卖出 {code} {pos['stock_name']} {qty}股@{price} | {reason}")
|
||||
|
||||
# ===== 买入逻辑 =====
|
||||
# 非持仓股: 使用 _compute_recommend(is_holding=False) 判断买入
|
||||
buy_candidates = []
|
||||
for code, scan in scan_map.items():
|
||||
if code in holding_codes:
|
||||
continue
|
||||
signal_type, display, reason, rate = _compute_recommend(
|
||||
scan['signal_status'], scan['indicators'],
|
||||
scan['triggered_count'], is_holding=False
|
||||
)
|
||||
if signal_type == 'buy':
|
||||
buy_candidates.append({
|
||||
'code': code, 'name': scan['name'] or '',
|
||||
'recommend_rate': rate,
|
||||
'reason': reason,
|
||||
})
|
||||
|
||||
# 按推荐率降序排序,取top 3
|
||||
buy_candidates.sort(key=lambda x: x['recommend_rate'], reverse=True)
|
||||
buy_candidates = buy_candidates[:3]
|
||||
|
||||
for cand in buy_candidates:
|
||||
code = cand['code']
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as cnt FROM sim_trades
|
||||
WHERE user_id=%s AND stock_code=%s AND trade_date=%s AND trade_type='buy'
|
||||
""", (user_id, code, today))
|
||||
if cur.fetchone()['cnt'] > 0:
|
||||
continue
|
||||
|
||||
price = _get_latest_price(code)
|
||||
if not price or price <= 0:
|
||||
continue
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO sim_trades
|
||||
(user_id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date, trade_time, recommend_rate, signal_reason)
|
||||
VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s)
|
||||
""", (user_id, code, cand['name'], price, trade_quantity,
|
||||
today, now, cand['recommend_rate'], cand['reason']))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO sim_positions
|
||||
(user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, stock_code) DO UPDATE SET
|
||||
quantity = sim_positions.quantity + EXCLUDED.quantity,
|
||||
total_cost = sim_positions.total_cost + EXCLUDED.total_cost,
|
||||
avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) /
|
||||
(sim_positions.quantity + EXCLUDED.quantity),
|
||||
current_price = EXCLUDED.current_price,
|
||||
stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name),
|
||||
updated_at = NOW()
|
||||
""", (user_id, code, cand['name'], trade_quantity, price,
|
||||
price * trade_quantity, price))
|
||||
|
||||
results.append({
|
||||
'type': 'buy', 'code': code, 'name': cand['name'],
|
||||
'price': price, 'quantity': trade_quantity, 'reason': cand['reason']
|
||||
})
|
||||
print(f"[策略交易] 买入 {code} {cand['name']} {trade_quantity}股@{price} | {cand['reason']}")
|
||||
|
||||
# ===== 加仓逻辑 =====
|
||||
# 持仓股: 使用 _compute_recommend(is_holding=True) 判断加仓
|
||||
cur.execute("""
|
||||
SELECT stock_code, stock_name, quantity, avg_cost::float
|
||||
FROM sim_positions WHERE user_id = %s AND quantity > 0
|
||||
""", (user_id,))
|
||||
current_positions = cur.fetchall()
|
||||
|
||||
for pos in current_positions:
|
||||
code = pos['stock_code']
|
||||
scan = scan_map.get(code)
|
||||
if not scan:
|
||||
continue
|
||||
|
||||
signal_type, display, reason, rate = _compute_recommend(
|
||||
scan['signal_status'], scan['indicators'],
|
||||
scan['triggered_count'], is_holding=True
|
||||
)
|
||||
if display != '加仓':
|
||||
continue
|
||||
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as cnt FROM sim_trades
|
||||
WHERE user_id=%s AND stock_code=%s AND trade_date=%s AND trade_type='buy'
|
||||
""", (user_id, code, today))
|
||||
if cur.fetchone()['cnt'] > 0:
|
||||
continue
|
||||
|
||||
price = _get_latest_price(code)
|
||||
if not price or price <= 0:
|
||||
continue
|
||||
|
||||
add_qty = trade_quantity // 2
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO sim_trades
|
||||
(user_id, stock_code, stock_name, trade_type, price, quantity,
|
||||
trade_date, trade_time, recommend_rate, signal_reason)
|
||||
VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s)
|
||||
""", (user_id, code, pos['stock_name'], price, add_qty,
|
||||
today, now, rate, reason))
|
||||
|
||||
cur.execute("""
|
||||
UPDATE sim_positions SET
|
||||
quantity = quantity + %s,
|
||||
total_cost = total_cost + %s,
|
||||
avg_cost = (total_cost + %s) / (quantity + %s),
|
||||
current_price = %s,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
""", (add_qty, price * add_qty, price * add_qty, add_qty,
|
||||
price, user_id, code))
|
||||
|
||||
results.append({
|
||||
'type': 'buy', 'code': code, 'name': pos['stock_name'],
|
||||
'price': price, 'quantity': add_qty, 'reason': reason
|
||||
})
|
||||
print(f"[策略交易] 加仓 {code} {pos['stock_name']} {add_qty}股@{price} | {reason}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
buy_count = len([r for r in results if r['type'] == 'buy'])
|
||||
sell_count = len([r for r in results if r['type'] == 'sell'])
|
||||
print(f"[策略交易] 用户{user_id}完成: 买入{buy_count}笔, 卖出{sell_count}笔")
|
||||
|
||||
return {'success': True, 'results': results}
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {'error': str(e)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _get_latest_price(stock_code):
|
||||
"""获取股票最新价格 — 委托给 services.stock_algorithms.get_latest_price"""
|
||||
return get_latest_price(stock_code)
|
||||
|
||||
|
||||
def update_positions_price_for_user(user_id):
|
||||
"""更新用户持仓的当前价格(收盘时调用)— 使用腾讯财经API"""
|
||||
from db import get_db
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 获取持仓
|
||||
cur.execute("""
|
||||
SELECT stock_code FROM sim_positions
|
||||
WHERE user_id = %s AND quantity > 0
|
||||
""", (user_id,))
|
||||
positions = cur.fetchall()
|
||||
|
||||
# 批量获取持仓股票的实时价格(使用腾讯财经API,兼容腾讯云)
|
||||
codes = [pos['stock_code'] for pos in positions]
|
||||
if codes:
|
||||
try:
|
||||
import requests as _req
|
||||
tencent_codes = []
|
||||
for c in codes:
|
||||
if c.startswith('6'):
|
||||
tencent_codes.append(f'sh{c}')
|
||||
else:
|
||||
tencent_codes.append(f'sz{c}')
|
||||
_r = _req.get(f'http://qt.gtimg.cn/q={",".join(tencent_codes)}',
|
||||
timeout=10, headers={'Referer': 'https://finance.qq.com'})
|
||||
if _r.status_code == 200:
|
||||
for line in _r.text.strip().split(';'):
|
||||
if '\"' not in line:
|
||||
continue
|
||||
fields = line.split('\"')[1].split('~')
|
||||
if len(fields) > 3 and fields[3]:
|
||||
stock_code = fields[2]
|
||||
price = float(fields[3])
|
||||
if price > 0:
|
||||
cur.execute("""
|
||||
UPDATE sim_positions SET
|
||||
current_price = %s, updated_at = NOW()
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
""", (price, user_id, stock_code))
|
||||
except Exception as e:
|
||||
print(f"[定时任务] 腾讯API批量更新价格失败: {e}")
|
||||
|
||||
# 更新每日统计
|
||||
today = date.today()
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COALESCE(SUM(quantity * current_price), 0) as market_value,
|
||||
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||
COALESCE(SUM(quantity * current_price - total_cost), 0) as unrealized
|
||||
FROM sim_positions
|
||||
WHERE user_id = %s AND quantity > 0
|
||||
""", (user_id,))
|
||||
stats = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO sim_daily_stats
|
||||
(user_id, stat_date, total_market_value, total_cost, unrealized_profit)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, stat_date) DO UPDATE SET
|
||||
total_market_value = EXCLUDED.total_market_value,
|
||||
total_cost = EXCLUDED.total_cost,
|
||||
unrealized_profit = EXCLUDED.unrealized_profit
|
||||
""", (user_id, today, stats['market_value'], stats['total_cost'], stats['unrealized']))
|
||||
|
||||
conn.commit()
|
||||
print(f"[定时任务] 用户{user_id}持仓价格已更新")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"[定时任务] 更新用户{user_id}持仓价格失败: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def job_morning_trade():
|
||||
"""早盘交易任务(09:35执行 — v7最优买入时点)
|
||||
使用昨天收盘后的全景扫描数据做买入/卖出决策
|
||||
优先使用智能引擎(smart_trade_engine),降级到旧引擎(execute_auto_trade_for_user)
|
||||
"""
|
||||
print(f"[定时任务] ===== 早盘交易任务开始 {datetime.now()} =====")
|
||||
|
||||
if not is_trading_day():
|
||||
print("[定时任务] 今天不是交易日,跳过")
|
||||
return
|
||||
|
||||
users = get_all_users()
|
||||
print(f"[定时任务] 找到{len(users)}个用户需要执行自动交易")
|
||||
|
||||
for user in users:
|
||||
user_id = user['user_id']
|
||||
# 尝试使用智能引擎
|
||||
try:
|
||||
from services.smart_trade_engine import execute_smart_trade
|
||||
from db import get_db
|
||||
conn = get_db()
|
||||
if conn:
|
||||
result = execute_smart_trade(conn, user_id, scan_date=None)
|
||||
conn.close()
|
||||
if result.get('success'):
|
||||
print(f"[定时任务] 用户{user_id} 智能引擎执行成功 "
|
||||
f"(算法:{result.get('algo','?')}, 信号:{result.get('signals',0)})")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[定时任务] 用户{user_id} 智能引擎异常,降级到旧引擎: {e}")
|
||||
|
||||
# 降级:使用旧引擎
|
||||
trade_quantity = user.get('trade_quantity') or 1000
|
||||
execute_auto_trade_for_user(user_id, trade_quantity, scan_date=None)
|
||||
|
||||
print(f"[定时任务] ===== 早盘交易任务结束 {datetime.now()} =====")
|
||||
|
||||
|
||||
def job_afternoon_trade():
|
||||
"""午后交易任务(13:40执行 — v7最优卖出时点)
|
||||
使用当天中午的全景扫描数据做买入/卖出决策
|
||||
"""
|
||||
print(f"[定时任务] ===== 午后交易任务开始 {datetime.now()} =====")
|
||||
|
||||
if not is_trading_day():
|
||||
print("[定时任务] 今天不是交易日,跳过")
|
||||
return
|
||||
|
||||
users = get_all_users()
|
||||
|
||||
# 1. 先执行自动交易(使用今天中午11:50生成的扫描数据)
|
||||
today = date.today()
|
||||
print(f"[定时任务] 找到{len(users)}个用户需要执行午后自动交易")
|
||||
for user in users:
|
||||
user_id = user['user_id']
|
||||
# 尝试使用智能引擎
|
||||
try:
|
||||
from services.smart_trade_engine import execute_smart_trade
|
||||
from db import get_db
|
||||
conn = get_db()
|
||||
if conn:
|
||||
result = execute_smart_trade(conn, user_id, scan_date=today)
|
||||
conn.close()
|
||||
if result.get('success'):
|
||||
print(f"[定时任务] 用户{user_id} 午后智能引擎执行成功")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[定时任务] 用户{user_id} 智能引擎异常,降级: {e}")
|
||||
trade_quantity = user.get('trade_quantity') or 1000
|
||||
execute_auto_trade_for_user(user_id, trade_quantity, scan_date=today)
|
||||
|
||||
# 2. 更新持仓价格
|
||||
print(f"[定时任务] 更新{len(users)}个用户持仓价格")
|
||||
for user in users:
|
||||
user_id = user['user_id']
|
||||
update_positions_price_for_user(user_id)
|
||||
|
||||
print(f"[定时任务] ===== 午后交易任务结束 {datetime.now()} =====")
|
||||
|
||||
|
||||
def run_scheduler():
|
||||
"""运行定时任务调度器"""
|
||||
global _is_running
|
||||
|
||||
# 设置定时任务 — v7最优时点: 09:35买入 / 13:40卖出
|
||||
schedule.every().day.at("09:35").do(job_morning_trade)
|
||||
schedule.every().day.at("13:40").do(job_afternoon_trade)
|
||||
schedule.every().day.at("15:05").do(trigger_closing_update) # 收盘更新持仓价格
|
||||
|
||||
print("[定时任务] 调度器已启动 (v7最优时点)")
|
||||
print("[定时任务] - 09:35 早盘交易(使用昨日扫描数据 — 最优买入时点)")
|
||||
print("[定时任务] - 13:40 午后交易(使用当日中午扫描数据 — 最优卖出时点)")
|
||||
print("[定时任务] - 15:05 收盘更新持仓价格")
|
||||
|
||||
_is_running = True
|
||||
while _is_running:
|
||||
schedule.run_pending()
|
||||
time.sleep(30) # 每30秒检查一次
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""启动定时任务调度器(在后台线程中运行)"""
|
||||
global _scheduler_thread, _is_running
|
||||
|
||||
if _scheduler_thread is not None and _scheduler_thread.is_alive():
|
||||
print("[定时任务] 调度器已在运行中")
|
||||
return
|
||||
|
||||
_scheduler_thread = threading.Thread(target=run_scheduler, daemon=True)
|
||||
_scheduler_thread.start()
|
||||
print("[定时任务] 后台调度器线程已启动")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""停止定时任务调度器"""
|
||||
global _is_running
|
||||
_is_running = False
|
||||
print("[定时任务] 调度器已停止")
|
||||
|
||||
|
||||
# 手动触发任务(用于测试)
|
||||
def trigger_morning_trade():
|
||||
"""手动触发早盘交易任务"""
|
||||
job_morning_trade()
|
||||
|
||||
|
||||
def trigger_afternoon_trade():
|
||||
"""手动触发午后交易任务"""
|
||||
job_afternoon_trade()
|
||||
|
||||
|
||||
def trigger_closing_update():
|
||||
"""手动触发收盘更新(仅更新持仓价格)"""
|
||||
from db import get_db
|
||||
if not is_trading_day():
|
||||
print("[定时任务] 今天不是交易日,跳过")
|
||||
return
|
||||
users = get_all_users()
|
||||
for user in users:
|
||||
update_positions_price_for_user(user['user_id'])
|
||||
@@ -0,0 +1,663 @@
|
||||
"""
|
||||
交易信号检测模块(numpy向量化优化版)
|
||||
实现7个交易信号:主升浪、日线底背离、龙抬头、真龙、短底背离、老鼠仓、反弹
|
||||
|
||||
信号按胜率排名:
|
||||
1. 主升浪 85% - MACD零上金叉
|
||||
2. 日线底背离 80% - 价格新低但MACD不新低(20日版)
|
||||
3. 龙抬头 75% - SKDJ超跌金叉
|
||||
4. 真龙 70% - 趋势启动确认
|
||||
5. 短底背离 65% - 短周期底背离(10日版)
|
||||
6. 老鼠仓 60% - 盘中急跌后快速回收
|
||||
7. 反弹 55% - EMA3上穿EMA21
|
||||
|
||||
优化要点:
|
||||
- 所有信号检测函数使用 .values numpy原生数组替代 pandas .iloc
|
||||
- numpy arr[i] 访问 ~50ns,pandas iloc[i] 访问 ~5μs,提升 ~100x
|
||||
- 底背离函数使用 np.argmin 替代 pandas idxmin
|
||||
- detect_all_signals 智能跳过已是 float 的类型转换
|
||||
- _check_all_signal_status 使用 numpy 数组切片替代 pandas 切片
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from services.technical_indicators import calc_all_indicators
|
||||
|
||||
|
||||
def detect_main_rising_wave(df, lookback=5):
|
||||
"""
|
||||
主升浪信号(胜率85%)— numpy优化版
|
||||
条件:MACD零上金叉 —— DIF和DEA都在零轴上方,DIF从下往上穿越DEA
|
||||
含义:趋势走好,进入加速拉升阶段
|
||||
"""
|
||||
signals = []
|
||||
if len(df) < 30:
|
||||
return signals
|
||||
|
||||
dif = df['dif'].values
|
||||
dea = df['dea'].values
|
||||
dates = df['date'].values
|
||||
closes = df['close'].values
|
||||
n = len(dif)
|
||||
start = max(1, n - lookback)
|
||||
|
||||
for i in range(start, n):
|
||||
if dif[i] > 0 and dea[i] > 0 and dif[i - 1] <= dea[i - 1] and dif[i] > dea[i]:
|
||||
signals.append({
|
||||
'date': str(dates[i]),
|
||||
'type': 'main_rising_wave',
|
||||
'name': '主升浪',
|
||||
'direction': 'buy',
|
||||
'strength': 85,
|
||||
'price': float(closes[i]),
|
||||
'description': f"MACD零上金叉: DIF={dif[i]:.3f}, DEA={dea[i]:.3f},进入加速拉升阶段",
|
||||
})
|
||||
return signals
|
||||
|
||||
|
||||
def detect_daily_bottom_divergence(df, lookback=5, window=20):
|
||||
"""
|
||||
日线底背离信号(胜率80%)— numpy优化版
|
||||
条件:价格创20日新低,但MACD的DIF未创对应新低
|
||||
含义:真正跌透,迎来大级别反转
|
||||
"""
|
||||
signals = []
|
||||
if len(df) < window + 10:
|
||||
return signals
|
||||
|
||||
close = df['close'].values.astype(np.float64)
|
||||
dif = df['dif'].values.astype(np.float64)
|
||||
dates = df['date'].values
|
||||
n = len(close)
|
||||
start = max(window, n - lookback)
|
||||
|
||||
for i in range(start, n):
|
||||
window_slice = close[i - window:i + 1]
|
||||
curr_price = close[i]
|
||||
price_min = window_slice.min()
|
||||
|
||||
if curr_price > price_min * 1.01:
|
||||
continue
|
||||
|
||||
# 当前日必须是窗口内的实际最低点(等价于 idxmin() == index[-1])
|
||||
if np.argmin(window_slice) == len(window_slice) - 1:
|
||||
dif_window = dif[i - window:i]
|
||||
if len(dif_window) == 0:
|
||||
continue
|
||||
dif_at_prev_lows = dif_window.min()
|
||||
curr_dif = dif[i]
|
||||
|
||||
if curr_dif > dif_at_prev_lows and curr_dif < 0:
|
||||
signals.append({
|
||||
'date': str(dates[i]),
|
||||
'type': 'daily_bottom_divergence',
|
||||
'name': '日线底背离',
|
||||
'direction': 'buy',
|
||||
'strength': 80,
|
||||
'price': float(curr_price),
|
||||
'description': f"价格创{window}日新低,但MACD的DIF未创新低(DIF={curr_dif:.3f}),大级别反转信号",
|
||||
})
|
||||
return signals
|
||||
|
||||
|
||||
def detect_dragon_head(df, lookback=5):
|
||||
"""
|
||||
龙抬头信号(胜率75%)— numpy优化版
|
||||
条件:SKDJ的K值从超跌区域(<20)发生金叉(K上穿D),且信号稳定
|
||||
含义:短线起爆点,反弹稳定性强
|
||||
"""
|
||||
signals = []
|
||||
if len(df) < 20:
|
||||
return signals
|
||||
|
||||
sk = df['skdj_k'].values.astype(np.float64)
|
||||
sd = df['skdj_d'].values.astype(np.float64)
|
||||
dates = df['date'].values
|
||||
closes = df['close'].values
|
||||
n = len(sk)
|
||||
start = max(2, n - lookback)
|
||||
|
||||
for i in range(start, n):
|
||||
oversold = sk[i - 1] < 20 or sk[i] < 30
|
||||
golden_cross = sk[i - 1] <= sd[i - 1] and sk[i] > sd[i]
|
||||
|
||||
stable = True
|
||||
if i >= 3:
|
||||
recent_k = sk[i - 2:i + 1]
|
||||
# ddof=1 与 pandas Series.std() 保持一致
|
||||
stable = np.std(recent_k, ddof=1) < 15
|
||||
|
||||
if oversold and golden_cross and stable:
|
||||
signals.append({
|
||||
'date': str(dates[i]),
|
||||
'type': 'dragon_head',
|
||||
'name': '龙抬头',
|
||||
'direction': 'buy',
|
||||
'strength': 75,
|
||||
'price': float(closes[i]),
|
||||
'description': f"SKDJ超跌金叉: K={sk[i]:.1f}, D={sd[i]:.1f},短线起爆点",
|
||||
})
|
||||
return signals
|
||||
|
||||
|
||||
def detect_true_dragon(df, lookback=5):
|
||||
"""
|
||||
真龙信号(胜率70%)— numpy优化版
|
||||
条件:价格突破MA20,MA5上穿MA20(金叉),MACD柱由负转正,成交量放大
|
||||
含义:中期趋势刚刚启动
|
||||
"""
|
||||
signals = []
|
||||
if len(df) < 25:
|
||||
return signals
|
||||
|
||||
close = df['close'].values.astype(np.float64)
|
||||
ma5 = df['ma5'].values.astype(np.float64)
|
||||
ma20 = df['ma20'].values.astype(np.float64)
|
||||
macd = df['macd'].values.astype(np.float64)
|
||||
volume = df['volume'].values.astype(np.float64)
|
||||
dates = df['date'].values
|
||||
n = len(close)
|
||||
start = max(2, n - lookback)
|
||||
|
||||
for i in range(start, n):
|
||||
price_above_ma20 = close[i] > ma20[i]
|
||||
ma5_cross_ma20 = (ma5[i - 1] <= ma20[i - 1]) and (ma5[i] > ma20[i])
|
||||
ma5_above_ma20 = ma5[i] > ma20[i]
|
||||
macd_turn_positive = macd[i] > 0 and macd[i - 1] <= 0
|
||||
|
||||
vol_start = max(0, i - 10)
|
||||
vol_avg = volume[vol_start:i].mean() if i > vol_start else 0.0
|
||||
volume_up = volume[i] > vol_avg * 1.2 if vol_avg > 0 else False
|
||||
|
||||
conditions_met = sum([price_above_ma20, ma5_cross_ma20 or ma5_above_ma20, macd_turn_positive, volume_up])
|
||||
|
||||
if conditions_met >= 3 and price_above_ma20:
|
||||
desc_parts = []
|
||||
if ma5_cross_ma20:
|
||||
desc_parts.append("MA5金叉MA20")
|
||||
if macd_turn_positive:
|
||||
desc_parts.append("MACD翻红")
|
||||
if volume_up:
|
||||
desc_parts.append("放量")
|
||||
signals.append({
|
||||
'date': str(dates[i]),
|
||||
'type': 'true_dragon',
|
||||
'name': '真龙',
|
||||
'direction': 'buy',
|
||||
'strength': 70,
|
||||
'price': float(close[i]),
|
||||
'description': f"趋势启动: {'+'.join(desc_parts)},中期趋势确立",
|
||||
})
|
||||
return signals
|
||||
|
||||
|
||||
def detect_short_bottom_divergence(df, lookback=5, window=10):
|
||||
"""
|
||||
短底背离信号(胜率65%)— numpy优化版
|
||||
条件:价格创10日新低,但MACD的DIF未创对应新低
|
||||
含义:小级别反弹,灵敏度高但力度偏弱
|
||||
"""
|
||||
signals = []
|
||||
if len(df) < window + 5:
|
||||
return signals
|
||||
|
||||
close = df['close'].values.astype(np.float64)
|
||||
dif = df['dif'].values.astype(np.float64)
|
||||
dates = df['date'].values
|
||||
n = len(close)
|
||||
start = max(window, n - lookback)
|
||||
|
||||
for i in range(start, n):
|
||||
window_slice = close[i - window:i + 1]
|
||||
curr_price = close[i]
|
||||
price_min = window_slice.min()
|
||||
|
||||
if curr_price > price_min * 1.01:
|
||||
continue
|
||||
|
||||
# 当前日必须是窗口内的实际最低点
|
||||
if np.argmin(window_slice) == len(window_slice) - 1:
|
||||
dif_window = dif[i - window:i]
|
||||
if len(dif_window) == 0:
|
||||
continue
|
||||
dif_at_prev_lows = dif_window.min()
|
||||
curr_dif = dif[i]
|
||||
|
||||
if curr_dif > dif_at_prev_lows:
|
||||
signals.append({
|
||||
'date': str(dates[i]),
|
||||
'type': 'short_bottom_divergence',
|
||||
'name': '短底背离',
|
||||
'direction': 'buy',
|
||||
'strength': 65,
|
||||
'price': float(curr_price),
|
||||
'description': f"价格创{window}日新低,但DIF未新低(DIF={curr_dif:.3f}),小级别反弹信号",
|
||||
})
|
||||
return signals
|
||||
|
||||
|
||||
def detect_rat_trading(df, lookback=5):
|
||||
"""
|
||||
老鼠仓信号(胜率60%)— numpy优化版
|
||||
条件:盘中急跌(最低价大幅低于开盘价),但收盘收回(收盘价接近或高于开盘价),且成交量放大
|
||||
含义:主力偷偷吸筹,上涨不具备即时性
|
||||
"""
|
||||
signals = []
|
||||
if len(df) < 10:
|
||||
return signals
|
||||
|
||||
close = df['close'].values.astype(np.float64)
|
||||
open_p = df['open'].values.astype(np.float64)
|
||||
low = df['low'].values.astype(np.float64)
|
||||
high = df['high'].values.astype(np.float64)
|
||||
volume = df['volume'].values.astype(np.float64)
|
||||
dates = df['date'].values
|
||||
n = len(close)
|
||||
start = max(1, n - lookback)
|
||||
|
||||
for i in range(start, n):
|
||||
if open_p[i] <= 0:
|
||||
continue
|
||||
|
||||
drop_from_open = (low[i] - open_p[i]) / open_p[i] * 100
|
||||
hl_diff = high[i] - low[i]
|
||||
recovery = (close[i] - low[i]) / hl_diff * 100 if hl_diff != 0 else 50.0
|
||||
close_vs_open = (close[i] - open_p[i]) / open_p[i] * 100
|
||||
|
||||
vol_start = max(0, i - 10)
|
||||
vol_avg = volume[vol_start:i].mean() if i > vol_start else 0.0
|
||||
volume_up = volume[i] > vol_avg * 1.3 if vol_avg > 0 else False
|
||||
|
||||
if drop_from_open < -3 and recovery > 60 and close_vs_open > -1 and volume_up:
|
||||
signals.append({
|
||||
'date': str(dates[i]),
|
||||
'type': 'rat_trading',
|
||||
'name': '老鼠仓',
|
||||
'direction': 'buy',
|
||||
'strength': 60,
|
||||
'price': float(close[i]),
|
||||
'description': f"盘中急跌{drop_from_open:.1f}%后回收{recovery:.0f}%,放量吸筹信号",
|
||||
})
|
||||
return signals
|
||||
|
||||
|
||||
def detect_rebound(df, lookback=5):
|
||||
"""
|
||||
反弹信号(胜率55%)— numpy优化版
|
||||
条件:EMA3从下向上穿越EMA21
|
||||
含义:普通均线金叉,震荡市适用、熊市易现假反弹
|
||||
"""
|
||||
signals = []
|
||||
if len(df) < 25:
|
||||
return signals
|
||||
|
||||
ema3 = df['ema3'].values.astype(np.float64)
|
||||
ema21 = df['ema21'].values.astype(np.float64)
|
||||
dates = df['date'].values
|
||||
closes = df['close'].values
|
||||
n = len(ema3)
|
||||
start = max(1, n - lookback)
|
||||
|
||||
for i in range(start, n):
|
||||
if ema3[i - 1] <= ema21[i - 1] and ema3[i] > ema21[i]:
|
||||
signals.append({
|
||||
'date': str(dates[i]),
|
||||
'type': 'rebound',
|
||||
'name': '反弹',
|
||||
'direction': 'buy',
|
||||
'strength': 55,
|
||||
'price': float(closes[i]),
|
||||
'description': f"EMA3上穿EMA21: EMA3={ema3[i]:.2f}, EMA21={ema21[i]:.2f},均线金叉反弹",
|
||||
})
|
||||
return signals
|
||||
|
||||
|
||||
def detect_all_signals(df, lookback=5):
|
||||
"""
|
||||
检测所有7个交易信号(优化版)
|
||||
|
||||
参数:
|
||||
df: 包含 date, open, high, low, close, volume 列的DataFrame
|
||||
lookback: 向后检测的天数(默认检测最近5天)
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
'signals': [...], # 检测到的所有信号列表
|
||||
'latest_signals': [...], # 最新一天的信号
|
||||
'signal_summary': {...}, # 信号统计摘要
|
||||
'indicators': {...} # 最新技术指标值
|
||||
}
|
||||
|
||||
优化:智能类型转换,已是 float64 的列直接跳过
|
||||
"""
|
||||
required_cols = {'date', 'open', 'high', 'low', 'close', 'volume'}
|
||||
if not required_cols.issubset(set(df.columns)):
|
||||
missing = required_cols - set(df.columns)
|
||||
return {'error': f'缺少必要列: {missing}', 'signals': [], 'latest_signals': []}
|
||||
|
||||
df = df.copy()
|
||||
# 智能类型转换:仅在列不是 float 时才做转换(本地DB数据已是 float64,跳过)
|
||||
for col in ['open', 'high', 'low', 'close', 'volume']:
|
||||
if not np.issubdtype(df[col].dtype, np.floating):
|
||||
df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0).astype(np.float64)
|
||||
|
||||
df = calc_all_indicators(df)
|
||||
|
||||
all_signals = []
|
||||
all_signals.extend(detect_main_rising_wave(df, lookback))
|
||||
all_signals.extend(detect_daily_bottom_divergence(df, lookback))
|
||||
all_signals.extend(detect_dragon_head(df, lookback))
|
||||
all_signals.extend(detect_true_dragon(df, lookback))
|
||||
all_signals.extend(detect_short_bottom_divergence(df, lookback))
|
||||
all_signals.extend(detect_rat_trading(df, lookback))
|
||||
all_signals.extend(detect_rebound(df, lookback))
|
||||
|
||||
all_signals.sort(key=lambda x: (-x['strength'], x['date']), reverse=False)
|
||||
|
||||
latest_date = str(df['date'].values[-1]) if len(df) > 0 else ''
|
||||
latest_signals = [s for s in all_signals if s['date'] == latest_date]
|
||||
|
||||
signal_summary = {
|
||||
'total_signals': len(all_signals),
|
||||
'latest_date': latest_date,
|
||||
'latest_count': len(latest_signals),
|
||||
'signal_types': {},
|
||||
}
|
||||
for s in all_signals:
|
||||
t = s['type']
|
||||
if t not in signal_summary['signal_types']:
|
||||
signal_summary['signal_types'][t] = 0
|
||||
signal_summary['signal_types'][t] += 1
|
||||
|
||||
indicators = {}
|
||||
if len(df) > 0:
|
||||
last = df.iloc[-1]
|
||||
indicators = {
|
||||
'macd': {'dif': round(float(last.get('dif', 0)), 4),
|
||||
'dea': round(float(last.get('dea', 0)), 4),
|
||||
'macd': round(float(last.get('macd', 0)), 4)},
|
||||
'skdj': {'k': round(float(last.get('skdj_k', 0)), 2),
|
||||
'd': round(float(last.get('skdj_d', 0)), 2)},
|
||||
'kdj': {'k': round(float(last.get('kdj_k', 0)), 2),
|
||||
'd': round(float(last.get('kdj_d', 0)), 2),
|
||||
'j': round(float(last.get('kdj_j', 0)), 2)},
|
||||
'ema': {'ema3': round(float(last.get('ema3', 0)), 2),
|
||||
'ema21': round(float(last.get('ema21', 0)), 2)},
|
||||
'ma': {'ma5': round(float(last.get('ma5', 0)), 2),
|
||||
'ma10': round(float(last.get('ma10', 0)), 2),
|
||||
'ma20': round(float(last.get('ma20', 0)), 2)},
|
||||
}
|
||||
|
||||
signal_status = _check_all_signal_status(df)
|
||||
|
||||
return {
|
||||
'signals': all_signals,
|
||||
'latest_signals': latest_signals,
|
||||
'signal_summary': signal_summary,
|
||||
'indicators': indicators,
|
||||
'signal_status': signal_status,
|
||||
}
|
||||
|
||||
|
||||
def _check_all_signal_status(df):
|
||||
"""
|
||||
检查7个信号的当前状态,返回每个信号的就绪程度和说明(numpy优化版)
|
||||
"""
|
||||
if len(df) < 30:
|
||||
return []
|
||||
|
||||
n = len(df)
|
||||
last = df.iloc[-1]
|
||||
prev = df.iloc[-2] if n > 1 else last
|
||||
|
||||
dif = float(last.get('dif', 0))
|
||||
dea = float(last.get('dea', 0))
|
||||
macd_val = float(last.get('macd', 0))
|
||||
prev_dif = float(prev.get('dif', 0))
|
||||
prev_dea = float(prev.get('dea', 0))
|
||||
sk = float(last.get('skdj_k', 50))
|
||||
sd = float(last.get('skdj_d', 50))
|
||||
prev_sk = float(prev.get('skdj_k', 50))
|
||||
prev_sd = float(prev.get('skdj_d', 50))
|
||||
ema3 = float(last.get('ema3', 0))
|
||||
ema21 = float(last.get('ema21', 0))
|
||||
prev_ema3 = float(prev.get('ema3', 0))
|
||||
prev_ema21 = float(prev.get('ema21', 0))
|
||||
close = float(last.get('close', 0))
|
||||
open_p = float(last.get('open', 0))
|
||||
low = float(last.get('low', 0))
|
||||
high = float(last.get('high', 0))
|
||||
ma5 = float(last.get('ma5', 0))
|
||||
ma20 = float(last.get('ma20', 0))
|
||||
|
||||
status = []
|
||||
|
||||
# 1. 主升浪
|
||||
above_zero = dif > 0 and dea > 0
|
||||
golden = prev_dif <= prev_dea and dif > dea
|
||||
triggered = bool(above_zero and golden)
|
||||
if triggered:
|
||||
desc = f"✅ 已触发!DIF={dif:.3f}>0, DEA={dea:.3f}>0, DIF上穿DEA"
|
||||
elif dif > 0 and dea > 0:
|
||||
desc = f"DIF和DEA均在零上,等待DIF上穿DEA(差值{dif-dea:.3f})"
|
||||
elif dif > dea:
|
||||
desc = f"DIF已在DEA上方,但需等待两者都转正(DIF={dif:.3f})"
|
||||
else:
|
||||
desc = f"DIF={dif:.3f}, DEA={dea:.3f},均在零下,距离触发较远"
|
||||
status.append({
|
||||
'type': 'main_rising_wave', 'name': '主升浪', 'strength': 85,
|
||||
'triggered': triggered, 'description': desc,
|
||||
'readiness': _calc_readiness(dif, dea, 'main_rising_wave')
|
||||
})
|
||||
|
||||
# 2. 日线底背离 — 使用numpy数组切片替代pandas切片
|
||||
close_arr = df['close'].values.astype(np.float64)
|
||||
dif_arr = df['dif'].values.astype(np.float64)
|
||||
w20_start = max(0, n - 21)
|
||||
window_20 = close_arr[w20_start:]
|
||||
price_min_20 = float(window_20.min())
|
||||
dif_w20 = dif_arr[w20_start:n - 1] if n > w20_start + 1 else dif_arr[:max(0, n - 1)]
|
||||
dif_min_20 = float(dif_w20.min()) if len(dif_w20) > 0 else 0.0
|
||||
at_low = close <= price_min_20 * 1.01
|
||||
is_actual_min = bool(np.argmin(window_20) == len(window_20) - 1)
|
||||
dif_diverge = dif > dif_min_20 and dif < 0
|
||||
triggered = bool(at_low and is_actual_min and dif_diverge)
|
||||
if triggered:
|
||||
desc = f"✅ 已触发!价格接近20日新低,DIF({dif:.3f})高于前低({dif_min_20:.3f})"
|
||||
elif at_low and not is_actual_min:
|
||||
desc = f"价格接近20日低位({price_min_20:.2f}),但非当前最低点"
|
||||
elif at_low:
|
||||
desc = f"价格在20日低位,但DIF也在低位(DIF={dif:.3f}),暂无背离"
|
||||
elif dif < 0:
|
||||
desc = f"DIF在零下({dif:.3f}),需等待价格下探至20日新低({price_min_20:.2f})附近"
|
||||
else:
|
||||
desc = f"DIF={dif:.3f}在零上,价格距20日低点{price_min_20:.2f}较远"
|
||||
status.append({
|
||||
'type': 'daily_bottom_divergence', 'name': '日线底背离', 'strength': 80,
|
||||
'triggered': triggered, 'description': desc,
|
||||
'readiness': _calc_readiness_divergence(close, price_min_20, dif, dif_min_20)
|
||||
})
|
||||
|
||||
# 3. 龙抬头
|
||||
oversold = prev_sk < 20 or sk < 30
|
||||
sk_cross = prev_sk <= prev_sd and sk > sd
|
||||
# 稳定性检查:与检测函数一致,最近3根K值标准差 < 15
|
||||
sk_arr = df['skdj_k'].values.astype(np.float64)
|
||||
stable = True
|
||||
if len(sk_arr) >= 3:
|
||||
# ddof=1 与 pandas Series.std() 保持一致
|
||||
stable = float(np.std(sk_arr[-3:], ddof=1)) < 15
|
||||
triggered = bool(oversold and sk_cross and stable)
|
||||
if triggered:
|
||||
desc = f"✅ 已触发!SKDJ超跌金叉 K={sk:.1f}, D={sd:.1f}"
|
||||
elif sk < 20:
|
||||
desc = f"K={sk:.1f}在超卖区(<20),等待K上穿D(K-D={sk-sd:.1f})"
|
||||
elif sk < 30:
|
||||
desc = f"K={sk:.1f}接近超卖区(<20),继续下探可能触发"
|
||||
elif sk < 50:
|
||||
desc = f"K={sk:.1f}在中位,距超卖区(K<20)还有较大距离"
|
||||
else:
|
||||
desc = f"K={sk:.1f}偏高,远离超卖区,不满足条件"
|
||||
status.append({
|
||||
'type': 'dragon_head', 'name': '龙抬头', 'strength': 75,
|
||||
'triggered': triggered, 'description': desc,
|
||||
'readiness': _calc_readiness_dragon(sk, sd, prev_sk, prev_sd)
|
||||
})
|
||||
|
||||
# 4. 真龙
|
||||
cond_price = close > ma20
|
||||
cond_ma = ma5 > ma20
|
||||
cond_macd = macd_val > 0 and float(prev.get('macd', 0)) <= 0
|
||||
vol_arr = df['volume'].values.astype(np.float64)
|
||||
vol_avg = float(vol_arr[max(0, n - 11):n - 1].mean()) if n > 10 else float(vol_arr.mean())
|
||||
cond_vol = float(vol_arr[-1]) > vol_avg * 1.2 if vol_avg > 0 else False
|
||||
met = sum([cond_price, cond_ma, cond_macd, cond_vol])
|
||||
triggered = bool(met >= 3 and cond_price)
|
||||
parts = []
|
||||
if cond_price:
|
||||
parts.append(f"价格>{ma20:.2f}(MA20)✓")
|
||||
else:
|
||||
parts.append(f"价格{close:.2f}<{ma20:.2f}(MA20)✗")
|
||||
if cond_ma:
|
||||
parts.append("MA5>MA20✓")
|
||||
else:
|
||||
parts.append(f"MA5({ma5:.2f})<MA20({ma20:.2f})✗")
|
||||
if cond_macd:
|
||||
parts.append("MACD翻红✓")
|
||||
else:
|
||||
parts.append(f"MACD={macd_val:.3f}✗")
|
||||
if cond_vol:
|
||||
parts.append("放量✓")
|
||||
else:
|
||||
parts.append("未放量✗")
|
||||
if triggered:
|
||||
desc = f"✅ 已触发!{met}/4条件满足: {', '.join(parts)}"
|
||||
else:
|
||||
desc = f"{met}/4条件(需≥3): {', '.join(parts)}"
|
||||
status.append({
|
||||
'type': 'true_dragon', 'name': '真龙', 'strength': 70,
|
||||
'triggered': triggered, 'description': desc,
|
||||
'readiness': min(100, met * 25) if cond_price else min(50, met * 15)
|
||||
})
|
||||
|
||||
# 5. 短底背离
|
||||
w10_start = max(0, n - 11)
|
||||
window_10 = close_arr[w10_start:]
|
||||
price_min_10 = float(window_10.min())
|
||||
dif_w10 = dif_arr[w10_start:n - 1] if n > w10_start + 1 else dif_arr[:max(0, n - 1)]
|
||||
dif_min_10 = float(dif_w10.min()) if len(dif_w10) > 0 else 0.0
|
||||
at_low_10 = close <= price_min_10 * 1.01
|
||||
is_actual_min_10 = bool(np.argmin(window_10) == len(window_10) - 1)
|
||||
dif_div_10 = dif > dif_min_10
|
||||
triggered = bool(at_low_10 and is_actual_min_10 and dif_div_10)
|
||||
if triggered:
|
||||
desc = f"✅ 已触发!价格接近10日新低,DIF({dif:.3f})高于前低({dif_min_10:.3f})"
|
||||
elif at_low_10 and not is_actual_min_10:
|
||||
desc = f"价格接近10日低位({price_min_10:.2f}),但非当前最低点"
|
||||
elif at_low_10:
|
||||
desc = f"价格在10日低位,但DIF也在低位,暂无背离"
|
||||
else:
|
||||
desc = f"价格距10日低点{price_min_10:.2f}尚远,等待回调"
|
||||
status.append({
|
||||
'type': 'short_bottom_divergence', 'name': '短底背离', 'strength': 65,
|
||||
'triggered': triggered, 'description': desc,
|
||||
'readiness': _calc_readiness_divergence(close, price_min_10, dif, dif_min_10)
|
||||
})
|
||||
|
||||
# 6. 老鼠仓
|
||||
if open_p > 0:
|
||||
drop = (low - open_p) / open_p * 100
|
||||
recovery = (close - low) / (high - low) * 100 if high != low else 50
|
||||
close_vs_open = (close - open_p) / open_p * 100
|
||||
vol_avg_10 = float(vol_arr[max(0, n - 11):n - 1].mean()) if n > 10 else float(vol_arr.mean())
|
||||
vol_up = float(vol_arr[-1]) > vol_avg_10 * 1.3 if vol_avg_10 > 0 else False
|
||||
triggered = bool(drop < -3 and recovery > 60 and close_vs_open > -1 and vol_up)
|
||||
if triggered:
|
||||
desc = f"✅ 已触发!盘中跌{drop:.1f}%后回收{recovery:.0f}%,放量吸筹"
|
||||
else:
|
||||
parts = []
|
||||
if drop >= -3:
|
||||
parts.append(f"盘中最大跌幅{drop:.1f}%(需<-3%)")
|
||||
else:
|
||||
parts.append(f"盘中跌{drop:.1f}%✓")
|
||||
if recovery <= 60:
|
||||
parts.append(f"回收{recovery:.0f}%(需>60%)")
|
||||
else:
|
||||
parts.append(f"回收{recovery:.0f}%✓")
|
||||
if not vol_up:
|
||||
parts.append("未放量")
|
||||
desc = f"{', '.join(parts)}"
|
||||
else:
|
||||
triggered = False
|
||||
desc = "数据异常"
|
||||
status.append({
|
||||
'type': 'rat_trading', 'name': '老鼠仓', 'strength': 60,
|
||||
'triggered': triggered, 'description': desc,
|
||||
'readiness': 0
|
||||
})
|
||||
|
||||
# 7. 反弹
|
||||
cross = prev_ema3 <= prev_ema21 and ema3 > ema21
|
||||
triggered = bool(cross)
|
||||
gap = ema3 - ema21
|
||||
gap_pct = gap / ema21 * 100 if ema21 > 0 else 0
|
||||
if triggered:
|
||||
desc = f"✅ 已触发!EMA3({ema3:.2f})上穿EMA21({ema21:.2f})"
|
||||
elif ema3 < ema21:
|
||||
desc = f"EMA3({ema3:.2f})<EMA21({ema21:.2f}),差{abs(gap_pct):.2f}%,等待上穿"
|
||||
else:
|
||||
desc = f"EMA3({ema3:.2f})>EMA21({ema21:.2f}),已在上方但非刚穿越"
|
||||
status.append({
|
||||
'type': 'rebound', 'name': '反弹', 'strength': 55,
|
||||
'triggered': triggered, 'description': desc,
|
||||
'readiness': _calc_readiness_rebound(ema3, ema21, prev_ema3, prev_ema21)
|
||||
})
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def _calc_readiness(dif, dea, signal_type):
|
||||
if dif > 0 and dea > 0 and dif > dea:
|
||||
return 100
|
||||
elif dif > 0 and dea > 0:
|
||||
return 70
|
||||
elif dif > dea:
|
||||
return 40
|
||||
else:
|
||||
return max(0, int(20 + dif * 100))
|
||||
|
||||
|
||||
def _calc_readiness_divergence(close, price_min, dif, dif_min):
|
||||
price_near = close <= price_min * 1.03
|
||||
dif_higher = dif > dif_min
|
||||
if price_near and dif_higher:
|
||||
return 90
|
||||
elif price_near:
|
||||
return 50
|
||||
elif dif_higher and dif < 0:
|
||||
return 30
|
||||
return 10
|
||||
|
||||
|
||||
def _calc_readiness_dragon(sk, sd, prev_sk, prev_sd):
|
||||
if sk < 20 and sk > sd and prev_sk <= prev_sd:
|
||||
return 100
|
||||
elif sk < 20:
|
||||
return 70
|
||||
elif sk < 30:
|
||||
return 40
|
||||
elif sk < 50:
|
||||
return 20
|
||||
return 5
|
||||
|
||||
|
||||
def _calc_readiness_rebound(ema3, ema21, prev_ema3, prev_ema21):
|
||||
if prev_ema3 <= prev_ema21 and ema3 > ema21:
|
||||
return 100
|
||||
gap_pct = (ema3 - ema21) / ema21 * 100 if ema21 > 0 else 0
|
||||
if gap_pct > 0:
|
||||
return 60
|
||||
elif gap_pct > -1:
|
||||
return 40
|
||||
elif gap_pct > -3:
|
||||
return 20
|
||||
return 5
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,907 @@
|
||||
"""
|
||||
统一算法模块 — 全部核心算法的唯一定义处(Single Source of Truth)
|
||||
|
||||
包含:
|
||||
1. compute_recommend — 统一推荐逻辑(买入/卖出/加仓/观望等,严格遵循suanfa.md)
|
||||
2. get_kline_data — 获取K线数据并返回 DataFrame(优先本地DB → 阿里云 → 腾讯 → 麦蕊 → AKShare)
|
||||
3. fetch_kline_rows — 获取K线数据并返回 tuple 行列表(用于写入DB同步)
|
||||
4. get_latest_price — 获取股票最新价格
|
||||
5. code_to_market — 股票代码→市场判断(SH/SZ/BJ)
|
||||
6. 各 API session 管理
|
||||
7. compute_bull_stage — 牛股阶段识别(底部→起爆→确立→加速→补涨)
|
||||
8. find_bull_stocks — 从扫描结果中找出潜在牛股
|
||||
|
||||
调用方:
|
||||
- routes/analysis.py → compute_recommend, get_kline_data
|
||||
- services/scheduler.py → compute_recommend, get_latest_price
|
||||
- full_signal_scan.py → get_kline_data, API sessions
|
||||
- sync_kline.py → fetch_kline_rows, API sessions
|
||||
- routes/market.py → get_kline_data
|
||||
"""
|
||||
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 1. 股票代码 → 市场 工具函数
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def code_to_market(code):
|
||||
"""6位股票代码 → 市场代码(SH/SZ/BJ)"""
|
||||
if code.startswith(('0', '3')):
|
||||
return 'SZ'
|
||||
elif code.startswith(('8', '9')):
|
||||
return 'BJ'
|
||||
else:
|
||||
return 'SH'
|
||||
|
||||
|
||||
def is_bj_stock(code):
|
||||
"""是否是北交所股票"""
|
||||
return code.startswith(('8', '9'))
|
||||
|
||||
|
||||
def code_to_ali_symbol(code):
|
||||
"""6位股票代码 → 阿里云API格式(SH600519 / SZ000001 / BJ920720)"""
|
||||
return f'{code_to_market(code)}{code}'
|
||||
|
||||
|
||||
def code_to_tencent_symbol(code):
|
||||
"""6位股票代码 → 腾讯API格式(sh600519 / sz000001 / bj920720)"""
|
||||
return f'{code_to_market(code).lower()}{code}'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 2. API Session 管理(线程安全,连接池复用)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
_ali_session = None
|
||||
_ali_lock = threading.Lock()
|
||||
|
||||
_tencent_session = None
|
||||
_tencent_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_ali_session():
|
||||
"""获取阿里云API专用 Session(线程安全,单例)"""
|
||||
global _ali_session
|
||||
if _ali_session is None:
|
||||
with _ali_lock:
|
||||
if _ali_session is None:
|
||||
s = requests.Session()
|
||||
retry = Retry(total=2, backoff_factor=0.3,
|
||||
status_forcelist=[500, 502, 503, 504])
|
||||
adapter = HTTPAdapter(max_retries=retry,
|
||||
pool_connections=20, pool_maxsize=20)
|
||||
s.mount('https://', adapter)
|
||||
s.headers.update({
|
||||
'Authorization': f'APPCODE {Config.ALICLOUD_APPCODE}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
})
|
||||
_ali_session = s
|
||||
return _ali_session
|
||||
|
||||
|
||||
def get_tencent_session():
|
||||
"""获取腾讯K线API专用 Session(线程安全,单例)"""
|
||||
global _tencent_session
|
||||
if _tencent_session is None:
|
||||
with _tencent_lock:
|
||||
if _tencent_session is None:
|
||||
s = requests.Session()
|
||||
retry = Retry(total=2, backoff_factor=0.3,
|
||||
status_forcelist=[500, 502, 503, 504])
|
||||
adapter = HTTPAdapter(max_retries=retry,
|
||||
pool_connections=20, pool_maxsize=20)
|
||||
s.mount('https://', adapter)
|
||||
s.headers.update({
|
||||
'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36'),
|
||||
})
|
||||
_tencent_session = s
|
||||
return _tencent_session
|
||||
|
||||
|
||||
# API URL 常量
|
||||
ALICLOUD_KLINE_URL = Config.ALICLOUD_KLINE_URL
|
||||
TENCENT_KLINE_URL = 'https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 3. K线数据获取 — DataFrame 格式(供信号检测/分析用)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def get_kline_data(stock_code, days=120, use_local_db=True):
|
||||
"""
|
||||
获取K线数据,返回 pandas DataFrame (columns: date, open, high, low, close, volume)
|
||||
|
||||
数据源优先级: 本地DB → 阿里云API → 腾讯API → 麦蕊API → AKShare
|
||||
|
||||
参数:
|
||||
stock_code: 6位股票代码
|
||||
days: 获取天数
|
||||
use_local_db: 是否优先使用本地DB(全景扫描时为True, 实时分析时可为False)
|
||||
|
||||
返回:
|
||||
DataFrame 或 None
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
# 1. 优先从本地数据库读取
|
||||
if use_local_db:
|
||||
df = _get_kline_from_local_db(stock_code, days)
|
||||
if df is not None:
|
||||
return df
|
||||
|
||||
# 2. 阿里云K线API(沪深最稳定,北交所可能不支持)
|
||||
if not is_bj_stock(stock_code):
|
||||
df = _fetch_ali_kline_df(stock_code, days)
|
||||
if df is not None and len(df) >= 30:
|
||||
return df
|
||||
|
||||
# 3. 腾讯K线API(全市场,含北交所)
|
||||
df = _fetch_tencent_kline_df(stock_code, days)
|
||||
if df is not None and len(df) >= 30:
|
||||
return df
|
||||
|
||||
# 4. 麦蕊API
|
||||
df = _fetch_mairui_kline_df(stock_code, days)
|
||||
if df is not None and len(df) >= 30:
|
||||
return df
|
||||
|
||||
# 5. AKShare
|
||||
df = _fetch_akshare_kline_df(stock_code, days)
|
||||
if df is not None and len(df) >= 30:
|
||||
return df
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_kline_from_local_db(stock_code, days=120):
|
||||
"""从本地数据库读取K线(最快,毫秒级)"""
|
||||
import pandas as pd
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(
|
||||
host=Config.DB_HOST, port=Config.DB_PORT,
|
||||
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
|
||||
)
|
||||
conn.autocommit = True
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT trade_date, open, high, low, close, volume
|
||||
FROM stock_kline_daily
|
||||
WHERE code = %s AND trade_date >= %s
|
||||
ORDER BY trade_date
|
||||
""", (stock_code, start_date))
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
|
||||
if rows and len(rows) >= 30:
|
||||
df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
|
||||
df['date'] = df['date'].astype(str)
|
||||
for col in ('open', 'high', 'low', 'close', 'volume'):
|
||||
df[col] = df[col].astype(float)
|
||||
return df
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ---- 线程本地连接(供多线程扫描时使用,避免频繁建连) ----
|
||||
_thread_local = threading.local()
|
||||
|
||||
|
||||
def get_kline_from_local_db_threaded(stock_code, days=120):
|
||||
"""多线程扫描专用:使用线程本地连接从本地DB读取K线"""
|
||||
import pandas as pd
|
||||
try:
|
||||
conn = getattr(_thread_local, 'kline_conn', None)
|
||||
if conn is None or conn.closed:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(
|
||||
host=Config.DB_HOST, port=Config.DB_PORT,
|
||||
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
|
||||
)
|
||||
conn.autocommit = True
|
||||
_thread_local.kline_conn = conn
|
||||
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT trade_date, open, high, low, close, volume
|
||||
FROM stock_kline_daily
|
||||
WHERE code = %s AND trade_date >= %s
|
||||
ORDER BY trade_date
|
||||
""", (stock_code, start_date))
|
||||
rows = cur.fetchall()
|
||||
|
||||
if rows and len(rows) >= 30:
|
||||
df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
|
||||
df['date'] = df['date'].astype(str)
|
||||
for col in ('open', 'high', 'low', 'close', 'volume'):
|
||||
df[col] = df[col].astype(float)
|
||||
return df
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_ali_kline_df(stock_code, days=120):
|
||||
"""阿里云K线API → DataFrame"""
|
||||
import pandas as pd
|
||||
try:
|
||||
session = get_ali_session()
|
||||
symbol = code_to_ali_symbol(stock_code)
|
||||
resp = session.post(ALICLOUD_KLINE_URL, data={
|
||||
'symbol': symbol,
|
||||
'type': '240',
|
||||
'limit': str(min(days, 300)),
|
||||
'ma': '5',
|
||||
}, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get('success') and data.get('data', {}).get('list'):
|
||||
records = []
|
||||
for item in data['data']['list']:
|
||||
day_str = item.get('day', '')
|
||||
if not day_str or len(day_str) < 10:
|
||||
continue
|
||||
records.append({
|
||||
'date': day_str[:10],
|
||||
'open': float(item.get('open', 0)),
|
||||
'high': float(item.get('high', 0)),
|
||||
'low': float(item.get('low', 0)),
|
||||
'close': float(item.get('close', 0)),
|
||||
'volume': float(item.get('volume', 0)),
|
||||
})
|
||||
if records:
|
||||
return pd.DataFrame(records)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_tencent_kline_df(stock_code, days=120):
|
||||
"""腾讯K线API → DataFrame(全市场含北交所)"""
|
||||
import pandas as pd
|
||||
try:
|
||||
session = get_tencent_session()
|
||||
symbol = code_to_tencent_symbol(stock_code)
|
||||
start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d')
|
||||
resp = session.get(TENCENT_KLINE_URL, params={
|
||||
'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq',
|
||||
}, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
stock_data = data.get('data', {}).get(symbol, {})
|
||||
klines = stock_data.get('qfqday') or stock_data.get('day') or []
|
||||
if klines:
|
||||
records = []
|
||||
for item in klines:
|
||||
if len(item) < 6:
|
||||
continue
|
||||
# 腾讯格式: [date, open, close, high, low, volume, ...]
|
||||
records.append({
|
||||
'date': item[0][:10],
|
||||
'open': float(item[1]),
|
||||
'high': float(item[3]), # high = position 3
|
||||
'low': float(item[4]), # low = position 4
|
||||
'close': float(item[2]), # close = position 2
|
||||
'volume': float(item[5]),
|
||||
})
|
||||
if records:
|
||||
return pd.DataFrame(records)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_mairui_kline_df(stock_code, days=120):
|
||||
"""麦蕊API → DataFrame"""
|
||||
import pandas as pd
|
||||
try:
|
||||
from services.mairui_api import get_kline
|
||||
result = get_kline(stock_code, period='d', days=days, adjust='f')
|
||||
if result['success'] and result['data']:
|
||||
df = pd.DataFrame(result['data'])
|
||||
df.rename(columns={
|
||||
'date': 'date', 'open': 'open', 'high': 'high',
|
||||
'low': 'low', 'close': 'close', 'volume': 'volume',
|
||||
}, inplace=True)
|
||||
if len(df) >= 30:
|
||||
return df
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_akshare_kline_df(stock_code, days=120):
|
||||
"""AKShare → DataFrame (支持自动降级到腾讯数据源)"""
|
||||
import pandas as pd
|
||||
try:
|
||||
from utils.data_fetcher import fetch_stock_hist
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
|
||||
df = fetch_stock_hist(
|
||||
stock_code=stock_code, period='daily',
|
||||
start_date=start_date, end_date=end_date, adjust='qfq',
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
df = df.rename(columns={
|
||||
'日期': 'date', '开盘': 'open', '最高': 'high',
|
||||
'最低': 'low', '收盘': 'close', '成交量': 'volume',
|
||||
})
|
||||
df = df[['date', 'open', 'high', 'low', 'close', 'volume']]
|
||||
return df
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 4. K线数据获取 — tuple行格式(供 sync_kline.py 写入DB用)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def fetch_kline_rows(code, days):
|
||||
"""
|
||||
获取K线数据,返回 list of tuple: (code, date, open, high, low, close, volume, amount)
|
||||
|
||||
数据源优先级: 阿里云API → 腾讯API → 麦蕊API → AKShare
|
||||
北交所(8XX/9XX)直接走腾讯API
|
||||
|
||||
供 sync_kline.py 同步到本地数据库使用。
|
||||
"""
|
||||
bj = is_bj_stock(code)
|
||||
|
||||
# 北交所:直接用腾讯API(阿里云/Mairui不支持BJ)
|
||||
if bj:
|
||||
rows = _fetch_tencent_kline_rows(code, days)
|
||||
if rows:
|
||||
return rows
|
||||
return None
|
||||
|
||||
# 1. 首选:阿里云K线API
|
||||
rows = _fetch_ali_kline_rows(code, days)
|
||||
if rows:
|
||||
return rows
|
||||
|
||||
# 2. 回退:腾讯API
|
||||
rows = _fetch_tencent_kline_rows(code, days)
|
||||
if rows:
|
||||
return rows
|
||||
|
||||
# 3. 回退:Mairui API
|
||||
rows = _fetch_mairui_kline_rows(code, days)
|
||||
if rows:
|
||||
return rows
|
||||
|
||||
# 4. 最后回退:AKShare
|
||||
rows = _fetch_akshare_kline_rows(code, days)
|
||||
if rows:
|
||||
return rows
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_ali_kline_rows(code, days):
|
||||
"""阿里云K线API → tuple rows"""
|
||||
try:
|
||||
session = get_ali_session()
|
||||
symbol = code_to_ali_symbol(code)
|
||||
resp = session.post(ALICLOUD_KLINE_URL, data={
|
||||
'symbol': symbol,
|
||||
'type': '240',
|
||||
'limit': str(min(days, 300)),
|
||||
'ma': '5',
|
||||
}, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get('success') and data.get('data', {}).get('list'):
|
||||
rows = []
|
||||
for item in data['data']['list']:
|
||||
day_str = item.get('day', '')
|
||||
if not day_str or len(day_str) < 10:
|
||||
continue
|
||||
rows.append((
|
||||
code,
|
||||
day_str[:10],
|
||||
float(item.get('open', 0)),
|
||||
float(item.get('high', 0)),
|
||||
float(item.get('low', 0)),
|
||||
float(item.get('close', 0)),
|
||||
int(item.get('volume', 0)),
|
||||
float(item.get('amount', 0)),
|
||||
))
|
||||
if rows:
|
||||
return rows
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_tencent_kline_rows(code, days):
|
||||
"""腾讯K线API → tuple rows(全市场含北交所)"""
|
||||
try:
|
||||
symbol = code_to_tencent_symbol(code)
|
||||
session = get_tencent_session()
|
||||
start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d')
|
||||
resp = session.get(TENCENT_KLINE_URL, params={
|
||||
'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq',
|
||||
}, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
stock_data = data.get('data', {}).get(symbol, {})
|
||||
klines = stock_data.get('qfqday') or stock_data.get('day') or []
|
||||
if klines:
|
||||
rows = []
|
||||
for item in klines:
|
||||
if len(item) < 6:
|
||||
continue
|
||||
date_str = item[0]
|
||||
if not date_str or len(date_str) < 10:
|
||||
continue
|
||||
rows.append((
|
||||
code,
|
||||
date_str[:10],
|
||||
float(item[1]), # open
|
||||
float(item[3]), # high (position 3)
|
||||
float(item[4]), # low (position 4)
|
||||
float(item[2]), # close (position 2)
|
||||
int(float(item[5])), # volume
|
||||
float(item[8]) * 10000 if len(item) > 8 and item[8] else 0,
|
||||
))
|
||||
if rows:
|
||||
return rows
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_mairui_kline_rows(code, days):
|
||||
"""麦蕊API → tuple rows"""
|
||||
try:
|
||||
from services.mairui_api import get_kline
|
||||
result = get_kline(code, period='d', days=days, adjust='f')
|
||||
if result['success'] and result['data']:
|
||||
rows = []
|
||||
for item in result['data']:
|
||||
date_str = item.get('date', '')
|
||||
if not date_str:
|
||||
continue
|
||||
rows.append((
|
||||
code,
|
||||
date_str,
|
||||
item.get('open', 0),
|
||||
item.get('high', 0),
|
||||
item.get('low', 0),
|
||||
item.get('close', 0),
|
||||
int(item.get('volume', 0)),
|
||||
item.get('amount', 0),
|
||||
))
|
||||
if rows:
|
||||
return rows
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_akshare_kline_rows(code, days):
|
||||
"""AKShare → tuple rows (支持自动降级到腾讯数据源)"""
|
||||
try:
|
||||
from utils.data_fetcher import fetch_stock_hist
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
|
||||
df = fetch_stock_hist(
|
||||
stock_code=code, period='daily',
|
||||
start_date=start_date, end_date=end_date, adjust='qfq',
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
rows = []
|
||||
for _, r in df.iterrows():
|
||||
rows.append((
|
||||
code,
|
||||
str(r['日期']),
|
||||
float(r['开盘']),
|
||||
float(r['最高']),
|
||||
float(r['最低']),
|
||||
float(r['收盘']),
|
||||
int(r['成交量']),
|
||||
float(r.get('成交额', 0)),
|
||||
))
|
||||
if rows:
|
||||
return rows
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 5. 统一推荐算法
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def compute_recommend(signal_status, indicators, triggered_count, is_holding):
|
||||
"""
|
||||
统一推荐逻辑 — 全局唯一定义(严格遵循 suanfa.md 体系最强战法)。
|
||||
|
||||
体系最强战法流程(suanfa.md):
|
||||
1. 日线底背离 → 纳入关注范围
|
||||
2. 龙抬头出现 → 执行买入操作(实操核心买点)
|
||||
3. 真龙/主升浪 → 持有仓位+加仓(不是新买入!)
|
||||
4. 不见主升浪 → 不出场
|
||||
|
||||
参数:
|
||||
signal_status: list[dict] 信号状态列表(来自 signal_detector._check_all_signal_status)
|
||||
indicators: dict 最新技术指标(含 macd.dif, macd.dea 等)
|
||||
triggered_count: int 触发信号数量
|
||||
is_holding: bool 当前是否持仓该股票
|
||||
|
||||
返回:
|
||||
tuple: (signal_type, display_text, reason, recommend_rate)
|
||||
- signal_type: 'buy' | 'sell' | 'watch'
|
||||
- display_text: '买入' | '卖出' | '加仓' | '持有' | '关注' | '观察' | '观望'
|
||||
- reason: str 推荐理由
|
||||
- recommend_rate: int 推荐评分 0-100
|
||||
|
||||
使用场景:
|
||||
- 全景扫描结果推荐列
|
||||
- 策略建议分档
|
||||
- 提醒 tab 买卖推荐
|
||||
- 模拟交易自动买卖决策
|
||||
"""
|
||||
if not signal_status:
|
||||
return ('watch', '观望', '暂无信号数据', 0)
|
||||
|
||||
ss = signal_status
|
||||
sig_map = {}
|
||||
for s in ss:
|
||||
sig_map[s.get('type', '')] = s
|
||||
|
||||
has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False)
|
||||
has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False)
|
||||
has_dragon = sig_map.get('dragon_head', {}).get('triggered', False)
|
||||
has_real_dragon = sig_map.get('true_dragon', {}).get('triggered', False)
|
||||
|
||||
macd = (indicators or {}).get('macd', {})
|
||||
dif = macd.get('dif', 0)
|
||||
dea = macd.get('dea', 0)
|
||||
|
||||
triggered_signals = [s.get('name', s.get('type', '')) for s in ss if s.get('triggered')]
|
||||
|
||||
# ════════════════════════════════════════════
|
||||
# 持仓逻辑(suanfa.md 步骤3-4)
|
||||
# ════════════════════════════════════════════
|
||||
if is_holding:
|
||||
# 卖出条件: MACD死叉 + 无主升浪 → 趋势走弱,不见主升浪则出场
|
||||
if dif < dea and not has_main_wave:
|
||||
return ('sell', '卖出',
|
||||
f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f})+主升浪消失", 75)
|
||||
# 主升浪 → 加仓(suanfa.md: 主升浪=持有仓位+加仓)
|
||||
if has_main_wave:
|
||||
return ('buy', '加仓', '主升浪信号 → 加速拉升阶段,建议加仓', 90)
|
||||
# 真龙 → 持有(suanfa.md: 真龙=趋势确认,持有)
|
||||
if has_real_dragon:
|
||||
return ('buy', '持有', '真龙信号 → 趋势确立,继续持有', 70)
|
||||
return ('watch', '观望', '持仓中,等待主升浪信号', 50)
|
||||
|
||||
# ════════════════════════════════════════════
|
||||
# 非持仓逻辑(suanfa.md 步骤1-2)
|
||||
# ════════════════════════════════════════════
|
||||
|
||||
# 最佳买入: 底背离+龙抬头(suanfa.md 步骤1→2 的理想组合)
|
||||
# MACD 死叉时降级,避免与持仓卖出信号矛盾
|
||||
if has_divergence and has_dragon:
|
||||
macd_golden = (dif is None or dea is None or dif >= dea)
|
||||
if macd_golden:
|
||||
return ('buy', '买入', '日线底背离+龙抬头 → 最佳买入信号', 95)
|
||||
return ('watch', '关注',
|
||||
f'底背离+龙抬头但MACD死叉(DIF={dif:.3f}<DEA={dea:.3f}) → 等待MACD金叉确认', 65)
|
||||
|
||||
# 核心买入: 龙抬头(suanfa.md: "龙抬头出现 → 执行买入操作")
|
||||
# 但需要 MACD 配合:如果 MACD 死叉则信号冲突,降级为关注
|
||||
if has_dragon:
|
||||
macd_ok = (dif is None or dea is None or dif >= dea) # MACD 金叉或无数据
|
||||
if has_main_wave and macd_ok:
|
||||
return ('buy', '买入', '龙抬头+主升浪 → 强势买入信号', 90)
|
||||
if macd_ok:
|
||||
return ('buy', '买入', '龙抬头出现 → 短线起爆点,执行买入', 80)
|
||||
# MACD死叉 + 龙抬头 → 信号冲突,降级为关注
|
||||
return ('watch', '关注',
|
||||
f'龙抬头出现但MACD死叉(DIF={dif:.3f}<DEA={dea:.3f}) → 信号冲突,谨慎观望', 55)
|
||||
|
||||
# 主升浪(非持仓)→ 不是新买入!suanfa.md: 主升浪=加速段,属于持有/加仓信号
|
||||
if has_main_wave:
|
||||
return ('watch', '关注', '主升浪(加速段) → 已过最佳买点,关注回调机会', 75)
|
||||
|
||||
# 真龙 → 关注(趋势刚启动,可跟踪)
|
||||
if has_real_dragon:
|
||||
return ('watch', '关注', '真龙出现 → 趋势启动,等待龙抬头确认', 65)
|
||||
|
||||
# MACD死叉 → 卖出/回避
|
||||
if dif is not None and dea is not None and dif < dea:
|
||||
return ('sell', '卖出', f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f})", 75)
|
||||
|
||||
# 底背离 → 关注(suanfa.md 步骤1: 纳入关注范围)
|
||||
if has_divergence:
|
||||
return ('watch', '关注', '日线底背离出现 → 纳入关注,等待龙抬头', 60)
|
||||
|
||||
# 其他信号 → 观察
|
||||
if triggered_count and triggered_count > 0:
|
||||
sigs = '、'.join(triggered_signals[:3])
|
||||
return ('watch', '观察', f"触发{triggered_count}个信号: {sigs}", 40)
|
||||
|
||||
return ('watch', '观望', '无核心信号触发', 0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 6. 获取最新价格
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def get_latest_price(stock_code):
|
||||
"""
|
||||
获取股票最新价格(从本地 stock_realtime_price 表)
|
||||
|
||||
返回:
|
||||
float: 最新价格, 失败返回 0
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(
|
||||
host=Config.DB_HOST, port=Config.DB_PORT,
|
||||
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT price FROM stock_realtime_price
|
||||
WHERE code = %s AND price > 0
|
||||
""", (stock_code,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return float(row[0])
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 7. 牛股阶段识别(suanfa.md 标准牛股启动信号先后顺序)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
# 标准牛股启动流程(底部→拉升):
|
||||
# 阶段1 → 日线底背离/短底背离(跌到底部,停止下跌)
|
||||
# 阶段2 → 龙抬头(资金进场,短线起爆)
|
||||
# 阶段3 → 真龙(趋势正式确立)
|
||||
# 阶段4 → ★主升浪(进入加速段,利润兑现最快)
|
||||
# 阶段5 → 反弹(中途回调后的补涨信号)
|
||||
# 补充 → 老鼠仓可在底部任意位置提前出现
|
||||
|
||||
BULL_STAGES = {
|
||||
1: {'name': '底部探测', 'icon': '', 'color': '#2196F3',
|
||||
'desc': '日线底背离/短底背离 → 跌到底部,停止下跌'},
|
||||
2: {'name': '资金进场', 'icon': '', 'color': '#4CAF50',
|
||||
'desc': '龙抬头 → 资金进场,短线起爆点(最佳买入时机)'},
|
||||
3: {'name': '趋势确立', 'icon': '', 'color': '#FF9800',
|
||||
'desc': '真龙 → 中期趋势正式确立'},
|
||||
4: {'name': '加速拉升', 'icon': '', 'color': '#F44336',
|
||||
'desc': '★主升浪 → 进入加速段,利润兑现最快'},
|
||||
5: {'name': '回调补涨', 'icon': '', 'color': '#9C27B0',
|
||||
'desc': '反弹 → 中途回调后的补涨信号'},
|
||||
}
|
||||
|
||||
|
||||
def compute_bull_stage(signal_status):
|
||||
"""
|
||||
识别股票在标准牛股启动流程中的阶段。
|
||||
|
||||
参数:
|
||||
signal_status: list[dict] 信号状态列表
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
'stage': int (0-5, 0=未进入流程),
|
||||
'stage_name': str,
|
||||
'stage_icon': str,
|
||||
'stage_color': str,
|
||||
'stage_desc': str,
|
||||
'signals_active': list[str], # 当前活跃的信号名称
|
||||
'progress': int (0-100), # 牛股流程进度百分比
|
||||
'next_signal': str, # 下一个期待的信号
|
||||
'investment_advice': str, # 投资建议
|
||||
'has_rat_trading': bool, # 是否有老鼠仓(提前埋伏信号)
|
||||
}
|
||||
"""
|
||||
if not signal_status:
|
||||
return {
|
||||
'stage': 0, 'stage_name': '观望', 'stage_icon': '',
|
||||
'stage_color': '#9E9E9E', 'stage_desc': '无信号触发',
|
||||
'signals_active': [], 'progress': 0,
|
||||
'next_signal': '等待底背离/短底背离', 'investment_advice': '暂无操作机会',
|
||||
'has_rat_trading': False,
|
||||
}
|
||||
|
||||
sig_map = {}
|
||||
for s in signal_status:
|
||||
sig_map[s.get('type', '')] = s
|
||||
|
||||
has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False)
|
||||
has_short_div = sig_map.get('short_bottom_divergence', {}).get('triggered', False)
|
||||
has_dragon = sig_map.get('dragon_head', {}).get('triggered', False)
|
||||
has_true_dragon = sig_map.get('true_dragon', {}).get('triggered', False)
|
||||
has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False)
|
||||
has_rebound = sig_map.get('rebound', {}).get('triggered', False)
|
||||
has_rat = sig_map.get('rat_trading', {}).get('triggered', False)
|
||||
|
||||
signals_active = []
|
||||
if has_divergence:
|
||||
signals_active.append('日线底背离')
|
||||
if has_short_div:
|
||||
signals_active.append('短底背离')
|
||||
if has_dragon:
|
||||
signals_active.append('龙抬头')
|
||||
if has_true_dragon:
|
||||
signals_active.append('真龙')
|
||||
if has_main_wave:
|
||||
signals_active.append('主升浪')
|
||||
if has_rebound:
|
||||
signals_active.append('反弹')
|
||||
if has_rat:
|
||||
signals_active.append('老鼠仓')
|
||||
|
||||
# 确定阶段(按最高阶段判定)
|
||||
stage = 0
|
||||
if has_main_wave:
|
||||
stage = 4
|
||||
elif has_true_dragon:
|
||||
stage = 3
|
||||
elif has_dragon:
|
||||
stage = 2
|
||||
elif has_divergence or has_short_div:
|
||||
stage = 1
|
||||
elif has_rebound:
|
||||
stage = 5
|
||||
elif has_rat:
|
||||
stage = 1 # 老鼠仓归入底部阶段
|
||||
|
||||
if stage == 0:
|
||||
return {
|
||||
'stage': 0, 'stage_name': '观望', 'stage_icon': '',
|
||||
'stage_color': '#9E9E9E', 'stage_desc': '无核心信号触发',
|
||||
'signals_active': signals_active, 'progress': 0,
|
||||
'next_signal': '等待底背离/短底背离',
|
||||
'investment_advice': '暂无操作机会',
|
||||
'has_rat_trading': has_rat,
|
||||
}
|
||||
|
||||
info = BULL_STAGES[stage]
|
||||
|
||||
# 计算流程进度(越靠后越高)
|
||||
# 加分项:多信号叠加说明流程更完整
|
||||
base_progress = {1: 20, 2: 45, 3: 65, 4: 85, 5: 50}
|
||||
progress = base_progress.get(stage, 0)
|
||||
if stage <= 2 and has_divergence:
|
||||
progress += 10 # 有底背离做基础更好
|
||||
if stage >= 2 and has_dragon:
|
||||
progress += 5
|
||||
if stage >= 3 and has_true_dragon:
|
||||
progress += 5
|
||||
if has_rat:
|
||||
progress += 5 # 老鼠仓加分
|
||||
progress = min(progress, 100)
|
||||
|
||||
# 下一步信号期待
|
||||
next_signals = {
|
||||
1: '等待龙抬头(资金进场信号)',
|
||||
2: '等待真龙(趋势确认信号)',
|
||||
3: '等待主升浪(加速拉升信号)',
|
||||
4: '持有!不见主升浪消失不出场',
|
||||
5: '等待龙抬头/真龙确认趋势',
|
||||
}
|
||||
|
||||
# 投资建议
|
||||
advices = {
|
||||
1: '纳入关注池,等待龙抬头出现后买入',
|
||||
2: '最佳买入时机!龙抬头=实操核心买点',
|
||||
3: '趋势已确立,可以追入,建议等回调买入',
|
||||
4: '已在加速段,持仓者加仓/持有,新入者谨慎追高',
|
||||
5: '回调中可关注,但需确认不是假反弹',
|
||||
}
|
||||
|
||||
return {
|
||||
'stage': stage,
|
||||
'stage_name': info['name'],
|
||||
'stage_icon': info['icon'],
|
||||
'stage_color': info['color'],
|
||||
'stage_desc': info['desc'],
|
||||
'signals_active': signals_active,
|
||||
'progress': progress,
|
||||
'next_signal': next_signals.get(stage, ''),
|
||||
'investment_advice': advices.get(stage, ''),
|
||||
'has_rat_trading': has_rat,
|
||||
}
|
||||
|
||||
|
||||
def find_bull_stocks(scan_rows, holding_codes=None):
|
||||
"""
|
||||
从扫描结果中找出潜在牛股,按阶段分组排序。
|
||||
|
||||
参数:
|
||||
scan_rows: list[dict] 扫描结果列表 (含 code, name, signal_status, indicators, triggered_count)
|
||||
holding_codes: set 持仓代码集合
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
'stages': {1: [...], 2: [...], ...}, # 按阶段分组的股票列表
|
||||
'summary': {1: count, 2: count, ...}, # 各阶段数量统计
|
||||
'total': int, # 有信号的总数
|
||||
}
|
||||
"""
|
||||
if holding_codes is None:
|
||||
holding_codes = set()
|
||||
|
||||
stages = {1: [], 2: [], 3: [], 4: [], 5: []}
|
||||
summary = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||||
|
||||
for row in scan_rows:
|
||||
signal_status = row.get('signal_status') or []
|
||||
if not signal_status:
|
||||
summary[0] += 1
|
||||
continue
|
||||
|
||||
# 判断牛股阶段
|
||||
bull = compute_bull_stage(signal_status)
|
||||
stage = bull['stage']
|
||||
summary[stage] += 1
|
||||
|
||||
if stage == 0:
|
||||
continue
|
||||
|
||||
# 计算推荐
|
||||
is_holding = row.get('code', '') in holding_codes
|
||||
st, disp, reason, rate = compute_recommend(
|
||||
signal_status, row.get('indicators'),
|
||||
row.get('triggered_count'), is_holding,
|
||||
)
|
||||
|
||||
item = {
|
||||
'code': row.get('code', ''),
|
||||
'name': row.get('name', ''),
|
||||
'stage': stage,
|
||||
'stage_name': bull['stage_name'],
|
||||
'stage_icon': bull['stage_icon'],
|
||||
'stage_color': bull['stage_color'],
|
||||
'signals_active': bull['signals_active'],
|
||||
'progress': bull['progress'],
|
||||
'next_signal': bull['next_signal'],
|
||||
'investment_advice': bull['investment_advice'],
|
||||
'has_rat_trading': bull['has_rat_trading'],
|
||||
'recommend_type': st,
|
||||
'recommend_text': disp,
|
||||
'recommend_reason': reason,
|
||||
'recommend_rate': rate,
|
||||
'is_holding': is_holding,
|
||||
'triggered_count': row.get('triggered_count', 0),
|
||||
}
|
||||
|
||||
stages[stage].append(item)
|
||||
|
||||
# 每个阶段内按推荐评分降序排序
|
||||
for stage_num in stages:
|
||||
stages[stage_num].sort(key=lambda x: (-x['recommend_rate'], -x['progress']))
|
||||
|
||||
total = sum(len(v) for v in stages.values())
|
||||
|
||||
return {
|
||||
'stages': stages,
|
||||
'summary': summary,
|
||||
'total': total,
|
||||
'stage_info': BULL_STAGES,
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
股票数据服务 - 获取、缓存、分析
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
import traceback
|
||||
import json
|
||||
import os
|
||||
from config import Config
|
||||
|
||||
|
||||
# ========== 股票名称缓存 ==========
|
||||
_stock_name_cache = {}
|
||||
|
||||
|
||||
def _load_stock_name_cache():
|
||||
"""从本地文件加载股票名称缓存"""
|
||||
global _stock_name_cache
|
||||
try:
|
||||
if os.path.exists(Config.STOCK_NAME_CACHE_FILE):
|
||||
with open(Config.STOCK_NAME_CACHE_FILE, 'r', encoding='utf-8') as f:
|
||||
_stock_name_cache = json.load(f)
|
||||
print(f"加载股票名称缓存:{len(_stock_name_cache)}条")
|
||||
except Exception as e:
|
||||
print(f"加载股票名称缓存失败: {e}")
|
||||
|
||||
|
||||
def _save_stock_name_cache():
|
||||
"""保存股票名称缓存到本地"""
|
||||
try:
|
||||
with open(Config.STOCK_NAME_CACHE_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(_stock_name_cache, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"保存股票名称缓存失败: {e}")
|
||||
|
||||
|
||||
def get_stock_name(stock_code):
|
||||
"""获取股票名称 — 使用腾讯财经API"""
|
||||
global _stock_name_cache
|
||||
|
||||
if stock_code in _stock_name_cache:
|
||||
return _stock_name_cache[stock_code]
|
||||
|
||||
# 腾讯财经API获取股票名称
|
||||
try:
|
||||
import requests as _req
|
||||
tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code
|
||||
_r = _req.get(f'http://qt.gtimg.cn/q={tcode}', timeout=5,
|
||||
headers={'Referer': 'https://finance.qq.com'})
|
||||
if _r.status_code == 200 and '\"' in _r.text:
|
||||
_fields = _r.text.split('\"')[1].split('~')
|
||||
if len(_fields) > 2 and _fields[1]:
|
||||
_stock_name_cache[stock_code] = _fields[1]
|
||||
_save_stock_name_cache()
|
||||
return _fields[1]
|
||||
except Exception as e:
|
||||
print(f"获取股票名称失败(腾讯): {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ========== 股票数据缓存 ==========
|
||||
|
||||
def _get_cache_file_path(stock_code):
|
||||
"""获取缓存文件路径"""
|
||||
return os.path.join(Config.STOCK_DATA_CACHE_DIR, f'{stock_code}.json')
|
||||
|
||||
|
||||
def load_cached_data(stock_code):
|
||||
"""加载缓存的股票数据"""
|
||||
cache_file = _get_cache_file_path(stock_code)
|
||||
if os.path.exists(cache_file):
|
||||
try:
|
||||
with open(cache_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
df = pd.DataFrame(data['records'])
|
||||
if not df.empty and '日期' in df.columns:
|
||||
df['日期'] = pd.to_datetime(df['日期'])
|
||||
return df, data.get('stock_name'), data.get('last_update')
|
||||
except Exception as e:
|
||||
print(f"加载缓存数据失败: {e}")
|
||||
return None, None, None
|
||||
|
||||
|
||||
def save_cached_data(stock_code, df, stock_name):
|
||||
"""保存股票数据到缓存"""
|
||||
cache_file = _get_cache_file_path(stock_code)
|
||||
try:
|
||||
df_copy = df.copy()
|
||||
df_copy['日期'] = df_copy['日期'].dt.strftime('%Y-%m-%d')
|
||||
records = df_copy.to_dict('records')
|
||||
|
||||
data = {
|
||||
'stock_code': stock_code,
|
||||
'stock_name': stock_name,
|
||||
'last_update': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'records': records
|
||||
}
|
||||
|
||||
with open(cache_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
print(f"已保存 {stock_code} 数据,共 {len(records)} 条")
|
||||
except Exception as e:
|
||||
print(f"保存缓存数据失败: {e}")
|
||||
|
||||
|
||||
# ========== 获取股票资金流向数据 ==========
|
||||
|
||||
def get_stock_fund_flow(stock_code, start_date, end_date, force_refresh=False):
|
||||
"""
|
||||
获取股票资金流向数据(支持缓存,增量获取)
|
||||
force_refresh: 强制刷新缓存
|
||||
返回: (DataFrame, stock_name, error_msg)
|
||||
"""
|
||||
try:
|
||||
# 判断市场
|
||||
if stock_code.startswith('6'):
|
||||
market = 'sh'
|
||||
elif stock_code.startswith('0') or stock_code.startswith('3'):
|
||||
market = 'sz'
|
||||
else:
|
||||
return None, None, "无法识别股票代码所属市场"
|
||||
|
||||
stock_name = get_stock_name(stock_code)
|
||||
start = pd.to_datetime(start_date)
|
||||
end = pd.to_datetime(end_date)
|
||||
|
||||
# 加载缓存
|
||||
cached_df, cached_name, last_update = load_cached_data(stock_code)
|
||||
need_fetch = force_refresh
|
||||
new_data_df = None
|
||||
|
||||
if cached_df is not None and not cached_df.empty:
|
||||
# 如果缓存是今天的,直接使用
|
||||
if last_update:
|
||||
try:
|
||||
update_date = pd.to_datetime(last_update.split()[0])
|
||||
today = pd.to_datetime(datetime.now().strftime('%Y-%m-%d'))
|
||||
if update_date >= today and not force_refresh:
|
||||
# 今天已更新,直接使用缓存
|
||||
df = cached_df[(cached_df['日期'] >= start) & (cached_df['日期'] <= end)]
|
||||
df = df.sort_values('日期').reset_index(drop=True)
|
||||
return df, cached_name or stock_name, None
|
||||
except:
|
||||
pass
|
||||
cached_max_date = cached_df['日期'].max()
|
||||
today = pd.to_datetime(datetime.now().strftime('%Y-%m-%d'))
|
||||
|
||||
# 如果缓存数据不超过2天,直接使用(优化分析速度)
|
||||
if cached_max_date >= today - timedelta(days=2) and not force_refresh:
|
||||
if cached_df['日期'].min() <= start:
|
||||
need_fetch = False
|
||||
new_data_df = cached_df
|
||||
print(f"使用缓存数据: {stock_code}, 最新日期: {cached_max_date.strftime('%Y-%m-%d')}")
|
||||
|
||||
if stock_name is None and cached_name:
|
||||
stock_name = cached_name
|
||||
|
||||
if need_fetch:
|
||||
# 东方财富资金流向API已不可用(腾讯云网络限制),使用缓存数据
|
||||
print(f"资金流向API不可用,使用缓存: {stock_code}")
|
||||
if cached_df is not None:
|
||||
new_data_df = cached_df
|
||||
else:
|
||||
return None, None, "资金流向API不可用(东方财富已封锁),且无缓存数据"
|
||||
|
||||
if new_data_df is None or new_data_df.empty:
|
||||
# 最后尝试使用缓存数据(即使不在日期范围内)
|
||||
if cached_df is not None and not cached_df.empty:
|
||||
print(f"使用全部缓存数据: {stock_code}")
|
||||
df = cached_df.sort_values('日期').reset_index(drop=True)
|
||||
return df, cached_name or stock_name, None
|
||||
return None, None, "无法获取数据"
|
||||
|
||||
# 筛选日期范围
|
||||
df = new_data_df[(new_data_df['日期'] >= start) & (new_data_df['日期'] <= end)]
|
||||
|
||||
# 如果筛选后为空,使用全部数据
|
||||
if df.empty and not new_data_df.empty:
|
||||
print(f"日期范围无数据,使用全部缓存: {stock_code}")
|
||||
df = new_data_df
|
||||
|
||||
df = df.sort_values('日期').reset_index(drop=True)
|
||||
|
||||
return df, stock_name, None
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return None, None, f"获取数据失败: {str(e)}"
|
||||
|
||||
|
||||
# ========== 分析股票数据 ==========
|
||||
|
||||
def analyze_fund_flow_impact(df):
|
||||
"""分析资金流向对股价的影响(含成交量分析)"""
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
|
||||
df = df.sort_values('日期').reset_index(drop=True)
|
||||
threshold = 2.0
|
||||
|
||||
if '超大单净流入-净占比' not in df.columns:
|
||||
return None
|
||||
|
||||
df['超大单净流入-净占比'] = df['超大单净流入-净占比'].fillna(0)
|
||||
df['主力净流入-净占比'] = df['主力净流入-净占比'].fillna(0)
|
||||
|
||||
df['超大单流向'] = df['超大单净流入-净占比'].apply(
|
||||
lambda x: '大额流入' if x >= threshold else ('大额流出' if x <= -threshold else '普通')
|
||||
)
|
||||
df['主力流向'] = df['主力净流入-净占比'].apply(
|
||||
lambda x: '大额流入' if x >= threshold else ('大额流出' if x <= -threshold else '普通')
|
||||
)
|
||||
|
||||
# 计算价格位置(改为60日)
|
||||
latest = df.iloc[-1]
|
||||
lookback = 60 # 从20日改为60日
|
||||
try:
|
||||
actual_lookback = min(len(df), lookback)
|
||||
if actual_lookback >= 5: # 至少需要5天数据
|
||||
recent = df.tail(actual_lookback)
|
||||
high = float(recent['收盘价'].max() or 0)
|
||||
low = float(recent['收盘价'].min() or 0)
|
||||
current_price = float(latest.get('收盘价') or 0)
|
||||
price_position = (current_price - low) / (high - low) * 100 if high != low else 50
|
||||
else:
|
||||
price_position = 50
|
||||
except:
|
||||
price_position = 50
|
||||
|
||||
# 成交量分析(基于主力净流入-净额作为成交额指标)
|
||||
volume_ratio = 1.0 # 默认值
|
||||
volume_trend = '普通'
|
||||
try:
|
||||
if '主力净流入-净额' in df.columns and len(df) >= 10:
|
||||
# 使用主力净流入绝对值作为活跃度指标
|
||||
df['活跃度'] = df['主力净流入-净额'].abs()
|
||||
recent_5 = df.tail(5)['活跃度'].mean()
|
||||
recent_20 = df.tail(min(20, len(df)))['活跃度'].mean()
|
||||
|
||||
if recent_20 > 0:
|
||||
volume_ratio = recent_5 / recent_20
|
||||
if volume_ratio >= 1.5:
|
||||
volume_trend = '放量'
|
||||
elif volume_ratio <= 0.5:
|
||||
volume_trend = '缩量'
|
||||
else:
|
||||
volume_trend = '正常'
|
||||
except:
|
||||
pass
|
||||
|
||||
# 计算均线MA5和MA20
|
||||
ma5 = 0
|
||||
ma20 = 0
|
||||
try:
|
||||
if '收盘价' in df.columns and len(df) >= 5:
|
||||
ma5 = df.tail(5)['收盘价'].mean()
|
||||
if '收盘价' in df.columns and len(df) >= 20:
|
||||
ma20 = df.tail(20)['收盘价'].mean()
|
||||
except:
|
||||
pass
|
||||
|
||||
# 处理日期格式(可能是datetime或字符串)
|
||||
def format_date(d):
|
||||
if hasattr(d, 'strftime'):
|
||||
return d.strftime('%Y-%m-%d')
|
||||
return str(d)[:10] if d else ''
|
||||
|
||||
def safe_float(val, default=0):
|
||||
try:
|
||||
return float(val) if val is not None else default
|
||||
except:
|
||||
return default
|
||||
|
||||
return {
|
||||
'最新数据': {
|
||||
'日期': format_date(latest['日期']),
|
||||
'收盘价': safe_float(latest.get('收盘价')),
|
||||
'涨跌幅': safe_float(latest.get('涨跌幅')),
|
||||
'价格位置': safe_float(price_position),
|
||||
'超大单净流入占比': safe_float(latest.get('超大单净流入-净占比')),
|
||||
'主力净流入占比': safe_float(latest.get('主力净流入-净占比')),
|
||||
'超大单流向': latest.get('超大单流向', '普通'),
|
||||
'主力流向': latest.get('主力流向', '普通'),
|
||||
'成交量比': safe_float(volume_ratio, 1.0),
|
||||
'量能趋势': volume_trend,
|
||||
'MA5': safe_float(ma5),
|
||||
'MA20': safe_float(ma20)
|
||||
},
|
||||
'数据概览': {
|
||||
'总交易日数': len(df),
|
||||
'计算周期': min(len(df), lookback),
|
||||
'日期范围': {
|
||||
'开始': format_date(df['日期'].min()),
|
||||
'结束': format_date(df['日期'].max())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ========== 实时价格 ==========
|
||||
|
||||
def get_realtime_price(stock_code):
|
||||
"""获取实时价格(使用mairuiapi,更稳定)"""
|
||||
try:
|
||||
from services.mairui_api import get_realtime_price as mairui_get_price
|
||||
result = mairui_get_price(stock_code)
|
||||
if result['success']:
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"mairuiapi获取实时价格失败({stock_code}): {e}")
|
||||
|
||||
# 备用方案2:使用腾讯财经API(腾讯云可用)
|
||||
try:
|
||||
import requests as _req
|
||||
tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code
|
||||
_r = _req.get(f'http://qt.gtimg.cn/q={tcode}', timeout=5,
|
||||
headers={'Referer': 'https://finance.qq.com'})
|
||||
if _r.status_code == 200 and '\"' in _r.text:
|
||||
_fields = _r.text.split('\"')[1].split('~')
|
||||
if len(_fields) > 35 and _fields[3]:
|
||||
return {
|
||||
'success': True,
|
||||
'data': {
|
||||
'code': stock_code,
|
||||
'name': _fields[1],
|
||||
'price': float(_fields[3]),
|
||||
'change': float(_fields[32]) if _fields[32] else 0,
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"腾讯财经备用方案失败({stock_code}): {e}")
|
||||
|
||||
return {'success': False, 'error': '获取失败'}
|
||||
|
||||
|
||||
def get_realtime_prices_batch(stock_codes):
|
||||
"""批量获取实时价格"""
|
||||
try:
|
||||
from services.mairui_api import get_realtime_prices_batch as mairui_batch
|
||||
return mairui_batch(stock_codes)
|
||||
except Exception as e:
|
||||
print(f"mairuiapi批量获取失败: {e}")
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# ========== 热门股票 ==========
|
||||
|
||||
def get_hot_stocks(limit=100):
|
||||
"""获取热门股票 — 东方财富API已不可用,返回空"""
|
||||
# stock_hot_rank_em 为东方财富API,已在腾讯云被封锁
|
||||
return []
|
||||
|
||||
|
||||
# 初始化时加载缓存
|
||||
_load_stock_name_cache()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
技术指标计算模块(numpy向量化优化版)
|
||||
实现 MACD、SKDJ、EMA 等技术指标
|
||||
|
||||
优化要点:
|
||||
- calc_sma 使用 numpy 原生数组替代 pandas.iloc,速度提升 5-10x
|
||||
- calc_all_indicators 智能跳过已是 float 的类型转换
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
|
||||
def calc_ema(series, period):
|
||||
"""计算指数移动平均线(EMA) — 使用pandas的C底层ewm实现,已足够快"""
|
||||
return series.ewm(span=period, adjust=False).mean()
|
||||
|
||||
|
||||
def calc_sma(series, period, weight=1):
|
||||
"""
|
||||
计算SMA(通达信公式风格) — numpy优化版
|
||||
SMA(X, N, M) = (M * X + (N - M) * prev_SMA) / N
|
||||
|
||||
优化:使用 numpy 原生数组 arr[i] 替代 pandas series.iloc[i]
|
||||
numpy 数组元素访问约 50ns,pandas iloc 约 5μs,提升 ~100x
|
||||
"""
|
||||
arr = series.values.astype(np.float64)
|
||||
n = len(arr)
|
||||
result = np.empty(n, dtype=np.float64)
|
||||
result[0] = arr[0]
|
||||
w = np.float64(weight)
|
||||
carry = np.float64(period - weight)
|
||||
inv_p = np.float64(1.0 / period)
|
||||
for i in range(1, n):
|
||||
result[i] = (w * arr[i] + carry * result[i - 1]) * inv_p
|
||||
return pd.Series(result, index=series.index)
|
||||
|
||||
|
||||
def calc_macd(close, fast=12, slow=26, signal=9):
|
||||
"""
|
||||
计算MACD指标
|
||||
返回: DIF, DEA, MACD柱
|
||||
"""
|
||||
ema_fast = calc_ema(close, fast)
|
||||
ema_slow = calc_ema(close, slow)
|
||||
dif = ema_fast - ema_slow
|
||||
dea = calc_ema(dif, signal)
|
||||
macd_hist = 2 * (dif - dea)
|
||||
return dif, dea, macd_hist
|
||||
|
||||
|
||||
def calc_kdj(high, low, close, n=9, m1=3, m2=3):
|
||||
"""
|
||||
计算KDJ指标
|
||||
返回: K, D, J
|
||||
"""
|
||||
lowest_low = low.rolling(window=n, min_periods=1).min()
|
||||
highest_high = high.rolling(window=n, min_periods=1).max()
|
||||
|
||||
rsv = pd.Series(np.where(
|
||||
highest_high == lowest_low, 50,
|
||||
(close - lowest_low) / (highest_high - lowest_low) * 100
|
||||
), index=close.index, dtype=float)
|
||||
|
||||
k = calc_sma(rsv, m1, 1)
|
||||
d = calc_sma(k, m2, 1)
|
||||
j = 3 * k - 2 * d
|
||||
return k, d, j
|
||||
|
||||
|
||||
def calc_skdj(high, low, close, n=9, m=3):
|
||||
"""
|
||||
计算SKDJ(慢速随机指标)
|
||||
对RSV先做一次SMA得到K_fast,再对K_fast做两次SMA得到SKDJ的K和D
|
||||
返回: K, D
|
||||
"""
|
||||
lowest_low = low.rolling(window=n, min_periods=1).min()
|
||||
highest_high = high.rolling(window=n, min_periods=1).max()
|
||||
|
||||
rsv = pd.Series(np.where(
|
||||
highest_high == lowest_low, 50,
|
||||
(close - lowest_low) / (highest_high - lowest_low) * 100
|
||||
), index=close.index, dtype=float)
|
||||
|
||||
k_fast = calc_sma(rsv, m, 1)
|
||||
k = calc_sma(k_fast, m, 1)
|
||||
d = calc_sma(k, m, 1)
|
||||
return k, d
|
||||
|
||||
|
||||
def calc_all_indicators(df):
|
||||
"""
|
||||
计算所有技术指标并添加到DataFrame(优化版)
|
||||
df 需要包含: close, high, low, open, volume 列
|
||||
返回: 添加了指标列的DataFrame
|
||||
|
||||
优化:智能跳过已是 float64 的列,避免重复 astype
|
||||
"""
|
||||
close = df['close']
|
||||
high = df['high']
|
||||
low = df['low']
|
||||
|
||||
# 智能类型转换:仅在需要时转换
|
||||
if not np.issubdtype(close.dtype, np.floating):
|
||||
close = close.astype(np.float64)
|
||||
high = high.astype(np.float64)
|
||||
low = low.astype(np.float64)
|
||||
|
||||
df['ema3'] = calc_ema(close, 3)
|
||||
df['ema21'] = calc_ema(close, 21)
|
||||
|
||||
dif, dea, macd_hist = calc_macd(close)
|
||||
df['dif'] = dif
|
||||
df['dea'] = dea
|
||||
df['macd'] = macd_hist
|
||||
|
||||
k, d, j = calc_kdj(high, low, close)
|
||||
df['kdj_k'] = k
|
||||
df['kdj_d'] = d
|
||||
df['kdj_j'] = j
|
||||
|
||||
sk, sd = calc_skdj(high, low, close)
|
||||
df['skdj_k'] = sk
|
||||
df['skdj_d'] = sd
|
||||
|
||||
df['ma5'] = close.rolling(5).mean()
|
||||
df['ma10'] = close.rolling(10).mean()
|
||||
df['ma20'] = close.rolling(20).mean()
|
||||
df['ma60'] = close.rolling(60).mean()
|
||||
|
||||
return df
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# 在服务器上配置全景扫描定时任务
|
||||
# 每个交易日执行两次:11:35(午休)和 16:00(收盘后)
|
||||
# 用法:在 stock-html 目录下执行 ./setup_cron_scan.sh
|
||||
|
||||
set -e
|
||||
SERVER="${STOCK_SERVER:-root@8.146.207.22}"
|
||||
APP_DIR="${STOCK_APP_DIR:-/opt/stock-app}"
|
||||
|
||||
echo "目标服务器: $SERVER"
|
||||
echo "应用目录: $APP_DIR"
|
||||
echo "定时规则: 每周一至周五 11:50(午休)+ 16:30(收盘后)执行全景扫描"
|
||||
echo ""
|
||||
|
||||
# 1. 确保 auto_scan.sh 已同步到服务器
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
if [ -f "$SCRIPT_DIR/auto_scan.sh" ]; then
|
||||
echo "[1/3] 同步 auto_scan.sh 到服务器..."
|
||||
rsync -avz "$SCRIPT_DIR/auto_scan.sh" "$SERVER:${APP_DIR}/"
|
||||
else
|
||||
echo "[1/3] 未找到 auto_scan.sh,跳过同步(请确认服务器上已有该文件)"
|
||||
fi
|
||||
|
||||
# 2. 在服务器上设为可执行并添加 crontab(两条定时任务)
|
||||
echo "[2/3] 设置可执行并添加定时任务..."
|
||||
ssh "$SERVER" "chmod +x ${APP_DIR}/auto_scan.sh && (crontab -l 2>/dev/null | grep -v auto_scan.sh || true; echo '50 11 * * 1-5 ${APP_DIR}/auto_scan.sh >> ${APP_DIR}/auto_scan.log 2>&1'; echo '30 16 * * 1-5 ${APP_DIR}/auto_scan.sh >> ${APP_DIR}/auto_scan.log 2>&1') | crontab -"
|
||||
|
||||
echo "[3/3] 当前服务器 crontab:"
|
||||
ssh "$SERVER" "crontab -l"
|
||||
|
||||
# 3.5. 同步 5分钟K线采集脚本
|
||||
if [ -f "$SCRIPT_DIR/auto_sync_kline_5min.sh" ]; then
|
||||
echo "[3.5/4] 同步 auto_sync_kline_5min.sh 到服务器..."
|
||||
rsync -avz "$SCRIPT_DIR/auto_sync_kline_5min.sh" "$SCRIPT_DIR/sync_kline_5min.py" "$SERVER:${APP_DIR}/"
|
||||
ssh "$SERVER" "chmod +x ${APP_DIR}/auto_sync_kline_5min.sh"
|
||||
fi
|
||||
|
||||
# 4. 添加 5分钟K线每日采集定时任务
|
||||
echo "[4/4] 添加5分钟K线采集定时任务..."
|
||||
ssh "$SERVER" "(crontab -l 2>/dev/null | grep -v auto_sync_kline_5min || true; echo ''; echo '# 5分钟K线数据每日采集(收盘后,约50分钟完成)'; echo '30 17 * * 1-5 ${APP_DIR}/auto_sync_kline_5min.sh >> ${APP_DIR}/sync_kline_5min.log 2>&1') | crontab -"
|
||||
|
||||
echo ""
|
||||
echo "配置完成。每个交易日将自动执行:"
|
||||
echo " - 11:50 午休扫描(上午收盘数据,供下午参考)"
|
||||
echo " - 16:30 收盘扫描(全天完整数据)"
|
||||
echo " - 17:30 5分钟K线采集(全市场约50分钟)"
|
||||
echo ""
|
||||
echo "日志:"
|
||||
echo " ${APP_DIR}/auto_scan.log"
|
||||
echo " ${APP_DIR}/sync_kline_5min.log"
|
||||
echo "手动测试: ssh $SERVER \"${APP_DIR}/auto_scan.sh\""
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 启动Flask服务
|
||||
python app.py
|
||||
@@ -0,0 +1,266 @@
|
||||
/* ═══════════════════════════════════════
|
||||
auth.css - Login and registration styles
|
||||
Lines: 261
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
/* ========== 登录相关样式 ========== */
|
||||
|
||||
.title-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-bar h1 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-btn, .logout-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 240, 240, 0.9));
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(255, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* 登录弹窗 */
|
||||
.login-modal {
|
||||
background: var(--bg-dark);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-head.title-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.title-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.current-model {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-field label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-field input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.login-field input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: #ff4444;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-success {
|
||||
color: #00ff88;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 240, 240, 0.9));
|
||||
color: var(--bg-dark);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-submit:hover {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.login-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-switch {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.login-switch a {
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
/* 登录选项卡 */
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-tab {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-tab.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.login-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
/* 登录页面(未登录状态) */
|
||||
.login-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-page-header h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.login-page-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-modal-inline {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.loading-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
/* ═══════════════════════════════════════
|
||||
base.css - Root variables, body, navigation, layout
|
||||
Lines: 700
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0a0a;
|
||||
--bg-glass: rgba(255, 255, 255, 0.05);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.08);
|
||||
--border-glass: rgba(255, 255, 255, 0.1);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.4);
|
||||
--accent: #ffffff;
|
||||
--success: #00ff88;
|
||||
--danger: #ff4444;
|
||||
--warning: #ffaa00;
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', sans-serif;
|
||||
background: var(--bg-dark);
|
||||
min-height: 100vh;
|
||||
color: var(--text-primary);
|
||||
padding: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 16px;
|
||||
padding-top: max(20px, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.main-title-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.market-status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.market-status.open {
|
||||
color: var(--success);
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
|
||||
.market-status.closed {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 玻璃卡片基础样式 */
|
||||
.glass {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* 导航标签 */
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 4px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 紧凑导航样式 */
|
||||
.nav-tabs-compact {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nav-tabs-compact .nav-tab {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 240, 240, 0.9));
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.nav-tab.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
/* 子导航标签 */
|
||||
.sub-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 3px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sub-tab {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sub-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sub-tab.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sub-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub-tab.active .sub-badge {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.add-watch-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-left: auto;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.add-watch-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.add-watch-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-glass);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.add-watch-input input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.add-watch-input input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.add-watch-input button {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-watch-input button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.add-watch-input button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 输入区域 */
|
||||
.input-section {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px);
|
||||
border: none;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.analyze-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-dark);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.analyze-btn:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.analyze-btn:disabled {
|
||||
background: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 扫描控制区 */
|
||||
.scan-control {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scan-source-select {
|
||||
flex: 0 0 150px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.scan-source-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
|
||||
.scan-source-select option {
|
||||
background: #1a1a2e;
|
||||
color: var(--text-primary);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.scan-btn {
|
||||
flex: 1;
|
||||
padding: 14px 28px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.scan-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.scan-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.scan-btn:disabled {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.scan-status-bar {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 10px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--bg-glass);
|
||||
border-radius: 8px;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* 扫描结果 */
|
||||
.scan-results {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scan-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scan-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.scan-item .rank {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scan-item:nth-child(-n+3) .rank {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.scan-stock-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.scan-stock-info:hover {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
|
||||
.scan-code {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
min-width: 55px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scan-name {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scan-price {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scan-rate {
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scan-rate.buy {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.scan-add-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scan-add-btn:hover {
|
||||
color: var(--success);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.scan-add-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.scan-add-btn:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 历史记录下拉 */
|
||||
.history-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1a1a1a;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
z-index: 100;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-item:active {
|
||||
background: var(--bg-glass-hover);
|
||||
}
|
||||
|
||||
.history-item .code {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.history-item .name {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.history-item .remove {
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* 股票信息条 */
|
||||
.stock-info-bar {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.stock-info-bar .stock-code {
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-dark);
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stock-info-bar .stock-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
border: 1px solid rgba(255, 68, 68, 0.2);
|
||||
color: var(--danger);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 信号卡片 */
|
||||
.recommendation-card {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.recommendation-card.buy {
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
|
||||
.recommendation-card.sell {
|
||||
border-color: rgba(255, 68, 68, 0.3);
|
||||
background: rgba(255, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.signal-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.signal-text {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.recommendation-card.buy .signal-text { color: var(--success); }
|
||||
.recommendation-card.sell .signal-text { color: var(--danger); }
|
||||
|
||||
.signal-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.signal-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.signal-detail-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.signal-detail-item h4 {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signal-detail-item .value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
/* ═══════════════════════════════════════
|
||||
responsive.css - PC / 桌面端适配
|
||||
手机端保持现有样式不变
|
||||
断点: 768px (PC 及以上应用以下样式)
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
@media (min-width: 768px) {
|
||||
/* ===== 主容器 ===== */
|
||||
.container {
|
||||
max-width: 880px;
|
||||
padding: 28px 32px;
|
||||
padding-top: max(28px, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
/* ===== 标题栏 ===== */
|
||||
.title-bar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-bar h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.main-title-date {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.current-model {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ===== 导航标签 ===== */
|
||||
.nav-tabs {
|
||||
margin-bottom: 24px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.nav-tabs-compact .nav-tab {
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sub-tabs {
|
||||
margin-bottom: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.sub-tab {
|
||||
padding: 12px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== 提醒汇总区 ===== */
|
||||
.alerts-summary.top-summary {
|
||||
padding: 16px 24px;
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.alerts-summary.top-summary .summary-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alerts-summary.top-summary .summary-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* ===== 卡片与区块 ===== */
|
||||
.card,
|
||||
.input-section,
|
||||
.scan-results {
|
||||
padding: 24px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.recommendation-card {
|
||||
padding: 28px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.signal-details {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.signal-detail-item .value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* ===== 图表 ===== */
|
||||
.chart-container {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
/* ===== 数据表格 ===== */
|
||||
.data-table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
/* ===== 汇总网格 ===== */
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
/* ===== 弹窗 / 模态框 ===== */
|
||||
.login-modal {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
min-width: 360px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.smart-result-modal {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
/* ===== 扫描控制 ===== */
|
||||
.scan-control {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.scan-source-select {
|
||||
flex: 0 0 180px;
|
||||
}
|
||||
|
||||
.scan-btn {
|
||||
padding: 16px 32px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ===== 扫描结果项 ===== */
|
||||
.scan-item {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.scan-code {
|
||||
font-size: 14px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.scan-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.scan-price,
|
||||
.scan-rate {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.analyze-btn {
|
||||
height: 48px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ===== 技术信号 ===== */
|
||||
.tech-result-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tech-result-header h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ===== 模拟交易 ===== */
|
||||
.sim-stats-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sim-stats-header h4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sim-stats-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sim-stat-value {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sim-stat-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ===== 信号排名 ===== */
|
||||
.signal-rank-item {
|
||||
grid-template-columns: 28px 80px 48px 1fr;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ===== 输入组 ===== */
|
||||
.input-group label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
padding: 16px 18px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ===== Toast ===== */
|
||||
.toast-container {
|
||||
max-width: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 更大屏幕 (1200px+) 进一步放宽 */
|
||||
@media (min-width: 1200px) {
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
padding: 32px 40px;
|
||||
}
|
||||
|
||||
.signal-details {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" fill="none">
|
||||
<rect width="192" height="192" rx="32" fill="#0a0a0a"/>
|
||||
<path d="M32 144L64 104L96 128L144 72L160 96" stroke="url(#grad)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M144 72H160V88" stroke="url(#grad)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="64" cy="104" r="8" fill="#00ff88"/>
|
||||
<circle cx="96" cy="128" r="8" fill="#00ff88"/>
|
||||
<circle cx="144" cy="72" r="8" fill="#00ff88"/>
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="32" y1="72" x2="160" y2="144" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#00ff88"/>
|
||||
<stop offset="100%" stop-color="#00ccff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 770 B |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "股票投资",
|
||||
"short_name": "股票投资",
|
||||
"description": "股票投资分析与管理系统",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Stock Data Collection Service
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/stock-app
|
||||
Environment="DB_HOST=localhost"
|
||||
Environment="DB_PORT=5432"
|
||||
Environment="DB_NAME=stock_app"
|
||||
Environment="DB_USER=postgres"
|
||||
Environment="DB_PASSWORD=stock_password_2025"
|
||||
ExecStart=/opt/stock-app/venv/bin/python stock_data_service.py daemon
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,11 @@
|
||||
2026-02-23 08:57:20,177 [INFO] 开始更新实时价格...
|
||||
2026-02-23 08:58:35,748 [INFO] 实时价格更新完成: 5810 条
|
||||
2026-02-23 08:59:04,812 [INFO] 开始更新今日资金流向...
|
||||
2026-02-23 08:59:16,109 [INFO] 今日资金流向更新完成: 5270 条
|
||||
2026-02-23 08:59:24,434 [INFO] 开始更新历史资金流向...
|
||||
2026-02-23 08:59:24,563 [INFO] 需要更新 2 只股票
|
||||
2026-02-23 08:59:25,341 [INFO] 600519: 120 条
|
||||
2026-02-23 08:59:25,410 [INFO] 000001: 120 条
|
||||
2026-02-23 08:59:25,412 [INFO] 历史资金流向更新完成: 240 条, 错误: 0
|
||||
2026-02-25 10:19:05,463 [INFO] 开始更新实时价格...
|
||||
2026-02-25 10:19:18,105 [ERROR] 更新实时价格失败: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
|
||||
@@ -0,0 +1,574 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
股票数据采集服务
|
||||
定时采集实时行情数据并存入数据库
|
||||
数据源: 腾讯财经 (qt.gtimg.cn)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import schedule
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values
|
||||
from datetime import datetime
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler('stock_data_service.log')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 数据库配置
|
||||
DB_CONFIG = {
|
||||
'host': os.environ.get('DB_HOST', 'localhost'),
|
||||
'port': int(os.environ.get('DB_PORT', 5432)),
|
||||
'database': os.environ.get('DB_NAME', 'stock_app'),
|
||||
'user': os.environ.get('DB_USER', 'postgres'),
|
||||
'password': os.environ.get('DB_PASSWORD', '')
|
||||
}
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库连接"""
|
||||
try:
|
||||
return psycopg2.connect(**DB_CONFIG)
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def log_update(data_type, status, records_count=0, error_message=None, started_at=None):
|
||||
"""记录更新日志"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO data_update_log (data_type, status, records_count, error_message, started_at)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""", (data_type, status, records_count, error_message, started_at))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"记录日志失败: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _to_tencent_code(code):
|
||||
"""将纯数字股票代码转为腾讯格式 (sh/sz/bj前缀)"""
|
||||
if code.startswith('6'):
|
||||
return f'sh{code}'
|
||||
elif code.startswith('0') or code.startswith('3'):
|
||||
return f'sz{code}'
|
||||
elif code.startswith('8') or code.startswith('4'):
|
||||
return f'bj{code}'
|
||||
else:
|
||||
return f'sz{code}'
|
||||
|
||||
|
||||
def _fetch_realtime_from_tencent(stock_codes):
|
||||
"""
|
||||
从腾讯财经API批量获取实时行情(最佳数据源,腾讯云极快)
|
||||
腾讯API字段(88个)关键映射:
|
||||
[1]=名称 [2]=代码 [3]=现价 [4]=昨收 [5]=开盘
|
||||
[6]=成交量(手) [31]=涨跌额 [32]=涨跌% [33]=最高 [34]=最低
|
||||
[37]=成交额(万) [39]=市盈率 [45]=总市值(亿) [46]=市净率
|
||||
"""
|
||||
import requests
|
||||
import math
|
||||
|
||||
def safe_float(val, default=0):
|
||||
try:
|
||||
if val is None or val == '' or val == ' ':
|
||||
return default
|
||||
f = float(val)
|
||||
return default if math.isnan(f) else f
|
||||
except:
|
||||
return default
|
||||
|
||||
logger.info(f" 使用腾讯财经数据源 ({len(stock_codes)} 只股票)...")
|
||||
tencent_codes = [_to_tencent_code(c) for c in stock_codes]
|
||||
|
||||
records = []
|
||||
batch_size = 80
|
||||
errors = 0
|
||||
|
||||
for i in range(0, len(tencent_codes), batch_size):
|
||||
batch = tencent_codes[i:i+batch_size]
|
||||
url = f"http://qt.gtimg.cn/q={','.join(batch)}"
|
||||
try:
|
||||
r = requests.get(url, timeout=15, headers={'Referer': 'https://finance.qq.com'})
|
||||
if r.status_code != 200:
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
lines = r.text.strip().split(';')
|
||||
for line in lines:
|
||||
if '\"' not in line:
|
||||
continue
|
||||
data = line.split('\"')[1]
|
||||
fields = data.split('~')
|
||||
if len(fields) < 40 or not fields[3]:
|
||||
continue
|
||||
|
||||
code = fields[2]
|
||||
price = safe_float(fields[3])
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
# 成交量: 腾讯API返回的是手(1手=100股)
|
||||
volume_hands = safe_float(fields[6])
|
||||
volume = int(volume_hands * 100)
|
||||
# 成交额: 万元 -> 元
|
||||
amount = safe_float(fields[37]) * 10000
|
||||
# 总市值: 亿元 -> 元
|
||||
total_market_cap_yi = safe_float(fields[45]) if len(fields) > 45 else 0
|
||||
total_market_cap = total_market_cap_yi * 100000000 if total_market_cap_yi > 0 else None
|
||||
|
||||
records.append((
|
||||
code, # 代码
|
||||
fields[1], # 名称
|
||||
price, # 现价
|
||||
safe_float(fields[32]), # 涨跌%
|
||||
safe_float(fields[31]), # 涨跌额
|
||||
volume, # 成交量(股)
|
||||
amount, # 成交额(元)
|
||||
safe_float(fields[33]), # 最高
|
||||
safe_float(fields[34]), # 最低
|
||||
safe_float(fields[5]), # 开盘
|
||||
safe_float(fields[4]), # 昨收
|
||||
safe_float(fields[39]) if len(fields) > 39 and fields[39].strip() else None, # PE
|
||||
safe_float(fields[46]) if len(fields) > 46 and fields[46].strip() else None, # PB
|
||||
total_market_cap, # 总市值
|
||||
))
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
if errors <= 3:
|
||||
logger.warning(f" 腾讯API批次 {i//batch_size+1} 失败: {e}")
|
||||
|
||||
import time
|
||||
time.sleep(0.1) # 控制请求频率
|
||||
|
||||
if errors > 0:
|
||||
logger.warning(f" 腾讯API共 {errors} 个批次失败")
|
||||
|
||||
return records if records else None, 'tencent'
|
||||
|
||||
|
||||
def update_realtime_prices():
|
||||
"""更新实时价格(全市场A股)- 使用腾讯财经数据源"""
|
||||
started_at = datetime.now()
|
||||
logger.info("开始更新实时价格...")
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
log_update('realtime_price', 'failed', 0, '数据库连接失败', started_at)
|
||||
return
|
||||
|
||||
try:
|
||||
# 先从DB获取已有的股票代码列表
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT code FROM stock_realtime_price")
|
||||
existing_codes = [r[0] for r in cur.fetchall()]
|
||||
|
||||
records = None
|
||||
source = None
|
||||
|
||||
# 腾讯财经数据源
|
||||
if existing_codes:
|
||||
try:
|
||||
result = _fetch_realtime_from_tencent(existing_codes)
|
||||
if result and result[0]:
|
||||
records, source = result
|
||||
except Exception as e:
|
||||
logger.warning(f" 腾讯数据源失败: {e}")
|
||||
|
||||
if not records:
|
||||
log_update('realtime_price', 'failed', 0, '所有数据源均失败', started_at)
|
||||
return
|
||||
|
||||
logger.info(f" 数据源={source}, 获取 {len(records)} 条记录")
|
||||
|
||||
# 批量插入/更新
|
||||
cur = conn.cursor()
|
||||
execute_values(cur, """
|
||||
INSERT INTO stock_realtime_price
|
||||
(code, name, price, change_pct, change_amount, volume, amount,
|
||||
high, low, open, prev_close, pe, pb, total_market_cap, updated_at)
|
||||
VALUES %s
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
price = EXCLUDED.price,
|
||||
change_pct = EXCLUDED.change_pct,
|
||||
change_amount = EXCLUDED.change_amount,
|
||||
volume = EXCLUDED.volume,
|
||||
amount = EXCLUDED.amount,
|
||||
high = EXCLUDED.high,
|
||||
low = EXCLUDED.low,
|
||||
open = EXCLUDED.open,
|
||||
prev_close = EXCLUDED.prev_close,
|
||||
pe = COALESCE(EXCLUDED.pe, stock_realtime_price.pe),
|
||||
pb = COALESCE(EXCLUDED.pb, stock_realtime_price.pb),
|
||||
total_market_cap = COALESCE(EXCLUDED.total_market_cap, stock_realtime_price.total_market_cap),
|
||||
updated_at = NOW()
|
||||
""", records, template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"实时价格更新完成({source}): {len(records)} 条")
|
||||
log_update('realtime_price', 'success', len(records), f'source={source}', started_at)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新实时价格失败: {e}")
|
||||
log_update('realtime_price', 'failed', 0, str(e), started_at)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _calc_fund_flow_from_5min(conn, target_date=None):
|
||||
"""
|
||||
从5分钟K线数据计算资金流向(替代东方财富API)
|
||||
|
||||
算法:
|
||||
1. 每根5分钟K线根据 close vs open 判断方向(买入/卖出)
|
||||
2. 根据成交额(amount)分类:
|
||||
- 超大单: amount >= 100万
|
||||
- 大单: 20万 <= amount < 100万
|
||||
- 中单: 4万 <= amount < 20万
|
||||
- 小单: amount < 4万
|
||||
3. 主力 = 超大单 + 大单
|
||||
4. 聚合每只股票的各类净流入
|
||||
"""
|
||||
from datetime import date as date_cls
|
||||
if target_date is None:
|
||||
target_date = date_cls.today()
|
||||
|
||||
cur = conn.cursor()
|
||||
# 获取当天所有5分钟K线数据
|
||||
cur.execute("""
|
||||
SELECT code, open, close, volume, amount
|
||||
FROM stock_kline_5min
|
||||
WHERE dt::date = %s AND amount > 0
|
||||
ORDER BY code, dt
|
||||
""", (target_date,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {}
|
||||
|
||||
# 按股票聚合
|
||||
from collections import defaultdict
|
||||
stock_flows = defaultdict(lambda: {
|
||||
'super_buy': 0, 'super_sell': 0,
|
||||
'big_buy': 0, 'big_sell': 0,
|
||||
'mid_buy': 0, 'mid_sell': 0,
|
||||
'small_buy': 0, 'small_sell': 0,
|
||||
'total_amount': 0
|
||||
})
|
||||
|
||||
for code, open_p, close_p, volume, amount in rows:
|
||||
if not amount or float(amount) <= 0:
|
||||
continue
|
||||
|
||||
amt = float(amount)
|
||||
sf = stock_flows[code]
|
||||
sf['total_amount'] += amt
|
||||
|
||||
# 方向: close > open 视为买入, close < open 视为卖出, 相等则各半
|
||||
is_buy = float(close_p) >= float(open_p) if close_p and open_p else True
|
||||
|
||||
# 分类
|
||||
if amt >= 1000000: # 超大单 >= 100万
|
||||
cat = 'super'
|
||||
elif amt >= 200000: # 大单 >= 20万
|
||||
cat = 'big'
|
||||
elif amt >= 40000: # 中单 >= 4万
|
||||
cat = 'mid'
|
||||
else: # 小单
|
||||
cat = 'small'
|
||||
|
||||
if is_buy:
|
||||
sf[f'{cat}_buy'] += amt
|
||||
else:
|
||||
sf[f'{cat}_sell'] += amt
|
||||
|
||||
# 计算各类净流入和占比
|
||||
results = {}
|
||||
for code, sf in stock_flows.items():
|
||||
total = sf['total_amount']
|
||||
if total <= 0:
|
||||
continue
|
||||
|
||||
super_net = sf['super_buy'] - sf['super_sell']
|
||||
big_net = sf['big_buy'] - sf['big_sell']
|
||||
mid_net = sf['mid_buy'] - sf['mid_sell']
|
||||
small_net = sf['small_buy'] - sf['small_sell']
|
||||
main_net = super_net + big_net # 主力 = 超大单 + 大单
|
||||
|
||||
results[code] = {
|
||||
'main_net_inflow': round(main_net, 2),
|
||||
'main_net_inflow_pct': round(main_net / total * 100, 4) if total > 0 else 0,
|
||||
'super_net_inflow': round(super_net, 2),
|
||||
'super_net_inflow_pct': round(super_net / total * 100, 4) if total > 0 else 0,
|
||||
'big_net_inflow': round(big_net, 2),
|
||||
'big_net_inflow_pct': round(big_net / total * 100, 4) if total > 0 else 0,
|
||||
'mid_net_inflow': round(mid_net, 2),
|
||||
'mid_net_inflow_pct': round(mid_net / total * 100, 4) if total > 0 else 0,
|
||||
'small_net_inflow': round(small_net, 2),
|
||||
'small_net_inflow_pct': round(small_net / total * 100, 4) if total > 0 else 0,
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def update_fund_flow_today():
|
||||
"""更新今日资金流向 — 从5分钟K线数据自行计算"""
|
||||
started_at = datetime.now()
|
||||
logger.info("开始更新今日资金流向(从5分钟K线数据计算)...")
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
log_update('fund_flow_today', 'failed', 0, '数据库连接失败', started_at)
|
||||
return
|
||||
|
||||
try:
|
||||
flows = _calc_fund_flow_from_5min(conn)
|
||||
if not flows:
|
||||
logger.info("今日资金流向: 无5分钟K线数据,跳过")
|
||||
log_update('fund_flow_today', 'skipped', 0, '无5分钟K线数据', started_at)
|
||||
return
|
||||
|
||||
# 获取实时价格和名称
|
||||
cur = conn.cursor()
|
||||
codes = list(flows.keys())
|
||||
cur.execute("""
|
||||
SELECT code, name, price, change_pct
|
||||
FROM stock_realtime_price
|
||||
WHERE code = ANY(%s)
|
||||
""", (codes,))
|
||||
price_map = {}
|
||||
for row in cur.fetchall():
|
||||
price_map[row[0]] = {'name': row[1], 'price': float(row[2] or 0), 'change_pct': float(row[3] or 0)}
|
||||
|
||||
# 批量写入
|
||||
records = []
|
||||
for code, f in flows.items():
|
||||
info = price_map.get(code, {})
|
||||
records.append((
|
||||
code, info.get('name', ''),
|
||||
f['main_net_inflow'], f['main_net_inflow_pct'],
|
||||
f['super_net_inflow'], f['super_net_inflow_pct'],
|
||||
f['big_net_inflow'], f['big_net_inflow_pct'],
|
||||
f['mid_net_inflow'], f['mid_net_inflow_pct'],
|
||||
f['small_net_inflow'], f['small_net_inflow_pct'],
|
||||
info.get('price', 0), info.get('change_pct', 0),
|
||||
))
|
||||
|
||||
execute_values(cur, """
|
||||
INSERT INTO stock_fund_flow_today
|
||||
(code, name, main_net_inflow, main_net_inflow_pct,
|
||||
super_net_inflow, super_net_inflow_pct,
|
||||
big_net_inflow, big_net_inflow_pct,
|
||||
mid_net_inflow, mid_net_inflow_pct,
|
||||
small_net_inflow, small_net_inflow_pct,
|
||||
price, change_pct, updated_at)
|
||||
VALUES %s
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
main_net_inflow = EXCLUDED.main_net_inflow,
|
||||
main_net_inflow_pct = EXCLUDED.main_net_inflow_pct,
|
||||
super_net_inflow = EXCLUDED.super_net_inflow,
|
||||
super_net_inflow_pct = EXCLUDED.super_net_inflow_pct,
|
||||
big_net_inflow = EXCLUDED.big_net_inflow,
|
||||
big_net_inflow_pct = EXCLUDED.big_net_inflow_pct,
|
||||
mid_net_inflow = EXCLUDED.mid_net_inflow,
|
||||
mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct,
|
||||
small_net_inflow = EXCLUDED.small_net_inflow,
|
||||
small_net_inflow_pct = EXCLUDED.small_net_inflow_pct,
|
||||
price = EXCLUDED.price,
|
||||
change_pct = EXCLUDED.change_pct,
|
||||
updated_at = NOW()
|
||||
""", records,
|
||||
template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"今日资金流向更新完成: {len(records)} 条 (来源: 5分钟K线计算)")
|
||||
log_update('fund_flow_today', 'success', len(records), '来源: 5分钟K线计算', started_at)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新今日资金流向失败: {e}")
|
||||
log_update('fund_flow_today', 'failed', 0, str(e), started_at)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_fund_flow_history(stock_codes=None):
|
||||
"""更新历史资金流向 — 从5分钟K线数据自行计算
|
||||
|
||||
遍历有5分钟K线但尚未写入fund_flow_history的日期,补算资金流向
|
||||
"""
|
||||
started_at = datetime.now()
|
||||
logger.info("开始更新历史资金流向(从5分钟K线数据计算)...")
|
||||
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
log_update('fund_flow_history', 'failed', 0, '数据库连接失败', started_at)
|
||||
return
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
|
||||
# 查找有5分钟K线数据但尚未计算资金流向的日期
|
||||
cur.execute("""
|
||||
SELECT DISTINCT dt::date as d
|
||||
FROM stock_kline_5min
|
||||
WHERE dt::date NOT IN (
|
||||
SELECT DISTINCT trade_date FROM stock_fund_flow_history
|
||||
)
|
||||
AND dt::date < CURRENT_DATE
|
||||
ORDER BY d DESC
|
||||
LIMIT 30
|
||||
""")
|
||||
missing_dates = [row[0] for row in cur.fetchall()]
|
||||
|
||||
if not missing_dates:
|
||||
logger.info("历史资金流向: 无需补算")
|
||||
log_update('fund_flow_history', 'success', 0, '无需补算', started_at)
|
||||
return
|
||||
|
||||
logger.info(f"需补算 {len(missing_dates)} 天的历史资金流向")
|
||||
|
||||
total_records = 0
|
||||
for d in missing_dates:
|
||||
flows = _calc_fund_flow_from_5min(conn, target_date=d)
|
||||
if not flows:
|
||||
continue
|
||||
|
||||
# 获取当天收盘价和涨跌幅
|
||||
cur.execute("""
|
||||
SELECT code, close, change_pct
|
||||
FROM stock_kline_daily
|
||||
WHERE trade_date = %s AND code = ANY(%s)
|
||||
""", (d, list(flows.keys())))
|
||||
price_map = {}
|
||||
for row in cur.fetchall():
|
||||
price_map[row[0]] = {'close': float(row[1] or 0), 'change_pct': float(row[2] or 0)}
|
||||
|
||||
records = []
|
||||
for code, f in flows.items():
|
||||
info = price_map.get(code, {})
|
||||
records.append((
|
||||
code, d,
|
||||
info.get('close', 0), info.get('change_pct', 0),
|
||||
f['main_net_inflow'], f['main_net_inflow_pct'],
|
||||
f['super_net_inflow'], f['super_net_inflow_pct'],
|
||||
f['big_net_inflow'], f['big_net_inflow_pct'],
|
||||
f['mid_net_inflow'], f['mid_net_inflow_pct'],
|
||||
f['small_net_inflow'], f['small_net_inflow_pct'],
|
||||
))
|
||||
|
||||
if records:
|
||||
execute_values(cur, """
|
||||
INSERT INTO stock_fund_flow_history
|
||||
(code, trade_date, close_price, change_pct,
|
||||
main_net_inflow, main_net_inflow_pct,
|
||||
super_net_inflow, super_net_inflow_pct,
|
||||
big_net_inflow, big_net_inflow_pct,
|
||||
mid_net_inflow, mid_net_inflow_pct,
|
||||
small_net_inflow, small_net_inflow_pct,
|
||||
updated_at)
|
||||
VALUES %s
|
||||
ON CONFLICT (code, trade_date) DO UPDATE SET
|
||||
close_price = EXCLUDED.close_price,
|
||||
change_pct = EXCLUDED.change_pct,
|
||||
main_net_inflow = EXCLUDED.main_net_inflow,
|
||||
main_net_inflow_pct = EXCLUDED.main_net_inflow_pct,
|
||||
super_net_inflow = EXCLUDED.super_net_inflow,
|
||||
super_net_inflow_pct = EXCLUDED.super_net_inflow_pct,
|
||||
big_net_inflow = EXCLUDED.big_net_inflow,
|
||||
big_net_inflow_pct = EXCLUDED.big_net_inflow_pct,
|
||||
mid_net_inflow = EXCLUDED.mid_net_inflow,
|
||||
mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct,
|
||||
small_net_inflow = EXCLUDED.small_net_inflow,
|
||||
small_net_inflow_pct = EXCLUDED.small_net_inflow_pct,
|
||||
updated_at = NOW()
|
||||
""", records,
|
||||
template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())")
|
||||
conn.commit()
|
||||
total_records += len(records)
|
||||
logger.info(f" {d}: {len(records)} 只股票")
|
||||
|
||||
logger.info(f"历史资金流向补算完成: {total_records} 条记录,{len(missing_dates)} 天")
|
||||
log_update('fund_flow_history', 'success', total_records,
|
||||
f'补算{len(missing_dates)}天, 来源: 5分钟K线计算', started_at)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新历史资金流向失败: {e}")
|
||||
log_update('fund_flow_history', 'failed', 0, str(e), started_at)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_trading_time():
|
||||
"""检查当前是否为交易时间"""
|
||||
now = datetime.now()
|
||||
# 周一到周五
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
# 9:15-11:35, 12:55-15:05
|
||||
hour_min = now.hour * 100 + now.minute
|
||||
return (915 <= hour_min <= 1135) or (1255 <= hour_min <= 1505)
|
||||
|
||||
|
||||
def run_scheduled_tasks():
|
||||
"""运行定时任务"""
|
||||
logger.info("股票数据采集服务启动...")
|
||||
|
||||
# 交易时间每5分钟更新实时价格(腾讯财经数据源)
|
||||
schedule.every(5).minutes.do(lambda: update_realtime_prices() if is_trading_time() else None)
|
||||
|
||||
# 每天18:05更新今日资金流向(从5分钟K线计算,需在5分钟K线采集17:30后)
|
||||
schedule.every().day.at("18:05").do(update_fund_flow_today)
|
||||
|
||||
# 每天18:15补算历史资金流向(从5分钟K线计算)
|
||||
schedule.every().day.at("18:15").do(update_fund_flow_history)
|
||||
|
||||
# 立即执行一次
|
||||
logger.info("首次执行数据更新...")
|
||||
update_realtime_prices()
|
||||
|
||||
# 主循环
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
if len(sys.argv) > 1:
|
||||
cmd = sys.argv[1]
|
||||
if cmd == 'realtime':
|
||||
update_realtime_prices()
|
||||
elif cmd == 'fund_today':
|
||||
update_fund_flow_today()
|
||||
elif cmd == 'fund_history':
|
||||
stock_codes = sys.argv[2:] if len(sys.argv) > 2 else None
|
||||
update_fund_flow_history(stock_codes)
|
||||
elif cmd == 'daemon':
|
||||
run_scheduled_tasks()
|
||||
else:
|
||||
print(f"未知命令: {cmd}")
|
||||
print("用法: python stock_data_service.py [realtime|fund_today|fund_history|daemon]")
|
||||
else:
|
||||
# 默认执行一次实时价格更新
|
||||
update_realtime_prices()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+382
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
资金流向数据每日采集脚本
|
||||
|
||||
功能:从5分钟K线数据自行计算资金流向,存入 stock_fund_flow_history 和 stock_fund_flow_today 表。
|
||||
数据源:stock_kline_5min 表(自有数据,无需外部API)
|
||||
|
||||
算法:
|
||||
- 根据5分钟K线的 close vs open 判断买卖方向
|
||||
- 根据成交额(amount)分类:超大单(≥100万), 大单(20~100万), 中单(4~20万), 小单(<4万)
|
||||
- 主力 = 超大单 + 大单
|
||||
|
||||
用法:
|
||||
# 计算今日资金流向
|
||||
./venv/bin/python sync_fund_flow.py
|
||||
|
||||
# 补算历史(有5分钟K线但尚无资金流向的日期,最多30天)
|
||||
./venv/bin/python sync_fund_flow.py --backfill
|
||||
|
||||
建议定时任务:
|
||||
10 15 * * 1-5 /opt/stock-app/venv/bin/python /opt/stock-app/sync_fund_flow.py >> /opt/stock-app/sync_fund_flow.log 2>&1
|
||||
30 15 * * 1-5 /opt/stock-app/venv/bin/python /opt/stock-app/sync_fund_flow.py --backfill >> /opt/stock-app/sync_fund_flow.log 2>&1
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import argparse
|
||||
import fcntl
|
||||
import atexit
|
||||
from datetime import datetime, date
|
||||
from collections import defaultdict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values
|
||||
from config import Config
|
||||
|
||||
LOCK_FILE = '/tmp/sync_fund_flow.lock'
|
||||
_lock_fd = None
|
||||
|
||||
|
||||
def acquire_lock():
|
||||
"""获取进程锁,防止多实例同时运行"""
|
||||
global _lock_fd
|
||||
_lock_fd = open(LOCK_FILE, 'w')
|
||||
try:
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
_lock_fd.write(str(os.getpid()))
|
||||
_lock_fd.flush()
|
||||
atexit.register(release_lock)
|
||||
return True
|
||||
except IOError:
|
||||
try:
|
||||
with open(LOCK_FILE, 'r') as f:
|
||||
old_pid = f.read().strip()
|
||||
print(f"⚠️ 另一个实例正在运行 (PID: {old_pid}),退出", flush=True)
|
||||
except Exception:
|
||||
print(f"⚠️ 另一个实例正在运行,退出", flush=True)
|
||||
_lock_fd.close()
|
||||
_lock_fd = None
|
||||
return False
|
||||
|
||||
|
||||
def release_lock():
|
||||
"""释放进程锁"""
|
||||
global _lock_fd
|
||||
if _lock_fd:
|
||||
try:
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
|
||||
_lock_fd.close()
|
||||
except Exception:
|
||||
pass
|
||||
_lock_fd = None
|
||||
try:
|
||||
os.remove(LOCK_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_db_conn():
|
||||
return psycopg2.connect(
|
||||
host=Config.DB_HOST, port=Config.DB_PORT,
|
||||
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
def calc_fund_flow_from_5min(conn, target_date):
|
||||
"""
|
||||
从5分钟K线数据计算某日资金流向
|
||||
|
||||
算法:
|
||||
1. 每根5分钟K线根据 close vs open 判断方向(买入/卖出)
|
||||
2. 根据成交额(amount)分类:
|
||||
- 超大单: amount >= 100万
|
||||
- 大单: 20万 <= amount < 100万
|
||||
- 中单: 4万 <= amount < 20万
|
||||
- 小单: amount < 4万
|
||||
3. 主力 = 超大单 + 大单
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT code, open, close, volume, amount
|
||||
FROM stock_kline_5min
|
||||
WHERE dt::date = %s AND amount > 0
|
||||
ORDER BY code, dt
|
||||
""", (target_date,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {}
|
||||
|
||||
stock_flows = defaultdict(lambda: {
|
||||
'super_buy': 0, 'super_sell': 0,
|
||||
'big_buy': 0, 'big_sell': 0,
|
||||
'mid_buy': 0, 'mid_sell': 0,
|
||||
'small_buy': 0, 'small_sell': 0,
|
||||
'total_amount': 0
|
||||
})
|
||||
|
||||
for code, open_p, close_p, volume, amount in rows:
|
||||
if not amount or float(amount) <= 0:
|
||||
continue
|
||||
|
||||
amt = float(amount)
|
||||
sf = stock_flows[code]
|
||||
sf['total_amount'] += amt
|
||||
|
||||
is_buy = float(close_p) >= float(open_p) if close_p and open_p else True
|
||||
|
||||
if amt >= 1000000: # 超大单 >= 100万
|
||||
cat = 'super'
|
||||
elif amt >= 200000: # 大单 >= 20万
|
||||
cat = 'big'
|
||||
elif amt >= 40000: # 中单 >= 4万
|
||||
cat = 'mid'
|
||||
else: # 小单
|
||||
cat = 'small'
|
||||
|
||||
if is_buy:
|
||||
sf[f'{cat}_buy'] += amt
|
||||
else:
|
||||
sf[f'{cat}_sell'] += amt
|
||||
|
||||
results = {}
|
||||
for code, sf in stock_flows.items():
|
||||
total = sf['total_amount']
|
||||
if total <= 0:
|
||||
continue
|
||||
|
||||
super_net = sf['super_buy'] - sf['super_sell']
|
||||
big_net = sf['big_buy'] - sf['big_sell']
|
||||
mid_net = sf['mid_buy'] - sf['mid_sell']
|
||||
small_net = sf['small_buy'] - sf['small_sell']
|
||||
main_net = super_net + big_net
|
||||
|
||||
results[code] = {
|
||||
'main_net_inflow': round(main_net, 2),
|
||||
'main_net_inflow_pct': round(main_net / total * 100, 4) if total > 0 else 0,
|
||||
'super_net_inflow': round(super_net, 2),
|
||||
'super_net_inflow_pct': round(super_net / total * 100, 4) if total > 0 else 0,
|
||||
'big_net_inflow': round(big_net, 2),
|
||||
'big_net_inflow_pct': round(big_net / total * 100, 4) if total > 0 else 0,
|
||||
'mid_net_inflow': round(mid_net, 2),
|
||||
'mid_net_inflow_pct': round(mid_net / total * 100, 4) if total > 0 else 0,
|
||||
'small_net_inflow': round(small_net, 2),
|
||||
'small_net_inflow_pct': round(small_net / total * 100, 4) if total > 0 else 0,
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def update_today_flow(conn):
|
||||
"""更新今日资金流向到 stock_fund_flow_today"""
|
||||
today = date.today()
|
||||
flows = calc_fund_flow_from_5min(conn, today)
|
||||
|
||||
if not flows:
|
||||
print(f" 今日({today})无5分钟K线数据,跳过", flush=True)
|
||||
return 0
|
||||
|
||||
cur = conn.cursor()
|
||||
codes = list(flows.keys())
|
||||
cur.execute("""
|
||||
SELECT code, name, price, change_pct
|
||||
FROM stock_realtime_price WHERE code = ANY(%s)
|
||||
""", (codes,))
|
||||
price_map = {r[0]: {'name': r[1], 'price': float(r[2] or 0), 'change_pct': float(r[3] or 0)}
|
||||
for r in cur.fetchall()}
|
||||
|
||||
records = []
|
||||
for code, f in flows.items():
|
||||
info = price_map.get(code, {})
|
||||
records.append((
|
||||
code, info.get('name', ''),
|
||||
f['main_net_inflow'], f['main_net_inflow_pct'],
|
||||
f['super_net_inflow'], f['super_net_inflow_pct'],
|
||||
f['big_net_inflow'], f['big_net_inflow_pct'],
|
||||
f['mid_net_inflow'], f['mid_net_inflow_pct'],
|
||||
f['small_net_inflow'], f['small_net_inflow_pct'],
|
||||
info.get('price', 0), info.get('change_pct', 0),
|
||||
))
|
||||
|
||||
execute_values(cur, """
|
||||
INSERT INTO stock_fund_flow_today
|
||||
(code, name, main_net_inflow, main_net_inflow_pct,
|
||||
super_net_inflow, super_net_inflow_pct,
|
||||
big_net_inflow, big_net_inflow_pct,
|
||||
mid_net_inflow, mid_net_inflow_pct,
|
||||
small_net_inflow, small_net_inflow_pct,
|
||||
price, change_pct, updated_at)
|
||||
VALUES %s
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
main_net_inflow = EXCLUDED.main_net_inflow,
|
||||
main_net_inflow_pct = EXCLUDED.main_net_inflow_pct,
|
||||
super_net_inflow = EXCLUDED.super_net_inflow,
|
||||
super_net_inflow_pct = EXCLUDED.super_net_inflow_pct,
|
||||
big_net_inflow = EXCLUDED.big_net_inflow,
|
||||
big_net_inflow_pct = EXCLUDED.big_net_inflow_pct,
|
||||
mid_net_inflow = EXCLUDED.mid_net_inflow,
|
||||
mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct,
|
||||
small_net_inflow = EXCLUDED.small_net_inflow,
|
||||
small_net_inflow_pct = EXCLUDED.small_net_inflow_pct,
|
||||
price = EXCLUDED.price,
|
||||
change_pct = EXCLUDED.change_pct,
|
||||
updated_at = NOW()
|
||||
""", records,
|
||||
template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())")
|
||||
|
||||
conn.commit()
|
||||
print(f" ✅ 今日资金流向: {len(records)} 只股票", flush=True)
|
||||
return len(records)
|
||||
|
||||
|
||||
def backfill_history(conn, max_days=30):
|
||||
"""补算历史资金流向: 有5分钟K线但尚无 fund_flow_history 的日期"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT dt::date as d
|
||||
FROM stock_kline_5min
|
||||
WHERE dt::date NOT IN (
|
||||
SELECT DISTINCT trade_date FROM stock_fund_flow_history
|
||||
)
|
||||
AND dt::date < CURRENT_DATE
|
||||
ORDER BY d DESC
|
||||
LIMIT %s
|
||||
""", (max_days,))
|
||||
missing_dates = [row[0] for row in cur.fetchall()]
|
||||
|
||||
if not missing_dates:
|
||||
print(" ✅ 历史资金流向已完整,无需补算", flush=True)
|
||||
return 0
|
||||
|
||||
print(f" 需补算 {len(missing_dates)} 天的历史资金流向", flush=True)
|
||||
total_records = 0
|
||||
|
||||
for d in missing_dates:
|
||||
flows = calc_fund_flow_from_5min(conn, d)
|
||||
if not flows:
|
||||
continue
|
||||
|
||||
# 获取当天收盘价
|
||||
cur.execute("""
|
||||
SELECT code, close, change_pct
|
||||
FROM stock_kline_daily
|
||||
WHERE trade_date = %s AND code = ANY(%s)
|
||||
""", (d, list(flows.keys())))
|
||||
price_map = {r[0]: {'close': float(r[1] or 0), 'change_pct': float(r[2] or 0)}
|
||||
for r in cur.fetchall()}
|
||||
|
||||
records = []
|
||||
for code, f in flows.items():
|
||||
info = price_map.get(code, {})
|
||||
records.append((
|
||||
code, d,
|
||||
info.get('close', 0), info.get('change_pct', 0),
|
||||
f['main_net_inflow'], f['main_net_inflow_pct'],
|
||||
f['super_net_inflow'], f['super_net_inflow_pct'],
|
||||
f['big_net_inflow'], f['big_net_inflow_pct'],
|
||||
f['mid_net_inflow'], f['mid_net_inflow_pct'],
|
||||
f['small_net_inflow'], f['small_net_inflow_pct'],
|
||||
))
|
||||
|
||||
if records:
|
||||
execute_values(cur, """
|
||||
INSERT INTO stock_fund_flow_history
|
||||
(code, trade_date, close_price, change_pct,
|
||||
main_net_inflow, main_net_inflow_pct,
|
||||
super_net_inflow, super_net_inflow_pct,
|
||||
big_net_inflow, big_net_inflow_pct,
|
||||
mid_net_inflow, mid_net_inflow_pct,
|
||||
small_net_inflow, small_net_inflow_pct,
|
||||
updated_at)
|
||||
VALUES %s
|
||||
ON CONFLICT (code, trade_date) DO UPDATE SET
|
||||
close_price = EXCLUDED.close_price,
|
||||
change_pct = EXCLUDED.change_pct,
|
||||
main_net_inflow = EXCLUDED.main_net_inflow,
|
||||
main_net_inflow_pct = EXCLUDED.main_net_inflow_pct,
|
||||
super_net_inflow = EXCLUDED.super_net_inflow,
|
||||
super_net_inflow_pct = EXCLUDED.super_net_inflow_pct,
|
||||
big_net_inflow = EXCLUDED.big_net_inflow,
|
||||
big_net_inflow_pct = EXCLUDED.big_net_inflow_pct,
|
||||
mid_net_inflow = EXCLUDED.mid_net_inflow,
|
||||
mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct,
|
||||
small_net_inflow = EXCLUDED.small_net_inflow,
|
||||
small_net_inflow_pct = EXCLUDED.small_net_inflow_pct,
|
||||
updated_at = NOW()
|
||||
""", records,
|
||||
template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())")
|
||||
conn.commit()
|
||||
total_records += len(records)
|
||||
print(f" {d}: {len(records)} 只股票", flush=True)
|
||||
|
||||
print(f" ✅ 历史补算完成: {total_records} 条记录, {len(missing_dates)} 天", flush=True)
|
||||
return total_records
|
||||
|
||||
|
||||
def main():
|
||||
if not acquire_lock():
|
||||
sys.exit(1)
|
||||
|
||||
parser = argparse.ArgumentParser(description='资金流向计算(从5分钟K线数据)')
|
||||
parser.add_argument('--backfill', action='store_true',
|
||||
help='补算历史资金流向(有5分钟K线但尚无资金流向的日期)')
|
||||
parser.add_argument('--max-days', type=int, default=30,
|
||||
help='历史补算最大天数(默认30)')
|
||||
args = parser.parse_args()
|
||||
|
||||
today = date.today()
|
||||
print(f"{'='*60}", flush=True)
|
||||
print(f"💰 资金流向计算(来源: 5分钟K线数据)", flush=True)
|
||||
print(f"📅 日期: {today}", flush=True)
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
conn = get_db_conn()
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 始终计算今日
|
||||
print("\n📊 计算今日资金流向...", flush=True)
|
||||
today_count = update_today_flow(conn)
|
||||
|
||||
# 如果指定了 --backfill,补算历史
|
||||
if args.backfill:
|
||||
print(f"\n📜 补算历史资金流向(最多{args.max_days}天)...", flush=True)
|
||||
hist_count = backfill_history(conn, args.max_days)
|
||||
else:
|
||||
hist_count = 0
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# 显示数据库统计
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT count(*), count(DISTINCT code),
|
||||
min(trade_date), max(trade_date),
|
||||
count(DISTINCT trade_date)
|
||||
FROM stock_fund_flow_history
|
||||
""")
|
||||
cnt, codes, min_d, max_d, days = cur.fetchone()
|
||||
|
||||
print(f"\n{'='*60}", flush=True)
|
||||
print(f"✅ 完成! 耗时: {elapsed:.1f}秒", flush=True)
|
||||
print(f" 今日: {today_count} 条 | 历史补算: {hist_count} 条", flush=True)
|
||||
print(f"\n💰 stock_fund_flow_history 统计:", flush=True)
|
||||
print(f" 总记录: {cnt:,} 条 | {codes:,} 只股票 | {days} 个交易日", flush=True)
|
||||
if min_d:
|
||||
print(f" 日期范围: {min_d} ~ {max_d}", flush=True)
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {e}", flush=True)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
K线数据本地同步脚本
|
||||
支持四种数据源(按优先级):阿里云API → 新浪API → 麦蕊API → AKShare
|
||||
北交所(8XX/9XX)股票专用新浪API
|
||||
同步到本地PostgreSQL数据库
|
||||
|
||||
用法:
|
||||
python sync_kline.py # 增量同步(默认,只同步最近5天)
|
||||
python sync_kline.py --full # 全量同步(180天历史数据)
|
||||
python sync_kline.py --days 30 # 同步最近30天
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import argparse
|
||||
import signal as sig_module
|
||||
from datetime import datetime, date, timedelta
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
from config import Config
|
||||
from services.stock_algorithms import fetch_kline_rows
|
||||
|
||||
# ============ 配置 ============
|
||||
WORKERS = 10 # 并发线程数
|
||||
BATCH_SAVE_SIZE = 50 # 每批保存到DB的股票数
|
||||
FULL_SYNC_DAYS = 180 # 全量同步天数
|
||||
INCREMENTAL_DAYS = 5 # 增量同步天数(多取几天防遗漏)
|
||||
|
||||
_shutdown = False
|
||||
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
global _shutdown
|
||||
print("\n⚠️ 收到中断信号,正在优雅退出...")
|
||||
_shutdown = True
|
||||
|
||||
|
||||
sig_module.signal(sig_module.SIGINT, signal_handler)
|
||||
sig_module.signal(sig_module.SIGTERM, signal_handler)
|
||||
|
||||
|
||||
def get_db_conn():
|
||||
return psycopg2.connect(
|
||||
host=Config.DB_HOST, port=Config.DB_PORT,
|
||||
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
def get_all_stock_codes(conn):
|
||||
"""获取所有股票代码"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT code, name FROM stock_realtime_price ORDER BY code")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def fetch_kline_for_stock(code, days):
|
||||
"""获取K线数据 — 委托给 services.stock_algorithms.fetch_kline_rows"""
|
||||
return fetch_kline_rows(code, days)
|
||||
|
||||
|
||||
def save_kline_batch(conn, all_rows):
|
||||
"""批量保存K线数据到数据库(UPSERT)"""
|
||||
if not all_rows:
|
||||
return 0
|
||||
|
||||
with conn.cursor() as cur:
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO stock_kline_daily (code, trade_date, open, high, low, close, volume, amount)
|
||||
VALUES %s
|
||||
ON CONFLICT (code, trade_date) DO UPDATE SET
|
||||
open = EXCLUDED.open,
|
||||
high = EXCLUDED.high,
|
||||
low = EXCLUDED.low,
|
||||
close = EXCLUDED.close,
|
||||
volume = EXCLUDED.volume,
|
||||
amount = EXCLUDED.amount,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
all_rows,
|
||||
page_size=1000,
|
||||
)
|
||||
conn.commit()
|
||||
return len(all_rows)
|
||||
|
||||
|
||||
def main():
|
||||
global _shutdown
|
||||
|
||||
parser = argparse.ArgumentParser(description='K线数据本地同步')
|
||||
parser.add_argument('--full', action='store_true', help='全量同步(180天历史)')
|
||||
parser.add_argument('--days', type=int, default=None, help='同步天数')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.full:
|
||||
sync_days = FULL_SYNC_DAYS
|
||||
mode = '全量同步'
|
||||
elif args.days:
|
||||
sync_days = args.days
|
||||
mode = f'自定义同步({sync_days}天)'
|
||||
else:
|
||||
sync_days = INCREMENTAL_DAYS
|
||||
mode = '增量同步'
|
||||
|
||||
print(f"{'='*60}", flush=True)
|
||||
print(f"📊 K线数据本地同步", flush=True)
|
||||
print(f"📅 日期: {date.today()}", flush=True)
|
||||
print(f"🔄 模式: {mode}({sync_days}天)", flush=True)
|
||||
print(f"⚙️ 并发数: {WORKERS}", flush=True)
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
conn = get_db_conn()
|
||||
all_stocks = get_all_stock_codes(conn)
|
||||
total = len(all_stocks)
|
||||
print(f"📈 股票总数: {total}", flush=True)
|
||||
|
||||
start_time = time.time()
|
||||
done_count = 0
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
total_rows = 0
|
||||
save_buffer = []
|
||||
last_report_time = time.time()
|
||||
|
||||
print(f"\n🚀 开始同步...", flush=True)
|
||||
print(f"-" * 60, flush=True)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
|
||||
futures = {}
|
||||
for code, name in all_stocks:
|
||||
if _shutdown:
|
||||
break
|
||||
futures[executor.submit(fetch_kline_for_stock, code, sync_days)] = (code, name)
|
||||
|
||||
for future in as_completed(futures):
|
||||
if _shutdown:
|
||||
print("⏹️ 用户中断,正在保存当前数据...", flush=True)
|
||||
break
|
||||
|
||||
code, name = futures[future]
|
||||
done_count += 1
|
||||
|
||||
try:
|
||||
rows = future.result()
|
||||
except Exception:
|
||||
rows = None
|
||||
|
||||
if rows:
|
||||
save_buffer.extend(rows)
|
||||
success_count += 1
|
||||
else:
|
||||
error_count += 1
|
||||
|
||||
# 攒够一批就保存
|
||||
if len(save_buffer) >= BATCH_SAVE_SIZE * 80: # 约50股 × 80条/股
|
||||
saved = save_kline_batch(conn, save_buffer)
|
||||
total_rows += saved
|
||||
save_buffer = []
|
||||
|
||||
# 每3秒报告进度
|
||||
now = time.time()
|
||||
if now - last_report_time >= 3:
|
||||
elapsed = now - start_time
|
||||
speed = done_count / elapsed if elapsed > 0 else 0
|
||||
remaining = (total - done_count) / speed if speed > 0 else 0
|
||||
pct = done_count / total * 100
|
||||
print(f" [{pct:5.1f}%] {done_count}/{total} "
|
||||
f"| 速度: {speed:.1f}只/秒 | 剩余: {remaining/60:.1f}分钟 "
|
||||
f"| 成功: {success_count} | 失败: {error_count} "
|
||||
f"| 已保存: {total_rows}条", flush=True)
|
||||
last_report_time = now
|
||||
|
||||
# 保存剩余数据
|
||||
if save_buffer:
|
||||
saved = save_kline_batch(conn, save_buffer)
|
||||
total_rows += saved
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
speed = done_count / elapsed if elapsed > 0 else 0
|
||||
|
||||
print(f"\n{'='*60}", flush=True)
|
||||
print(f"✅ 同步{'中断' if _shutdown else '完成'}!", flush=True)
|
||||
print(f" 处理: {done_count} 只 | 成功: {success_count} | 失败: {error_count}", flush=True)
|
||||
print(f" 保存K线: {total_rows} 条 | 耗时: {elapsed/60:.1f}分钟", flush=True)
|
||||
print(f" 平均速度: {speed:.1f} 只/秒", flush=True)
|
||||
|
||||
# 显示数据库统计
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT count(*), count(DISTINCT code), min(trade_date), max(trade_date) FROM stock_kline_daily")
|
||||
cnt, codes, min_date, max_date = cur.fetchone()
|
||||
print(f"\n📊 数据库K线统计:", flush=True)
|
||||
print(f" 总记录: {cnt:,} 条 | 覆盖股票: {codes} 只", flush=True)
|
||||
print(f" 日期范围: {min_date} ~ {max_date}", flush=True)
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+715
@@ -0,0 +1,715 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
5分钟K线数据每日采集脚本
|
||||
|
||||
功能:每日收盘后自动采集全市场A股的5分钟K线数据,存入 stock_kline_5min 表。
|
||||
数据源:akshare stock_zh_a_minute(新浪财经,免费,腾讯云可用)
|
||||
|
||||
特点:
|
||||
- 增量采集:只采集当日新增数据
|
||||
- 断点续传:记录已采集的股票,中断后可继续
|
||||
- 频率控制:自动限速避免触发API封禁
|
||||
- 回填模式:可手动回填最近N天的历史分钟数据
|
||||
|
||||
用法:
|
||||
# 每日采集(推荐在 17:00 后运行,收盘后数据完整)
|
||||
./venv/bin/python sync_kline_5min.py
|
||||
|
||||
# 回填最近5天
|
||||
./venv/bin/python sync_kline_5min.py --backfill 5
|
||||
|
||||
# 只采集指定股票
|
||||
./venv/bin/python sync_kline_5min.py --codes 300720,000001
|
||||
|
||||
# 快速测试(只采集前10只)
|
||||
./venv/bin/python sync_kline_5min.py --limit 10
|
||||
|
||||
建议定时任务:
|
||||
30 17 * * 1-5 /opt/stock-app/venv/bin/python /opt/stock-app/sync_kline_5min.py >> /opt/stock-app/sync_kline_5min.log 2>&1
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import argparse
|
||||
import signal as sig_module
|
||||
import fcntl
|
||||
import atexit
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values
|
||||
from config import Config
|
||||
|
||||
# ============ 配置 ============
|
||||
API_DELAY = 0.8 # 每次API调用间隔(秒),东财限流严格需更保守
|
||||
BATCH_SAVE_SIZE = 500 # 每批保存行数
|
||||
PERIOD = '5' # K线级别:'1','5','15','30','60'
|
||||
MAX_RETRIES = 2 # 单只股票API重试次数
|
||||
RETRY_DELAY = 3 # 重试等待时间(秒)
|
||||
EM_CIRCUIT_BREAKER = 3 # 东财API连续失败N次后暂停使用(快速熔断)
|
||||
LOCK_FILE = '/tmp/sync_kline_5min.lock'
|
||||
DEFAULT_WORKERS = 3 # 默认并发数(东财API限流严格,不宜过高)
|
||||
|
||||
_shutdown = False
|
||||
_em_consecutive_errors = 0 # 东财API连续错误计数
|
||||
_em_disabled = False # 东财API是否被暂停
|
||||
_em_lock = threading.Lock() # 东财API状态锁
|
||||
_lock_fd = None # 进程锁文件描述符
|
||||
|
||||
|
||||
def _clean_stale_lock():
|
||||
"""清理残留的锁文件(进程已不存在或权限不对时)"""
|
||||
if not os.path.exists(LOCK_FILE):
|
||||
return
|
||||
try:
|
||||
with open(LOCK_FILE, 'r') as f:
|
||||
old_pid = f.read().strip()
|
||||
if old_pid and old_pid.isdigit():
|
||||
try:
|
||||
os.kill(int(old_pid), 0) # 检查进程是否存在
|
||||
return # 进程仍在运行,不清理
|
||||
except ProcessLookupError:
|
||||
pass # 进程已不存在,清理
|
||||
except PermissionError:
|
||||
return # 进程存在但无权限检查,不清理
|
||||
except (IOError, PermissionError):
|
||||
pass # 无法读取锁文件,尝试删除
|
||||
try:
|
||||
os.remove(LOCK_FILE)
|
||||
print(f"🧹 已清理残留锁文件 (旧PID: {old_pid if 'old_pid' in dir() else '未知'})", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def acquire_lock():
|
||||
"""获取进程锁,防止多实例同时运行"""
|
||||
global _lock_fd
|
||||
# 先尝试清理残留的锁文件
|
||||
_clean_stale_lock()
|
||||
try:
|
||||
_lock_fd = open(LOCK_FILE, 'w')
|
||||
except PermissionError:
|
||||
# 锁文件权限不对,尝试删除后重建
|
||||
try:
|
||||
os.remove(LOCK_FILE)
|
||||
_lock_fd = open(LOCK_FILE, 'w')
|
||||
except Exception as e:
|
||||
print(f"⚠️ 无法创建锁文件 {LOCK_FILE}: {e}", flush=True)
|
||||
return False
|
||||
try:
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
_lock_fd.write(str(os.getpid()))
|
||||
_lock_fd.flush()
|
||||
atexit.register(release_lock)
|
||||
return True
|
||||
except IOError:
|
||||
# 另一个实例正在运行,读取其PID
|
||||
try:
|
||||
with open(LOCK_FILE, 'r') as f:
|
||||
old_pid = f.read().strip()
|
||||
print(f"⚠️ 另一个实例正在运行 (PID: {old_pid}),退出", flush=True)
|
||||
except Exception:
|
||||
print(f"⚠️ 另一个实例正在运行,退出", flush=True)
|
||||
_lock_fd.close()
|
||||
_lock_fd = None
|
||||
return False
|
||||
|
||||
|
||||
def release_lock():
|
||||
"""释放进程锁"""
|
||||
global _lock_fd
|
||||
if _lock_fd:
|
||||
try:
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
|
||||
_lock_fd.close()
|
||||
except Exception:
|
||||
pass
|
||||
_lock_fd = None
|
||||
try:
|
||||
os.remove(LOCK_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
global _shutdown
|
||||
print("\n⚠️ 收到中断信号,正在优雅退出...", flush=True)
|
||||
_shutdown = True
|
||||
|
||||
|
||||
sig_module.signal(sig_module.SIGINT, signal_handler)
|
||||
sig_module.signal(sig_module.SIGTERM, signal_handler)
|
||||
|
||||
|
||||
def get_db_conn():
|
||||
return psycopg2.connect(
|
||||
host=Config.DB_HOST, port=Config.DB_PORT,
|
||||
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
def ensure_table(conn):
|
||||
"""确保 stock_kline_5min 表存在"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS stock_kline_5min (
|
||||
code VARCHAR(10) NOT NULL,
|
||||
dt TIMESTAMP NOT NULL,
|
||||
open DECIMAL(12, 4),
|
||||
high DECIMAL(12, 4),
|
||||
low DECIMAL(12, 4),
|
||||
close DECIMAL(12, 4),
|
||||
volume BIGINT,
|
||||
amount DECIMAL(20, 2),
|
||||
change_pct DECIMAL(8, 4),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (code, dt)
|
||||
)
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_kline_5min_dt ON stock_kline_5min(dt)")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_stock_codes(conn, only_codes=None):
|
||||
"""获取需要采集的股票列表"""
|
||||
with conn.cursor() as cur:
|
||||
if only_codes:
|
||||
placeholders = ','.join(['%s'] * len(only_codes))
|
||||
cur.execute(f"SELECT code, name FROM stock_realtime_price WHERE code IN ({placeholders}) ORDER BY code",
|
||||
only_codes)
|
||||
else:
|
||||
# 获取所有有效股票(价格>0的)
|
||||
cur.execute("""
|
||||
SELECT code, name FROM stock_realtime_price
|
||||
WHERE price > 0 AND code NOT LIKE 'BJ%%'
|
||||
ORDER BY code
|
||||
""")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def get_already_synced_codes(conn, target_date):
|
||||
"""获取今天已经同步过的股票(用于断点续传)"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT code FROM stock_kline_5min
|
||||
WHERE dt::date = %s
|
||||
""", (target_date,))
|
||||
return {r[0] for r in cur.fetchall()}
|
||||
|
||||
|
||||
def _code_to_sina_symbol(code):
|
||||
"""股票代码转新浪格式:000001 → sz000001, 600519 → sh600519"""
|
||||
if code.startswith(('0', '3')):
|
||||
return f'sz{code}'
|
||||
elif code.startswith(('6', '5')):
|
||||
return f'sh{code}'
|
||||
elif code.startswith(('8', '9', '4')):
|
||||
return f'bj{code}'
|
||||
return f'sz{code}'
|
||||
|
||||
|
||||
def fetch_5min_kline_sina(code):
|
||||
"""
|
||||
数据源1:新浪API(akshare stock_zh_a_minute)
|
||||
优点:稳定、不易被封、回溯约2个月
|
||||
返回: list of tuple (code, dt, open, high, low, close, volume, amount, change_pct)
|
||||
返回 None 表示无数据(非错误)
|
||||
返回 'error' 字符串表示API错误
|
||||
"""
|
||||
import akshare as ak
|
||||
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
symbol = _code_to_sina_symbol(code)
|
||||
df = ak.stock_zh_a_minute(symbol=symbol, period=PERIOD)
|
||||
|
||||
if df is None or df.empty:
|
||||
return None # 无数据,非错误
|
||||
|
||||
rows = []
|
||||
for _, row in df.iterrows():
|
||||
dt_str = str(row.get('day', ''))
|
||||
try:
|
||||
dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
o = float(row.get('open', 0))
|
||||
h = float(row.get('high', 0))
|
||||
l = float(row.get('low', 0))
|
||||
c = float(row.get('close', 0))
|
||||
v = int(float(row.get('volume', 0)))
|
||||
|
||||
rows.append((code, dt, o, h, l, c, v, 0.0, 0.0))
|
||||
return rows if rows else None
|
||||
|
||||
except (IndexError, KeyError, ValueError):
|
||||
# list index out of range / KeyError / ValueError
|
||||
# 这些是"该股票无5分钟数据"的表现,不是API故障
|
||||
return None # 无数据,非错误
|
||||
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
if attempt < MAX_RETRIES:
|
||||
wait = RETRY_DELAY * (attempt + 1)
|
||||
print(f" ⚠️ {code}(新浪) 第{attempt+1}次失败: {err_str[:60]}, {wait}s后重试", flush=True)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
return 'error' # 真正的API错误
|
||||
|
||||
return 'error'
|
||||
|
||||
|
||||
def fetch_5min_kline_em(code, start_date=None, end_date=None):
|
||||
"""
|
||||
数据源2:东方财富API(akshare stock_zh_a_hist_min_em)
|
||||
优点:有成交额和涨跌幅,回溯约2个月
|
||||
缺点:容易被限流
|
||||
返回: list of tuple 或 None(无数据) 或 'error'(API错误)
|
||||
"""
|
||||
import akshare as ak
|
||||
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
kwargs = {'symbol': code, 'period': PERIOD, 'adjust': ''}
|
||||
if start_date:
|
||||
kwargs['start_date'] = start_date
|
||||
if end_date:
|
||||
kwargs['end_date'] = end_date
|
||||
|
||||
df = ak.stock_zh_a_hist_min_em(**kwargs)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
|
||||
rows = []
|
||||
for _, row in df.iterrows():
|
||||
dt_str = str(row['时间'])
|
||||
try:
|
||||
dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
rows.append((
|
||||
code, dt,
|
||||
float(row.get('开盘', 0)),
|
||||
float(row.get('最高', 0)),
|
||||
float(row.get('最低', 0)),
|
||||
float(row.get('收盘', 0)),
|
||||
int(float(row.get('成交量', 0))),
|
||||
float(row.get('成交额', 0)),
|
||||
float(row.get('涨跌幅', 0)),
|
||||
))
|
||||
return rows if rows else None
|
||||
|
||||
except (IndexError, KeyError, ValueError):
|
||||
return None # 无数据,非错误
|
||||
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
# 连接被断开、限流等属于真正的API错误
|
||||
if attempt < MAX_RETRIES:
|
||||
wait = RETRY_DELAY * (attempt + 1) * 2 # 东财限流严重,加长等待
|
||||
print(f" ⚠️ {code}(东财) 第{attempt+1}次失败: {err_str[:60]}, {wait}s后重试", flush=True)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
return 'error'
|
||||
|
||||
return 'error'
|
||||
|
||||
|
||||
def fetch_5min_kline_tencent(code):
|
||||
"""
|
||||
数据源3:腾讯财经1分钟数据 → 聚合为5分钟K线
|
||||
优点:腾讯云服务器永不被封
|
||||
缺点:只有当天数据
|
||||
返回: list of tuple 或 None 或 'error'
|
||||
"""
|
||||
import requests as _requests
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
symbol = _code_to_sina_symbol(code) # sh/sz 格式通用
|
||||
url = f"https://web.ifzq.gtimg.cn/appstock/app/minute/query?code={symbol}"
|
||||
r = _requests.get(url, timeout=15, headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://stockapp.finance.qq.com",
|
||||
})
|
||||
if r.status_code != 200:
|
||||
return 'error'
|
||||
|
||||
text = r.text
|
||||
start_idx = text.find("=") + 1
|
||||
end_idx = text.rfind("}") + 1
|
||||
data = _json.loads(text[start_idx:end_idx])
|
||||
records = data.get("data", {}).get(symbol, {}).get("data", {}).get("data", [])
|
||||
if not records:
|
||||
return None
|
||||
|
||||
today = date.today()
|
||||
|
||||
# 解析1分钟数据: "0930 1466.99 153 22444946.66"
|
||||
# 格式: HHMM price volume amount(累积)
|
||||
min_data = []
|
||||
for rec in records:
|
||||
parts = rec.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
hhmm = parts[0]
|
||||
price = float(parts[1])
|
||||
vol = int(parts[2])
|
||||
try:
|
||||
h, m = int(hhmm[:2]), int(hhmm[2:])
|
||||
dt = datetime(today.year, today.month, today.day, h, m, 0)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
min_data.append((dt, price, vol))
|
||||
|
||||
if not min_data:
|
||||
return None
|
||||
|
||||
# 聚合为5分钟K线
|
||||
# 5分钟窗口: 09:30-09:35, 09:35-09:40, ...
|
||||
from collections import defaultdict
|
||||
bars = defaultdict(list)
|
||||
for dt, price, vol in min_data:
|
||||
# 5分钟窗口起始时间
|
||||
minute = dt.minute
|
||||
bar_min = (minute // 5) * 5
|
||||
bar_dt = dt.replace(minute=bar_min, second=0)
|
||||
bars[bar_dt].append((price, vol))
|
||||
|
||||
rows = []
|
||||
prev_vol = 0
|
||||
for bar_dt in sorted(bars.keys()):
|
||||
ticks = bars[bar_dt]
|
||||
o = ticks[0][0] # 第一个价格
|
||||
c = ticks[-1][0] # 最后一个价格
|
||||
h = max(p for p, _ in ticks)
|
||||
l = min(p for p, _ in ticks)
|
||||
# 腾讯的volume是累积值,取窗口最后的 - 窗口最前的之前
|
||||
last_vol = ticks[-1][1]
|
||||
bar_vol = last_vol - prev_vol if prev_vol > 0 else ticks[-1][1]
|
||||
prev_vol = last_vol
|
||||
rows.append((code, bar_dt, o, h, l, c, max(0, bar_vol), 0.0, 0.0))
|
||||
|
||||
return rows if rows else None
|
||||
|
||||
except (IndexError, KeyError, ValueError):
|
||||
return None
|
||||
except Exception:
|
||||
return 'error'
|
||||
|
||||
|
||||
def fetch_5min_kline(code, start_date=None, end_date=None):
|
||||
"""
|
||||
主入口:优先东财API → 新浪API → 腾讯聚合(仅当天)
|
||||
返回:
|
||||
- list of tuple: 成功获取数据
|
||||
- None: 该股票无5分钟数据(停牌、退市等,非错误)
|
||||
- 'error': API故障/限流
|
||||
"""
|
||||
global _em_consecutive_errors, _em_disabled
|
||||
|
||||
# 1) 优先使用东财API(数据最全)
|
||||
with _em_lock:
|
||||
em_ok = not _em_disabled
|
||||
|
||||
if em_ok:
|
||||
result = fetch_5min_kline_em(code, start_date, end_date)
|
||||
if result == 'error':
|
||||
with _em_lock:
|
||||
_em_consecutive_errors += 1
|
||||
if _em_consecutive_errors >= EM_CIRCUIT_BREAKER:
|
||||
_em_disabled = True
|
||||
print(f" ⚠️ 东财API连续失败{EM_CIRCUIT_BREAKER}次,尝试备用源", flush=True)
|
||||
time.sleep(2)
|
||||
else:
|
||||
with _em_lock:
|
||||
_em_consecutive_errors = 0
|
||||
return result
|
||||
|
||||
# 2) 备用:新浪API
|
||||
result = fetch_5min_kline_sina(code)
|
||||
if result is not None and result != 'error':
|
||||
return result
|
||||
|
||||
# 3) 终极备用:腾讯1分钟聚合(仅当天数据,但永不被封)
|
||||
return fetch_5min_kline_tencent(code)
|
||||
|
||||
|
||||
def save_batch(conn, all_rows):
|
||||
"""批量保存5分钟K线数据(UPSERT)"""
|
||||
if not all_rows:
|
||||
return 0
|
||||
|
||||
with conn.cursor() as cur:
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO stock_kline_5min (code, dt, open, high, low, close, volume, amount, change_pct)
|
||||
VALUES %s
|
||||
ON CONFLICT (code, dt) DO UPDATE SET
|
||||
open = EXCLUDED.open,
|
||||
high = EXCLUDED.high,
|
||||
low = EXCLUDED.low,
|
||||
close = EXCLUDED.close,
|
||||
volume = EXCLUDED.volume,
|
||||
amount = EXCLUDED.amount,
|
||||
change_pct = EXCLUDED.change_pct,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
all_rows,
|
||||
page_size=1000,
|
||||
)
|
||||
conn.commit()
|
||||
return len(all_rows)
|
||||
|
||||
|
||||
def filter_rows_by_date(rows, target_date):
|
||||
"""只保留目标日期的行"""
|
||||
if not rows:
|
||||
return None
|
||||
filtered = [r for r in rows if r[1].date() == target_date]
|
||||
return filtered if filtered else None
|
||||
|
||||
|
||||
def _probe_apis():
|
||||
"""启动时探测各API是否可用,提前设置熔断状态"""
|
||||
global _em_disabled, _em_consecutive_errors
|
||||
print("🔍 探测数据源可用性...", flush=True)
|
||||
|
||||
# 测试东财API(用最活跃的股票)
|
||||
em_ok = False
|
||||
try:
|
||||
import akshare as ak
|
||||
df = ak.stock_zh_a_hist_min_em(symbol='600519', period='5', adjust='')
|
||||
if df is not None and not df.empty:
|
||||
em_ok = True
|
||||
print(" ✅ 东财API: 可用", flush=True)
|
||||
else:
|
||||
print(" ❌ 东财API: 返回空数据", flush=True)
|
||||
except Exception as e:
|
||||
print(f" ❌ 东财API: {str(e)[:60]}", flush=True)
|
||||
|
||||
if not em_ok:
|
||||
with _em_lock:
|
||||
_em_disabled = True
|
||||
_em_consecutive_errors = EM_CIRCUIT_BREAKER
|
||||
print(" → 东财API已禁用,将使用备用源", flush=True)
|
||||
|
||||
# 测试新浪API
|
||||
sina_ok = False
|
||||
try:
|
||||
result = fetch_5min_kline_sina('600519')
|
||||
if result is not None and result != 'error':
|
||||
sina_ok = True
|
||||
print(" ✅ 新浪API: 可用", flush=True)
|
||||
else:
|
||||
print(" ❌ 新浪API: 不可用", flush=True)
|
||||
except Exception:
|
||||
print(" ❌ 新浪API: 异常", flush=True)
|
||||
|
||||
# 腾讯API(聚合方式)总是可用
|
||||
print(" ✅ 腾讯API: 始终可用(聚合1分钟→5分钟)", flush=True)
|
||||
|
||||
source = "东财" if em_ok else ("新浪" if sina_ok else "腾讯(聚合)")
|
||||
print(f" 📡 主数据源: {source}", flush=True)
|
||||
return em_ok, sina_ok
|
||||
|
||||
|
||||
def main():
|
||||
global _shutdown
|
||||
|
||||
# 进程锁 — 防止多实例同时运行
|
||||
if not acquire_lock():
|
||||
sys.exit(1)
|
||||
|
||||
parser = argparse.ArgumentParser(description='5分钟K线数据采集')
|
||||
parser.add_argument('--backfill', type=int, default=0, metavar='DAYS',
|
||||
help='回填最近N天的数据(默认0=只采集当天)')
|
||||
parser.add_argument('--codes', type=str, default=None,
|
||||
help='只采集指定股票(逗号分隔,如 300720,000001)')
|
||||
parser.add_argument('--limit', type=int, default=0,
|
||||
help='限制采集股票数量(0=全部,用于测试)')
|
||||
parser.add_argument('--delay', type=float, default=API_DELAY,
|
||||
help=f'API调用间隔秒数(默认{API_DELAY})')
|
||||
parser.add_argument('--resume', action='store_true',
|
||||
help='跳过今天已采集的股票(断点续传)')
|
||||
parser.add_argument('--workers', type=int, default=DEFAULT_WORKERS,
|
||||
help=f'并发线程数(默认{DEFAULT_WORKERS})')
|
||||
args = parser.parse_args()
|
||||
|
||||
today = date.today()
|
||||
target_dates = [today]
|
||||
if args.backfill > 0:
|
||||
for i in range(1, args.backfill + 1):
|
||||
d = today - timedelta(days=i)
|
||||
if d.weekday() < 5: # 跳过周末
|
||||
target_dates.append(d)
|
||||
target_dates.sort()
|
||||
|
||||
only_codes = args.codes.split(',') if args.codes else None
|
||||
|
||||
print(f"{'='*70}", flush=True)
|
||||
print(f"📊 5分钟K线数据采集(并发模式)", flush=True)
|
||||
print(f"📅 目标日期: {', '.join(str(d) for d in target_dates)}", flush=True)
|
||||
print(f"⏱️ API间隔: {args.delay}s | 并发: {args.workers} 线程", flush=True)
|
||||
if only_codes:
|
||||
print(f"🎯 指定股票: {only_codes}", flush=True)
|
||||
print(f"{'='*70}", flush=True)
|
||||
|
||||
# 启动时探测API可用性,提前熔断不可用的源
|
||||
em_ok, sina_ok = _probe_apis()
|
||||
# 如果主源不可用,腾讯聚合模式可以用更多并发(不限流)
|
||||
if not em_ok and not sina_ok:
|
||||
if args.workers < 5:
|
||||
args.workers = 5
|
||||
print(f" 📡 腾讯模式:提升并发到 {args.workers} 线程", flush=True)
|
||||
if args.delay > 0.3:
|
||||
args.delay = 0.3
|
||||
print(f" 📡 腾讯模式:降低延迟到 {args.delay}s", flush=True)
|
||||
|
||||
conn = get_db_conn()
|
||||
ensure_table(conn)
|
||||
|
||||
all_stocks = get_stock_codes(conn, only_codes)
|
||||
if args.limit > 0:
|
||||
all_stocks = all_stocks[:args.limit]
|
||||
total = len(all_stocks)
|
||||
print(f"📈 待采集股票: {total} 只", flush=True)
|
||||
|
||||
if args.resume:
|
||||
synced = get_already_synced_codes(conn, today)
|
||||
before = len(all_stocks)
|
||||
all_stocks = [(c, n) for c, n in all_stocks if c not in synced]
|
||||
print(f"🔄 断点续传: 跳过 {before - len(all_stocks)} 只已同步, 剩余 {len(all_stocks)} 只", flush=True)
|
||||
total = len(all_stocks)
|
||||
|
||||
start_time = time.time()
|
||||
done_count = 0
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
skip_count = 0
|
||||
total_rows = 0
|
||||
save_buffer = []
|
||||
last_report_time = time.time()
|
||||
|
||||
# 线程安全锁
|
||||
_stats_lock = threading.Lock()
|
||||
_buffer_lock = threading.Lock()
|
||||
|
||||
def _fetch_one(code_name):
|
||||
"""单只股票采集任务(在工作线程中运行)"""
|
||||
code, name = code_name
|
||||
if _shutdown:
|
||||
return None
|
||||
# 线程内延迟,分散API请求
|
||||
time.sleep(args.delay)
|
||||
result = fetch_5min_kline(code)
|
||||
return (code, name, result)
|
||||
|
||||
print(f"\n🚀 开始采集({args.workers}线程并发)...", flush=True)
|
||||
print(f"-" * 70, flush=True)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||
futures = {executor.submit(_fetch_one, item): item for item in all_stocks}
|
||||
|
||||
for future in as_completed(futures):
|
||||
if _shutdown:
|
||||
break
|
||||
|
||||
ret = future.result()
|
||||
if ret is None:
|
||||
continue
|
||||
|
||||
code, name, result = ret
|
||||
|
||||
with _stats_lock:
|
||||
done_count += 1
|
||||
|
||||
if isinstance(result, list):
|
||||
rows = result
|
||||
if args.backfill == 0:
|
||||
rows = filter_rows_by_date(rows, today)
|
||||
|
||||
if rows:
|
||||
with _buffer_lock:
|
||||
save_buffer.extend(rows)
|
||||
success_count += 1
|
||||
else:
|
||||
skip_count += 1
|
||||
elif result == 'error':
|
||||
error_count += 1
|
||||
else:
|
||||
skip_count += 1
|
||||
|
||||
# 攒够一批就保存
|
||||
with _buffer_lock:
|
||||
if len(save_buffer) >= BATCH_SAVE_SIZE:
|
||||
saved = save_batch(conn, save_buffer)
|
||||
total_rows += saved
|
||||
save_buffer = []
|
||||
|
||||
# 进度报告
|
||||
now = time.time()
|
||||
if now - last_report_time >= 5:
|
||||
elapsed = now - start_time
|
||||
speed = done_count / elapsed if elapsed > 0 else 0
|
||||
remaining = (total - done_count) / speed if speed > 0 else 0
|
||||
pct = done_count / total * 100 if total > 0 else 100
|
||||
print(f" [{pct:5.1f}%] {done_count}/{total} "
|
||||
f"| {speed:.1f}只/秒 | 剩余 {remaining/60:.1f}分钟 "
|
||||
f"| ✅{success_count} ❌{error_count} ⏭️{skip_count} "
|
||||
f"| 已保存 {total_rows}条", flush=True)
|
||||
last_report_time = now
|
||||
|
||||
# 保存剩余数据
|
||||
if save_buffer:
|
||||
saved = save_batch(conn, save_buffer)
|
||||
total_rows += saved
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
speed = done_count / elapsed if elapsed > 0 else 0
|
||||
|
||||
print(f"\n{'='*70}", flush=True)
|
||||
print(f"{'⏹️ 中断' if _shutdown else '✅ 完成'}!", flush=True)
|
||||
print(f" 处理: {done_count}/{total} 只", flush=True)
|
||||
print(f" 成功: {success_count} | 失败: {error_count} | 跳过: {skip_count}", flush=True)
|
||||
print(f" 保存: {total_rows} 条 5分钟K线", flush=True)
|
||||
print(f" 耗时: {elapsed/60:.1f} 分钟 ({speed:.1f} 只/秒)", flush=True)
|
||||
|
||||
# 显示数据库统计
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT count(*), count(DISTINCT code),
|
||||
min(dt::date), max(dt::date),
|
||||
count(DISTINCT dt::date)
|
||||
FROM stock_kline_5min
|
||||
""")
|
||||
cnt, codes, min_d, max_d, days = cur.fetchone()
|
||||
print(f"\n📊 stock_kline_5min 数据库统计:", flush=True)
|
||||
print(f" 总记录: {cnt:,} 条 | 覆盖: {codes} 只股票 | {days} 天", flush=True)
|
||||
print(f" 日期: {min_d} ~ {max_d}", flush=True)
|
||||
|
||||
# 按日期统计
|
||||
cur.execute("""
|
||||
SELECT dt::date AS trade_date, count(*), count(DISTINCT code)
|
||||
FROM stock_kline_5min
|
||||
GROUP BY dt::date
|
||||
ORDER BY dt::date DESC
|
||||
LIMIT 5
|
||||
""")
|
||||
print(f" 最近5天:", flush=True)
|
||||
for d, cnt, codes in cur.fetchall():
|
||||
print(f" {d}: {cnt:>8,} 条 ({codes} 只股票)", flush=True)
|
||||
|
||||
print(f"{'='*70}", flush=True)
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
更新股票缓存数据(从上年1月1日起)
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import glob
|
||||
from datetime import datetime, timedelta
|
||||
from services.stock_service import get_stock_fund_flow, save_cached_data
|
||||
|
||||
def update_all_caches():
|
||||
cache_dir = 'stock_data_cache'
|
||||
if not os.path.exists(cache_dir):
|
||||
print("缓存目录不存在")
|
||||
return
|
||||
|
||||
cache_files = glob.glob(f'{cache_dir}/*.json')
|
||||
print(f"找到 {len(cache_files)} 个缓存文件")
|
||||
|
||||
# 日期范围:从上年1月1日到今天
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = f'{datetime.now().year - 1}-01-01'
|
||||
print(f"更新日期范围: {start_date} ~ {end_date}")
|
||||
|
||||
updated = 0
|
||||
failed = 0
|
||||
|
||||
for i, cache_file in enumerate(cache_files):
|
||||
stock_code = os.path.basename(cache_file).replace('.json', '')
|
||||
print(f"[{i+1}/{len(cache_files)}] 更新 {stock_code}...", end=" ", flush=True)
|
||||
|
||||
try:
|
||||
# 检查现有缓存数据范围
|
||||
with open(cache_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
records = data.get('records', [])
|
||||
if records:
|
||||
first_date = records[0].get('日期', '')
|
||||
# 如果已经有上年1月的数据,跳过
|
||||
if first_date and first_date <= start_date:
|
||||
print(f"已有上年数据({first_date}),跳过")
|
||||
continue
|
||||
|
||||
# 需要更新
|
||||
df, name, error = get_stock_fund_flow(stock_code, start_date, end_date)
|
||||
if error:
|
||||
print(f"错误: {error}")
|
||||
failed += 1
|
||||
elif df is not None and len(df) > 0:
|
||||
save_cached_data(stock_code, df, name)
|
||||
print(f"成功 ({len(df)}条)")
|
||||
updated += 1
|
||||
else:
|
||||
print("无数据")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"异常: {e}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n完成! 更新: {updated}, 失败: {failed}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
update_all_caches()
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
批量更新数据库中缺少财务指标的基本面数据
|
||||
"""
|
||||
import akshare as ak
|
||||
import pandas as pd
|
||||
import psycopg2
|
||||
from datetime import date, datetime
|
||||
import time
|
||||
|
||||
# 数据库配置
|
||||
DB_CONFIG = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'dbname': 'stock_app',
|
||||
'user': 'postgres',
|
||||
'password': 'xypg5432'
|
||||
}
|
||||
|
||||
def get_db():
|
||||
"""获取数据库连接"""
|
||||
try:
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
return conn
|
||||
except Exception as e:
|
||||
print(f"数据库连接失败: {e}")
|
||||
return None
|
||||
|
||||
def get_stocks_need_update():
|
||||
"""获取需要更新财务指标的股票列表"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT code, name
|
||||
FROM stock_fundamental
|
||||
WHERE roe IS NULL OR eps IS NULL
|
||||
""")
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def fetch_financial_indicators(stock_code):
|
||||
"""从akshare获取股票财务指标"""
|
||||
try:
|
||||
df = ak.stock_financial_analysis_indicator(symbol=stock_code, start_year='2024')
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
|
||||
latest = df.iloc[-1]
|
||||
|
||||
def safe_float(val):
|
||||
if pd.isna(val):
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except:
|
||||
return None
|
||||
|
||||
return {
|
||||
'eps': safe_float(latest.get('摊薄每股收益(元)')),
|
||||
'bps': safe_float(latest.get('每股净资产_调整后(元)')),
|
||||
'roe': safe_float(latest.get('净资产收益率(%)')),
|
||||
'gross_margin': safe_float(latest.get('销售毛利率(%)')),
|
||||
'net_margin': safe_float(latest.get('销售净利率(%)')),
|
||||
'revenue_yoy': safe_float(latest.get('主营业务收入增长率(%)')),
|
||||
'profit_yoy': safe_float(latest.get('净利润增长率(%)')),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f" 获取 {stock_code} 财务指标失败: {e}")
|
||||
return None
|
||||
|
||||
def update_stock_fundamental(stock_code, data):
|
||||
"""更新股票基本面数据"""
|
||||
conn = get_db()
|
||||
if not conn:
|
||||
return False
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE stock_fundamental
|
||||
SET roe = %s, eps = %s, bps = %s,
|
||||
revenue_yoy = %s, profit_yoy = %s,
|
||||
gross_margin = %s, net_margin = %s,
|
||||
updated_at = NOW()
|
||||
WHERE code = %s
|
||||
""", (
|
||||
data.get('roe'),
|
||||
data.get('eps'),
|
||||
data.get('bps'),
|
||||
data.get('revenue_yoy'),
|
||||
data.get('profit_yoy'),
|
||||
data.get('gross_margin'),
|
||||
data.get('net_margin'),
|
||||
stock_code
|
||||
))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f" 更新 {stock_code} 失败: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print("批量更新基本面财务指标")
|
||||
print("=" * 50)
|
||||
|
||||
# 获取需要更新的股票
|
||||
stocks = get_stocks_need_update()
|
||||
print(f"\n需要更新的股票数量: {len(stocks)}")
|
||||
|
||||
if not stocks:
|
||||
print("所有股票财务指标已完整,无需更新")
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for i, (code, name) in enumerate(stocks, 1):
|
||||
print(f"\n[{i}/{len(stocks)}] 正在更新 {code} {name}...")
|
||||
|
||||
# 获取财务指标
|
||||
data = fetch_financial_indicators(code)
|
||||
|
||||
if data:
|
||||
# 更新数据库
|
||||
if update_stock_fundamental(code, data):
|
||||
print(f" ✓ 更新成功: EPS={data.get('eps')}, ROE={data.get('roe')}%")
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
else:
|
||||
print(f" ✗ 无法获取财务指标")
|
||||
fail_count += 1
|
||||
|
||||
# 避免请求过快
|
||||
time.sleep(0.5)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f"更新完成! 成功: {success_count}, 失败: {fail_count}")
|
||||
print("=" * 50)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
# utils package
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
统一数据获取模块 - 自动降级数据源
|
||||
当东方财富API被封(腾讯云等环境)时,自动切换腾讯财经/新浪等备用数据源
|
||||
|
||||
使用方法(替代 akshare 直接调用):
|
||||
from utils.data_fetcher import fetch_stock_hist
|
||||
df = fetch_stock_hist('000001', period='daily', start_date='20250101', end_date='20260226', adjust='qfq')
|
||||
"""
|
||||
import logging
|
||||
import requests
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 数据源状态追踪 (避免反复尝试已知失败的数据源)
|
||||
_source_status = {
|
||||
'eastmoney': True, # 是否可用
|
||||
'tencent': True,
|
||||
'sina': True,
|
||||
}
|
||||
_source_fail_count = {
|
||||
'eastmoney': 0,
|
||||
'tencent': 0,
|
||||
'sina': 0,
|
||||
}
|
||||
_MAX_FAIL_BEFORE_SKIP = 3 # 连续失败N次后暂时跳过
|
||||
|
||||
|
||||
def _mark_source_failed(source):
|
||||
"""标记数据源失败"""
|
||||
_source_fail_count[source] = _source_fail_count.get(source, 0) + 1
|
||||
if _source_fail_count[source] >= _MAX_FAIL_BEFORE_SKIP:
|
||||
_source_status[source] = False
|
||||
logger.warning(f"[数据源] {source} 连续失败 {_source_fail_count[source]} 次,暂时禁用")
|
||||
|
||||
|
||||
def _mark_source_ok(source):
|
||||
"""标记数据源成功"""
|
||||
_source_fail_count[source] = 0
|
||||
_source_status[source] = True
|
||||
|
||||
|
||||
def _to_tencent_symbol(stock_code):
|
||||
"""转为腾讯API格式: sz000001, sh600519"""
|
||||
code = str(stock_code).strip()
|
||||
if code.startswith('6'):
|
||||
return f'sh{code}'
|
||||
elif code.startswith('0') or code.startswith('3'):
|
||||
return f'sz{code}'
|
||||
elif code.startswith('8') or code.startswith('4'):
|
||||
return f'bj{code}'
|
||||
return f'sz{code}'
|
||||
|
||||
|
||||
def _fetch_hist_from_tencent(stock_code, start_date, end_date, adjust='qfq'):
|
||||
"""
|
||||
从腾讯财经获取历史日K线
|
||||
API: http://web.ifzq.gtimg.cn/appstock/app/fqkline/get
|
||||
返回格式: [date, open, close, high, low, volume]
|
||||
注意: 腾讯返回的是 [open, close],akshare返回的是 [开盘, 收盘]
|
||||
"""
|
||||
symbol = _to_tencent_symbol(stock_code)
|
||||
|
||||
# 腾讯最多返回约640条日线(约2.5年)
|
||||
# 格式化日期
|
||||
start_fmt = f'{start_date[:4]}-{start_date[4:6]}-{start_date[6:8]}' if len(start_date) == 8 else start_date
|
||||
end_fmt = f'{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}' if len(end_date) == 8 else end_date
|
||||
|
||||
# 计算请求的天数
|
||||
try:
|
||||
d1 = datetime.strptime(start_date[:8], '%Y%m%d')
|
||||
d2 = datetime.strptime(end_date[:8], '%Y%m%d')
|
||||
num_bars = (d2 - d1).days + 50 # 多请求一些,因为有非交易日
|
||||
num_bars = min(max(num_bars, 60), 640)
|
||||
except:
|
||||
num_bars = 320
|
||||
|
||||
# 前复权: qfqday, 不复权: day
|
||||
adj_key = 'qfqday' if adjust == 'qfq' else 'day'
|
||||
adj_param = 'qfq' if adjust == 'qfq' else ''
|
||||
|
||||
url = f'http://web.ifzq.gtimg.cn/appstock/app/fqkline/get'
|
||||
params = f'{symbol},day,{start_fmt},{end_fmt},{num_bars},{adj_param}'
|
||||
|
||||
r = requests.get(url, params={'param': params}, timeout=15)
|
||||
if r.status_code != 200:
|
||||
raise Exception(f'腾讯API返回 {r.status_code}')
|
||||
|
||||
data = r.json()
|
||||
stock_key = symbol # e.g. 'sz000001'
|
||||
klines = data.get('data', {}).get(stock_key, {}).get(adj_key, [])
|
||||
|
||||
if not klines:
|
||||
# 尝试不复权
|
||||
klines = data.get('data', {}).get(stock_key, {}).get('day', [])
|
||||
|
||||
if not klines:
|
||||
return pd.DataFrame()
|
||||
|
||||
# 构造与 akshare stock_zh_a_hist 兼容的 DataFrame
|
||||
# 腾讯格式: [date, open, close, high, low, volume]
|
||||
rows = []
|
||||
prev_close = None
|
||||
for k in klines:
|
||||
if len(k) < 6:
|
||||
continue
|
||||
date_str = k[0]
|
||||
open_price = float(k[1])
|
||||
close_price = float(k[2])
|
||||
high_price = float(k[3])
|
||||
low_price = float(k[4])
|
||||
volume = float(k[5])
|
||||
|
||||
# 计算衍生字段
|
||||
change_amount = close_price - prev_close if prev_close else 0
|
||||
change_pct = (change_amount / prev_close * 100) if prev_close and prev_close > 0 else 0
|
||||
amplitude = ((high_price - low_price) / prev_close * 100) if prev_close and prev_close > 0 else 0
|
||||
|
||||
rows.append({
|
||||
'日期': date_str,
|
||||
'开盘': open_price,
|
||||
'收盘': close_price,
|
||||
'最高': high_price,
|
||||
'最低': low_price,
|
||||
'成交量': int(volume),
|
||||
'成交额': 0, # 腾讯不提供成交额
|
||||
'振幅': round(amplitude, 2),
|
||||
'涨跌幅': round(change_pct, 2),
|
||||
'涨跌额': round(change_amount, 2),
|
||||
'换手率': 0, # 腾讯不提供换手率
|
||||
})
|
||||
prev_close = close_price
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
|
||||
# 过滤日期范围
|
||||
if not df.empty:
|
||||
df['日期'] = pd.to_datetime(df['日期'])
|
||||
start_dt = pd.to_datetime(start_fmt)
|
||||
end_dt = pd.to_datetime(end_fmt)
|
||||
df = df[(df['日期'] >= start_dt) & (df['日期'] <= end_dt)]
|
||||
df = df.sort_values('日期').reset_index(drop=True)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def fetch_stock_hist(stock_code, period='daily', start_date='20200101',
|
||||
end_date=None, adjust='qfq'):
|
||||
"""
|
||||
获取股票历史K线数据(腾讯财经为主数据源)
|
||||
|
||||
参数与 akshare.stock_zh_a_hist 完全兼容:
|
||||
stock_code: 股票代码 (纯数字,如 '000001')
|
||||
period: 'daily', 'weekly', 'monthly'
|
||||
start_date: 开始日期 'YYYYMMDD'
|
||||
end_date: 结束日期 'YYYYMMDD'
|
||||
adjust: 'qfq'(前复权) / 'hfq'(后复权) / ''(不复权)
|
||||
|
||||
返回: pandas DataFrame, 与 akshare 格式兼容
|
||||
"""
|
||||
if end_date is None:
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
# 数据源: 腾讯财经(日K线,腾讯云最快最稳)
|
||||
if period == 'daily':
|
||||
try:
|
||||
df = _fetch_hist_from_tencent(stock_code, start_date, end_date, adjust)
|
||||
if df is not None and not df.empty:
|
||||
return df
|
||||
except Exception as e:
|
||||
logger.warning(f"[数据源] 腾讯财经获取失败({stock_code}): {str(e)[:100]}")
|
||||
|
||||
logger.error(f"[数据源] 数据源获取失败: {stock_code}")
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def fetch_stock_codes():
|
||||
"""
|
||||
获取全部A股股票代码和名称(从数据库获取)
|
||||
返回: DataFrame with columns ['code', 'name']
|
||||
"""
|
||||
# 从数据库获取(最可靠,不依赖外部API)
|
||||
logger.info("[数据源] 从数据库获取股票列表")
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def reset_source_status():
|
||||
"""重置所有数据源状态(用于定时任务开始时)"""
|
||||
global _source_status, _source_fail_count
|
||||
_source_status = {'eastmoney': True, 'tencent': True, 'sina': True}
|
||||
_source_fail_count = {'eastmoney': 0, 'tencent': 0, 'sina': 0}
|
||||
logger.info("[数据源] 所有数据源状态已重置")
|
||||
Reference in New Issue
Block a user