Compare commits
5 Commits
2b5a32ca1e
...
79e869eeda
| Author | SHA1 | Date | |
|---|---|---|---|
| 79e869eeda | |||
| bb5a72767f | |||
| 03e095ded0 | |||
| b3c21c6f91 | |||
| 4b42eb80fd |
@@ -9,3 +9,4 @@ venv/
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
stock-html-backup-*/
|
||||
|
||||
@@ -77,6 +77,15 @@ def _start_scheduler_once():
|
||||
_scheduler_started = True
|
||||
from services.scheduler import start_scheduler
|
||||
start_scheduler()
|
||||
# 启动时预热市场级外部因素缓存(后台线程,不阻塞启动)
|
||||
import threading
|
||||
def _preheat():
|
||||
try:
|
||||
from services.score_engine import precompute_market_factors
|
||||
precompute_market_factors()
|
||||
except Exception as e:
|
||||
print(f"[启动] 外部因素预热失败: {e}")
|
||||
threading.Thread(target=_preheat, daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# 股票投资分析系统 - 远程服务器部署情况
|
||||
|
||||
> 最后更新: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 |
|
||||
@@ -7,11 +7,11 @@ set -e
|
||||
# 配置
|
||||
NEW_SERVER="ubuntu@152.136.182.184"
|
||||
APP_DIR="/opt/stock-app"
|
||||
LOCAL_DIR="/Users/freedak/Documents/go-new/stock/stock-html"
|
||||
LOCAL_DIR="/Users/freedak/Documents/AIDashboard/stock/stock-html"
|
||||
|
||||
echo "=========================================="
|
||||
echo "部署到新服务器: 152.136.182.184"
|
||||
echo "域名: stock.allbyai.cn"
|
||||
echo "部署到服务器: 152.136.182.184"
|
||||
echo "域名: stock.all8ai.top"
|
||||
echo "=========================================="
|
||||
|
||||
# 1. 同步代码(使用 sudo 写入 /opt/stock-app)
|
||||
@@ -29,11 +29,6 @@ rsync -avz --progress --rsync-path="sudo rsync" ${LOCAL_DIR}/ ${NEW_SERVER}:${AP
|
||||
--exclude='watchlist.json' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.playwright-mcp' \
|
||||
--exclude='/app.js' \
|
||||
--exclude='/index.html' \
|
||||
--exclude='/main.css' \
|
||||
--exclude='/css' \
|
||||
--exclude='/js' \
|
||||
--exclude='/.windsurfrules'
|
||||
|
||||
# 2. 在服务器上执行初始化(首次部署时需要)
|
||||
@@ -63,11 +58,9 @@ echo "部署完成!"
|
||||
echo ""
|
||||
echo "访问地址:"
|
||||
echo " - IP直连: http://152.136.182.184:3333"
|
||||
echo " - HTTPS访问: https://stock.allbyai.cn"
|
||||
echo " - HTTPS访问: https://stock.all8ai.top"
|
||||
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 " - SSL证书: 由acme.sh管理"
|
||||
echo " - 手动续期: /home/ubuntu/.acme.sh/acme.sh --renew -d stock.all8ai.top --ecc --force"
|
||||
echo "=========================================="
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
#!/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 "=========================================="
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,19 +0,0 @@
|
||||
[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
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/bin/bash
|
||||
# 快速同步代码到新服务器并重启 - 152.136.182.184 (stock.allbyai.cn)
|
||||
# 快速同步代码到服务器并重启 - 152.136.182.184 (stock.all8ai.top)
|
||||
# 用于日常代码更新
|
||||
|
||||
set -e
|
||||
|
||||
NEW_SERVER="ubuntu@152.136.182.184"
|
||||
APP_DIR="/opt/stock-app"
|
||||
LOCAL_DIR="/Users/freedak/Documents/go-new/stock/stock-html"
|
||||
LOCAL_DIR="/Users/freedak/Documents/AIDashboard/stock/stock-html"
|
||||
|
||||
echo "同步代码到 stock.allbyai.cn (152.136.182.184)..."
|
||||
echo "同步代码到 stock.all8ai.top (152.136.182.184)..."
|
||||
|
||||
rsync -avz ${LOCAL_DIR}/ ${NEW_SERVER}:${APP_DIR}/ \
|
||||
--exclude='.git' \
|
||||
@@ -23,14 +23,9 @@ rsync -avz ${LOCAL_DIR}/ ${NEW_SERVER}:${APP_DIR}/ \
|
||||
--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"
|
||||
echo "访问: http://stock.all8ai.top"
|
||||
|
||||
@@ -0,0 +1,825 @@
|
||||
# 股票投资分析系统 — 算法分析文档 v2.0
|
||||
|
||||
> 最后更新:2026-07-18
|
||||
> 变更说明:v2.0 重点改进推荐逻辑——推荐股票时以**综合得分**为准,并列明**技术得分**、**外部得分**和**综合得分**三项。
|
||||
|
||||
---
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
系统分为五层,数据从下往上流动:
|
||||
|
||||
```
|
||||
技术指标层(technical_indicators.py) ← 第二章
|
||||
↓ 计算MACD、SKDJ、KDJ、EMA、MA等基础指标
|
||||
信号检测层(signal_detector.py) ← 第三章
|
||||
↓ 基于7个信号检测买卖点
|
||||
外部因素模块(fund_flow/sentiment/external/news)← 第四章
|
||||
↓ 资金面/情绪/北向/美股/商品/公告/政策/汇率 → 各因素评分
|
||||
算法决策层(stock_algorithms.py) ← 第五章
|
||||
↓ 统一推荐逻辑、牛股阶段识别、技术面深度分析(7维度)→ 技术面基础分
|
||||
综合评分引擎(score_engine.py) ← 第五章
|
||||
↓ 技术面基础分 + 外部因素加减分 → 最终评分 → AI解说
|
||||
```
|
||||
|
||||
| 层级 | 文件 | 职责 | 章节 |
|
||||
|------|------|------|------|
|
||||
| 技术指标层 | `services/technical_indicators.py` | 计算 MACD、SKDJ、KDJ、EMA、MA 等基础指标 | 二 |
|
||||
| 信号检测层 | `services/signal_detector.py` | 基于7个信号检测买卖点 | 三 |
|
||||
| 外部因素模块 | `fund_flow_analyzer.py` / `market_sentiment.py` / `external_factors.py` / `news_analyzer.py` | 资金面、市场情绪、北向资金、美股、大宗商品、公告/政策、汇率 | 四 |
|
||||
| 算法决策层 | `services/stock_algorithms.py` | 统一推荐逻辑、牛股阶段识别、技术面深度分析 | 五 |
|
||||
| 综合评分引擎 | `services/score_engine.py` | 整合外部因素(P0-P7)与技术面评分,输出最终评分 | 五 |
|
||||
|
||||
---
|
||||
|
||||
## 二、技术指标层
|
||||
|
||||
`calc_all_indicators` 一次性计算所有指标,附加到 DataFrame 的列中。
|
||||
|
||||
### 2.1 EMA(指数移动平均线)
|
||||
|
||||
**白话解释**:均线就是最近N天价格的平均值,用来判断趋势方向。EMA 比普通均线更敏感,越近的价格权重越大,反应更快。
|
||||
|
||||
| 指标 | 参数 | 用途 |
|
||||
|------|------|------|
|
||||
| EMA3 | 3日 | 超短期均线,反映最近3天的平均价格 |
|
||||
| EMA21 | 21日 | 中期均线,反映最近21天的平均价格 |
|
||||
|
||||
**怎么用**:当 EMA3 从下往上穿过 EMA21,说明短期价格变强了,是反弹信号。
|
||||
|
||||
### 2.2 MACD(指数平滑异同移动平均线)
|
||||
|
||||
**白话解释**:MACD 是最经典的趋势指标。它用两条均线(快线12日、慢线26日)的差值来判断趋势的方向和强弱。
|
||||
|
||||
| 输出 | 含义 | 白话 |
|
||||
|------|------|------|
|
||||
| DIF | 快线减慢线的差值 | 短期价格和中期价格的差距,正数说明短期比中期强 |
|
||||
| DEA | DIF 的9日均线 | DIF的平均值,用来判断DIF的趋势 |
|
||||
| MACD柱 | 2 × (DIF - DEA) | 红绿柱子,红柱=DIF在DEA上方=多头力量,绿柱=空头力量 |
|
||||
|
||||
**怎么用**:
|
||||
- **金叉**:DIF 从下往上穿过 DEA → 买入信号
|
||||
- **死叉**:DIF 从上往下穿过 DEA → 卖出信号
|
||||
- **零轴上方金叉**:DIF 和 DEA 都在0以上时金叉 → 主升浪,最强买入信号
|
||||
- **底背离**:价格创新低但 DIF 没创新低 → 下跌动力不足,可能要反转
|
||||
|
||||
### 2.3 KDJ(随机指标)
|
||||
|
||||
**白话解释**:KDJ 用来判断价格是在"超买"还是"超卖"。就像弹簧,压得太紧(超卖)容易弹起来,拉得太开(超买)容易缩回去。
|
||||
|
||||
| 输出 | 含义 | 白话 |
|
||||
|------|------|------|
|
||||
| K | 快线 | 对价格变化最敏感,反应最快 |
|
||||
| D | 慢线 | K的平均值,更平稳 |
|
||||
| J | 超前线 | 3K-2D,比K更超前,可以提前预判 |
|
||||
|
||||
**怎么用**:
|
||||
- K > 80 → 超买区,价格可能要回调
|
||||
- K < 20 → 超卖区,价格可能要反弹
|
||||
- K 上穿 D → 金叉,买入信号
|
||||
- K 下穿 D → 死叉,卖出信号
|
||||
|
||||
### 2.4 SKDJ(慢速随机指标)
|
||||
|
||||
**白话解释**:SKDJ 是 KDJ 的"慢速版",对价格变化做了两次平滑,信号更少但更可靠。龙抬头信号就是用 SKDJ 来判断的。
|
||||
|
||||
| 输出 | 含义 | 白话 |
|
||||
|------|------|------|
|
||||
| K | 慢速K值 | 经过两次平滑的K线,比普通KDJ的K更稳 |
|
||||
| D | 慢速D值 | K的平均值,最平稳 |
|
||||
|
||||
**怎么用**:
|
||||
- K < 20 → 超卖区,股票被过度抛售
|
||||
- K 从超卖区上穿 D → 龙抬头信号,短线起爆点
|
||||
- 还要检查最近3天K值的波动不能太大(标准差<15),确保信号稳定
|
||||
|
||||
### 2.5 MA(简单移动平均线)
|
||||
|
||||
**白话解释**:最基础的均线,就是最近N天收盘价的简单平均。用来判断中长期趋势方向。
|
||||
|
||||
| 指标 | 参数 | 用途 |
|
||||
|------|------|------|
|
||||
| MA5 | 5日 | 一周均价,超短期趋势 |
|
||||
| MA10 | 10日 | 两周均价,短期趋势 |
|
||||
| MA20 | 20日 | 一个月均价,中期趋势 |
|
||||
| MA60 | 60日 | 三个月均价,长期趋势 |
|
||||
|
||||
**怎么用**:
|
||||
- MA5 > MA10 > MA20 > MA60 → 多头排列(从短期到长期依次排列),强势上涨趋势
|
||||
- MA5 < MA10 < MA20 < MA60 → 空头排列,弱势下跌趋势
|
||||
- 交叉纠缠 → 趋势不明,需要等待
|
||||
|
||||
---
|
||||
|
||||
## 三、信号检测层
|
||||
|
||||
### 3.1 7个交易信号(按胜率从高到低排名)
|
||||
|
||||
#### 信号1:主升浪(胜率85%)
|
||||
|
||||
**白话解释**:主升浪就是股票进入"加速上涨"的阶段。就像汽车挂了最高档,速度最快,利润兑现最快。
|
||||
|
||||
**触发条件**:
|
||||
- DIF > 0 且 DEA > 0(两条线都在零轴上方,说明大趋势向上)
|
||||
- DIF 从下往上穿过 DEA(金叉,说明短期又开始加速)
|
||||
|
||||
**含义**:趋势大好,进入加速拉升阶段。持仓者应该加仓,不要轻易出场。
|
||||
|
||||
#### 信号2:日线底背离(胜率80%)
|
||||
|
||||
**白话解释**:股价创新低了,但 MACD 指标没有创新低。这说明"虽然价格还在跌,但下跌的动力已经不足了",就像皮球落地,虽然还在最低点,但已经开始反弹了。
|
||||
|
||||
**触发条件**:
|
||||
- 收盘价创20日新低(最近20天最低价)
|
||||
- 但 DIF 值没有创20日新低(下跌动力在减弱)
|
||||
- DIF < 0(还在零轴下方,确认是在下跌趋势中)
|
||||
|
||||
**含义**:大级别反转信号,真正"跌透了",可能迎来一波像样的反弹。
|
||||
|
||||
#### 信号3:龙抬头(胜率75%)
|
||||
|
||||
**白话解释**:龙抬头是短线最佳买点。经过一段下跌后,SKDJ指标在超卖区(K<20)发生金叉,就像龙从水面抬起头来,说明资金开始进场了。
|
||||
|
||||
**触发条件**:
|
||||
- SKDJ 的 K 值在超卖区(前一天 K < 20,或今天 K < 30)
|
||||
- K 从下往上穿过 D(金叉)
|
||||
- 最近3天 K 值标准差 < 15(信号稳定,不是剧烈波动中的假信号)
|
||||
|
||||
**含义**:短线起爆点,反弹稳定性强。这是体系中的**实操核心买点**。
|
||||
|
||||
#### 信号4:真龙(胜率70%)
|
||||
|
||||
**白话解释**:真龙是趋势正式确立的信号。价格站上20日均线,短期均线上穿中期均线,MACD翻红,成交量放大——多个条件同时满足,说明趋势真的来了。
|
||||
|
||||
**触发条件**(4个条件满足3个即可,但价格必须在MA20上方):
|
||||
- 价格 > MA20(站上中期均线)
|
||||
- MA5 上穿 MA20(短期均线金叉中期均线)
|
||||
- MACD柱从负转正(多头力量开始占优)
|
||||
- 成交量 > 10日均量的1.2倍(放量确认)
|
||||
|
||||
**含义**:中期趋势刚刚启动,可以追入,但最好等回调买入。
|
||||
|
||||
#### 信号5:短底背离(胜率65%)
|
||||
|
||||
**白话解释**:和日线底背离类似,但看的是10日窗口。价格创10日新低但DIF没创新低,说明短期下跌动力不足。
|
||||
|
||||
**触发条件**:
|
||||
- 收盘价创10日新低
|
||||
- 但 DIF 值没有创10日新低
|
||||
|
||||
**含义**:小级别反弹信号,灵敏度高但力度偏弱。适合短线操作。
|
||||
|
||||
#### 信号6:老鼠仓(胜率60%)
|
||||
|
||||
**白话解释**:盘中突然急跌(跌了3%以上),但收盘又收回来了,而且成交量放大。这很可能是主力在"偷偷吸筹"——故意打压价格吓跑散户,然后低价买入。
|
||||
|
||||
**触发条件**:
|
||||
- 盘中最大跌幅 > 3%(最低价远低于开盘价)
|
||||
- 收盘回收 > 60%(从最低点反弹回大部分)
|
||||
- 收盘价接近开盘价(跌幅不超过1%)
|
||||
- 成交量 > 10日均量的1.3倍(放量)
|
||||
|
||||
**含义**:主力吸筹信号,上涨可能不会立竿见影,但后续大概率会涨。
|
||||
|
||||
#### 信号7:反弹(胜率55%)
|
||||
|
||||
**白话解释**:最简单的均线金叉信号——EMA3(3日均线)从下往上穿过 EMA21(21日均线)。说明短期价格开始强于中期价格了。
|
||||
|
||||
**触发条件**:
|
||||
- 前一天 EMA3 ≤ EMA21
|
||||
- 今天 EMA3 > EMA21
|
||||
|
||||
**含义**:普通均线金叉,震荡市适用,但熊市中容易出现假反弹,需要结合其他信号确认。
|
||||
|
||||
### 3.2 信号状态检查
|
||||
|
||||
系统不仅检测信号是否触发,还会计算每个信号的**就绪程度(readiness 0-100)**,告诉用户"距离触发还有多远"。
|
||||
|
||||
例如龙抬头信号:
|
||||
- K < 20 且 K > D 且 前一天 K ≤ D → readiness = 100(已触发)
|
||||
- K < 20 → readiness = 70(在超卖区,等金叉)
|
||||
- K < 30 → readiness = 40(接近超卖区)
|
||||
- K < 50 → readiness = 20(在中位,还远)
|
||||
- K ≥ 50 → readiness = 5(偏高,不满足条件)
|
||||
|
||||
---
|
||||
|
||||
## 四、外部影响因素分析与实现
|
||||
|
||||
> 系统已将以下外部因素全部纳入综合评分引擎,与技术面评分叠加为最终评分。
|
||||
> 各因素独立计算,失败时返回0分不影响主流程。
|
||||
|
||||
### 4.1 主力资金进出(P0,已实现)
|
||||
|
||||
**白话解释**:股市里的"主力"就是那些资金量很大的机构投资者(基金、券商、险资等)。他们买卖的金额巨大,足以影响股价走向。就像一条大鱼在小池塘里游,方向一目了然。
|
||||
|
||||
#### 影响机制
|
||||
|
||||
| 资金类型 | 单笔金额 | 影响力 | 白话 |
|
||||
|----------|----------|--------|------|
|
||||
| 超大单 | ≥100万/笔 | 最强 | 大机构的大动作,直接推动股价 |
|
||||
| 大单 | 20-100万/笔 | 强 | 中型机构的操作,趋势的重要推手 |
|
||||
| 中单 | 4-20万/笔 | 中等 | 游资和大户,短期波动源 |
|
||||
| 小单 | <4万/笔 | 弱 | 散户交易,通常被主力"收割" |
|
||||
|
||||
**主力净流入 = 超大单净流入 + 大单净流入**,正值说明主力在买入,负值说明在卖出。
|
||||
|
||||
#### 预测信号(已实现)
|
||||
|
||||
| 信号 | 含义 | 可靠度 | 评分 |
|
||||
|------|------|--------|------|
|
||||
| 连续3日主力净流入 | 主力持续吸筹,后市看涨 | ★★★★ | +10 |
|
||||
| 主力净流入+价格不涨 | 暗中吸筹(压价买货),可能即将拉升 | ★★★★★ | +8 |
|
||||
| 主力净流出+价格不跌 | 暗中出货(托价卖出),危险信号 | ★★★★★ | -8 |
|
||||
| 超大单突然大幅流入 | 大机构突击入场,短线可能拉升 | ★★★ | +5 |
|
||||
| 主力净流入占比>10% | 主力主导行情,散户跟风空间大 | ★★★★ | +5 |
|
||||
|
||||
#### 实现模块
|
||||
|
||||
- **模块文件**:`services/fund_flow_analyzer.py`
|
||||
- **数据来源**:`stock_fund_flow_history` 表(由 `sync_fund_flow.py` 每日同步)
|
||||
- **API端点**:`GET /api/fund_flow_analysis/<code>`
|
||||
- **评分范围**:±20
|
||||
|
||||
### 4.2 市场情绪指标(P1,已实现)
|
||||
|
||||
**白话解释**:市场情绪是整个A股的"温度计"。涨停的股票多说明市场热情高,跌停的多说明恐慌蔓延。情绪好的时候,技术面信号更容易兑现;情绪差的时候,再好的形态也可能被砸盘。
|
||||
|
||||
| 指标 | 含义 | 获取方式 | 评分 |
|
||||
|------|------|----------|------|
|
||||
| 涨停/跌停家数比 | >5:1 偏多,<1:1 偏空 | 从实时行情统计 | +5/-5 |
|
||||
| 连板高度 | 最高连板数,反映市场热度 | 从涨停家数估算 | +3 |
|
||||
| 换手率中位数 | 反映市场活跃度 | 从实时行情统计 | — |
|
||||
| 两市成交额 | >1.2万亿偏热,<6000亿偏冷 | 从行情数据 | +2/-2 |
|
||||
|
||||
#### 实现模块
|
||||
|
||||
- **模块文件**:`services/market_sentiment.py` → `calc_market_sentiment()`
|
||||
- **数据来源**:`stock_realtime_price` 表(已有数据,无需额外数据源)
|
||||
- **API端点**:`GET /api/market_sentiment`
|
||||
- **评分范围**:±10
|
||||
|
||||
### 4.3 北向资金(P2,已实现)
|
||||
|
||||
**白话解释**:北向资金是从香港流入A股的"外资",被市场视为"聪明钱"。北向大幅买入通常被视为利好信号。
|
||||
|
||||
| 信号 | 含义 | 可靠度 | 评分 |
|
||||
|------|------|--------|------|
|
||||
| 北向单日净流入>50亿 | 外资看好,市场偏多 | ★★★★ | +5 |
|
||||
| 北向单日净流出>50亿 | 外资看空,注意风险 | ★★★★ | -5 |
|
||||
| 北向连续3日净流入 | 外资持续看好,中期偏多 | ★★★★★ | +3 |
|
||||
| 北向连续3日净流出 | 外资持续撤离,中期偏空 | ★★★★ | -3 |
|
||||
|
||||
#### 实现模块
|
||||
|
||||
- **模块文件**:`services/external_factors.py` → `get_northbound_capital()`
|
||||
- **数据来源**:AKShare `stock_hsgt_north_net_flow_in_em`(北向资金净流入)
|
||||
- **评分范围**:±10
|
||||
|
||||
### 4.4 美股隔夜板块变化(P3,已实现)
|
||||
|
||||
**白话解释**:美股是全球股市的"风向标"。美股晚上涨跌,第二天A股往往跟着反应。尤其是美股的板块变化——如果美股科技股大涨,A股科技板块大概率高开;美股新能源车跌了,A股相关产业链也容易跟跌。
|
||||
|
||||
#### 影响机制
|
||||
|
||||
| 美股板块 | 对应A股板块 | 影响强度 | 传导逻辑 |
|
||||
|----------|------------|----------|----------|
|
||||
| 科技(纳斯达克) | 半导体、软件、消费电子 | ★★★★★ | 全球科技产业链联动 |
|
||||
| 新能源车(特斯拉) | 锂电池、汽车零部件 | ★★★★★ | 产业链直接关联 |
|
||||
| 金融(银行/保险) | 银行、保险、券商 | ★★★★ | 全球金融情绪传导 |
|
||||
| 能源(石油) | 石油开采、化工 | ★★★★ | 大宗商品价格联动 |
|
||||
| 医药生物 | 创新药、医疗器械 | ★★★ | 审批/研发进展联动 |
|
||||
| 消费零售 | 消费、白酒 | ★★ | 消费趋势参考 |
|
||||
| 房地产 | 地产链 | ★★ | 政策面差异大 |
|
||||
|
||||
#### 预测场景
|
||||
|
||||
| 场景 | A股大概率反应 | 注意事项 |
|
||||
|------|------------|----------|
|
||||
| 美股三大指数全线大涨 | A股高开0.5-1.5% | 高开后可能回落,不追高 |
|
||||
| 美股某板块暴涨>3% | A股对应板块高开跟涨 | 关注龙头股,散户跟风 |
|
||||
| 美股暴跌>2% | A股低开1%左右 | 低开后可能反弹,看资金面 |
|
||||
| 美股V型反转 | A股影响较小 | 说明美股自身企稳 |
|
||||
| 美股连续创新高 | A股情绪偏暖 | 但A股有自己的节奏 |
|
||||
| 美联储加息/降息 | 全市场情绪波动 | 加息偏空,降息偏多 |
|
||||
|
||||
**重要提醒**:美股影响主要是**开盘阶段**(9:25-10:00),之后A股会回归自身逻辑。不能仅凭美股涨跌做全天决策。
|
||||
|
||||
#### 实现模块
|
||||
|
||||
- **模块文件**:`services/external_factors.py` → `get_us_market_overview()`
|
||||
- **数据来源**:AKShare `index_global`(全球指数)
|
||||
- **板块映射**:内置 美股板块→A股板块 映射表
|
||||
- **评分范围**:±10
|
||||
|
||||
### 4.5 大宗商品价格(P4,已实现)
|
||||
|
||||
**白话解释**:石油、黄金、铜等大宗商品价格变化,直接影响A股相关板块。
|
||||
|
||||
| 商品 | 影响板块 | 传导逻辑 | 评分 |
|
||||
|------|----------|----------|------|
|
||||
| 原油 | 石油开采(利好)、航空(利空) | 油价涨→开采盈利增→航空成本增 | ±1 |
|
||||
| 黄金 | 黄金股、珠宝 | 金价涨→黄金企业盈利增 | ±1 |
|
||||
| 铜 | 有色金属、电缆 | 铜价涨→铜企受益 | ±1 |
|
||||
| 螺纹钢 | 钢铁(利好)、基建/地产(利空) | 钢价涨→钢企受益,基建成本增 | ±1 |
|
||||
| 碳酸锂 | 锂矿/锂电池(利好)、新能源车(利空) | 锂价涨→锂矿受益,新能源车成本增 | ±1 |
|
||||
|
||||
#### 实现模块
|
||||
|
||||
- **模块文件**:`services/external_factors.py` → `get_commodity_overview()`
|
||||
- **数据来源**:AKShare `futures_main_sina`(商品期货行情)
|
||||
- **内置商品→A股板块影响映射表**:`COMMODITY_A_SECTOR_MAP`
|
||||
- **评分范围**:±5
|
||||
|
||||
### 4.6 上市公司并购消息(P5,已实现)
|
||||
|
||||
**白话解释**:并购就是一家公司买下或合并另一家公司。好的并购能让公司"1+1>2",股价暴涨;坏的并购可能拖累业绩,股价下跌。并购消息往往是股价的"催化剂"——技术面再好,没有消息催化也涨不起来;技术面一般,一个并购消息就能连续涨停。
|
||||
|
||||
#### 影响机制
|
||||
|
||||
| 消息类型 | 影响方向 | 持续时间 | 典型幅度 | 评分 |
|
||||
|----------|----------|----------|----------|------|
|
||||
| 被收购溢价并购 | 大涨 | 1-3个涨停 | +10%~+30% | +10 |
|
||||
| 收购优质资产 | 大涨 | 3-5日 | +5%~+20% | +10 |
|
||||
| 收购劣质资产 | 下跌 | 3-5日 | -5%~-15% | -8 |
|
||||
| 合并重组 | 看涨 | 5-10日 | +5%~+30% | +10 |
|
||||
| 资产剥离 | 看涨 | 1-3日 | +3%~+10% | +5 |
|
||||
| 股权转让 | 看涨 | 1-3日 | +3%~+10% | +5 |
|
||||
| 定增引入战投 | 看涨 | 3-5日 | +3%~+15% | +5 |
|
||||
| 商誉减值 | 大跌 | 1-2日 | -5%~-20% | -8 |
|
||||
|
||||
#### 预测策略
|
||||
|
||||
| 策略 | 可行性 | 说明 |
|
||||
|------|--------|------|
|
||||
| 消息面监控 | ★★★★ | 监控公司公告/新闻,第一时间发现并购消息 |
|
||||
| 股价异动预警 | ★★★★ | 监测异常放量涨跌,反推可能有消息 |
|
||||
| 停牌复牌跟踪 | ★★★★ | 停牌公司复牌后通常有大幅波动 |
|
||||
| 龙虎榜数据 | ★★★ | 看到机构大举买入,可能提前知道消息 |
|
||||
| 技术面预判 | ★★ | 有些股票并购前有资金提前布局的痕迹 |
|
||||
|
||||
#### 实现模块
|
||||
|
||||
- **模块文件**:`services/news_analyzer.py` → `analyze_announcement_sentiment()` + `detect_price_anomaly()`
|
||||
- **数据来源**:AKShare `stock_notice_report`(公告数据)
|
||||
- **LLM分析**:豆包AI 对重要公告做情感分析(规则评分兜底)
|
||||
- **异动检测**:量比>3 + 涨跌幅>5% 标记为"可能有消息面催化"
|
||||
- **API端点**:`GET /api/news_analysis/<code>`
|
||||
- **评分范围**:±15
|
||||
|
||||
### 4.7 政策面(P6,已实现)
|
||||
|
||||
**白话解释**:A股是"政策市",政策的影响力往往超过技术面。一个政策出台,整个板块可能集体涨停或跌停。
|
||||
|
||||
| 政策类型 | 影响范围 | 典型案例 | 评分 |
|
||||
|----------|----------|----------|------|
|
||||
| 行业扶持政策 | 对应板块暴涨 | 新能源补贴、芯片国产替代 | +2/条 |
|
||||
| 行业监管政策 | 对应板块暴跌 | 教育双减、互联网反垄断 | -3/条 |
|
||||
| 货币政策(降准/降息) | 全市场偏多 | 流动性增加,资金入市 | +2/条 |
|
||||
| 财政政策(基建/减税) | 相关板块受益 | 基建投资、减税降费 | +2/条 |
|
||||
| IPO/再融资政策 | 市场情绪 | 加速IPO偏空,放缓偏多 | 中性 |
|
||||
| 交易规则变化 | 短期情绪 | 降印花税、限制减持 | 中性 |
|
||||
|
||||
#### 实现模块
|
||||
|
||||
- **模块文件**:`services/news_analyzer.py` → `analyze_policy_impact()`
|
||||
- **数据来源**:AKShare `stock_info_global_em`(财经新闻)
|
||||
- **关键词分类**:扶持/监管/货币/财政/资本市场
|
||||
- **LLM深度分析**:重大政策调用豆包AI分析(规则评分兜底)
|
||||
- **评分范围**:±10
|
||||
|
||||
### 4.8 汇率变化(P7,已实现)
|
||||
|
||||
**白话解释**:人民币升值利好进口型企业(航空、造纸),贬值利好出口型企业(纺织、电子代工)。
|
||||
|
||||
| 汇率变化 | 受益板块 | 受损板块 | 评分 |
|
||||
|----------|----------|----------|------|
|
||||
| 人民币升值 | 航空、造纸、房地产 | 纺织、家电出口、电子代工 | +2 |
|
||||
| 人民币贬值 | 纺织、家电、电子代工 | 航空、造纸 | -2 |
|
||||
| 汇率稳定 | — | — | 0 |
|
||||
|
||||
#### 实现模块
|
||||
|
||||
- **模块文件**:`services/external_factors.py` → `get_fx_overview()`
|
||||
- **数据来源**:AKShare `currency_boc_sina`(人民币汇率)
|
||||
- **评分范围**:±3
|
||||
|
||||
### 4.9 集成架构与评分体系
|
||||
|
||||
#### 架构
|
||||
|
||||
```
|
||||
当前架构(已实现):
|
||||
K线数据 → 技术指标 → 信号检测 → 深度分析(技术面基础分) ──┐
|
||||
资金流向数据 → 资金信号 ────────────────────────────────┤
|
||||
美股隔夜数据 → 外盘情绪 ────────────────────────────────┤→ 综合评分引擎 → 最终评分 → 买卖建议/AI解说
|
||||
公告/新闻 → LLM情感分析 ───────────────────────────────┤
|
||||
北向资金 → 外资动向 ────────────────────────────────────┤
|
||||
市场情绪指标 → 情绪评分 ────────────────────────────────┤
|
||||
大宗商品 → 板块影响 ────────────────────────────────────┤
|
||||
汇率 → 进出口影响 ──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 评分权重
|
||||
|
||||
| 因素 | 评分范围 | 说明 |
|
||||
|------|----------|------|
|
||||
| 技术面基础分 | 0-100 | `compute_deep_analysis` 原始分 |
|
||||
| P0 资金面 | ±20 | 连续流入+10,吸筹+8,大单突击+5 |
|
||||
| P1 市场情绪 | ±10 | 涨跌停比+5/-5,连板+3,成交额+2/-2 |
|
||||
| P2 北向资金 | ±10 | 大幅流入+5,连续流入+3 |
|
||||
| P3 美股外盘 | ±10 | 美股大涨+5,大跌-5 |
|
||||
| P4 大宗商品 | ±5 | 单品种涨跌±1 |
|
||||
| P5 公告/异动 | ±15 | 并购+10,业绩预增+8,异动±5 |
|
||||
| P6 政策面 | ±10 | 扶持+2,监管-3 |
|
||||
| P7 汇率 | ±3 | 升值+2,贬值-2 |
|
||||
| **P5+P6 合并上限** | **±20** | `analyze_news_factors` 统一计算后限制 |
|
||||
| **外部总分上限** | **±40** | 避免外部因素喧宾夺主 |
|
||||
|
||||
**核心原则**:技术面仍是基础(权重60%+),外部因素作为加减分项,避免外部因素喧宾夺主。
|
||||
|
||||
### 4.10 新增模块和API
|
||||
|
||||
#### 新增模块文件
|
||||
|
||||
| 模块 | 文件 | 功能 |
|
||||
|------|------|------|
|
||||
| 资金流向分析 | `services/fund_flow_analyzer.py` | 从DB读取资金流向,计算连续流入/流出、量价背离、大单突击 |
|
||||
| 市场情绪指标 | `services/market_sentiment.py` | 从实时行情表计算涨停跌停比、连板高度、换手率中位数、两市成交额 |
|
||||
| 外部因素 | `services/external_factors.py` | 北向资金、美股隔夜板块、大宗商品、汇率变化 |
|
||||
| 新闻/公告分析 | `services/news_analyzer.py` | 公告采集+分类、LLM情感分析、异动检测、政策面监控 |
|
||||
| 综合评分引擎 | `services/score_engine.py` | 汇总技术面+所有外部因素,输出最终评分 |
|
||||
|
||||
#### 新增API端点
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/market_sentiment` | GET | 市场情绪指标 |
|
||||
| `/api/external_factors` | GET | 外部因素综合数据(北向/美股/商品/汇率) |
|
||||
| `/api/fund_flow_analysis/<code>` | GET | 个股资金流向分析 |
|
||||
| `/api/news_analysis/<code>` | GET | 个股消息面分析(公告+政策+异动) |
|
||||
|
||||
### 4.11 数据流
|
||||
|
||||
```
|
||||
deep_analyze 接口调用流程:
|
||||
1. 获取K线数据 → calc_all_indicators → detect_all_signals
|
||||
2. compute_deep_analysis(技术面评分 0-100)→ 技术得分
|
||||
3. score_engine.compute_comprehensive_score:
|
||||
├─ fund_flow_analyzer.analyze_fund_flow(P0)
|
||||
├─ market_sentiment.calc_market_sentiment(P1)
|
||||
├─ external_factors.get_all_external_factors(P2-P4,C7)
|
||||
└─ news_analyzer.analyze_news_factors(P5-P6)
|
||||
4. 最终评分 = 技术得分 + 外部得分(上限100,下限0)→ 综合得分
|
||||
5. 根据综合得分修正买卖建议
|
||||
6. LLM润色AI解说
|
||||
```
|
||||
|
||||
### 4.12 容错机制
|
||||
|
||||
- 所有外部因素模块均有 try/except 保护,失败时返回中性评分(0分)
|
||||
- AKShare 数据源不可用时自动降级,不影响主流程
|
||||
- LLM 分析失败时回退到规则评分
|
||||
- 当日缓存避免重复调用外部API
|
||||
|
||||
---
|
||||
|
||||
## 五、算法决策层
|
||||
|
||||
> **v2.0 核心变更**:推荐股票时以**综合得分**为准,并列明**技术得分**、**外部得分**和**综合得分**三项。
|
||||
> - **技术得分**(technical_score):`compute_deep_analysis` 产出的 0-100 分,纯技术面
|
||||
> - **外部得分**(external_score):P0-P7 外部因素加减分,范围 ±40
|
||||
> - **综合得分**(final_score)= 技术得分 + 外部得分,限制在 0-100
|
||||
|
||||
### 5.1 统一推荐算法 `compute_recommend`
|
||||
|
||||
> ⚠️ 本节推荐基于技术信号(MACD/龙抬头/底背离等),仅作为**技术面初筛推荐**。
|
||||
> 在 `deep_analyze` 深度分析中,综合评分引擎计算完成后,会根据**综合得分**修正买卖建议——当综合评级与技术面推荐矛盾时,**以综合得分为准**。
|
||||
|
||||
遵循"体系最强战法"流程,分**持仓**和**非持仓**两套逻辑:
|
||||
|
||||
#### 持仓时(已经持有该股票)
|
||||
|
||||
| 条件 | 技术面推荐 | 评分 | 白话 |
|
||||
|------|------|------|------|
|
||||
| MACD死叉 + 无主升浪 | 卖出 | 75 | 趋势走弱了,该走了 |
|
||||
| 主升浪 | 加仓 | 90 | 加速拉升中,加码赚钱 |
|
||||
| 真龙 | 持有 | 70 | 趋势确认了,拿着别动 |
|
||||
| 其他 | 观望 | 50 | 拿着等主升浪 |
|
||||
|
||||
#### 非持仓时(还没买)
|
||||
|
||||
| 条件 | 技术面推荐 | 评分 | 白话 |
|
||||
|------|------|------|------|
|
||||
| 底背离 + 龙抬头 + MACD金叉 | 买入 | 95 | 最佳买点!跌透了+资金进场+趋势配合 |
|
||||
| 龙抬头 + 主升浪 + MACD金叉 | 买入 | 90 | 强势买入!资金进场+加速段 |
|
||||
| 龙抬头 + MACD金叉 | 买入 | 80 | 核心买点!资金进场了 |
|
||||
| 底背离 + 龙抬头 + MACD死叉 | 关注 | 65 | 好信号但趋势没配合,等一等 |
|
||||
| 龙抬头 + MACD死叉 | 关注 | 55 | 信号冲突,谨慎观望 |
|
||||
| 主升浪(非持仓) | 关注 | 75 | 已过最佳买点,等回调 |
|
||||
| 真龙 | 关注 | 65 | 趋势刚启动,等龙抬头确认 |
|
||||
| MACD死叉 | 回避 | 25 | 趋势偏弱,别碰 |
|
||||
| 底背离 | 关注 | 60 | 跌透了,纳入关注池 |
|
||||
| 有信号触发 | 观察 | 40 | 有信号但不够强 |
|
||||
| 无信号 | 观望 | 0 | 没机会,别动 |
|
||||
|
||||
### 5.2 牛股阶段识别 `compute_bull_stage`
|
||||
|
||||
> ⚠️ 本节阶段识别仅基于技术信号,**不含外部因素**。
|
||||
|
||||
把股票在"牛股启动流程"中的位置分为5个阶段:
|
||||
|
||||
```
|
||||
阶段1:底部探测 → 阶段2:资金进场 → 阶段3:趋势确立 → 阶段4:加速拉升
|
||||
↓
|
||||
阶段5:回调补涨
|
||||
```
|
||||
|
||||
| 阶段 | 名称 | 触发信号 | 进度 | 白话建议 |
|
||||
|------|------|----------|------|----------|
|
||||
| 1 | 底部探测 | 底背离/短底背离/老鼠仓 | 20% | 跌得差不多了,放进关注池盯着 |
|
||||
| 2 | 资金进场 | 龙抬头 | 45% | **最佳买入时机!** 资金开始进场了 |
|
||||
| 3 | 趋势确立 | 真龙 | 65% | 趋势确认了,可以追,但等回调买更好 |
|
||||
| 4 | 加速拉升 | 主升浪 | 85% | 已经涨起来了,持仓的加仓,没买的别追高 |
|
||||
| 5 | 回调补涨 | 反弹 | 50% | 回调后可能补涨,但要小心是假反弹 |
|
||||
|
||||
多信号叠加会加分(底背离+10%、龙抬头+5%、真龙+5%、老鼠仓+5%),说明流程更完整,牛股可能性更大。
|
||||
|
||||
### 5.3 深度分析 `compute_deep_analysis` + 综合评分引擎
|
||||
|
||||
对单只股票进行**技术面7维度 + 外部因素8维度**的深度分析,最终由综合评分引擎汇总为统一评分。
|
||||
|
||||
#### 5.3.1 三项得分定义
|
||||
|
||||
| 得分项 | 字段名 | 来源 | 范围 | 说明 |
|
||||
|--------|--------|------|------|------|
|
||||
| **技术得分** | `technical_score` | `compute_deep_analysis` → `deep_score` | 0-100 | 纯技术面评分,基于均线/量价/形态/信号等7维度 |
|
||||
| **外部得分** | `external_score` | `score_engine.compute_comprehensive_score` | -40 ~ +40 | P0-P7 八大外部因素加减分总和 |
|
||||
| **综合得分** | `final_score` | `technical_score + external_score` | 0-100 | 最终评分,**推荐股票以此为准** |
|
||||
|
||||
#### 5.3.2 技术面维度(7个,技术得分 0-100)
|
||||
|
||||
###### 维度1:均线系统
|
||||
|
||||
判断 MA5/10/20/60 的排列方式:
|
||||
- **多头排列**:MA5 > MA10 > MA20 → 短期比中期强,中期比长期强,上涨趋势
|
||||
- **空头排列**:MA5 < MA10 < MA20 → 依次向下,下跌趋势
|
||||
- **交叉整理**:均线纠缠在一起 → 方向不明
|
||||
|
||||
###### 维度2:价格位置
|
||||
|
||||
计算当前价格在20/60/120日高低区间的百分位(0-100%):
|
||||
|
||||
**白话解释**:就像一把尺子,0%是最低点,100%是最高点。当前价格在尺子上的位置。
|
||||
|
||||
- < 20% → 低位区间,可能存在反弹机会
|
||||
- 20%-50% → 中低位置,相对安全
|
||||
- 50%-80% → 中高位置,还有一定上涨空间
|
||||
- > 80% → 高位区间,追高要小心
|
||||
|
||||
###### 维度3:支撑与压力位
|
||||
|
||||
**白话解释**:支撑位是"价格跌到这里容易止跌"的位置,压力位是"价格涨到这里容易受阻"的位置。
|
||||
|
||||
支撑位来源:
|
||||
- 当前价格下方的均线(MA5/10/20/60)
|
||||
- 20/60/120日的最低点
|
||||
|
||||
压力位来源:
|
||||
- 当前价格上方的均线
|
||||
- 20/60/120日的最高点
|
||||
|
||||
按距离当前价格从近到远排序,取前5个。
|
||||
|
||||
###### 维度4:成交量分析
|
||||
|
||||
计算量比 = 今日成交量 / 20日平均成交量:
|
||||
|
||||
| 量比 | 判断 | 白话 |
|
||||
|------|------|------|
|
||||
| < 0.6 | 缩量 | 市场冷清,没人交易 |
|
||||
| 0.6-1.3 | 平量 | 正常水平 |
|
||||
| 1.3-2.0 | 温和放量 | 有资金在活跃参与 |
|
||||
| > 2.0 | 大幅放量 | 市场关注度很高,要留意是主力进场还是出货 |
|
||||
|
||||
###### 维度5:形态识别
|
||||
|
||||
系统会自动识别以下技术形态:
|
||||
|
||||
| 形态 | 类型 | 白话 |
|
||||
|------|------|------|
|
||||
| 平台突破 | 看涨 | 股价横盘了很久(10日波动率<1.5%),今天终于突破了 |
|
||||
| 窄幅整理 | 中性 | 横盘中,蓄势待变,可能要选方向了 |
|
||||
| 创20日新高 | 看涨 | 股价达到近20天最高点,强势 |
|
||||
| 双底突破 | 看涨 | 两次探底价格接近,且突破中间的高点(颈线),经典反转形态 |
|
||||
| 量价齐升 | 看涨 | 近5天成交量和价格同步上升,资金在持续买入 |
|
||||
| 均线粘合发散 | 看涨 | MA5/10/20靠得很近(离散<1%)后开始多头排列,即将选择方向 |
|
||||
| 大阳线 | 看涨 | 当天涨幅≥5%,强势上涨 |
|
||||
| 大阴线 | 看跌 | 当天跌幅≥5%,强势下跌 |
|
||||
|
||||
###### 维度6:空间估算
|
||||
|
||||
计算最近压力位和最近支撑位之间的风险收益比:
|
||||
|
||||
**白话解释**:往上能涨多少 vs 往下能跌多少。
|
||||
|
||||
- 风险收益比 ≥ 2 → 性价比不错,潜在收益是风险的2倍以上
|
||||
- 1-2 → 性价比一般
|
||||
- < 0.8 → 下行风险大于上涨空间,不划算
|
||||
|
||||
###### 维度7:技术得分计算
|
||||
|
||||
基础分50分,根据以上各维度加减分:
|
||||
|
||||
| 评分项 | 加分/扣分 | 白话 |
|
||||
|--------|-----------|------|
|
||||
| 均线多头排列 | +10 | 趋势向上 |
|
||||
| 均线空头排列 | -10 | 趋势向下 |
|
||||
| 放量(量比≥1.3) | +5 | 有资金参与 |
|
||||
| 缩量(量比<0.6) | -3 | 市场冷清 |
|
||||
| 平台突破 | +10 | 蓄势后突破 |
|
||||
| 创20日新高 | +5 | 强势 |
|
||||
| 双底突破 | +10 | 经典反转形态 |
|
||||
| 量价齐升 | +8 | 资金持续买入 |
|
||||
| 均线粘合发散 | +7 | 即将选择方向(多头) |
|
||||
| 120日位置偏低 | +5 | 安全边际高 |
|
||||
| 120日位置偏高 | -5 | 追高风险 |
|
||||
| 20日位置偏低 | +3 | 相对安全 |
|
||||
| 20日位置偏高 | -3 | 注意风险 |
|
||||
| 3+信号共振 | +15 | 多信号确认 |
|
||||
| 2信号叠加 | +10 | 信号较多 |
|
||||
| 1个信号 | +5 | 有信号但不强 |
|
||||
| 风险收益比≥2 | +5 | 性价比好 |
|
||||
| 风险收益比<0.8 | -5 | 性价比差 |
|
||||
|
||||
技术得分映射:
|
||||
|
||||
| 技术得分 | 判定 | 白话 |
|
||||
|------|------|------|
|
||||
| ≥ 80 | 强烈看多 | 各方面都很好,值得关注 |
|
||||
| ≥ 65 | 看多 | 整体偏积极 |
|
||||
| ≥ 50 | 中性偏多 | 多空均衡,略偏积极 |
|
||||
| ≥ 35 | 中性偏空 | 多空均衡,略偏消极 |
|
||||
| < 35 | 看空 | 各方面都不好,回避 |
|
||||
|
||||
#### 5.3.3 外部因素维度(8个,外部得分 ±40 上限)
|
||||
|
||||
技术得分计算完成后,综合评分引擎 `score_engine.compute_comprehensive_score` 会叠加以下外部因素,产出外部得分:
|
||||
|
||||
| 维度 | 模块 | 评分范围 | 白话 |
|
||||
|------|------|----------|------|
|
||||
| P0 资金面 | `fund_flow_analyzer` | ±20 | 主力在买还是在卖?有没有暗中吸筹/出货? |
|
||||
| P1 市场情绪 | `market_sentiment` | ±10 | 今天涨停的股票多还是跌停的多?市场热不热? |
|
||||
| P2 北向资金 | `external_factors` | ±10 | 外资今天是买还是卖? |
|
||||
| P3 美股外盘 | `external_factors` | ±10 | 昨晚美股涨了还是跌了? |
|
||||
| P4 大宗商品 | `external_factors` | ±5 | 原油/黄金/铜的价格变化对相关板块的影响 |
|
||||
| P5 公告/异动 | `news_analyzer` | ±15 | 有没有并购/业绩预告等重大消息?股价有没有异动? |
|
||||
| P6 政策面 | `news_analyzer` | ±10 | 近期有没有行业扶持/监管政策? |
|
||||
| P7 汇率 | `external_factors` | ±3 | 人民币升值还是贬值? |
|
||||
|
||||
> P5+P6 由 `analyze_news_factors` 统一计算,合计上限 ±20(非各自独立累加)。
|
||||
|
||||
#### 5.3.4 综合得分计算
|
||||
|
||||
```
|
||||
综合得分 = 技术得分(0-100) + 外部得分(±40上限)
|
||||
= max(0, min(100, technical_score + external_score))
|
||||
```
|
||||
|
||||
综合得分评级映射:
|
||||
|
||||
| 综合得分 | 评级 | 白话 |
|
||||
|----------|------|------|
|
||||
| ≥ 80 | 强烈看多 | 技术面+外部因素共振看好 |
|
||||
| ≥ 65 | 看多 | 整体偏积极 |
|
||||
| ≥ 50 | 中性偏多 | 多空均衡,略偏积极 |
|
||||
| ≥ 35 | 中性偏空 | 多空均衡,略偏消极 |
|
||||
| < 35 | 看空 | 各方面都不好,回避 |
|
||||
|
||||
> 各外部因素独立计算,失败时返回0分不影响主流程。详见第四章。
|
||||
|
||||
### 5.4 综合推荐逻辑(v2.0 核心变更)
|
||||
|
||||
> **v2.0 核心原则:推荐股票以综合得分为准**
|
||||
|
||||
系统在 `deep_analyze` 接口中,先通过 `compute_recommend` 得出技术面初筛推荐,再由综合评分引擎计算三项得分,最后根据**综合得分**修正最终推荐。
|
||||
|
||||
#### 5.4.1 三项得分输出格式
|
||||
|
||||
`deep_analyze` 接口返回的 `comprehensive` 字段包含完整的三项得分:
|
||||
|
||||
```json
|
||||
{
|
||||
"comprehensive": {
|
||||
"technical_score": 72, // 技术得分(0-100)
|
||||
"external_score": +12, // 外部得分(-40 ~ +40)
|
||||
"final_score": 84, // 综合得分(0-100)
|
||||
"verdict": "强烈看多", // 综合评级
|
||||
"factors": { ... }, // 各因素详细数据
|
||||
"all_reasons": [ ... ], // 所有评分原因
|
||||
"summary": "..." // 综合白话总结
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同时,`report` 中的顶层字段也更新为综合得分:
|
||||
- `report['deep_score']` → 综合得分(`final_score`)
|
||||
- `report['verdict']` → 综合评级
|
||||
- `report['score_reasons']` → 技术面原因 + 外部因素原因
|
||||
- `report['recommend']['rate']` → 综合得分
|
||||
|
||||
#### 5.4.2 综合推荐修正规则
|
||||
|
||||
| 场景 | 技术面推荐 | 综合得分 | 最终推荐 | 修正逻辑 |
|
||||
|------|-----------|----------|----------|----------|
|
||||
| 技术面买入但外部因素拖累 | 买入/加仓 | < 50 | **关注** | 外部因素重大利空,降级为关注 |
|
||||
| 技术面观望但外部因素共振看好 | 观望/关注/观察 | ≥ 80 | **买入** | 外部因素共振看好,升级为买入 |
|
||||
| 技术面持有但外部因素重大利空 | 持有/观望 | < 35 | **卖出** | 外部因素重大利空,降级为卖出 |
|
||||
| 技术面与综合得分一致 | 任意 | 与技术面一致 | **保持技术面推荐** | 评分更新为综合得分 |
|
||||
|
||||
#### 5.4.3 推荐股票排序规则
|
||||
|
||||
在全景扫描/牛股筛选中,推荐股票按以下规则排序:
|
||||
|
||||
1. **主排序**:综合得分(`final_score`)降序
|
||||
2. **次排序**:技术得分(`technical_score`)降序
|
||||
3. **三级排序**:牛股阶段进度(`progress`)降序
|
||||
|
||||
#### 5.4.4 推荐展示格式
|
||||
|
||||
每只推荐股票展示以下信息:
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| 股票代码 | 6位代码 | 600519 |
|
||||
| 股票名称 | 中文名称 | 贵州茅台 |
|
||||
| 技术得分 | 纯技术面评分 | 72 |
|
||||
| 外部得分 | 外部因素加减分 | +12 |
|
||||
| 综合得分 | 最终评分 | 84 |
|
||||
| 综合评级 | 评级标签 | 强烈看多 |
|
||||
| 推荐操作 | 买入/卖出/关注等 | 买入 |
|
||||
| 推荐理由 | 综合原因说明 | 技术面观望,但综合评级「强烈看多」(外部因素共振看好),建议买入 |
|
||||
| 牛股阶段 | 阶段名称+进度 | 资金进场(45%) |
|
||||
| 活跃信号 | 当前触发的信号 | 龙抬头、底背离 |
|
||||
|
||||
### 5.5 AI 通俗解说
|
||||
|
||||
系统会将以上技术分析结果自动转成口语化的中文解说,涵盖:
|
||||
1. 当前走势概况(涨跌情况+均线趋势)
|
||||
2. 价格位置(在高位还是低位)
|
||||
3. 支撑压力(上方压力位和下方支撑位在哪)
|
||||
4. 成交量情况(放量还是缩量)
|
||||
5. 形态识别(发现了什么技术形态)
|
||||
6. 综合建议(根据**综合得分**给出操作建议)
|
||||
7. 空间估算(风险收益比如何)
|
||||
8. 外部因素(资金面/市场情绪/北向资金/美股/消息面等综合影响)
|
||||
9. **三项得分汇总**(技术得分、外部得分、综合得分)
|
||||
|
||||
还可以调用豆包 LLM 对规则文本进行润色,让表达更自然生动。
|
||||
|
||||
---
|
||||
|
||||
## 六、数据源优先级
|
||||
|
||||
K线数据获取的多级容灾机制:
|
||||
|
||||
| 优先级 | 数据源 | 覆盖范围 | 说明 |
|
||||
|--------|--------|----------|------|
|
||||
| 1 | 本地数据库 | 全市场 | 最快(毫秒级),优先使用 |
|
||||
| 2 | 阿里云API | 沪深(不含北交所) | 最稳定的云端源 |
|
||||
| 3 | 腾讯API | 全市场(含北交所) | 阿里云不支持北交所时使用 |
|
||||
| 4 | 麦蕊API | 沪深 | 第三方付费数据源 |
|
||||
| 5 | AKShare | 全市场 | 开源免费数据源,兜底 |
|
||||
|
||||
---
|
||||
|
||||
## 七、性能优化
|
||||
|
||||
| 优化点 | 说明 |
|
||||
|--------|------|
|
||||
| numpy 向量化 | 信号检测使用 `.values` numpy 数组替代 pandas iloc,元素访问从 5μs 降到 50ns |
|
||||
| 智能类型转换 | 已是 float64 的列跳过转换,避免重复 astype |
|
||||
| 线程本地连接 | 多线程扫描时使用 `threading.local()` 复用 DB 连接 |
|
||||
| 连接池 | 全局 `ThreadedConnectionPool`(2-20连接),避免频繁建连 |
|
||||
| API Session 复用 | 阿里云/腾讯 API 使用 `requests.Session` 单例 + 连接池 + 自动重试 |
|
||||
|
||||
---
|
||||
|
||||
## 八、v2.0 变更摘要
|
||||
|
||||
### 8.1 核心变更
|
||||
|
||||
| 变更项 | v1.0 | v2.0 |
|
||||
|--------|------|------|
|
||||
| 推荐依据 | 技术面推荐为主,外部因素为辅 | **以综合得分为准** |
|
||||
| 得分展示 | 仅展示最终评分 | **并列技术得分、外部得分、综合得分** |
|
||||
| 推荐修正 | 综合评级与技术面矛盾时修正 | 修正规则更明确,4种场景覆盖 |
|
||||
| 排序规则 | 按推荐评分排序 | **按综合得分排序**,技术得分和进度为次级 |
|
||||
|
||||
### 8.2 三项得分对照表
|
||||
|
||||
| 得分 | 字段 | 来源 | 范围 | 用途 |
|
||||
|------|------|------|------|------|
|
||||
| 技术得分 | `technical_score` | `compute_deep_analysis` | 0-100 | 纯技术面评估 |
|
||||
| 外部得分 | `external_score` | `compute_comprehensive_score` | -40 ~ +40 | 外部因素加减分 |
|
||||
| 综合得分 | `final_score` | 技术得分 + 外部得分 | 0-100 | **推荐股票的最终依据** |
|
||||
@@ -193,6 +193,35 @@ def deep_analyze():
|
||||
# 其他情况保持技术面推荐,但更新评分为综合评分
|
||||
else:
|
||||
report['recommend']['rate'] = final_score
|
||||
|
||||
# ---- 追加三项得分汇总到 AI 解说 ----
|
||||
tech_score = comprehensive.get('technical_score', 0)
|
||||
ext_score = comprehensive.get('external_score', 0)
|
||||
fin_score = comprehensive.get('final_score', 0)
|
||||
fin_verdict = comprehensive.get('verdict', '')
|
||||
ext_summary = comprehensive.get('summary', '')
|
||||
|
||||
score_line = (
|
||||
f'综合评分汇总:技术得分{tech_score:.0f}分,'
|
||||
f'外部得分{"+" if ext_score >= 0 else ""}{ext_score}分,'
|
||||
f'综合得分{fin_score}分({fin_verdict})。'
|
||||
)
|
||||
if ext_summary:
|
||||
score_line += f'外部因素:{ext_summary}。'
|
||||
if report.get('ai_summary'):
|
||||
report['ai_summary']['text'] += score_line
|
||||
# 更新 action_tip 以综合得分为准
|
||||
if fin_score >= 80:
|
||||
report['ai_summary']['action_tip'] = '综合评级强烈看多,技术面与外部因素共振看好,可以考虑积极参与。'
|
||||
elif fin_score >= 65:
|
||||
report['ai_summary']['action_tip'] = '综合评级看多,整体偏积极,可以逢低关注。'
|
||||
elif fin_score >= 50:
|
||||
report['ai_summary']['action_tip'] = '综合评级中性偏多,多空均衡,建议观望为主。'
|
||||
elif fin_score >= 35:
|
||||
report['ai_summary']['action_tip'] = '综合评级中性偏空,外部因素拖累,不建议急于买入。'
|
||||
else:
|
||||
report['ai_summary']['action_tip'] = '综合评级看空,外部因素重大利空,建议回避或减仓。'
|
||||
report['ai_summary']['confidence'] = '高' if fin_score >= 70 or fin_score <= 30 else '中'
|
||||
except Exception as e:
|
||||
print(f"综合评分引擎计算失败,使用技术面评分: {e}")
|
||||
|
||||
@@ -655,6 +684,7 @@ def get_scan_results():
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = int(request.args.get('per_page', 50))
|
||||
sort_by = request.args.get('sort', 'triggered_count')
|
||||
with_scores = request.args.get('with_scores', 'false').lower() == 'true'
|
||||
holding_codes_str = request.args.get('holding_codes', '')
|
||||
holding_set = set(c.strip() for c in holding_codes_str.split(',') if c.strip())
|
||||
recommend_text = (request.args.get('recommend_text') or '').strip()
|
||||
@@ -777,6 +807,29 @@ def get_scan_results():
|
||||
item['holding_note'] = f"若已持仓:{dh}({rh})"
|
||||
results.append(item)
|
||||
|
||||
# ---- 批量计算综合评分,按综合得分重排序 ----
|
||||
if with_scores and results:
|
||||
try:
|
||||
from services.score_engine import compute_comprehensive_score_batch
|
||||
stocks_input = [
|
||||
{'stock_code': r['code'], 'stock_name': r.get('name', ''),
|
||||
'technical_score': r.get('recommend_rate', 50)}
|
||||
for r in results
|
||||
]
|
||||
scores_map = compute_comprehensive_score_batch(stocks_input)
|
||||
for r in results:
|
||||
sc = scores_map.get(r['code'])
|
||||
if sc:
|
||||
r['technical_score'] = sc['technical_score']
|
||||
r['external_score'] = sc['external_score']
|
||||
r['final_score'] = sc['final_score']
|
||||
r['verdict'] = sc['verdict']
|
||||
r['recommend_rate'] = sc['final_score']
|
||||
# 按综合得分降序重排当前页
|
||||
results.sort(key=lambda x: (-x.get('final_score', 0), -x.get('triggered_count', 0)))
|
||||
except Exception as e:
|
||||
print(f'批量综合评分计算失败: {e}')
|
||||
|
||||
if codes_for_page is not None:
|
||||
if codes_for_page:
|
||||
placeholders = ','.join(['%s'] * len(codes_for_page))
|
||||
@@ -808,6 +861,28 @@ def get_scan_results():
|
||||
by_code[code] = item
|
||||
results = [by_code[c] for c in codes_for_page if c in by_code]
|
||||
|
||||
# ---- 批量计算综合评分(recommend_text 筛选路径)----
|
||||
if with_scores and results and codes_for_page is not None:
|
||||
try:
|
||||
from services.score_engine import compute_comprehensive_score_batch
|
||||
stocks_input = [
|
||||
{'stock_code': r['code'], 'stock_name': r.get('name', ''),
|
||||
'technical_score': r.get('recommend_rate', 50)}
|
||||
for r in results
|
||||
]
|
||||
scores_map = compute_comprehensive_score_batch(stocks_input)
|
||||
for r in results:
|
||||
sc = scores_map.get(r['code'])
|
||||
if sc:
|
||||
r['technical_score'] = sc['technical_score']
|
||||
r['external_score'] = sc['external_score']
|
||||
r['final_score'] = sc['final_score']
|
||||
r['verdict'] = sc['verdict']
|
||||
r['recommend_rate'] = sc['final_score']
|
||||
results.sort(key=lambda x: (-x.get('final_score', 0), -x.get('triggered_count', 0)))
|
||||
except Exception as e:
|
||||
print(f'批量综合评分计算失败(recommend_text路径): {e}')
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
s.value->>'name' as signal_name,
|
||||
@@ -1021,6 +1096,33 @@ def _is_scan_running():
|
||||
return False
|
||||
|
||||
|
||||
@bp.route('/stock_score_detail/<code>', methods=['GET'])
|
||||
def get_stock_score_detail(code):
|
||||
"""获取单只股票的综合评分详情(外部因素)
|
||||
|
||||
通过 query 参数 tech_score 传入列表中的技术得分,确保明细与列表分数一致。
|
||||
"""
|
||||
try:
|
||||
from services.score_engine import compute_comprehensive_score_batch
|
||||
tech_score = float(request.args.get('tech_score', 50))
|
||||
stocks_input = [{'stock_code': code, 'stock_name': '', 'technical_score': tech_score}]
|
||||
scores_map = compute_comprehensive_score_batch(stocks_input)
|
||||
sc = scores_map.get(code)
|
||||
if sc:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'technical_score': sc['technical_score'],
|
||||
'external_score': sc['external_score'],
|
||||
'final_score': sc['final_score'],
|
||||
'verdict': sc['verdict'],
|
||||
'factors': sc.get('factors', {}),
|
||||
'summary': sc.get('summary', ''),
|
||||
})
|
||||
return jsonify({'success': False, 'error': '未找到评分数据'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/scan_status', methods=['GET'])
|
||||
def get_scan_status():
|
||||
"""查询扫描进度"""
|
||||
@@ -1276,8 +1378,29 @@ def get_bull_stocks():
|
||||
|
||||
conn.close()
|
||||
|
||||
# 使用统一算法找牛股
|
||||
result = find_bull_stocks(scan_rows, holding_codes)
|
||||
# ---- 批量计算综合评分 ----
|
||||
scores_map = None
|
||||
try:
|
||||
from services.score_engine import compute_comprehensive_score_batch
|
||||
# 先用 compute_recommend 算出技术面基础分
|
||||
stocks_input = []
|
||||
for row in scan_rows:
|
||||
sig_status = row.get('signal_status') or []
|
||||
indicators = row.get('indicators') or {}
|
||||
tc = row.get('triggered_count') or 0
|
||||
is_holding = row.get('code', '') in holding_codes
|
||||
_, _, _, rate = compute_recommend(sig_status, indicators, tc, is_holding)
|
||||
stocks_input.append({
|
||||
'stock_code': row.get('code', ''),
|
||||
'stock_name': row.get('name', ''),
|
||||
'technical_score': rate,
|
||||
})
|
||||
scores_map = compute_comprehensive_score_batch(stocks_input)
|
||||
except Exception as e:
|
||||
print(f'牛股筛选综合评分计算失败: {e}')
|
||||
|
||||
# 使用统一算法找牛股(传入综合评分)
|
||||
result = find_bull_stocks(scan_rows, holding_codes, scores_map=scores_map)
|
||||
|
||||
# 为每只股票附加价格信息
|
||||
for stage_num, stocks in result['stages'].items():
|
||||
|
||||
@@ -540,7 +540,7 @@ def market_sentiment():
|
||||
|
||||
@bp.route('/external_factors', methods=['GET'])
|
||||
def external_factors():
|
||||
"""获取外部因素综合数据(北向资金、美股隔夜、大宗商品、汇率)"""
|
||||
"""获取外部因素综合数据(南向资金、美股隔夜、大宗商品、汇率)"""
|
||||
try:
|
||||
from services.external_factors import get_all_external_factors
|
||||
result = get_all_external_factors()
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
外部因素分析模块(P2/P3/P4/P7)
|
||||
|
||||
包含:
|
||||
- P2: 北向资金(外资动向)
|
||||
- P2: 南向资金(港股通跨境资金动向)
|
||||
- P3: 美股隔夜板块变化
|
||||
- P4: 大宗商品价格
|
||||
- P7: 汇率变化
|
||||
|
||||
数据源:AKShare(开源免费)
|
||||
数据源:
|
||||
- P2: AKShare stock_hsgt_hist_em(南向资金历史)+ stock_hsgt_fund_flow_summary_em(今日汇总)
|
||||
- P3: 腾讯财经API(美股指数实时)
|
||||
- P4: 腾讯财经API(商品期货实时)
|
||||
- P7: 新浪财经API(人民币汇率)
|
||||
|
||||
所有数据采集均带超时和异常处理,失败时返回中性评分不影响主流程。
|
||||
"""
|
||||
import logging
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,84 +42,118 @@ def _set_cache(key, value):
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# P2: 北向资金
|
||||
# P2: 南向资金
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def get_northbound_capital():
|
||||
def get_southbound_capital():
|
||||
"""
|
||||
获取北向资金净流入数据
|
||||
获取跨境资金流向数据
|
||||
|
||||
南向资金(港股通)反映内地资金配置港股的意愿,是跨境资金情绪的重要指标:
|
||||
- 南向净流入 > 0:内地资金积极配置港股,大中华区risk-on,对A股偏正面
|
||||
- 南向净流入 < 0:内地资金撤出港股,risk-off,对A股偏负面
|
||||
|
||||
数据源:
|
||||
1. AKShare stock_hsgt_fund_flow_summary_em(今日汇总)
|
||||
2. AKShare stock_hsgt_hist_em(南向资金历史,用于连续天数计算)
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
'net_inflow': float, # 今日净流入(亿)
|
||||
'net_inflow': float, # 今日南向净流入(亿)
|
||||
'score': int, # 评分增减(-10 ~ +10)
|
||||
'summary': str, # 白话总结
|
||||
'reasons': list, # 评分原因
|
||||
}
|
||||
"""
|
||||
cached = _get_cache('northbound')
|
||||
cached = _get_cache('southbound')
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
import akshare as ak
|
||||
import pandas as pd
|
||||
|
||||
# 获取北向资金净流入数据
|
||||
df = ak.stock_hsgt_north_net_flow_in_em(symbol="北向")
|
||||
if df is None or df.empty:
|
||||
return _neutral_result('北向资金数据为空')
|
||||
# 1. 用 stock_hsgt_fund_flow_summary_em 获取今日汇总
|
||||
today_inflow = 0
|
||||
try:
|
||||
df_summary = ak.stock_hsgt_fund_flow_summary_em()
|
||||
if df_summary is not None and not df_summary.empty:
|
||||
# 筛选南向资金行
|
||||
south_rows = df_summary[df_summary['资金方向'] == '南向']
|
||||
if not south_rows.empty:
|
||||
# 成交净买额列求和
|
||||
vals = south_rows['成交净买额'].tolist()
|
||||
today_inflow = float(sum(v for v in vals if pd.notna(v) and v != 0))
|
||||
except Exception as e:
|
||||
logger.debug(f"stock_hsgt_fund_flow_summary_em失败: {e}")
|
||||
|
||||
# 取最近5个交易日
|
||||
recent = df.tail(5)
|
||||
today_inflow = float(recent.iloc[-1].get('当日净流入', 0) or 0)
|
||||
|
||||
# 连续流入/流出天数
|
||||
# 2. 用 stock_hsgt_hist_em 获取南向资金历史(计算连续天数)
|
||||
consecutive_inflow = 0
|
||||
consecutive_outflow = 0
|
||||
for _, row in recent[::-1].iterrows():
|
||||
val = float(row.get('当日净流入', 0) or 0)
|
||||
if val > 0:
|
||||
if consecutive_outflow > 0:
|
||||
break
|
||||
consecutive_inflow += 1
|
||||
elif val < 0:
|
||||
if consecutive_inflow > 0:
|
||||
break
|
||||
consecutive_outflow += 1
|
||||
try:
|
||||
df = ak.stock_hsgt_hist_em(symbol="南向资金")
|
||||
if df is not None and not df.empty:
|
||||
recent = df.tail(5)
|
||||
for _, row in recent[::-1].iterrows():
|
||||
val = float(row.get('当日成交净买额', 0) or 0)
|
||||
if str(val) == 'nan' or pd.isna(val):
|
||||
val = 0
|
||||
if val > 0:
|
||||
if consecutive_outflow > 0:
|
||||
break
|
||||
consecutive_inflow += 1
|
||||
elif val < 0:
|
||||
if consecutive_inflow > 0:
|
||||
break
|
||||
consecutive_outflow += 1
|
||||
except Exception as e:
|
||||
logger.debug(f"stock_hsgt_hist_em南向失败: {e}")
|
||||
|
||||
# 评分
|
||||
# 如果今日数据也为0或NaN,说明无法获取
|
||||
if str(today_inflow) == 'nan' or today_inflow == 0:
|
||||
# 尝试从历史数据取最新值
|
||||
try:
|
||||
df = ak.stock_hsgt_hist_em(symbol="南向资金")
|
||||
if df is not None and not df.empty:
|
||||
last_val = float(df.iloc[-1].get('当日成交净买额', 0) or 0)
|
||||
if str(last_val) != 'nan' and not pd.isna(last_val) and last_val != 0:
|
||||
today_inflow = last_val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 评分(基于南向资金,逻辑与北向一致:净流入=正面,净流出=负面)
|
||||
score = 0
|
||||
reasons = []
|
||||
summary_parts = []
|
||||
|
||||
if today_inflow > 50:
|
||||
if today_inflow > 80:
|
||||
score += 5
|
||||
reasons.append(f'北向今日净流入{today_inflow:.1f}亿(+5)')
|
||||
summary_parts.append(f'外资今日大幅买入{today_inflow:.1f}亿元')
|
||||
elif today_inflow > 20:
|
||||
reasons.append(f'南向今日净流入{today_inflow:.1f}亿(+5)')
|
||||
summary_parts.append(f'南向资金大幅流入{today_inflow:.1f}亿元,跨境资金情绪偏暖')
|
||||
elif today_inflow > 30:
|
||||
score += 3
|
||||
reasons.append(f'北向今日净流入{today_inflow:.1f}亿(+3)')
|
||||
summary_parts.append(f'外资今日净流入{today_inflow:.1f}亿元')
|
||||
elif today_inflow < -50:
|
||||
reasons.append(f'南向今日净流入{today_inflow:.1f}亿(+3)')
|
||||
summary_parts.append(f'南向资金净流入{today_inflow:.1f}亿元')
|
||||
elif today_inflow < -80:
|
||||
score -= 5
|
||||
reasons.append(f'北向今日净流出{abs(today_inflow):.1f}亿(-5)')
|
||||
summary_parts.append(f'外资今日大幅卖出{abs(today_inflow):.1f}亿元')
|
||||
elif today_inflow < -20:
|
||||
reasons.append(f'南向今日净流出{abs(today_inflow):.1f}亿(-5)')
|
||||
summary_parts.append(f'南向资金大幅流出{abs(today_inflow):.1f}亿元,跨境资金情绪偏冷')
|
||||
elif today_inflow < -30:
|
||||
score -= 3
|
||||
reasons.append(f'北向今日净流出{abs(today_inflow):.1f}亿(-3)')
|
||||
summary_parts.append(f'外资今日净流出{abs(today_inflow):.1f}亿元')
|
||||
reasons.append(f'南向今日净流出{abs(today_inflow):.1f}亿(-3)')
|
||||
summary_parts.append(f'南向资金净流出{abs(today_inflow):.1f}亿元')
|
||||
else:
|
||||
summary_parts.append(f'外资今日净流入{today_inflow:.1f}亿元,方向不明')
|
||||
summary_parts.append(f'南向资金净流入{today_inflow:.1f}亿元,方向不明')
|
||||
|
||||
if consecutive_inflow >= 3:
|
||||
score += 3
|
||||
reasons.append(f'北向连续{consecutive_inflow}日净流入(+3)')
|
||||
summary_parts.append(f'已连续{consecutive_inflow}天买入')
|
||||
reasons.append(f'南向连续{consecutive_inflow}日净流入(+3)')
|
||||
summary_parts.append(f'已连续{consecutive_inflow}天流入')
|
||||
|
||||
if consecutive_outflow >= 3:
|
||||
score -= 3
|
||||
reasons.append(f'北向连续{consecutive_outflow}日净流出(-3)')
|
||||
summary_parts.append(f'已连续{consecutive_outflow}天卖出')
|
||||
reasons.append(f'南向连续{consecutive_outflow}日净流出(-3)')
|
||||
summary_parts.append(f'已连续{consecutive_outflow}天流出')
|
||||
|
||||
score = max(-10, min(10, score))
|
||||
|
||||
@@ -125,12 +165,12 @@ def get_northbound_capital():
|
||||
'summary': ','.join(summary_parts),
|
||||
'reasons': reasons,
|
||||
}
|
||||
_set_cache('northbound', result)
|
||||
_set_cache('southbound', result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取北向资金数据失败: {e}")
|
||||
return _neutral_result('北向资金数据获取失败')
|
||||
logger.warning(f"获取跨境资金数据失败: {e}")
|
||||
return _neutral_result('南向资金数据获取失败')
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
@@ -156,10 +196,12 @@ def get_us_market_overview():
|
||||
"""
|
||||
获取美股隔夜收盘数据,计算外盘情绪
|
||||
|
||||
数据源:腾讯财经API(qt.gtimg.cn)
|
||||
获取道琼斯、纳斯达克、标普500三大指数实时行情。
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
'indices': dict, # 三大指数涨跌
|
||||
'sectors': dict, # 主要板块涨跌
|
||||
'score': int, # 评分增减(-10 ~ +10)
|
||||
'summary': str, # 白话总结
|
||||
'reasons': list, # 评分原因
|
||||
@@ -171,32 +213,35 @@ def get_us_market_overview():
|
||||
return cached
|
||||
|
||||
try:
|
||||
import akshare as ak
|
||||
# 腾讯财经API获取美股指数
|
||||
# 格式: v_usDJI="200~道琼斯~.DJI~price~...~change_pct~..."
|
||||
url = 'https://qt.gtimg.cn/q=usDJI,usIXIC,usSPX'
|
||||
resp = requests.get(url, timeout=10)
|
||||
text = resp.content.decode('gbk', errors='replace')
|
||||
|
||||
# 获取全球主要指数
|
||||
df = ak.index_global()
|
||||
if df is None or df.empty:
|
||||
return _neutral_result('美股指数数据为空')
|
||||
|
||||
# 筛选美股主要指数
|
||||
us_indices = {}
|
||||
for _, row in df.iterrows():
|
||||
name = str(row.get('名称', ''))
|
||||
if '纳斯达克' in name:
|
||||
us_indices['nasdaq'] = {
|
||||
'name': name,
|
||||
'change_pct': float(row.get('涨跌幅', 0) or 0),
|
||||
}
|
||||
elif '道琼斯' in name:
|
||||
us_indices['dow'] = {
|
||||
'name': name,
|
||||
'change_pct': float(row.get('涨跌幅', 0) or 0),
|
||||
}
|
||||
elif '标普500' in name:
|
||||
us_indices['sp500'] = {
|
||||
'name': name,
|
||||
'change_pct': float(row.get('涨跌幅', 0) or 0),
|
||||
}
|
||||
for line in text.strip().split(';'):
|
||||
line = line.strip()
|
||||
if not line or 'v_pv_none_match' in line:
|
||||
continue
|
||||
# 解析 v_usDJI="..."
|
||||
if '=' not in line:
|
||||
continue
|
||||
var_name = line.split('=')[0].strip().replace('var ', '').replace('v_', '')
|
||||
value = line.split('"')[1] if '"' in line else ''
|
||||
fields = value.split('~')
|
||||
if len(fields) < 33:
|
||||
continue
|
||||
|
||||
name = fields[1]
|
||||
change_pct = float(fields[32]) if fields[32] else 0
|
||||
|
||||
if 'DJI' in var_name.upper() or '道琼斯' in name:
|
||||
us_indices['dow'] = {'name': name, 'change_pct': change_pct}
|
||||
elif 'IXIC' in var_name.upper() or '纳斯达克' in name:
|
||||
us_indices['nasdaq'] = {'name': name, 'change_pct': change_pct}
|
||||
elif 'SPX' in var_name.upper() or '标普' in name:
|
||||
us_indices['sp500'] = {'name': name, 'change_pct': change_pct}
|
||||
|
||||
if not us_indices:
|
||||
return _neutral_result('未找到美股指数')
|
||||
@@ -301,6 +346,9 @@ def get_commodity_overview():
|
||||
"""
|
||||
获取主要大宗商品价格变化
|
||||
|
||||
数据源:腾讯财经API(qt.gtimg.cn)
|
||||
获取纽约黄金、纽约原油、美铜等商品期货实时行情。
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
'commodities': dict, # 各商品涨跌
|
||||
@@ -315,28 +363,46 @@ def get_commodity_overview():
|
||||
return cached
|
||||
|
||||
try:
|
||||
import akshare as ak
|
||||
# 腾讯财经API获取商品期货
|
||||
# hf_GC=纽约黄金, hf_CL=纽约原油, hf_HG=美铜
|
||||
url = 'https://qt.gtimg.cn/q=hf_GC,hf_CL,hf_HG'
|
||||
resp = requests.get(url, timeout=10)
|
||||
text = resp.content.decode('gbk', errors='replace')
|
||||
|
||||
# 获取国内商品期货行情
|
||||
df = ak.futures_main_sina()
|
||||
if df is None or df.empty:
|
||||
return _neutral_result('大宗商品数据为空')
|
||||
# 商品名称映射
|
||||
symbol_map = {
|
||||
'hf_GC': '黄金',
|
||||
'hf_CL': '原油',
|
||||
'hf_HG': '铜',
|
||||
}
|
||||
|
||||
# 关注的商品
|
||||
target_commodities = ['原油', '黄金', '铜', '螺纹钢', '碳酸锂']
|
||||
commodities = {}
|
||||
for line in text.strip().split(';'):
|
||||
line = line.strip()
|
||||
if not line or 'v_pv_none_match' in line:
|
||||
continue
|
||||
if '=' not in line:
|
||||
continue
|
||||
var_name = line.split('=')[0].strip().replace('var ', '').replace('v_', '')
|
||||
value = line.split('"')[1] if '"' in line else ''
|
||||
fields = value.split(',')
|
||||
if len(fields) < 10:
|
||||
continue
|
||||
|
||||
for _, row in df.iterrows():
|
||||
symbol = str(row.get('symbol', ''))
|
||||
for target in target_commodities:
|
||||
if target in symbol:
|
||||
change = float(row.get('change', 0) or 0)
|
||||
pct = float(row.get('change_pct', 0) or 0)
|
||||
commodities[target] = {
|
||||
'symbol': symbol,
|
||||
'change_pct': round(pct, 2),
|
||||
}
|
||||
break
|
||||
target = symbol_map.get(var_name)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
# 腾讯商品格式: price,change_pct,prev_close,open,high,low,time,...,name
|
||||
current_price = float(fields[0]) if fields[0] else 0
|
||||
change_pct = float(fields[1]) if fields[1] else 0
|
||||
name = fields[-1].rstrip(';"')
|
||||
|
||||
commodities[target] = {
|
||||
'price': current_price,
|
||||
'change_pct': round(change_pct, 2),
|
||||
'name': name,
|
||||
}
|
||||
|
||||
if not commodities:
|
||||
return _neutral_result('未找到关注的大宗商品')
|
||||
@@ -415,6 +481,9 @@ def get_fx_overview():
|
||||
"""
|
||||
获取人民币汇率变化
|
||||
|
||||
数据源:新浪财经API(hq.sinajs.cn)
|
||||
获取在岸人民币兑美元实时汇率。
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
'usd_cny': float, # 美元兑人民币汇率
|
||||
@@ -431,25 +500,37 @@ def get_fx_overview():
|
||||
return cached
|
||||
|
||||
try:
|
||||
import akshare as ak
|
||||
# 新浪财经API获取在岸人民币汇率
|
||||
# 格式: var hq_str_fx_susdcny="time,bid,ask,prev_close,...,name,change_pct,..."
|
||||
url = 'https://hq.sinajs.cn/list=fx_susdcny'
|
||||
resp = requests.get(url, timeout=10, headers={'Referer': 'https://finance.sina.com.cn'})
|
||||
text = resp.content.decode('gbk', errors='replace')
|
||||
|
||||
# 获取人民币汇率
|
||||
df = ak.currency_boc_sina(symbol="美元")
|
||||
if df is None or df.empty:
|
||||
return _neutral_result('汇率数据为空')
|
||||
# 解析汇率数据
|
||||
if 'hq_str_fx_susdcny' not in text:
|
||||
return _neutral_result('汇率数据解析失败')
|
||||
|
||||
# 取最近2条计算变化
|
||||
recent = df.tail(2)
|
||||
if len(recent) < 2:
|
||||
return _neutral_result('汇率数据不足')
|
||||
value = text.split('"')[1] if '"' in text else ''
|
||||
fields = value.split(',')
|
||||
if len(fields) < 11:
|
||||
return _neutral_result('汇率数据格式异常')
|
||||
|
||||
today_rate = float(recent.iloc[-1].get('中行折算价', 0) or 0)
|
||||
prev_rate = float(recent.iloc[-2].get('中行折算价', 0) or 0)
|
||||
# 新浪汇率格式: time,bid,ask,prev_close,?,mid,?,?,?,name,change_pct,...
|
||||
today_rate = float(fields[5]) if fields[5] else 0 # 中间价
|
||||
prev_rate = float(fields[3]) if fields[3] else 0 # 昨收价
|
||||
change_pct = float(fields[10]) if fields[10] else 0 # 涨跌幅
|
||||
|
||||
if today_rate == 0:
|
||||
today_rate = float(fields[1]) if fields[1] else 0
|
||||
if prev_rate == 0:
|
||||
prev_rate = float(fields[3]) if fields[3] else 0
|
||||
|
||||
if today_rate == 0 or prev_rate == 0:
|
||||
return _neutral_result('汇率数据异常')
|
||||
|
||||
change_pct = round((today_rate / prev_rate - 1) * 100, 3)
|
||||
# 如果涨跌幅为0,自行计算
|
||||
if change_pct == 0:
|
||||
change_pct = round((today_rate / prev_rate - 1) * 100, 3)
|
||||
|
||||
# 判断方向(美元兑人民币:涨=人民币贬值,跌=人民币升值)
|
||||
if change_pct > 0.1:
|
||||
@@ -499,34 +580,34 @@ def get_all_external_factors():
|
||||
获取所有外部因素数据,返回综合结果
|
||||
|
||||
返回:
|
||||
dict: 包含北向资金、美股、大宗商品、汇率的综合数据
|
||||
dict: 包含南向资金、美股、大宗商品、汇率的综合数据
|
||||
"""
|
||||
northbound = get_northbound_capital()
|
||||
southbound = get_southbound_capital()
|
||||
us_market = get_us_market_overview()
|
||||
commodity = get_commodity_overview()
|
||||
fx = get_fx_overview()
|
||||
|
||||
total_score = (
|
||||
northbound.get('score', 0) +
|
||||
southbound.get('score', 0) +
|
||||
us_market.get('score', 0) +
|
||||
commodity.get('score', 0) +
|
||||
fx.get('score', 0)
|
||||
)
|
||||
|
||||
all_reasons = []
|
||||
all_reasons.extend(northbound.get('reasons', []))
|
||||
all_reasons.extend(southbound.get('reasons', []))
|
||||
all_reasons.extend(us_market.get('reasons', []))
|
||||
all_reasons.extend(commodity.get('reasons', []))
|
||||
all_reasons.extend(fx.get('reasons', []))
|
||||
|
||||
summaries = []
|
||||
for name, data in [('北向资金', northbound), ('美股', us_market), ('大宗商品', commodity), ('汇率', fx)]:
|
||||
for name, data in [('南向资金', southbound), ('美股', us_market), ('大宗商品', commodity), ('汇率', fx)]:
|
||||
s = data.get('summary', '')
|
||||
if s and '失败' not in s and '为空' not in s:
|
||||
summaries.append(f'{name}:{s}')
|
||||
|
||||
return {
|
||||
'northbound_capital': northbound,
|
||||
'southbound_capital': southbound,
|
||||
'us_market': us_market,
|
||||
'commodity': commodity,
|
||||
'fx': fx,
|
||||
|
||||
@@ -73,10 +73,105 @@ def get_fund_flow_history(stock_code, days=10):
|
||||
put_db(conn)
|
||||
|
||||
|
||||
def _get_fund_flow_from_mairui(stock_code, days=10):
|
||||
"""从麦蕊智数API获取资金流向数据,转换为与DB记录相同的格式。
|
||||
|
||||
API: https://api.mairuiapi.com/hsstock/history/transaction/{code}/{licence}?lt={n}
|
||||
字段: zmbtdcje=主买特大单, zmbddcje=主买大单, zmbzdcje=主买中单, zmbxdcje=主买小单
|
||||
zmstdcje=主卖特大单, zmsddcje=主卖大单, zmszdcje=主卖中单, zmsxdcje=主卖小单
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
from config import Config
|
||||
LICENCE = Config.MAIRUI_LICENCE or "5352ED2F-94E5-4E96-8B7F-B57BA75284E3"
|
||||
url = f"https://api.mairuiapi.com/hsstock/history/transaction/{stock_code}/{LICENCE}?lt={days}"
|
||||
resp = requests.get(url, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"麦蕊资金流向API返回{resp.status_code}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
if not data or not isinstance(data, list):
|
||||
return []
|
||||
|
||||
records = []
|
||||
for item in data:
|
||||
# 主买总额 = 特大单+大单+中单+小单
|
||||
buy_total = (
|
||||
float(item.get('zmbtdcje', 0) or 0) +
|
||||
float(item.get('zmbddcje', 0) or 0) +
|
||||
float(item.get('zmbzdcje', 0) or 0) +
|
||||
float(item.get('zmbxdcje', 0) or 0)
|
||||
)
|
||||
# 主卖总额
|
||||
sell_total = (
|
||||
float(item.get('zmstdcje', 0) or 0) +
|
||||
float(item.get('zmsddcje', 0) or 0) +
|
||||
float(item.get('zmszdcje', 0) or 0) +
|
||||
float(item.get('zmsxdcje', 0) or 0)
|
||||
)
|
||||
# 主力净流入 = (特大单+大单)买 - (特大单+大单)卖
|
||||
main_buy = float(item.get('zmbtdcje', 0) or 0) + float(item.get('zmbddcje', 0) or 0)
|
||||
main_sell = float(item.get('zmstdcje', 0) or 0) + float(item.get('zmsddcje', 0) or 0)
|
||||
main_net = main_buy - main_sell
|
||||
|
||||
# 超大单净流入
|
||||
super_net = float(item.get('zmbtdcje', 0) or 0) - float(item.get('zmstdcje', 0) or 0)
|
||||
|
||||
# 总成交额
|
||||
total_amount = buy_total + sell_total
|
||||
main_net_pct = round(main_net / total_amount * 100, 2) if total_amount > 0 else 0
|
||||
super_net_pct = round(super_net / total_amount * 100, 2) if total_amount > 0 else 0
|
||||
|
||||
# 大单净流入
|
||||
big_net = float(item.get('zmbddcje', 0) or 0) - float(item.get('zmsddcje', 0) or 0)
|
||||
big_net_pct = round(big_net / total_amount * 100, 2) if total_amount > 0 else 0
|
||||
|
||||
# 中单净流入
|
||||
mid_net = float(item.get('zmbzdcje', 0) or 0) - float(item.get('zmszdcje', 0) or 0)
|
||||
mid_net_pct = round(mid_net / total_amount * 100, 2) if total_amount > 0 else 0
|
||||
|
||||
# 小单净流入
|
||||
small_net = float(item.get('zmbxdcje', 0) or 0) - float(item.get('zmsxdcje', 0) or 0)
|
||||
small_net_pct = round(small_net / total_amount * 100, 2) if total_amount > 0 else 0
|
||||
|
||||
# 日期解析
|
||||
t_str = str(item.get('t', ''))
|
||||
date_str = t_str[:10] if t_str else ''
|
||||
|
||||
records.append({
|
||||
'date': date_str,
|
||||
'close_price': 0,
|
||||
'change_pct': 0,
|
||||
'main_net_inflow': round(main_net, 2),
|
||||
'main_net_inflow_pct': main_net_pct,
|
||||
'super_net_inflow': round(super_net, 2),
|
||||
'super_net_inflow_pct': super_net_pct,
|
||||
'big_net_inflow': round(big_net, 2),
|
||||
'big_net_inflow_pct': big_net_pct,
|
||||
'mid_net_inflow': round(mid_net, 2),
|
||||
'mid_net_inflow_pct': mid_net_pct,
|
||||
'small_net_inflow': round(small_net, 2),
|
||||
'small_net_inflow_pct': small_net_pct,
|
||||
})
|
||||
|
||||
# 按日期升序排列
|
||||
records.sort(key=lambda x: x['date'])
|
||||
logger.info(f"麦蕊API获取{stock_code}资金流向{len(records)}条")
|
||||
return records
|
||||
except Exception as e:
|
||||
logger.warning(f"麦蕊资金流向API失败({stock_code}): {e}")
|
||||
return []
|
||||
|
||||
|
||||
def analyze_fund_flow(stock_code, days=5):
|
||||
"""
|
||||
分析主力资金流向,返回资金面评分和信号
|
||||
|
||||
数据源优先级:
|
||||
1. DB stock_fund_flow_history 表(有最新数据时)
|
||||
2. 麦蕊智数API hsstock/history/transaction(DB数据过期时补充)
|
||||
|
||||
参数:
|
||||
stock_code: 股票代码
|
||||
days: 分析最近几天的资金流向
|
||||
@@ -91,6 +186,28 @@ def analyze_fund_flow(stock_code, days=5):
|
||||
}
|
||||
"""
|
||||
records = get_fund_flow_history(stock_code, days=days + 5)
|
||||
|
||||
# 检查DB数据是否足够新(最近3天内有数据)
|
||||
use_mairui = False
|
||||
if len(records) < 2:
|
||||
use_mairui = True
|
||||
else:
|
||||
from datetime import date
|
||||
latest_date = records[-1].get('date', '')
|
||||
if latest_date:
|
||||
try:
|
||||
latest = datetime.strptime(latest_date, '%Y-%m-%d').date()
|
||||
if (date.today() - latest).days > 5:
|
||||
use_mairui = True
|
||||
except ValueError:
|
||||
use_mairui = True
|
||||
|
||||
if use_mairui:
|
||||
# 用麦蕊API获取资金流向数据
|
||||
mairui_records = _get_fund_flow_from_mairui(stock_code, days + 5)
|
||||
if mairui_records:
|
||||
records = mairui_records
|
||||
|
||||
if len(records) < 2:
|
||||
return {
|
||||
'score': 0,
|
||||
|
||||
@@ -50,8 +50,7 @@ def calc_market_sentiment():
|
||||
COUNT(*) FILTER (WHERE change_pct < 0) AS down_count,
|
||||
COUNT(*) FILTER (WHERE change_pct = 0) AS flat_count,
|
||||
COUNT(*) AS total,
|
||||
COALESCE(SUM(amount), 0) AS total_amount,
|
||||
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY turnover), 0) AS turnover_median
|
||||
COALESCE(SUM(amount), 0) AS total_amount
|
||||
FROM stock_realtime_price
|
||||
WHERE volume > 0 AND price > 0
|
||||
""")
|
||||
@@ -66,7 +65,7 @@ def calc_market_sentiment():
|
||||
flat_count = int(row[4] or 0)
|
||||
total = int(row[5] or 1)
|
||||
total_amount = float(row[6] or 0) / 1e8 # 转为亿
|
||||
turnover_median = float(row[7] or 0)
|
||||
turnover_median = 0 # DB无turnover字段,不再使用
|
||||
|
||||
# 涨跌停比
|
||||
up_down_ratio = round(limit_up / limit_down, 1) if limit_down > 0 else float(limit_up)
|
||||
|
||||
@@ -575,16 +575,33 @@ def job_afternoon_trade():
|
||||
print(f"[定时任务] ===== 午后交易任务结束 {datetime.now()} =====")
|
||||
|
||||
|
||||
def job_precompute_market_factors():
|
||||
"""开市前预热市场级外部因素缓存(09:15执行)
|
||||
|
||||
预计算 P1市场情绪、P2北向、P3美股、P4商品、P7汇率、P6政策面,
|
||||
结果存入 score_engine 内存缓存,后续全景扫描和评分直接复用。
|
||||
"""
|
||||
print(f"[定时任务] ===== 开市前预热外部因素 {datetime.now()} =====")
|
||||
try:
|
||||
from services.score_engine import precompute_market_factors
|
||||
precompute_market_factors()
|
||||
print("[定时任务] 外部因素预热完成")
|
||||
except Exception as e:
|
||||
print(f"[定时任务] 外部因素预热失败: {e}")
|
||||
|
||||
|
||||
def run_scheduler():
|
||||
"""运行定时任务调度器"""
|
||||
global _is_running
|
||||
|
||||
# 设置定时任务 — v7最优时点: 09:35买入 / 13:40卖出
|
||||
schedule.every().day.at("09:15").do(job_precompute_market_factors) # 开市前预热外部因素
|
||||
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:15 开市前预热外部因素缓存")
|
||||
print("[定时任务] - 09:35 早盘交易(使用昨日扫描数据 — 最优买入时点)")
|
||||
print("[定时任务] - 13:40 午后交易(使用当日中午扫描数据 — 最优卖出时点)")
|
||||
print("[定时任务] - 15:05 收盘更新持仓价格")
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- 技术面评分(compute_deep_analysis 原始分):基础分(0-100)
|
||||
- P0 资金面:±20
|
||||
- P1 市场情绪:±10
|
||||
- P2 北向资金:±10
|
||||
- P2 南向资金:±10
|
||||
- P3 美股外盘:±10
|
||||
- P4 大宗商品:±5
|
||||
- P5 公告/异动:±15
|
||||
@@ -17,9 +17,96 @@
|
||||
最终评分 = 技术面基础分 + 外部因素加减分(上限100,下限0)
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 市场级因素缓存(开市前预计算,日内复用)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
_market_cache = {
|
||||
'data': None, # (market_score, market_factors, market_reasons, summaries)
|
||||
'timestamp': 0, # 计算时间戳
|
||||
'ttl': 4 * 3600, # 缓存有效期 4 小时
|
||||
}
|
||||
|
||||
# 个股资金面缓存(30分钟 TTL)
|
||||
_fund_flow_cache = {}
|
||||
_FUND_FLOW_TTL = 30 * 60
|
||||
|
||||
|
||||
def precompute_market_factors():
|
||||
"""预计算市场级外部因素并缓存(供定时任务在开市前调用)。
|
||||
|
||||
计算 P1 市场情绪、P2 南向、P3 美股、P4 商品、P7 汇率、P6 政策面,
|
||||
结果存入内存缓存,后续 compute_comprehensive_score_batch 直接复用。
|
||||
"""
|
||||
market_score = 0
|
||||
market_factors = {}
|
||||
market_reasons = []
|
||||
summaries = []
|
||||
|
||||
# P1: 市场情绪
|
||||
try:
|
||||
from services.market_sentiment import calc_market_sentiment
|
||||
sentiment_result = calc_market_sentiment()
|
||||
market_factors['market_sentiment'] = sentiment_result
|
||||
market_score += sentiment_result.get('score', 0)
|
||||
market_reasons.extend(sentiment_result.get('reasons', []))
|
||||
s = sentiment_result.get('sentiment', '')
|
||||
if s and '无数据' not in s:
|
||||
summaries.append(
|
||||
f'市场情绪:{s}(涨跌停{sentiment_result.get("limit_up_count", 0)}:'
|
||||
f'{sentiment_result.get("limit_down_count", 0)})'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"预计算P1市场情绪失败: {e}")
|
||||
market_factors['market_sentiment'] = {'score': 0, 'summary': '分析失败', 'reasons': []}
|
||||
|
||||
# P2/P3/P4/P7: 外部因素(南向/美股/商品/汇率)
|
||||
try:
|
||||
from services.external_factors import get_all_external_factors
|
||||
ext_result = get_all_external_factors()
|
||||
market_factors['external'] = ext_result
|
||||
market_score += ext_result.get('total_score', 0)
|
||||
market_reasons.extend(ext_result.get('all_reasons', []))
|
||||
s = ext_result.get('summary', '')
|
||||
if s:
|
||||
summaries.append(s)
|
||||
except Exception as e:
|
||||
logger.warning(f"预计算P2-P7外部因素失败: {e}")
|
||||
market_factors['external'] = {'total_score': 0, 'summary': '分析失败', 'all_reasons': []}
|
||||
|
||||
# P6: 政策面(市场级,只算一次)
|
||||
try:
|
||||
from services.news_analyzer import get_policy_news, analyze_policy_impact
|
||||
policy_news = get_policy_news(days=3)
|
||||
policy_result = analyze_policy_impact(policy_news)
|
||||
market_factors['policy'] = policy_result
|
||||
market_score += policy_result.get('score', 0)
|
||||
market_reasons.extend(policy_result.get('reasons', []))
|
||||
s = policy_result.get('summary', '')
|
||||
if s and '失败' not in s:
|
||||
summaries.append(f'政策面:{s}')
|
||||
except Exception as e:
|
||||
logger.warning(f"预计算P6政策面失败: {e}")
|
||||
market_factors['policy'] = {'score': 0, 'summary': '分析失败', 'reasons': []}
|
||||
|
||||
_market_cache['data'] = (market_score, market_factors, market_reasons, summaries)
|
||||
_market_cache['timestamp'] = time.time()
|
||||
print(f'[评分引擎] 市场级因素预计算完成 (score={market_score}, {datetime.now():%H:%M:%S})')
|
||||
return market_score, market_factors, market_reasons, summaries
|
||||
|
||||
|
||||
def _get_market_factors():
|
||||
"""获取市场级因素(优先读缓存,过期则重新计算)"""
|
||||
now = time.time()
|
||||
if _market_cache['data'] is not None and (now - _market_cache['timestamp']) < _market_cache['ttl']:
|
||||
return _market_cache['data']
|
||||
# 缓存不存在或过期,重新计算
|
||||
return precompute_market_factors()
|
||||
|
||||
|
||||
def compute_comprehensive_score(stock_code, stock_name, technical_score, df=None):
|
||||
"""
|
||||
@@ -75,7 +162,7 @@ def compute_comprehensive_score(stock_code, stock_name, technical_score, df=None
|
||||
logger.warning(f"P1市场情绪分析失败: {e}")
|
||||
factors['market_sentiment'] = {'score': 0, 'summary': '分析失败', 'reasons': []}
|
||||
|
||||
# ---- P2/P3/P4/P7: 外部因素(北向/美股/商品/汇率)----
|
||||
# ---- P2/P3/P4/P7: 外部因素(南向/美股/商品/汇率)----
|
||||
try:
|
||||
from services.external_factors import get_all_external_factors
|
||||
ext_result = get_all_external_factors()
|
||||
@@ -132,3 +219,91 @@ def compute_comprehensive_score(stock_code, stock_name, technical_score, df=None
|
||||
'all_reasons': all_reasons,
|
||||
'summary': summary,
|
||||
}
|
||||
|
||||
|
||||
def compute_comprehensive_score_batch(stocks_data):
|
||||
"""
|
||||
批量计算综合评分 — 市场级因素只计算一次,个股级因素逐只计算。
|
||||
|
||||
优化点:
|
||||
- P1 市场情绪、P2 南向、P3 美股、P4 商品、P7 汇率、P6 政策 → 市场级,只算一次
|
||||
- P0 资金面 → 个股级,逐只从DB读取
|
||||
- P5 公告/异动 → 批量模式跳过(需AKShare API + LLM,太慢),在深度分析时补充
|
||||
|
||||
参数:
|
||||
stocks_data: list[dict],每个元素包含:
|
||||
- stock_code: str 股票代码
|
||||
- stock_name: str 股票名称(可选)
|
||||
- technical_score: float 技术得分(0-100)
|
||||
|
||||
返回:
|
||||
dict: {stock_code: {technical_score, external_score, final_score, verdict, factors, all_reasons, summary}}
|
||||
"""
|
||||
# ---- 市场级因素(从缓存读取,开市前由定时任务预计算)----
|
||||
market_score, market_factors, market_reasons, summaries = _get_market_factors()
|
||||
|
||||
# ---- 为每只股票计算个股级因素 ----
|
||||
results = {}
|
||||
for stock in stocks_data:
|
||||
code = stock.get('stock_code', '')
|
||||
name = stock.get('stock_name', '')
|
||||
tech_score = stock.get('technical_score', 50)
|
||||
|
||||
stock_external = market_score
|
||||
stock_factors = {
|
||||
'market_sentiment': market_factors.get('market_sentiment', {}),
|
||||
'external': market_factors.get('external', {}),
|
||||
'policy': market_factors.get('policy', {}),
|
||||
}
|
||||
stock_reasons = list(market_reasons)
|
||||
|
||||
# P0: 资金面(个股级,从DB读取,带30分钟缓存)
|
||||
try:
|
||||
now = time.time()
|
||||
cached_ff = _fund_flow_cache.get(code)
|
||||
if cached_ff and (now - cached_ff[1]) < _FUND_FLOW_TTL:
|
||||
fund_result = cached_ff[0]
|
||||
else:
|
||||
from services.fund_flow_analyzer import analyze_fund_flow
|
||||
fund_result = analyze_fund_flow(code, days=5)
|
||||
_fund_flow_cache[code] = (fund_result, now)
|
||||
stock_factors['fund_flow'] = fund_result
|
||||
stock_external += fund_result.get('score', 0)
|
||||
stock_reasons.extend(fund_result.get('reasons', []))
|
||||
except Exception as e:
|
||||
logger.warning(f"批量P0资金面分析失败 {code}: {e}")
|
||||
stock_factors['fund_flow'] = {'score': 0, 'summary': '分析失败', 'reasons': []}
|
||||
|
||||
# P5: 公告/异动 — 批量模式跳过(需AKShare API + LLM,在深度分析时补充)
|
||||
stock_factors['news'] = {
|
||||
'total_score': 0, 'summary': '批量模式跳过,请使用深度分析查看',
|
||||
'all_reasons': [],
|
||||
}
|
||||
|
||||
# 外部得分上限 ±40
|
||||
stock_external = max(-40, min(40, stock_external))
|
||||
final_score = max(0, min(100, int(tech_score + stock_external)))
|
||||
|
||||
# 评级
|
||||
if final_score >= 80:
|
||||
verdict = '强烈看多'
|
||||
elif final_score >= 65:
|
||||
verdict = '看多'
|
||||
elif final_score >= 50:
|
||||
verdict = '中性偏多'
|
||||
elif final_score >= 35:
|
||||
verdict = '中性偏空'
|
||||
else:
|
||||
verdict = '看空'
|
||||
|
||||
results[code] = {
|
||||
'technical_score': round(tech_score, 0),
|
||||
'external_score': stock_external,
|
||||
'final_score': final_score,
|
||||
'verdict': verdict,
|
||||
'factors': stock_factors,
|
||||
'all_reasons': stock_reasons,
|
||||
'summary': ' | '.join(summaries) if summaries else '',
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
@@ -830,13 +830,15 @@ def compute_bull_stage(signal_status):
|
||||
}
|
||||
|
||||
|
||||
def find_bull_stocks(scan_rows, holding_codes=None):
|
||||
def find_bull_stocks(scan_rows, holding_codes=None, scores_map=None):
|
||||
"""
|
||||
从扫描结果中找出潜在牛股,按阶段分组排序。
|
||||
|
||||
参数:
|
||||
scan_rows: list[dict] 扫描结果列表 (含 code, name, signal_status, indicators, triggered_count)
|
||||
holding_codes: set 持仓代码集合
|
||||
scores_map: dict 综合评分映射 {code: {technical_score, external_score, final_score, verdict}}
|
||||
当提供时,每只股票附加三项得分,并按综合得分排序
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
@@ -872,8 +874,21 @@ def find_bull_stocks(scan_rows, holding_codes=None):
|
||||
row.get('triggered_count'), is_holding,
|
||||
)
|
||||
|
||||
# 附加综合评分(如果提供了 scores_map)
|
||||
code = row.get('code', '')
|
||||
technical_score = rate
|
||||
external_score = 0
|
||||
final_score = rate
|
||||
verdict = ''
|
||||
if scores_map and code in scores_map:
|
||||
sc = scores_map[code]
|
||||
technical_score = sc.get('technical_score', rate)
|
||||
external_score = sc.get('external_score', 0)
|
||||
final_score = sc.get('final_score', rate)
|
||||
verdict = sc.get('verdict', '')
|
||||
|
||||
item = {
|
||||
'code': row.get('code', ''),
|
||||
'code': code,
|
||||
'name': row.get('name', ''),
|
||||
'stage': stage,
|
||||
'stage_name': bull['stage_name'],
|
||||
@@ -887,16 +902,26 @@ def find_bull_stocks(scan_rows, holding_codes=None):
|
||||
'recommend_type': st,
|
||||
'recommend_text': disp,
|
||||
'recommend_reason': reason,
|
||||
'recommend_rate': rate,
|
||||
'recommend_rate': final_score,
|
||||
'is_holding': is_holding,
|
||||
'triggered_count': row.get('triggered_count', 0),
|
||||
'technical_score': technical_score,
|
||||
'external_score': external_score,
|
||||
'final_score': final_score,
|
||||
'verdict': verdict,
|
||||
}
|
||||
|
||||
stages[stage].append(item)
|
||||
|
||||
# 每个阶段内按推荐评分降序排序
|
||||
for stage_num in stages:
|
||||
stages[stage_num].sort(key=lambda x: (-x['recommend_rate'], -x['progress']))
|
||||
# 每个阶段内排序:有综合评分时按综合得分→技术得分→进度,否则按推荐评分→进度
|
||||
if scores_map:
|
||||
for stage_num in stages:
|
||||
stages[stage_num].sort(
|
||||
key=lambda x: (-x.get('final_score', 0), -x.get('technical_score', 0), -x['progress'])
|
||||
)
|
||||
else:
|
||||
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())
|
||||
|
||||
|
||||
@@ -120,14 +120,14 @@
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: #ff4444;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-success {
|
||||
color: #00ff88;
|
||||
color: var(--success);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
|
||||
@@ -7,14 +7,67 @@
|
||||
--bg-dark: #0a0a0a;
|
||||
--bg-glass: rgba(255, 255, 255, 0.05);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.08);
|
||||
--bg-secondary: rgba(255, 255, 255, 0.06);
|
||||
--border-glass: rgba(255, 255, 255, 0.1);
|
||||
--border-color: 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;
|
||||
--primary: #6c5ce7;
|
||||
--success: #00ff88;
|
||||
--danger: #ff4444;
|
||||
--warning: #ffaa00;
|
||||
--info: #3b82f6;
|
||||
/* 信号强度色系 */
|
||||
--sig-85: #ff4444;
|
||||
--sig-80: #ff6b35;
|
||||
--sig-75: #4caf50;
|
||||
--sig-70: #2196f3;
|
||||
--sig-65: #9c27b0;
|
||||
--sig-60: #ff9800;
|
||||
--sig-55: #607d8b;
|
||||
/* 涨跌色(红涨绿跌) */
|
||||
--up: #ff4444;
|
||||
--down: #00c853;
|
||||
/* 辅助语义色 */
|
||||
--watch: #6495ed;
|
||||
--gold: #ffd700;
|
||||
--hold: #ffd93d;
|
||||
--hot: #ff6b35;
|
||||
--card-bg: #1e1e2e;
|
||||
--bg-input: #1a1a1a;
|
||||
--font-mono: 'Courier New', monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', sans-serif;
|
||||
/* 深度分析辅助色 */
|
||||
--up-soft: #ef9a9a;
|
||||
--down-soft: #a5d6a7;
|
||||
--info-light: #64b5f6;
|
||||
--info-bg: #2196F3;
|
||||
--score-low: #78909c;
|
||||
--sig-name: #ff9800;
|
||||
--sig-strength: #ffb74d;
|
||||
--step-watch: #748ffc;
|
||||
/* 打印色(深色版本,用于@print) */
|
||||
--print-up: #c0392b;
|
||||
--print-down: #27ae60;
|
||||
--print-mid: #e67e22;
|
||||
--print-low: #7f8c8d;
|
||||
/* 牛股/推荐标签辅助色 */
|
||||
--teal: #00ce9e;
|
||||
--purple-light: #ce93d8;
|
||||
--gray-mid: #9E9E9E;
|
||||
/* 渐变色 */
|
||||
--grad-strategy: linear-gradient(135deg, #ff6b6b, #ffd93d);
|
||||
--grad-strategy-text: #1a1a2e;
|
||||
--grad-auto: linear-gradient(135deg, #00ff88, #00ccff);
|
||||
--grad-rank2: linear-gradient(135deg, var(--success), #00b4d8);
|
||||
--grad-rank3: linear-gradient(135deg, #748ffc, #9775fa);
|
||||
--grad-algo: linear-gradient(135deg, #667eea, #764ba2);
|
||||
--grad-progress: linear-gradient(90deg, #2196F3, #4CAF50);
|
||||
/* 内联样式辅助色 */
|
||||
--accent-cyan: #4ecdc4;
|
||||
--text-dim: #555;
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
@@ -28,7 +81,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
min-height: 100vh;
|
||||
color: var(--text-primary);
|
||||
@@ -368,7 +421,7 @@
|
||||
}
|
||||
|
||||
.scan-source-select option {
|
||||
background: #1a1a2e;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -549,7 +602,7 @@
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1a1a1a;
|
||||
background: var(--bg-input);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
z-index: 100;
|
||||
@@ -706,4 +759,98 @@
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 骨架屏 ===== */
|
||||
.skeleton-card {
|
||||
background: var(--bg-glass);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.skeleton-line.short { width: 40%; }
|
||||
.skeleton-line.medium { width: 65%; }
|
||||
.skeleton-line.long { width: 90%; }
|
||||
.skeleton-line:last-child { margin-bottom: 0; }
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ===== 空状态优化 ===== */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty-state-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.empty-state-title {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty-state-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== 内联样式提取的公共 class ===== */
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.margin-top-sm { margin-top: 8px; }
|
||||
.margin-top-md { margin-top: 15px; }
|
||||
.margin-bottom-sm { margin-bottom: 8px; }
|
||||
.margin-bottom-md { margin-bottom: 12px; }
|
||||
.margin-left-auto { margin-left: auto; }
|
||||
.margin-right-sm { margin-right: 6px; }
|
||||
.text-xs { font-size: 11px; }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-md { font-size: 13px; }
|
||||
.text-muted-2 { color: var(--text-muted); }
|
||||
.text-secondary-2 { color: var(--text-secondary); }
|
||||
.text-danger { color: var(--danger); }
|
||||
.text-success { color: var(--success); }
|
||||
.text-warning { color: var(--warning); }
|
||||
.text-info { color: var(--info); }
|
||||
.font-weight-600 { font-weight: 600; }
|
||||
.flex-center { display: flex; align-items: center; gap: 4px; }
|
||||
.flex-wrap-gap { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.grid-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.hidden { display: none; }
|
||||
.opacity-70 { opacity: 0.7; }
|
||||
.padding-y-sm { padding: 8px 0; }
|
||||
.max-height-scroll { max-height: 300px; overflow-y: auto; }
|
||||
.icon-sm { width: 16px; height: 16px; vertical-align: -2px; margin-right: 4px; }
|
||||
.icon-xs { width: 14px; height: 14px; vertical-align: -2px; margin-right: 3px; }
|
||||
.input-mini {
|
||||
width: 60px; background: var(--bg-dark); border: 1px solid var(--border-color);
|
||||
color: var(--text-primary); border-radius: 4px; padding: 2px 6px;
|
||||
}
|
||||
.input-mini-wide {
|
||||
width: 80px; background: var(--bg-dark); border: 1px solid var(--border-color);
|
||||
color: var(--text-primary); border-radius: 4px; padding: 2px 6px;
|
||||
}
|
||||
.badge-mini {
|
||||
background: var(--bg-dark); padding: 1px 6px; border-radius: 8px; font-size: 10px;
|
||||
}
|
||||
.border-top-divider {
|
||||
margin-top: 10px; border-top: 1px solid var(--border-color); padding-top: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
}
|
||||
|
||||
.stoploss-sell-btn:hover {
|
||||
background: #ff6666;
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
/* 基本面弹窗样式 */
|
||||
@@ -502,7 +502,7 @@
|
||||
}
|
||||
|
||||
.watch-btn.add:hover {
|
||||
background: #00cc6a;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.watch-btn.remove {
|
||||
@@ -592,7 +592,7 @@
|
||||
}
|
||||
.signal-chip.triggered {
|
||||
background: rgba(239,68,68,0.12);
|
||||
color: #ef4444;
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
}
|
||||
.signal-chip.inactive {
|
||||
@@ -606,7 +606,7 @@
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.signal-dot.on { background: #ef4444; }
|
||||
.signal-dot.on { background: var(--danger); }
|
||||
.signal-dot.off { background: var(--text-muted); opacity: 0.4; }
|
||||
.signal-summary {
|
||||
font-size: 12px;
|
||||
@@ -614,7 +614,7 @@
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.signal-strong { color: #ef4444; font-weight: 600; }
|
||||
.signal-strong { color: var(--danger); font-weight: 600; }
|
||||
.signal-normal { color: var(--text-secondary); }
|
||||
.signal-none { color: var(--text-muted); }
|
||||
|
||||
@@ -1826,7 +1826,7 @@
|
||||
}
|
||||
.alert-price {
|
||||
font-size: 12px;
|
||||
color: #ffd700;
|
||||
color: var(--warning);
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1839,14 +1839,14 @@
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.alert-change.up { color: #ff4444; }
|
||||
.alert-change.down { color: #00ce9e; }
|
||||
.alert-change.up { color: var(--up); }
|
||||
.alert-change.down { color: var(--down); }
|
||||
.alert-sig-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 206, 158, 0.15);
|
||||
color: #00ce9e;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
@@ -1932,7 +1932,7 @@
|
||||
|
||||
.toast-container.info .toast-icon {
|
||||
background: rgba(100, 150, 255, 0.2);
|
||||
color: #6496ff;
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.toast-container.success .toast-icon {
|
||||
|
||||
+121
-83
@@ -27,13 +27,13 @@
|
||||
font-weight: 600; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.tech-btn.batch { background: var(--warning); color: #333; }
|
||||
.tech-btn.fullscan { background: #6c5ce7; color: #fff; }
|
||||
.tech-btn.strategy { background: linear-gradient(135deg, #ff6b6b, #ffd93d); color: #1a1a2e; font-weight: 700; }
|
||||
.tech-btn.fullscan { background: var(--primary); color: #fff; }
|
||||
.tech-btn.strategy { background: var(--grad-strategy); color: var(--grad-strategy-text); font-weight: 700; }
|
||||
.tech-btn.rescan {
|
||||
background: transparent; border: 1px solid rgba(255,255,255,0.15);
|
||||
color: var(--text-muted); font-size: 11px;
|
||||
}
|
||||
.tech-btn.rescan:hover { border-color: #ff6b6b; color: #ff6b6b; }
|
||||
.tech-btn.rescan:hover { border-color: var(--danger); color: var(--danger); }
|
||||
.tech-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.tech-legend {
|
||||
@@ -41,13 +41,13 @@
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 3px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.dot.s85 { background: #e74c3c; }
|
||||
.dot.s80 { background: #e67e22; }
|
||||
.dot.s75 { background: #f39c12; }
|
||||
.dot.s70 { background: #27ae60; }
|
||||
.dot.s65 { background: #2980b9; }
|
||||
.dot.s60 { background: #8e44ad; }
|
||||
.dot.s55 { background: #95a5a6; }
|
||||
.dot.s85 { background: var(--sig-85); }
|
||||
.dot.s80 { background: var(--sig-80); }
|
||||
.dot.s75 { background: var(--sig-75); }
|
||||
.dot.s70 { background: var(--sig-70); }
|
||||
.dot.s65 { background: var(--sig-65); }
|
||||
.dot.s60 { background: var(--sig-60); }
|
||||
.dot.s55 { background: var(--sig-55); }
|
||||
|
||||
.tech-result-card {
|
||||
background: var(--bg-glass); border-radius: 16px;
|
||||
@@ -89,8 +89,8 @@
|
||||
.tech-recommend-reason { font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
|
||||
.tech-holding-note {
|
||||
margin-top: 8px; padding: 6px 10px;
|
||||
background: rgba(255,152,0,0.08); border-radius: 6px;
|
||||
font-size: 12px; color: #e67e00; line-height: 1.5;
|
||||
background: rgba(255,170,0,0.08); border-radius: 6px;
|
||||
font-size: 12px; color: var(--warning); line-height: 1.5;
|
||||
}
|
||||
|
||||
.tech-indicators {
|
||||
@@ -137,13 +137,13 @@
|
||||
font-size: 11px; padding: 2px 8px; border-radius: 8px;
|
||||
font-weight: 600; color: white;
|
||||
}
|
||||
.batch-signal-tag.strength-85 { background: #e74c3c; }
|
||||
.batch-signal-tag.strength-80 { background: #e67e22; }
|
||||
.batch-signal-tag.strength-75 { background: #f39c12; }
|
||||
.batch-signal-tag.strength-70 { background: #27ae60; }
|
||||
.batch-signal-tag.strength-65 { background: #2980b9; }
|
||||
.batch-signal-tag.strength-60 { background: #8e44ad; }
|
||||
.batch-signal-tag.strength-55 { background: #95a5a6; }
|
||||
.batch-signal-tag.strength-85 { background: var(--sig-85); }
|
||||
.batch-signal-tag.strength-80 { background: var(--sig-80); }
|
||||
.batch-signal-tag.strength-75 { background: var(--sig-75); }
|
||||
.batch-signal-tag.strength-70 { background: var(--sig-70); }
|
||||
.batch-signal-tag.strength-65 { background: var(--sig-65); }
|
||||
.batch-signal-tag.strength-60 { background: var(--sig-60); }
|
||||
.batch-signal-tag.strength-55 { background: var(--sig-55); }
|
||||
|
||||
|
||||
/* ========== 模拟交易页面样式 ========== */
|
||||
@@ -189,7 +189,7 @@
|
||||
}
|
||||
|
||||
.sim-btn.auto {
|
||||
background: linear-gradient(135deg, #00ff88, #00ccff);
|
||||
background: var(--grad-auto);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@@ -700,16 +700,16 @@
|
||||
color: #fff;
|
||||
background: #555;
|
||||
}
|
||||
.rank-1 .rank-badge { background: linear-gradient(135deg, #ff6b6b, #ffd93d); }
|
||||
.rank-2 .rank-badge { background: linear-gradient(135deg, #00ce9e, #00b4d8); }
|
||||
.rank-3 .rank-badge { background: linear-gradient(135deg, #748ffc, #9775fa); }
|
||||
.rank-1 .rank-badge { background: linear-gradient(135deg, var(--danger), var(--warning)); }
|
||||
.rank-2 .rank-badge { background: var(--grad-rank2); }
|
||||
.rank-3 .rank-badge { background: var(--grad-rank3); }
|
||||
.rank-name { font-weight: 600; color: var(--text-primary); }
|
||||
.rank-1 .rank-name { color: #ffd93d; }
|
||||
.rank-2 .rank-name { color: #00ce9e; }
|
||||
.rank-3 .rank-name { color: #748ffc; }
|
||||
.rank-1 .rank-name { color: var(--warning); }
|
||||
.rank-2 .rank-name { color: var(--success); }
|
||||
.rank-3 .rank-name { color: var(--step-watch); }
|
||||
.rank-rate {
|
||||
font-weight: 700;
|
||||
color: #00ce9e;
|
||||
color: var(--success);
|
||||
text-align: right;
|
||||
}
|
||||
.rank-desc { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -842,10 +842,10 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.step-watch .step-action { color: #748ffc; }
|
||||
.step-buy .step-action { color: #ff6b6b; }
|
||||
.step-add .step-action { color: #00ce9e; }
|
||||
.step-hold .step-action { color: #ffd93d; }
|
||||
.step-watch .step-action { color: var(--step-watch); }
|
||||
.step-buy .step-action { color: var(--danger); }
|
||||
.step-add .step-action { color: var(--success); }
|
||||
.step-hold .step-action { color: var(--warning); }
|
||||
|
||||
/* 核心信号一句话总结 */
|
||||
.signal-summary-list {
|
||||
@@ -869,11 +869,11 @@
|
||||
}
|
||||
.summary-tag.core {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
color: #ff6b6b;
|
||||
color: var(--danger);
|
||||
}
|
||||
.summary-tag.normal {
|
||||
background: rgba(0, 206, 158, 0.15);
|
||||
color: #00ce9e;
|
||||
color: var(--success);
|
||||
}
|
||||
.summary-tag.aux {
|
||||
background: rgba(255,255,255,0.08);
|
||||
@@ -1287,13 +1287,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ss-dot.s85 { background: #ff4444; }
|
||||
.ss-dot.s80 { background: #ff6b35; }
|
||||
.ss-dot.s75 { background: #4CAF50; }
|
||||
.ss-dot.s70 { background: #2196F3; }
|
||||
.ss-dot.s65 { background: #9C27B0; }
|
||||
.ss-dot.s60 { background: #FF9800; }
|
||||
.ss-dot.s55 { background: #607D8B; }
|
||||
.ss-dot.s85 { background: var(--sig-85); }
|
||||
.ss-dot.s80 { background: var(--sig-80); }
|
||||
.ss-dot.s75 { background: var(--sig-75); }
|
||||
.ss-dot.s70 { background: var(--sig-70); }
|
||||
.ss-dot.s65 { background: var(--sig-65); }
|
||||
.ss-dot.s60 { background: var(--sig-60); }
|
||||
.ss-dot.s55 { background: var(--sig-55); }
|
||||
|
||||
.ss-name {
|
||||
font-weight: 600;
|
||||
@@ -1315,7 +1315,7 @@
|
||||
|
||||
.ss-badge.active {
|
||||
background: rgba(0, 200, 83, 0.2);
|
||||
color: #00c853;
|
||||
color: var(--down);
|
||||
border: 1px solid rgba(0, 200, 83, 0.4);
|
||||
}
|
||||
|
||||
@@ -1383,7 +1383,7 @@
|
||||
}
|
||||
|
||||
.algo-badge {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
background: var(--grad-algo);
|
||||
color: #fff;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
@@ -1431,12 +1431,12 @@
|
||||
|
||||
.trade-type-tag.buy {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.trade-type-tag.sell, .trade-type-tag.partial_sell {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.trade-stock {
|
||||
@@ -1451,7 +1451,7 @@
|
||||
}
|
||||
|
||||
.trade-fee {
|
||||
color: #ff9800;
|
||||
color: var(--warning);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -1474,22 +1474,22 @@
|
||||
|
||||
.result-reason-item.reason-limit {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border-left: 3px solid #ff9800;
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
.result-reason-item.reason-cash {
|
||||
background: rgba(33, 150, 243, 0.1);
|
||||
border-left: 3px solid #2196f3;
|
||||
border-left: 3px solid var(--info);
|
||||
}
|
||||
|
||||
.result-reason-item.reason-sell {
|
||||
background: rgba(244, 67, 54, 0.08);
|
||||
border-left: 3px solid #f44336;
|
||||
border-left: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.result-reason-item.reason-buy {
|
||||
background: rgba(76, 175, 80, 0.08);
|
||||
border-left: 3px solid #4caf50;
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.result-reason-item.reason-info {
|
||||
@@ -1564,7 +1564,7 @@
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #2196F3;
|
||||
background: var(--info-bg);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -1588,8 +1588,8 @@
|
||||
.deep-stock-code { font-size: 12px; color: rgba(255,255,255,0.5); margin-top: 2px; }
|
||||
.deep-price { font-size: 22px; font-weight: 700; color: #fff; text-align: center; }
|
||||
.deep-change { font-size: 14px; text-align: center; margin-top: 2px; }
|
||||
.deep-change.up { color: #f44336; }
|
||||
.deep-change.down { color: #4caf50; }
|
||||
.deep-change.up { color: var(--up); }
|
||||
.deep-change.down { color: var(--down); }
|
||||
|
||||
.deep-score-circle {
|
||||
width: 50px; height: 50px; border-radius: 50%;
|
||||
@@ -1598,11 +1598,49 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
.score-num { font-size: 20px; color: #fff; }
|
||||
.deep-score-circle.score-high { background: linear-gradient(135deg, #f44336, #ff5722); }
|
||||
.deep-score-circle.score-mid { background: linear-gradient(135deg, #ff9800, #ffc107); }
|
||||
.deep-score-circle.score-low { background: linear-gradient(135deg, #607d8b, #78909c); }
|
||||
.deep-score-circle.score-high { background: linear-gradient(135deg, var(--danger), #ff5722); }
|
||||
.deep-score-circle.score-mid { background: linear-gradient(135deg, var(--warning), #ffc107); }
|
||||
.deep-score-circle.score-low { background: linear-gradient(135deg, var(--sig-55), #78909c); }
|
||||
.deep-verdict { text-align: center; font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 4px; }
|
||||
|
||||
/* 三项得分明细 */
|
||||
.deep-score-breakdown {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.score-breakdown-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.score-breakdown-item .breakdown-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.score-breakdown-item .breakdown-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.score-breakdown-item .breakdown-value.pos { color: var(--danger); }
|
||||
.score-breakdown-item .breakdown-value.neg { color: var(--success); }
|
||||
.score-breakdown-item.highlight {
|
||||
background: rgba(108,92,231,0.12);
|
||||
border-radius: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.score-breakdown-item.highlight .breakdown-value {
|
||||
color: var(--primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.deep-section {
|
||||
background: var(--card-bg, #1e1e2e);
|
||||
border-radius: 12px;
|
||||
@@ -1646,11 +1684,11 @@
|
||||
border-radius: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.deep-sig-name { font-weight: 600; color: #ff9800; font-size: 13px; }
|
||||
.deep-sig-name { font-weight: 600; color: var(--sig-name); font-size: 13px; }
|
||||
.deep-sig-strength {
|
||||
font-size: 11px;
|
||||
background: rgba(255,152,0,0.2);
|
||||
color: #ffb74d;
|
||||
color: var(--sig-strength);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -1669,26 +1707,26 @@
|
||||
.pos-bar-wrap { display: flex; align-items: center; gap: 4px; }
|
||||
.pos-bar-bg { flex: 1; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
|
||||
.pos-bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s; }
|
||||
.pos-bar-fill.high { background: #f44336; }
|
||||
.pos-bar-fill.mid { background: #ff9800; }
|
||||
.pos-bar-fill.low { background: #4caf50; }
|
||||
.pos-bar-fill.high { background: var(--up); }
|
||||
.pos-bar-fill.mid { background: var(--warning); }
|
||||
.pos-bar-fill.low { background: var(--down); }
|
||||
.pos-pct { font-size: 11px; color: rgba(255,255,255,0.5); min-width: 28px; }
|
||||
.pos-space { display: flex; gap: 6px; font-size: 11px; }
|
||||
.space-up { color: #f44336; }
|
||||
.space-down { color: #4caf50; }
|
||||
.space-up { color: var(--up); }
|
||||
.space-down { color: var(--down); }
|
||||
|
||||
.deep-sr-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.sr-col-title { font-size: 12px; font-weight: 600; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.support-title { color: #4caf50; }
|
||||
.resist-title { color: #f44336; }
|
||||
.support-title { color: var(--down); }
|
||||
.resist-title { color: var(--up); }
|
||||
.sr-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sr-item.support .sr-level { color: #4caf50; font-weight: 600; }
|
||||
.sr-item.resist .sr-level { color: #f44336; font-weight: 600; }
|
||||
.sr-item.support .sr-level { color: var(--down); font-weight: 600; }
|
||||
.sr-item.resist .sr-level { color: var(--up); font-weight: 600; }
|
||||
.sr-name { color: rgba(255,255,255,0.5); }
|
||||
.sr-empty { color: rgba(255,255,255,0.2); font-size: 12px; text-align: center; padding: 8px; }
|
||||
|
||||
@@ -1699,8 +1737,8 @@
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
.vol-tag.vol-up { background: rgba(244,67,54,0.15); color: #f44336; }
|
||||
.vol-tag.vol-dn { background: rgba(76,175,80,0.15); color: #4caf50; }
|
||||
.vol-tag.vol-up { background: rgba(244,67,54,0.15); color: var(--up); }
|
||||
.vol-tag.vol-dn { background: rgba(76,175,80,0.15); color: var(--down); }
|
||||
.vol-detail { font-size: 12px; color: rgba(255,255,255,0.4); }
|
||||
|
||||
.deep-patterns { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
@@ -1711,8 +1749,8 @@
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.pattern-tag.bullish { background: rgba(244,67,54,0.12); color: #ef9a9a; }
|
||||
.pattern-tag.bearish { background: rgba(76,175,80,0.12); color: #a5d6a7; }
|
||||
.pattern-tag.bullish { background: rgba(244,67,54,0.12); color: var(--up-soft); }
|
||||
.pattern-tag.bearish { background: rgba(76,175,80,0.12); color: var(--down-soft); }
|
||||
.pattern-tag small { opacity: 0.7; }
|
||||
|
||||
.deep-ma-info {
|
||||
@@ -1726,9 +1764,9 @@
|
||||
font-size: 12px; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
.ma-trend-tag.bullish { background: rgba(244,67,54,0.15); color: #f44336; }
|
||||
.ma-trend-tag.bearish { background: rgba(76,175,80,0.15); color: #4caf50; }
|
||||
.ma-trend-tag.mixed { background: rgba(255,152,0,0.15); color: #ff9800; }
|
||||
.ma-trend-tag.bullish { background: rgba(244,67,54,0.15); color: var(--up); }
|
||||
.ma-trend-tag.bearish { background: rgba(76,175,80,0.15); color: var(--down); }
|
||||
.ma-trend-tag.mixed { background: rgba(255,152,0,0.15); color: var(--warning); }
|
||||
.ma-val { font-size: 12px; color: rgba(255,255,255,0.4); }
|
||||
|
||||
.deep-fundamental {
|
||||
@@ -1744,15 +1782,15 @@
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.score-reason-tag.positive { background: rgba(244,67,54,0.12); color: #ef9a9a; }
|
||||
.score-reason-tag.negative { background: rgba(76,175,80,0.12); color: #a5d6a7; }
|
||||
.score-reason-tag.positive { background: rgba(244,67,54,0.12); color: var(--up-soft); }
|
||||
.score-reason-tag.negative { background: rgba(76,175,80,0.12); color: var(--down-soft); }
|
||||
|
||||
/* AI通俗解说 */
|
||||
.deep-ai-summary {
|
||||
background: linear-gradient(135deg, rgba(33,150,243,0.08), rgba(156,39,176,0.06));
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
border-left: 3px solid #2196F3;
|
||||
border-left: 3px solid var(--info-bg);
|
||||
}
|
||||
.deep-ai-title {
|
||||
display: flex;
|
||||
@@ -1760,7 +1798,7 @@
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64b5f6;
|
||||
color: var(--info-light);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.deep-ai-text {
|
||||
@@ -1782,7 +1820,7 @@
|
||||
}
|
||||
.ai-action-label {
|
||||
font-weight: 600;
|
||||
color: #ff9800;
|
||||
color: var(--warning);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
@@ -1838,17 +1876,17 @@
|
||||
.buy-card-center { text-align: center; min-width: 70px; }
|
||||
.buy-card-price { font-size: 14px; font-weight: 600; color: #fff; }
|
||||
.buy-card-change { font-size: 12px; display: block; }
|
||||
.buy-card-change.up { color: #f44336; }
|
||||
.buy-card-change.down { color: #4caf50; }
|
||||
.buy-card-change.up { color: var(--up); }
|
||||
.buy-card-change.down { color: var(--down); }
|
||||
.buy-card-right { text-align: center; min-width: 50px; }
|
||||
.buy-card-score {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
}
|
||||
.buy-card-score.score-high { color: #f44336; }
|
||||
.buy-card-score.score-mid { color: #ff9800; }
|
||||
.buy-card-score.score-low { color: #78909c; }
|
||||
.buy-card-score.score-high { color: var(--up); }
|
||||
.buy-card-score.score-mid { color: var(--warning); }
|
||||
.buy-card-score.score-low { color: var(--score-low); }
|
||||
.buy-card-verdict { font-size: 11px; color: rgba(255,255,255,0.5); }
|
||||
.buy-card-arrow {
|
||||
font-size: 14px;
|
||||
|
||||
@@ -235,3 +235,163 @@
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板过渡断点 (500-768px) */
|
||||
@media (min-width: 500px) and (max-width: 767px) {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.nav-tabs-compact .nav-tab {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sub-tab {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.input-section {
|
||||
padding: 18px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.alerts-summary.top-summary {
|
||||
padding: 14px 20px;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.alerts-summary.top-summary .summary-value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.signal-details {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scan-summary-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.deep-score-breakdown {
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.score-breakdown-item .breakdown-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超大屏幕 (1440px+) 限制最大宽度避免过宽 */
|
||||
@media (min-width: 1440px) {
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 打印样式 ===== */
|
||||
@media print {
|
||||
/* 隐藏导航、操作按钮、非打印元素 */
|
||||
.nav-tabs,
|
||||
.sub-tabs,
|
||||
.title-bar .user-info,
|
||||
.toast-container,
|
||||
.confirm-overlay,
|
||||
.modal-overlay,
|
||||
.deep-input-card,
|
||||
.deep-analyze-btn,
|
||||
.alert-action-btn,
|
||||
.scan-control,
|
||||
.scan-btn,
|
||||
.analyze-btn,
|
||||
.trade-actions-bar,
|
||||
.add-trade-btn,
|
||||
.refresh-price-btn,
|
||||
.buy-card-arrow,
|
||||
.skeleton-card,
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 重置背景为白色 */
|
||||
body {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* 卡片去阴影、改为黑白边框 */
|
||||
.deep-report,
|
||||
.deep-header-card,
|
||||
.deep-section,
|
||||
.deep-ai-summary,
|
||||
.buy-analysis-card,
|
||||
.buy-card-detail,
|
||||
.card,
|
||||
.glass-card {
|
||||
background: #fff !important;
|
||||
border: 1px solid #ccc !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 4px !important;
|
||||
margin-bottom: 8px !important;
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
|
||||
/* 文字改为黑色 */
|
||||
.deep-stock-name,
|
||||
.deep-stock-code,
|
||||
.deep-price,
|
||||
.score-num,
|
||||
.deep-section-title,
|
||||
.deep-ai-title,
|
||||
.deep-ai-text,
|
||||
.ai-action-tip,
|
||||
.buy-card-name,
|
||||
.buy-card-code,
|
||||
.breakdown-label,
|
||||
.breakdown-value {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* 涨跌色保持可区分(改为深色) */
|
||||
.deep-change.up, .batch-change.up, .alert-change.up,
|
||||
.positive, .trade-profit.profit, .holding-profit.profit {
|
||||
color: var(--print-up) !important;
|
||||
}
|
||||
.deep-change.down, .batch-change.down, .alert-change.down,
|
||||
.negative, .trade-profit.loss, .holding-profit.loss {
|
||||
color: var(--print-down) !important;
|
||||
}
|
||||
|
||||
/* 评分圆环保持色差 */
|
||||
.deep-score-circle.score-high { background: var(--print-up) !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.deep-score-circle.score-mid { background: var(--print-mid) !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.deep-score-circle.score-low { background: var(--print-low) !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.score-num { color: #fff !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
|
||||
/* SVG 保色 */
|
||||
svg {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* 避免分页截断 */
|
||||
.deep-header-card,
|
||||
.deep-section,
|
||||
.buy-analysis-card {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
+169
-67
@@ -81,8 +81,8 @@
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.batch-change.up { color: #ff4444; }
|
||||
.batch-change.down { color: #00c853; }
|
||||
.batch-change.up { color: var(--up); }
|
||||
.batch-change.down { color: var(--down); }
|
||||
|
||||
/* 信号计数徽章 */
|
||||
.batch-count-badge {
|
||||
@@ -93,16 +93,16 @@
|
||||
text-align: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
color: #4fc3f7;
|
||||
color: var(--info);
|
||||
background: rgba(79,195,247,0.10);
|
||||
}
|
||||
.batch-count-badge small { font-size: 10px; font-weight: 400; opacity: 0.6; }
|
||||
.batch-count-badge.warm {
|
||||
color: #00c853;
|
||||
color: var(--down);
|
||||
background: rgba(0,200,83,0.12);
|
||||
}
|
||||
.batch-count-badge.hot {
|
||||
color: #ff6b35;
|
||||
color: var(--hot);
|
||||
background: rgba(255,107,53,0.14);
|
||||
}
|
||||
.batch-count-badge.empty {
|
||||
@@ -134,13 +134,13 @@
|
||||
font-size: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.pill-s85 { color: #ff6666; background: rgba(255,68,68,0.12); }
|
||||
.pill-s80 { color: #ff8a50; background: rgba(255,107,53,0.12); }
|
||||
.pill-s75 { color: #66bb6a; background: rgba(76,175,80,0.12); }
|
||||
.pill-s70 { color: #64b5f6; background: rgba(33,150,243,0.12); }
|
||||
.pill-s65 { color: #ce93d8; background: rgba(156,39,176,0.10); }
|
||||
.pill-s60 { color: #ffb74d; background: rgba(255,152,0,0.12); }
|
||||
.pill-s55 { color: #90a4ae; background: rgba(96,125,139,0.12); }
|
||||
.pill-s85 { color: var(--sig-85); background: rgba(255,68,68,0.12); }
|
||||
.pill-s80 { color: var(--sig-80); background: rgba(255,107,53,0.12); }
|
||||
.pill-s75 { color: var(--sig-75); background: rgba(76,175,80,0.12); }
|
||||
.pill-s70 { color: var(--sig-70); background: rgba(33,150,243,0.12); }
|
||||
.pill-s65 { color: var(--sig-65); background: rgba(156,39,176,0.10); }
|
||||
.pill-s60 { color: var(--sig-60); background: rgba(255,152,0,0.12); }
|
||||
.pill-s55 { color: var(--sig-55); background: rgba(96,125,139,0.12); }
|
||||
|
||||
/* 描述区域 */
|
||||
.batch-desc-area {
|
||||
@@ -160,13 +160,13 @@
|
||||
flex-shrink: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.dot-s85 { background: #ff4444; }
|
||||
.dot-s80 { background: #ff6b35; }
|
||||
.dot-s75 { background: #4CAF50; }
|
||||
.dot-s70 { background: #2196F3; }
|
||||
.dot-s65 { background: #9C27B0; }
|
||||
.dot-s60 { background: #FF9800; }
|
||||
.dot-s55 { background: #607D8B; }
|
||||
.dot-s85 { background: var(--sig-85); }
|
||||
.dot-s80 { background: var(--sig-80); }
|
||||
.dot-s75 { background: var(--sig-75); }
|
||||
.dot-s70 { background: var(--sig-70); }
|
||||
.dot-s65 { background: var(--sig-65); }
|
||||
.dot-s60 { background: var(--sig-60); }
|
||||
.dot-s55 { background: var(--sig-55); }
|
||||
.desc-text {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-secondary);
|
||||
@@ -175,6 +175,7 @@
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
@@ -198,16 +199,16 @@
|
||||
.batch-rec-tag.rec-buy,
|
||||
.batch-rec-tag.rec-add {
|
||||
background: rgba(0,206,158,0.18);
|
||||
color: #00ce9e;
|
||||
color: var(--success);
|
||||
}
|
||||
.batch-rec-tag.rec-sell {
|
||||
background: rgba(255,68,68,0.18);
|
||||
color: #ff4444;
|
||||
color: var(--danger);
|
||||
}
|
||||
.batch-rec-tag.rec-watch,
|
||||
.batch-rec-tag.rec-hold {
|
||||
background: rgba(100,149,237,0.18);
|
||||
color: #6495ed;
|
||||
color: var(--watch);
|
||||
}
|
||||
.batch-rec-reason {
|
||||
font-size: 12px;
|
||||
@@ -229,8 +230,8 @@
|
||||
.scan-holding-note,
|
||||
.strategy-holding-note {
|
||||
font-size: 11.5px;
|
||||
color: #ff9800;
|
||||
background: rgba(255,152,0,0.08);
|
||||
color: var(--warning);
|
||||
background: rgba(255,170,0,0.08);
|
||||
padding: 4px 12px;
|
||||
margin: 4px 16px 0;
|
||||
border-radius: 6px;
|
||||
@@ -272,13 +273,13 @@
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ss-dot-sm.s85 { background: #ff4444; }
|
||||
.ss-dot-sm.s80 { background: #ff6b35; }
|
||||
.ss-dot-sm.s75 { background: #4CAF50; }
|
||||
.ss-dot-sm.s70 { background: #2196F3; }
|
||||
.ss-dot-sm.s65 { background: #9C27B0; }
|
||||
.ss-dot-sm.s60 { background: #FF9800; }
|
||||
.ss-dot-sm.s55 { background: #607D8B; }
|
||||
.ss-dot-sm.s85 { background: var(--sig-85); }
|
||||
.ss-dot-sm.s80 { background: var(--sig-80); }
|
||||
.ss-dot-sm.s75 { background: var(--sig-75); }
|
||||
.ss-dot-sm.s70 { background: var(--sig-70); }
|
||||
.ss-dot-sm.s65 { background: var(--sig-65); }
|
||||
.ss-dot-sm.s60 { background: var(--sig-60); }
|
||||
.ss-dot-sm.s55 { background: var(--sig-55); }
|
||||
|
||||
/* 全量扫描触发区域 */
|
||||
.full-scan-trigger-section {
|
||||
@@ -303,7 +304,7 @@
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(108, 92, 231, 0.3);
|
||||
border-top-color: #6c5ce7;
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@@ -352,14 +353,14 @@
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6c5ce7, #00ce9e);
|
||||
background: linear-gradient(90deg, var(--primary), var(--success));
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.progress-done {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #00ce9e;
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -401,7 +402,7 @@
|
||||
}
|
||||
.section-close:hover {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
color: #ff6b6b;
|
||||
color: var(--danger);
|
||||
}
|
||||
.section-close.inline {
|
||||
display: inline-flex;
|
||||
@@ -449,7 +450,7 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.scan-summary-text .highlight { color: #ffd93d; }
|
||||
.scan-summary-text .highlight { color: var(--hold); }
|
||||
.scan-summary-toggle {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
@@ -484,7 +485,7 @@
|
||||
background: rgba(108, 92, 231, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
.dist-tag b { margin-left: 4px; color: #ffd93d; }
|
||||
.dist-tag b { margin-left: 4px; color: var(--hold); }
|
||||
|
||||
.scan-filters {
|
||||
display: flex;
|
||||
@@ -522,10 +523,10 @@
|
||||
color: #fff;
|
||||
}
|
||||
.filter-tag.rec-all.active { background: var(--primary, #6c5ce7); color: #fff; }
|
||||
.filter-tag.rec-buy.active { background: rgba(0, 206, 158, 0.35); color: #00ce9e; }
|
||||
.filter-tag.rec-sell.active { background: rgba(255, 68, 68, 0.35); color: #ff4444; }
|
||||
.filter-tag.rec-hold.active { background: rgba(255, 217, 61, 0.35); color: #ffd93d; }
|
||||
.filter-tag.rec-watch.active { background: rgba(100, 149, 237, 0.35); color: #6495ed; }
|
||||
.filter-tag.rec-buy.active { background: rgba(0, 206, 158, 0.35); color: var(--success); }
|
||||
.filter-tag.rec-sell.active { background: rgba(255, 68, 68, 0.35); color: var(--danger); }
|
||||
.filter-tag.rec-hold.active { background: rgba(255, 217, 61, 0.35); color: var(--hold); }
|
||||
.filter-tag.rec-watch.active { background: rgba(100, 149, 237, 0.35); color: var(--watch); }
|
||||
|
||||
.full-scan-list {
|
||||
display: flex;
|
||||
@@ -551,10 +552,11 @@
|
||||
}
|
||||
.scan-result-card.is-watched {
|
||||
/* 不再使用左边线颜色,仅保留星标等标识 */
|
||||
border-color: var(--border);
|
||||
}
|
||||
.scan-result-card:hover { background: rgba(255,255,255,0.06); }
|
||||
.scan-watched-icon {
|
||||
color: #f59e0b;
|
||||
color: var(--warning);
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -566,7 +568,7 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.scan-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--primary, #6c5ce7);
|
||||
font-weight: 600;
|
||||
@@ -574,15 +576,15 @@
|
||||
.scan-name { font-size: 13px; color: var(--text-primary); }
|
||||
.scan-price {
|
||||
font-size: 12px;
|
||||
color: #ffd700;
|
||||
color: var(--gold);
|
||||
}
|
||||
.scan-change {
|
||||
font-size: 11px;
|
||||
margin-left: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.scan-change.up { color: #ff4757; }
|
||||
.scan-change.down { color: #00ce9e; }
|
||||
.scan-change.up { color: var(--up); }
|
||||
.scan-change.down { color: var(--down); }
|
||||
.scan-recommend {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -590,10 +592,10 @@
|
||||
border-radius: 3px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.scan-recommend.buy { background: rgba(0, 206, 158, 0.2); color: #00ce9e; }
|
||||
.scan-recommend.sell { background: rgba(255, 68, 68, 0.2); color: #ff4444; }
|
||||
.scan-recommend.hold { background: rgba(255, 217, 61, 0.2); color: #ffd93d; }
|
||||
.scan-recommend.watch-active { background: rgba(100, 149, 237, 0.2); color: #6495ed; }
|
||||
.scan-recommend.buy { background: rgba(0, 206, 158, 0.2); color: var(--success); }
|
||||
.scan-recommend.sell { background: rgba(255, 68, 68, 0.2); color: var(--danger); }
|
||||
.scan-recommend.hold { background: rgba(255, 217, 61, 0.2); color: var(--hold); }
|
||||
.scan-recommend.watch-active { background: rgba(100, 149, 237, 0.2); color: var(--watch); }
|
||||
.scan-recommend.watch { background: rgba(255, 255, 255, 0.08); color: var(--text-muted); }
|
||||
.scan-card-info {
|
||||
display: flex;
|
||||
@@ -605,7 +607,7 @@
|
||||
.scan-info-left { display: flex; align-items: center; gap: 6px; }
|
||||
.scan-triggered {
|
||||
font-size: 12px;
|
||||
color: #ffd93d;
|
||||
color: var(--hold);
|
||||
font-weight: 600;
|
||||
}
|
||||
.scan-no-signal {
|
||||
@@ -625,7 +627,7 @@
|
||||
}
|
||||
.scan-signal-tag.active {
|
||||
background: rgba(0, 206, 158, 0.15);
|
||||
color: #00ce9e;
|
||||
color: var(--success);
|
||||
}
|
||||
.scan-signal-tag.inactive {
|
||||
background: rgba(255,255,255,0.03);
|
||||
@@ -737,13 +739,13 @@
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.step-divergence .step-action { background: rgba(100, 149, 237, 0.25); color: #6495ed; }
|
||||
.step-divergence .step-action { background: rgba(100, 149, 237, 0.25); color: var(--watch); }
|
||||
.step-divergence.active { border-color: rgba(100, 149, 237, 0.4); }
|
||||
.step-dragon .step-action { background: rgba(0, 206, 158, 0.25); color: #00ce9e; }
|
||||
.step-dragon .step-action { background: rgba(0, 206, 158, 0.25); color: var(--teal); }
|
||||
.step-dragon.active { border-color: rgba(0, 206, 158, 0.4); }
|
||||
.step-true-dragon .step-action { background: rgba(0, 200, 83, 0.25); color: #00c853; }
|
||||
.step-true-dragon .step-action { background: rgba(0, 200, 83, 0.25); color: var(--down); }
|
||||
.step-true-dragon.active { border-color: rgba(0, 200, 83, 0.4); }
|
||||
.step-main-wave .step-action { background: rgba(255, 68, 68, 0.25); color: #ff6b6b; }
|
||||
.step-main-wave .step-action { background: rgba(255, 68, 68, 0.25); color: var(--up); }
|
||||
.step-main-wave.active { border-color: rgba(255, 68, 68, 0.4); }
|
||||
.bull-flow-arrow {
|
||||
color: var(--text-muted);
|
||||
@@ -928,7 +930,7 @@
|
||||
.bull-stock-card.stage-s5 { border-left: 3px solid rgba(156, 39, 176, 0.5); }
|
||||
|
||||
/* 回调补涨阶段样式 */
|
||||
.step-rebound .step-action { background: rgba(156, 39, 176, 0.25); color: #ce93d8; }
|
||||
.step-rebound .step-action { background: rgba(156, 39, 176, 0.25); color: var(--purple-light); }
|
||||
.step-rebound.active { border-color: rgba(156, 39, 176, 0.4); }
|
||||
|
||||
/* 信号标签列表 */
|
||||
@@ -943,7 +945,7 @@
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 206, 158, 0.12);
|
||||
color: #00ce9e;
|
||||
color: var(--success);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 推荐标签 + 下一信号 */
|
||||
@@ -960,12 +962,12 @@
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rec-buy { background: rgba(244, 67, 54, 0.15); color: #f44336; }
|
||||
.rec-sell { background: rgba(33, 150, 243, 0.15); color: #2196F3; }
|
||||
.rec-add { background: rgba(255, 152, 0, 0.15); color: #FF9800; }
|
||||
.rec-hold { background: rgba(76, 175, 80, 0.15); color: #4CAF50; }
|
||||
.rec-watch { background: rgba(156, 39, 176, 0.15); color: #ce93d8; }
|
||||
.rec-observe { background: rgba(158, 158, 158, 0.15); color: #9E9E9E; }
|
||||
.rec-buy { background: rgba(244, 67, 54, 0.15); color: var(--up); }
|
||||
.rec-sell { background: rgba(33, 150, 243, 0.15); color: var(--info-bg); }
|
||||
.rec-add { background: rgba(255, 152, 0, 0.15); color: var(--sig-name); }
|
||||
.rec-hold { background: rgba(76, 175, 80, 0.15); color: var(--sig-75); }
|
||||
.rec-watch { background: rgba(156, 39, 176, 0.15); color: var(--purple-light); }
|
||||
.rec-observe { background: rgba(158, 158, 158, 0.15); color: var(--gray-mid); }
|
||||
.rec-reason {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
@@ -987,8 +989,8 @@
|
||||
text-align: right;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.bull-card-change.up { color: #ff4444; }
|
||||
.bull-card-change.down { color: #00c853; }
|
||||
.bull-card-change.up { color: var(--up); }
|
||||
.bull-card-change.down { color: var(--down); }
|
||||
/* 迷你进度条 */
|
||||
.bull-card-progress {
|
||||
display: flex;
|
||||
@@ -1007,7 +1009,7 @@
|
||||
.mini-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, #2196F3, #4CAF50);
|
||||
background: var(--grad-progress);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.bull-card-progress .progress-label {
|
||||
@@ -1016,3 +1018,103 @@
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 扫描卡片综合得分胶囊 */
|
||||
.scan-score-pill {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: help;
|
||||
}
|
||||
.scan-score-pill.score-high {
|
||||
color: var(--danger);
|
||||
background: rgba(255,68,68,0.12);
|
||||
}
|
||||
.scan-score-pill.score-mid {
|
||||
color: var(--warning);
|
||||
background: rgba(255,170,0,0.12);
|
||||
}
|
||||
.scan-score-pill.score-low {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
/* 综合评分明细 */
|
||||
.scan-score-breakdown {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
margin: 8px 0;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.score-breakdown-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 60px;
|
||||
}
|
||||
.score-breakdown-item .breakdown-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.score-breakdown-item .breakdown-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.score-breakdown-item .breakdown-value.pos { color: var(--success); }
|
||||
.score-breakdown-item .breakdown-value.neg { color: var(--danger); }
|
||||
.score-breakdown-item.highlight .breakdown-value {
|
||||
color: var(--warning);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 外部因素详情面板 */
|
||||
.scan-external-factors {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.scan-external-factors.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ext-factor-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
.ext-factor-item:last-child { border-bottom: none; }
|
||||
.ext-factor-label {
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ext-factor-score {
|
||||
flex-shrink: 0;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ext-factor-score.pos { color: var(--success); }
|
||||
.ext-factor-score.neg { color: var(--danger); }
|
||||
.ext-factor-summary {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
+64
-17
@@ -541,10 +541,10 @@
|
||||
const response = await axios.get('/api/me');
|
||||
if (response.data.success && response.data.user) {
|
||||
this.currentUser = response.data.user;
|
||||
console.log('已登录:', this.currentUser.username);
|
||||
console.debug('已登录:', this.currentUser.username);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('未登录');
|
||||
console.debug('未登录');
|
||||
} finally {
|
||||
this.checkingAuth = false;
|
||||
}
|
||||
@@ -791,7 +791,7 @@
|
||||
return response.data.data.stock_name;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('获取股票名称失败');
|
||||
console.debug('获取股票名称失败');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -843,6 +843,7 @@
|
||||
} catch (err) {
|
||||
console.error('请求错误:', err);
|
||||
this.error = err.response?.data?.error || err.message || '请求失败';
|
||||
this.showToast(this.error, 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -1160,6 +1161,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载关注列表失败', e);
|
||||
this.showToast('加载关注列表失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1189,6 +1191,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('移除关注失败', e);
|
||||
this.showToast('移除关注失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1235,19 +1238,19 @@
|
||||
|
||||
// 版本不匹配则强制刷新
|
||||
if (cacheVer < ALERT_CACHE_VERSION) {
|
||||
console.log(`缓存版本过旧(v${cacheVer} < v${ALERT_CACHE_VERSION}),强制刷新`);
|
||||
console.debug(`缓存版本过旧(v${cacheVer} < v${ALERT_CACHE_VERSION}),强制刷新`);
|
||||
} else {
|
||||
this.stockAlerts = cachedAlerts;
|
||||
console.log(`加载缓存数据: ${cachedAlerts.length}条, 更新时间: ${lastUpdate}, v${cacheVer}`);
|
||||
console.debug(`加载缓存数据: ${cachedAlerts.length}条, 更新时间: ${lastUpdate}, v${cacheVer}`);
|
||||
|
||||
if (cacheDate === today) {
|
||||
console.log('缓存是今天的,无需更新');
|
||||
console.debug('缓存是今天的,无需更新');
|
||||
this.alertsLoading = false;
|
||||
this.updateHoldingPricesFromAlerts();
|
||||
const cachedCodes = new Set(cachedAlerts.map(a => a.code));
|
||||
const newStocks = allStocks.filter(s => !cachedCodes.has(s.code));
|
||||
if (newStocks.length > 0) {
|
||||
console.log(`发现${newStocks.length}只新股票,增量更新`);
|
||||
console.debug(`发现${newStocks.length}只新股票,增量更新`);
|
||||
await this.analyzeNewStocks(newStocks, today);
|
||||
}
|
||||
return;
|
||||
@@ -1255,7 +1258,7 @@
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('加载缓存失败,将重新分析:', err);
|
||||
console.debug('加载缓存失败,将重新分析:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1264,7 +1267,7 @@
|
||||
this.alertsProgress = { current: 0, total: allStocks.length };
|
||||
|
||||
try {
|
||||
console.log(`开始信号分析 ${allStocks.length} 只股票...`);
|
||||
console.debug(`开始信号分析 ${allStocks.length} 只股票...`);
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await axios.post('/api/signal_alerts', {
|
||||
@@ -1273,14 +1276,14 @@
|
||||
});
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`信号分析完成,耗时 ${elapsed}s`);
|
||||
console.debug(`信号分析完成,耗时 ${elapsed}s`);
|
||||
|
||||
if (response.data.success) {
|
||||
const results = response.data.results || [];
|
||||
this.alertsProgress.current = allStocks.length;
|
||||
this.stockAlerts = [...results];
|
||||
|
||||
console.log(`成功: ${response.data.success_count}`);
|
||||
console.debug(`成功: ${response.data.success_count}`);
|
||||
|
||||
// 3. 保存到缓存
|
||||
this.saveAlertsCache(this.stockAlerts);
|
||||
@@ -1296,7 +1299,7 @@
|
||||
} catch (err) {
|
||||
console.error('批量分析请求失败:', err);
|
||||
// 回退到逐个分析
|
||||
console.log('回退到逐个分析模式...');
|
||||
console.debug('回退到逐个分析模式...');
|
||||
await this.analyzeStocksOneByOne(allStocks, today, forceRefresh);
|
||||
}
|
||||
|
||||
@@ -1432,6 +1435,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('增量信号分析失败:', err);
|
||||
this.showToast('信号分析失败,请稍后重试', 'error');
|
||||
}
|
||||
this.alertsLoading = false;
|
||||
this.saveAlertsCache(this.stockAlerts);
|
||||
@@ -1445,7 +1449,7 @@
|
||||
lastUpdate: new Date().toISOString().replace('T', ' ').split('.')[0],
|
||||
version: ALERT_CACHE_VERSION
|
||||
});
|
||||
console.log('分析结果已保存到缓存');
|
||||
console.debug('分析结果已保存到缓存');
|
||||
} catch (err) {
|
||||
console.error('保存缓存失败:', err);
|
||||
}
|
||||
@@ -1507,6 +1511,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('移除关注失败', e);
|
||||
this.showToast('移除关注失败', 'error');
|
||||
}
|
||||
|
||||
// 从提醒列表中移除
|
||||
@@ -1562,6 +1567,7 @@
|
||||
await this.loadAvailableCash();
|
||||
} catch (err) {
|
||||
console.error('加载交易记录失败:', err);
|
||||
this.showToast('加载交易记录失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1573,6 +1579,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载可用资金失败:', err);
|
||||
this.showToast('加载可用资金失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1589,7 +1596,7 @@
|
||||
try {
|
||||
const amount = parseFloat(this.cashInputValue);
|
||||
if (isNaN(amount)) {
|
||||
alert('请输入有效金额');
|
||||
this.showToast('请输入有效金额', 'warning');
|
||||
return;
|
||||
}
|
||||
const response = await axios.put('/api/available_cash', { amount });
|
||||
@@ -1597,11 +1604,11 @@
|
||||
this.availableCash = response.data.available_cash;
|
||||
this.editingCash = false;
|
||||
} else {
|
||||
alert('保存失败: ' + (response.data.error || '未知错误'));
|
||||
this.showToast('保存失败: ' + (response.data.error || '未知错误'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('保存可用资金失败:', err);
|
||||
alert('保存失败');
|
||||
this.showToast('保存失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1738,6 +1745,7 @@
|
||||
await this.checkStopLoss();
|
||||
} catch (e) {
|
||||
console.error('刷新持仓价格异常:', e);
|
||||
this.showToast('刷新持仓价格失败,请稍后重试', 'error');
|
||||
} finally {
|
||||
this.priceRefreshing = false;
|
||||
}
|
||||
@@ -1752,6 +1760,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查止损失败:', error);
|
||||
this.showToast('检查止损失败,请稍后重试', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1799,6 +1808,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error);
|
||||
this.showToast('获取基本面数据失败', 'error');
|
||||
} finally {
|
||||
this.fundamentalLoading = false;
|
||||
// 绘制K线图
|
||||
@@ -1822,6 +1832,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取K线数据失败:', error);
|
||||
this.showToast('获取K线数据失败', 'error');
|
||||
} finally {
|
||||
this.klineLoading = false;
|
||||
}
|
||||
@@ -2110,6 +2121,13 @@
|
||||
recommend_rate: scanItem.recommend_rate,
|
||||
has_scan_data: true,
|
||||
scan_triggered_count: scanCount,
|
||||
final_score: scanItem.final_score,
|
||||
technical_score: scanItem.technical_score,
|
||||
external_score: scanItem.external_score,
|
||||
verdict: scanItem.verdict,
|
||||
score_factors: null,
|
||||
score_summary: '',
|
||||
score_detail_loading: true,
|
||||
realtime_loading: true,
|
||||
realtime_signal_status: null,
|
||||
realtime_triggered_count: null,
|
||||
@@ -2147,6 +2165,24 @@
|
||||
this.techSignalResult.realtime_error = err.message || '获取失败';
|
||||
}
|
||||
}
|
||||
// 异步请求评分详情(外部因素),传入列表技术得分确保一致
|
||||
try {
|
||||
const techScore = scanItem.final_score ? Math.round(scanItem.final_score - (scanItem.external_score || 0)) : 50;
|
||||
const scoreResp = await axios.get(`/api/stock_score_detail/${code}?tech_score=${techScore}`);
|
||||
if (scoreResp.data.success && this.techSignalResult && this.techSignalResult.stock_code === code) {
|
||||
this.techSignalResult.score_factors = scoreResp.data.factors || null;
|
||||
this.techSignalResult.score_summary = scoreResp.data.summary || '';
|
||||
this.techSignalResult.final_score = scoreResp.data.final_score;
|
||||
this.techSignalResult.technical_score = scoreResp.data.technical_score;
|
||||
this.techSignalResult.external_score = scoreResp.data.external_score;
|
||||
this.techSignalResult.verdict = scoreResp.data.verdict;
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('评分详情获取失败:', e);
|
||||
}
|
||||
if (this.techSignalResult && this.techSignalResult.stock_code === code) {
|
||||
this.techSignalResult.score_detail_loading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -2161,6 +2197,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('技术信号检测失败:', err);
|
||||
this.showToast('技术信号检测失败,请稍后重试', 'error');
|
||||
} finally {
|
||||
this.techLoading = false;
|
||||
}
|
||||
@@ -2168,7 +2205,7 @@
|
||||
|
||||
async batchTechSignals() {
|
||||
if (!this.searchHistory || this.searchHistory.length === 0) {
|
||||
alert('请先添加关注股票');
|
||||
this.showToast('请先添加关注股票', 'warning');
|
||||
return;
|
||||
}
|
||||
this.techLoading = true;
|
||||
@@ -2187,6 +2224,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('批量技术信号检测失败:', err);
|
||||
this.showToast('批量检测失败,请稍后重试', 'error');
|
||||
} finally {
|
||||
this.techLoading = false;
|
||||
}
|
||||
@@ -2220,6 +2258,7 @@
|
||||
signal_type: signalTypes,
|
||||
holding_codes: (this.holdingStocks || []).join(','),
|
||||
recommend_text: this.fullScanFilterRecommend === 'all' ? '' : this.fullScanFilterRecommend,
|
||||
with_scores: true,
|
||||
}
|
||||
});
|
||||
if (resp.data.success) {
|
||||
@@ -2240,6 +2279,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取扫描结果失败:', err);
|
||||
this.showToast('获取扫描结果失败,请稍后重试', 'error');
|
||||
} finally {
|
||||
this.fullScanLoading = false;
|
||||
}
|
||||
@@ -2675,6 +2715,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载模拟交易统计失败', e);
|
||||
this.showToast('加载模拟交易统计失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2686,6 +2727,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载模拟持仓失败', e);
|
||||
this.showToast('加载模拟持仓失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2697,6 +2739,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载模拟交易记录失败', e);
|
||||
this.showToast('加载交易记录失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2711,6 +2754,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载算法配置失败', e);
|
||||
this.showToast('加载算法配置失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2722,6 +2766,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载算法模板失败', e);
|
||||
this.showToast('加载算法模板失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2733,6 +2778,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载信号日志失败', e);
|
||||
this.showToast('加载信号日志失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2744,6 +2790,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载持仓元数据失败', e);
|
||||
this.showToast('加载持仓元数据失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -8,18 +8,22 @@
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #0a0a0a;
|
||||
--bg-dark: #020617;
|
||||
--bg-glass: rgba(255, 255, 255, 0.05);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.08);
|
||||
--bg-content: #0f172a;
|
||||
--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;
|
||||
--accent: #fbbf24;
|
||||
--primary: #fbbf24;
|
||||
--success: #00ff88;
|
||||
--danger: #ff4444;
|
||||
--warning: #ffaa00;
|
||||
--warning: #fbbf24;
|
||||
--info: #3b82f6;
|
||||
--info-light: #60a5fa;
|
||||
--purple: #a855f7;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
@@ -29,12 +33,13 @@
|
||||
color: var(--text-primary);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.admin-wrap { max-width: 800px; margin: 0 auto; padding: 20px 16px; padding-top: max(20px, env(safe-area-inset-top)); }
|
||||
.admin-wrap { max-width: 800px; margin: 0 auto; padding: 20px 16px; padding-top: max(20px, env(safe-area-inset-top)); background: var(--bg-content); min-height: 100vh; }
|
||||
|
||||
/* Header */
|
||||
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.admin-header h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.5px; display: flex; align-items: center; gap: 8px; }
|
||||
.admin-header h1 .icon { width: 22px; height: 22px; opacity: 0.9; }
|
||||
.admin-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 6px; font-size: 10px; font-weight: 700; letter-spacing: 0.5px; background: rgba(251,191,36,0.15); color: var(--accent); border: 1px solid rgba(251,191,36,0.3); }
|
||||
.back-link { color: var(--text-muted); text-decoration: none; font-size: 13px; display: flex; align-items: center; gap: 4px; transition: color 0.2s; }
|
||||
.back-link:hover { color: var(--text-primary); }
|
||||
.back-link svg { width: 14px; height: 14px; }
|
||||
@@ -43,7 +48,7 @@
|
||||
.nav-tabs { display: flex; gap: 2px; margin-bottom: 20px; background: var(--bg-glass); border-radius: 12px; padding: 3px; }
|
||||
.nav-tab { flex: 1; padding: 8px 4px; border: none; background: transparent; color: var(--text-muted); cursor: pointer; border-radius: 10px; font-size: 13px; font-weight: 500; transition: all 0.2s; display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.nav-tab svg { width: 18px; height: 18px; opacity: 0.5; transition: opacity 0.2s; }
|
||||
.nav-tab.active { background: var(--bg-glass-hover); color: var(--text-primary); }
|
||||
.nav-tab.active { background: var(--bg-glass-hover); color: var(--accent); }
|
||||
.nav-tab.active svg { opacity: 1; }
|
||||
.nav-tab:hover:not(.active) { color: var(--text-secondary); }
|
||||
|
||||
@@ -57,11 +62,11 @@
|
||||
.stat-card { background: var(--bg-glass); border-radius: 14px; padding: 14px; display: flex; align-items: center; gap: 12px; }
|
||||
.stat-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.stat-icon svg { width: 18px; height: 18px; }
|
||||
.stat-icon.blue { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
||||
.stat-icon.green { background: rgba(0,255,136,0.15); color: #00ff88; }
|
||||
.stat-icon.orange { background: rgba(255,170,0,0.15); color: #ffaa00; }
|
||||
.stat-icon.red { background: rgba(255,68,68,0.15); color: #ff4444; }
|
||||
.stat-icon.purple { background: rgba(168,85,247,0.15); color: #a855f7; }
|
||||
.stat-icon.blue { background: rgba(59,130,246,0.15); color: var(--info); }
|
||||
.stat-icon.green { background: rgba(0,255,136,0.15); color: var(--success); }
|
||||
.stat-icon.orange { background: rgba(255,170,0,0.15); color: var(--warning); }
|
||||
.stat-icon.red { background: rgba(255,68,68,0.15); color: var(--danger); }
|
||||
.stat-icon.purple { background: rgba(168,85,247,0.15); color: var(--purple); }
|
||||
.stat-info .stat-val { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.stat-info .stat-lbl { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
|
||||
|
||||
@@ -265,6 +270,7 @@
|
||||
<h1>
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
管理后台
|
||||
<span class="admin-badge">ADMIN</span>
|
||||
</h1>
|
||||
<a class="back-link" href="javascript:void(0)" onclick="if(window.opener||window.history.length<=1){window.close()}else{window.location.href='/'}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
@@ -334,7 +340,7 @@
|
||||
</div>
|
||||
<div class="sys-info-row">
|
||||
<span class="lbl">🌐 外网IP</span>
|
||||
<span class="val" style="font-family:monospace;color:#60a5fa;">{{ dash.server_info.external_ip }}</span>
|
||||
<span class="val" style="font-family:monospace;color:var(--info-light);">{{ dash.server_info.external_ip }}</span>
|
||||
</div>
|
||||
<div class="sys-info-row">
|
||||
<span class="lbl">🏠 内网IP</span>
|
||||
@@ -342,11 +348,11 @@
|
||||
</div>
|
||||
<div class="sys-info-row">
|
||||
<span class="lbl">🔌 应用端口</span>
|
||||
<span class="val" style="font-family:monospace;color:#fbbf24;">{{ dash.server_info.app_port }}</span>
|
||||
<span class="val" style="font-family:monospace;color:var(--accent);">{{ dash.server_info.app_port }}</span>
|
||||
</div>
|
||||
<div class="sys-info-row">
|
||||
<span class="lbl">🌍 访问域名</span>
|
||||
<span class="val" style="font-family:monospace;color:#34d399;">{{ dash.server_info.scheme }}://{{ dash.server_info.domain }}</span>
|
||||
<span class="val" style="font-family:monospace;color:var(--success);">{{ dash.server_info.scheme }}://{{ dash.server_info.domain }}</span>
|
||||
</div>
|
||||
<div class="sys-info-row">
|
||||
<span class="lbl">🖥️ 主机名</span>
|
||||
|
||||
+288
-181
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user