feat: 新增外部因素分析模块+综合评分引擎+算法文档重构

新增模块:
- fund_flow_analyzer.py: 主力资金流向分析(P0, ±20)
- market_sentiment.py: 市场情绪指标(P1, ±10)
- external_factors.py: 北向资金/美股/大宗商品/汇率(P2-P4,P7)
- news_analyzer.py: 公告/并购/政策面LLM分析(P5-P6)
- score_engine.py: 综合评分引擎,整合技术面+外部因素

路由更新:
- analysis.py: deep_analyze接入综合评分,根据最终评级修正买卖建议
- market.py: 新增4个外部因素API端点
- trades.py: 交易路由更新

算法文档重构:
- 章节重排: 技术面(二三)→外部因素(四)→买卖决策(五)→数据源(六)→性能(七)
- 架构图更新为五层,标注章节对应
- 5.1/5.2标注纯技术面,5.3整合外部因素修正推荐
This commit is contained in:
selfrelease
2026-07-17 23:50:42 +08:00
parent 04f8b9f951
commit 2b5a32ca1e
23 changed files with 4191 additions and 268 deletions
+24
View File
@@ -0,0 +1,24 @@
# 股票投资分析系统 - 环境变量配置模板
# 复制此文件为 .env 并填入实际值
# Flask
SECRET_KEY=your-secret-key-here
# PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_NAME=stock_app
DB_USER=postgres
DB_PASSWORD=
# 阿里云K线API
ALICLOUD_APPCODE=
# 豆包AI API
DOUBAO_API_KEY=
# 麦蕊API
MAIRUI_LICENCE=
# 服务端口
PORT=3333
+33 -4
View File
@@ -13,6 +13,10 @@ app = Flask(__name__, template_folder='templates', static_folder='static')
app.config['SECRET_KEY'] = Config.SECRET_KEY
CORS(app, supports_credentials=True)
# 初始化数据库连接池
from db import init_db_pool
init_db_pool()
# 注册路由蓝图
from routes.auth import bp as auth_bp
from routes.trades import bp as trades_bp
@@ -47,8 +51,34 @@ def health():
return jsonify({'status': 'ok', 'message': '服务运行正常'})
# ========== 静态文件安全 ==========
@app.route('/robots.txt')
def robots():
return "User-agent: *\nDisallow: /api/\nDisallow: /admin\n", 200, {'Content-Type': 'text/plain'}
@app.route('/.env')
def block_env():
from flask import abort
abort(404)
# ========== 启动 ==========
_scheduler_started = False
def _start_scheduler_once():
"""确保调度器只启动一次(兼容 gunicorn preload + Flask dev reloader"""
global _scheduler_started
if _scheduler_started:
return
_scheduler_started = True
from services.scheduler import start_scheduler
start_scheduler()
if __name__ == '__main__':
print("=" * 60)
print("股票投资分析系统启动")
@@ -58,8 +88,7 @@ if __name__ == '__main__':
print(f"健康检查: http://localhost:{Config.PORT}/api/health")
print("=" * 60)
# 启动模拟交易定时任务调度器
from services.scheduler import start_scheduler
start_scheduler()
_start_scheduler_once()
app.run(debug=True, host='0.0.0.0', port=Config.PORT)
else:
_start_scheduler_once()
+21 -2
View File
@@ -1,8 +1,21 @@
"""
应用配置
应用配置 — 所有敏感信息从环境变量读取,支持 .env 文件
"""
import os
# 加载 .env 文件(如果存在)
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env')
if os.path.exists(_env_path):
with open(_env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, _, value = line.partition('=')
key, value = key.strip(), value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
class Config:
# Flask 配置
SECRET_KEY = os.environ.get('SECRET_KEY', 'stock-app-secret-key-2026')
@@ -23,9 +36,15 @@ class Config:
ALERTS_CACHE_FILE = os.path.join(BASE_DIR, 'alerts_cache.json')
# 阿里云K线API
ALICLOUD_APPCODE = os.environ.get('ALICLOUD_APPCODE', '50528b6544ac4234a8ccb5c9f2c01607')
ALICLOUD_APPCODE = os.environ.get('ALICLOUD_APPCODE', '')
ALICLOUD_KLINE_URL = 'https://jmqqgphqcx.market.alicloudapi.com/finance/a-shares-kline'
# 豆包AI API
DOUBAO_API_KEY = os.environ.get('DOUBAO_API_KEY', '')
# 麦蕊API
MAIRUI_LICENCE = os.environ.get('MAIRUI_LICENCE', '')
# 服务端口
PORT = int(os.environ.get('PORT', 3333))
@@ -0,0 +1,21 @@
[Unit]
Description=Stock Data Collection Service
After=network.target postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/stock-app
Environment="DB_HOST=localhost"
Environment="DB_PORT=5432"
Environment="DB_NAME=stock_app"
Environment="DB_USER=postgres"
Environment="DB_PASSWORD=stock_password_2025"
Environment="ALICLOUD_APPCODE=50528b6544ac4234a8ccb5c9f2c01607"
Environment="MAIRUI_LICENCE=5352ED2F-94E5-4E96-8B7F-B57BA75284E3"
ExecStart=/opt/stock-app/venv/bin/python stock_data_service.py daemon
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
+712
View File
@@ -0,0 +1,712 @@
# 股票投资分析系统 — 算法分析文档
> 最后更新:2026-07-18
---
## 一、整体架构
系统分为五层,数据从下往上流动:
```
技术指标层(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_flowP0
├─ market_sentiment.calc_market_sentimentP1
├─ external_factors.get_all_external_factorsP2-P4,P7
└─ news_analyzer.analyze_news_factorsP5-P6
4. 最终评分 = 技术面 + 外部加减分(上限100,下限0)
5. LLM润色AI解说
```
### 4.12 容错机制
- 所有外部因素模块均有 try/except 保护,失败时返回中性评分(0分)
- AKShare 数据源不可用时自动降级,不影响主流程
- LLM 分析失败时回退到规则评分
- 当日缓存避免重复调用外部API
---
## 五、算法决策层
> 本章包含四部分:5.1~5.2 基于技术信号给出买卖建议和牛股阶段识别(纯技术面);5.3 深度分析产出技术面基础分后,由综合评分引擎叠加第四章的外部因素(P0-P7)形成最终评级,并据此修正 5.1 的买卖建议;5.4 AI解说涵盖内外因素的综合解读。
### 5.1 统一推荐算法 `compute_recommend`
> ⚠️ 本节推荐基于技术信号(MACD/龙抬头/底背离等)。在 `deep_analyze` 深度分析中,综合评分引擎计算完成后,会根据最终评级(含外部因素 P0-P7)修正买卖建议——当综合评级与技术面推荐矛盾时,以综合评级为准。
遵循"体系最强战法"流程,分**持仓**和**非持仓**两套逻辑:
#### 持仓时(已经持有该股票)
| 条件 | 推荐 | 评分 | 白话 |
|------|------|------|------|
| 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维度**的深度分析,最终由综合评分引擎汇总为统一评分。
##### 技术面维度(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 | 看空 | 各方面都不好,回避 |
> 以上为技术面基础分映射。最终评分 = 技术面基础分 + 外部因素加减分,评级标准相同,详见第四章。
##### 外部因素维度(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(非各自独立累加)。
**最终评分 = 技术面基础分(0-100) + 外部因素加减分(±40上限)**
> 各因素独立计算,失败时返回0分不影响主流程。详见第四章。
### 5.4 AI 通俗解说
系统会将以上技术分析结果自动转成口语化的中文解说,涵盖:
1. 当前走势概况(涨跌情况+均线趋势)
2. 价格位置(在高位还是低位)
3. 支撑压力(上方压力位和下方支撑位在哪)
4. 成交量情况(放量还是缩量)
5. 形态识别(发现了什么技术形态)
6. 综合建议(根据评分给出操作建议)
7. 空间估算(风险收益比如何)
8. 外部因素(资金面/市场情绪/北向资金/美股/消息面等综合影响)
还可以调用豆包 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` 单例 + 连接池 + 自动重试 |
+8 -1
View File
@@ -56,8 +56,15 @@ def get_db_conn():
def get_all_stock_codes(conn):
"""获取可交易的股票列表(排除退市、停牌、ST等无效股票)"""
with conn.cursor() as cur:
cur.execute("SELECT code, name FROM stock_realtime_price ORDER BY code")
cur.execute("""
SELECT code, name FROM stock_realtime_price
WHERE volume > 0 AND price > 0
AND name NOT LIKE '%%退%%'
AND name NOT LIKE 'PT%%'
ORDER BY code
""")
return cur.fetchall()
+208
View File
@@ -11,6 +11,7 @@ from services.stock_service import (
from services.stock_algorithms import (
compute_recommend, get_kline_data as algo_get_kline_data,
compute_bull_stage, find_bull_stocks, BULL_STAGES,
compute_deep_analysis,
)
from db import (
login_required, get_current_user_id,
@@ -88,6 +89,143 @@ def analyze():
return jsonify({'error': str(e)}), 500
@bp.route('/deep_analyze', methods=['POST'])
def deep_analyze():
"""单股深度分析(价格位置、压力支撑、量价、空间、综合评分)"""
try:
data = request.get_json()
stock_code = data.get('stock_code', '').strip()
if not stock_code:
return jsonify({'error': '股票代码不能为空'}), 400
df = algo_get_kline_data(stock_code, days=180)
if df is None or len(df) < 30:
return jsonify({'error': 'K线数据不足'}), 400
from services.technical_indicators import calc_all_indicators
from services.signal_detector import detect_all_signals
df = calc_all_indicators(df)
signal_result = detect_all_signals(df, lookback=5)
from db import get_db, put_db
from psycopg2.extras import RealDictCursor
realtime_info = None
conn = get_db()
if conn:
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT code, name, price, change_pct, volume, amount,
high, low, open, prev_close, pe, pb, total_market_cap
FROM stock_realtime_price WHERE code = %s
""", (stock_code,))
realtime_info = cur.fetchone()
finally:
put_db(conn)
report = compute_deep_analysis(df, signal_result, realtime_info)
stock_name = get_stock_name(stock_code) or (realtime_info or {}).get('name', '')
sig_status = signal_result.get('signal_status', [])
indicators = signal_result.get('indicators', {})
sig_count = signal_result.get('signal_summary', {}).get('total_signals', 0)
rec = compute_recommend(sig_status, indicators, sig_count, False)
report['stock_code'] = stock_code
report['stock_name'] = stock_name
report['recommend'] = {
'signal_type': rec[0],
'display': rec[1],
'reason': rec[2],
'rate': rec[3],
}
report['signals'] = signal_result.get('signals', [])
report['signal_status'] = sig_status
# ---- 综合评分引擎:整合外部因素(P0-P7)----
try:
from services.score_engine import compute_comprehensive_score
tech_score = report.get('deep_score', 50)
comprehensive = compute_comprehensive_score(
stock_code, stock_name, tech_score, df
)
report['comprehensive'] = comprehensive
# 用综合评分更新最终评分和评级
report['deep_score'] = comprehensive['final_score']
report['verdict'] = comprehensive['verdict']
report['score_reasons'].extend(comprehensive.get('all_reasons', []))
# ---- 根据综合评级修正买卖建议 ----
# 技术面推荐(compute_recommend)不含外部因素,
# 当综合评级与技术面推荐矛盾时,以综合评级为准调整推荐
final_score = comprehensive['final_score']
final_verdict = comprehensive['verdict']
orig_display = report['recommend'].get('display', '')
orig_reason = report['recommend'].get('reason', '')
orig_rate = report['recommend'].get('rate', 0)
# 综合评级偏空但技术面建议买入/加仓 → 降级为关注
if final_score < 50 and orig_display in ('买入', '加仓'):
report['recommend'] = {
'signal_type': 'watch',
'display': '关注',
'reason': f"技术面信号偏多,但综合评级「{final_verdict}」(外部因素拖累),建议观望",
'rate': final_score,
}
# 综合评级强烈看多但技术面建议观望/关注 → 升级为买入
elif final_score >= 80 and orig_display in ('观望', '关注', '观察'):
report['recommend'] = {
'signal_type': 'buy',
'display': '买入',
'reason': f"技术面{orig_display},但综合评级「{final_verdict}」(外部因素共振看好),建议买入",
'rate': final_score,
}
# 综合评级看空但技术面建议持有 → 降级为卖出
elif final_score < 35 and orig_display in ('持有', '观望'):
report['recommend'] = {
'signal_type': 'sell',
'display': '卖出',
'reason': f"技术面{orig_display},但综合评级「{final_verdict}」(外部因素重大利空),建议卖出",
'rate': final_score,
}
# 其他情况保持技术面推荐,但更新评分为综合评分
else:
report['recommend']['rate'] = final_score
except Exception as e:
print(f"综合评分引擎计算失败,使用技术面评分: {e}")
if realtime_info:
report['realtime'] = {
'price': float(realtime_info.get('price') or 0),
'change_pct': float(realtime_info.get('change_pct') or 0),
'pe': float(realtime_info.get('pe') or 0),
'pb': float(realtime_info.get('pb') or 0),
'total_market_cap': float(realtime_info.get('total_market_cap') or 0),
'volume': int(realtime_info.get('volume') or 0),
}
skip_llm = data.get('skip_llm', False)
if report.get('ai_summary') and not skip_llm:
try:
polished = _llm_polish_summary(
stock_name, stock_code, report['ai_summary'],
report.get('deep_score', 0), report.get('verdict', '')
)
if polished:
report['ai_summary']['text'] = polished['text']
report['ai_summary']['action_tip'] = polished['action_tip']
except Exception as e:
print(f"LLM润色失败,使用规则文本: {e}")
return jsonify({'success': True, 'report': report})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
@bp.route('/realtime_price/<stock_code>', methods=['GET'])
def realtime_price(stock_code):
"""获取实时价格(直接调用实时API,不使用数据库缓存)"""
@@ -1178,3 +1316,73 @@ def get_bull_stocks():
import traceback
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)}), 500
def _llm_polish_summary(stock_name, stock_code, ai_summary, score, verdict):
"""调用豆包LLM将规则模板生成的分析文本润色成更自然流畅的表达"""
import requests as _req
from config import Config
api_key = Config.DOUBAO_API_KEY
if not api_key:
return None
draft_text = ai_summary.get('text', '')
draft_action = ai_summary.get('action_tip', '')
prompt = f"""你是一位资深股票分析师,擅长用通俗易懂的语言给普通投资者解读技术分析。
以下是对{stock_name}({stock_code})的技术分析草稿,综合评分{score}分({verdict}):
【分析草稿】
{draft_text}
【操作建议草稿】
{draft_action}
请你将上面的草稿改写成更自然、更生动的表达。要求:
1. 用口语化表达,像老朋友聊天一样,避免专业术语堆砌
2. 保留所有关键数据和结论,不要遗漏
3. 适当加入比喻或生活化的表达,让小白也能听懂
4. 操作建议要明确、具体,有可操作性
5. 总字数控制在200字以内
6. 不要用markdown格式,纯文本即可
请严格按以下JSON格式输出,不要输出其他内容:
{{"text": "润色后的分析文本", "action_tip": "润色后的操作建议"}}"""
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
payload = {
"model": "doubao-seed-1-6-251015",
"max_completion_tokens": 2048,
"stream": False,
"messages": [{"role": "user", "content": prompt}]
}
resp = _req.post(
"https://ark.cn-beijing.volces.com/api/v3/chat/completions",
headers=headers, json=payload, timeout=45
)
if resp.status_code != 200:
return None
data = resp.json()
content = data.get('choices', [{}])[0].get('message', {}).get('content', '')
if not content:
return None
content = content.strip()
if content.startswith('```'):
content = content.split('\n', 1)[-1].rsplit('```', 1)[0].strip()
import json as _json
result = _json.loads(content)
if result.get('text') and result.get('action_tip'):
return result
return None
except Exception as e:
print(f"LLM polish error: {e}")
return None
+9 -7
View File
@@ -113,15 +113,17 @@ def get_current_user():
email = session.get('email', session.get('username', ''))
is_admin = False
try:
from db import get_db
from db import get_db, put_db
conn = get_db()
if conn:
cur = conn.cursor()
cur.execute("SELECT is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
if row:
is_admin = bool(row[0])
conn.close()
try:
cur = conn.cursor()
cur.execute("SELECT is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
if row:
is_admin = bool(row[0])
finally:
put_db(conn)
except Exception:
pass
return jsonify({
+49
View File
@@ -523,3 +523,52 @@ def get_fundamental(stock_code):
return jsonify({'success': True, 'data': result, 'source': 'api'})
except Exception as e:
return jsonify({'error': str(e)}), 500
# ============ 市场情绪 & 外部因素 API ============
@bp.route('/market_sentiment', methods=['GET'])
def market_sentiment():
"""获取市场情绪指标(涨停跌停比、连板高度、换手率中位数、两市成交额)"""
try:
from services.market_sentiment import calc_market_sentiment
result = calc_market_sentiment()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/external_factors', methods=['GET'])
def external_factors():
"""获取外部因素综合数据(北向资金、美股隔夜、大宗商品、汇率)"""
try:
from services.external_factors import get_all_external_factors
result = get_all_external_factors()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/fund_flow_analysis/<stock_code>', methods=['GET'])
def fund_flow_analysis(stock_code):
"""获取个股资金流向分析(P0:主力资金进出评分和信号)"""
try:
from services.fund_flow_analyzer import analyze_fund_flow
days = request.args.get('days', 5, type=int)
result = analyze_fund_flow(stock_code, days=days)
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/news_analysis/<stock_code>', methods=['GET'])
def news_analysis(stock_code):
"""获取个股消息面分析(P5公告+P6政策+异动检测)"""
try:
from services.news_analyzer import analyze_news_factors
from services.stock_service import get_stock_name
stock_name = get_stock_name(stock_code) or ''
result = analyze_news_factors(stock_code, stock_name)
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
+171 -123
View File
@@ -1,16 +1,47 @@
"""
交易记录 API 路由纯数据库版
交易记录 API 路由纯数据库版事务安全
"""
from flask import Blueprint, request, jsonify, session
from psycopg2.extras import RealDictCursor
from db import (
login_required, get_current_user_id,
db_get_trades, db_get_trade, db_add_trade, db_update_trade, db_delete_trade,
db_get_available_cash, db_update_available_cash
login_required, get_current_user_id, get_db, put_db,
db_get_trades, db_get_available_cash, db_update_available_cash
)
bp = Blueprint('trades', __name__, url_prefix='/api')
def _parse_float(val):
if val is None or val == '':
return None
try:
return round(float(val), 4)
except Exception:
return None
def _parse_int(val):
if val is None or val == '':
return None
try:
return int(val)
except Exception:
return None
def _calc_cash_delta(trade_type, price, quantity):
"""计算交易对可用资金的影响"""
if not trade_type or price is None or quantity is None:
return 0
amount = round(float(price) * int(quantity), 2)
t = trade_type.lower()
if t == 'buy':
return -amount
elif t == 'sell':
return amount
return 0
@bp.route('/trades', methods=['GET'])
@login_required
def get_trades():
@@ -23,153 +54,170 @@ def get_trades():
@bp.route('/trades', methods=['POST'])
@login_required
def add_trade():
"""添加交易记录"""
"""添加交易记录(trade 插入 + 可用资金更新在同一事务中)"""
conn = get_db()
if not conn:
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
try:
user_id = get_current_user_id()
data = request.get_json()
# 处理数值(空字符串转为None
def parse_float(val):
if val is None or val == '':
return None
try:
return round(float(val), 4)
except:
return None
def parse_int(val):
if val is None or val == '':
return None
try:
return int(val)
except:
return None
data['price'] = parse_float(data.get('price'))
data['quantity'] = parse_int(data.get('quantity'))
data['profit_amount'] = parse_float(data.get('profit_amount'))
data['stop_loss_price'] = parse_float(data.get('stop_loss_price'))
trade, error = db_add_trade(user_id, data)
if error:
return jsonify({'success': False, 'error': error}), 400
# 根据交易类型自动更新可用资金
trade_type = (data.get('trade_type') or '').lower()
price = data.get('price')
quantity = data.get('quantity')
if trade_type in ('buy', 'sell') and price is not None and quantity is not None:
amount = round(float(price) * int(quantity), 2)
current = db_get_available_cash(user_id)
if trade_type == 'buy':
new_cash = round(current - amount, 2)
else:
new_cash = round(current + amount, 2)
if new_cash < 0:
new_cash = 0
ok, _ = db_update_available_cash(user_id, new_cash)
if ok:
return jsonify({'success': True, 'trade': trade, 'available_cash': new_cash})
return jsonify({'success': True, 'trade': trade})
data['price'] = _parse_float(data.get('price'))
data['quantity'] = _parse_int(data.get('quantity'))
data['profit_amount'] = _parse_float(data.get('profit_amount'))
data['stop_loss_price'] = _parse_float(data.get('stop_loss_price'))
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
INSERT INTO trades (user_id, stock_code, stock_name, trade_type, price,
quantity, trade_date, reason, result, profit_amount,
stop_loss_price, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, stock_code, stock_name, trade_type, price, quantity,
trade_date::text, reason, result, profit_amount, stop_loss_price,
notes, created_at::text
""", (user_id, data.get('stock_code'), data.get('stock_name'),
data.get('trade_type'), data.get('price'), data.get('quantity'),
data.get('trade_date'), data.get('reason'), data.get('result'),
data.get('profit_amount'), data.get('stop_loss_price'), data.get('notes')))
trade = cur.fetchone()
delta = _calc_cash_delta(data.get('trade_type'), data.get('price'), data.get('quantity'))
new_cash = None
if delta != 0:
cur.execute("SELECT available_cash FROM users WHERE id = %s FOR UPDATE", (user_id,))
row = cur.fetchone()
current = float(row['available_cash'] or 0) if row else 0
new_cash = max(0, round(current + delta, 2))
cur.execute("UPDATE users SET available_cash = %s WHERE id = %s", (new_cash, user_id))
conn.commit()
resp = {'success': True, 'trade': trade}
if new_cash is not None:
resp['available_cash'] = new_cash
return jsonify(resp)
except Exception as e:
return jsonify({'error': str(e)}), 400
conn.rollback()
return jsonify({'success': False, 'error': str(e)}), 400
finally:
put_db(conn)
@bp.route('/trades/<int:trade_id>', methods=['PUT'])
@login_required
def update_trade(trade_id):
"""更新交易记录"""
"""更新交易记录(同一事务内回滚旧资金 + 应用新资金)"""
conn = get_db()
if not conn:
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
try:
user_id = get_current_user_id()
data = request.get_json()
# 处理数值(空字符串转为None
def parse_float(val):
if val is None or val == '':
return None
try:
return round(float(val), 4)
except:
return None
def parse_int(val):
if val is None or val == '':
return None
try:
return int(val)
except:
return None
if 'price' in data:
data['price'] = parse_float(data.get('price'))
data['price'] = _parse_float(data.get('price'))
if 'quantity' in data:
data['quantity'] = parse_int(data.get('quantity'))
data['quantity'] = _parse_int(data.get('quantity'))
if 'profit_amount' in data:
data['profit_amount'] = parse_float(data.get('profit_amount'))
data['profit_amount'] = _parse_float(data.get('profit_amount'))
if 'stop_loss_price' in data:
data['stop_loss_price'] = parse_float(data.get('stop_loss_price'))
old_trade = db_get_trade(user_id, trade_id)
data['stop_loss_price'] = _parse_float(data.get('stop_loss_price'))
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT id, stock_code, stock_name, trade_type, price, quantity,
trade_date::text, reason, result, profit_amount, stop_loss_price, notes
FROM trades WHERE id = %s AND user_id = %s
""", (trade_id, user_id))
old_trade = cur.fetchone()
if not old_trade:
put_db(conn)
return jsonify({'error': '交易记录不存在'}), 404
trade, error = db_update_trade(user_id, trade_id, data)
if error:
return jsonify({'success': False, 'error': error}), 400
cur.execute("""
UPDATE trades SET
stock_code = COALESCE(%s, stock_code), stock_name = COALESCE(%s, stock_name),
trade_type = COALESCE(%s, trade_type), price = COALESCE(%s, price),
quantity = COALESCE(%s, quantity), trade_date = COALESCE(%s, trade_date),
reason = COALESCE(%s, reason), result = COALESCE(%s, result),
profit_amount = COALESCE(%s, profit_amount),
stop_loss_price = COALESCE(%s, stop_loss_price), notes = COALESCE(%s, notes)
WHERE id = %s AND user_id = %s
RETURNING id, stock_code, stock_name, trade_type, price, quantity,
trade_date::text, reason, result, profit_amount, stop_loss_price,
notes, created_at::text
""", (data.get('stock_code'), data.get('stock_name'), data.get('trade_type'),
data.get('price'), data.get('quantity'), data.get('trade_date'),
data.get('reason'), data.get('result'), data.get('profit_amount'),
data.get('stop_loss_price'), data.get('notes'), trade_id, user_id))
trade = cur.fetchone()
if not trade:
conn.rollback()
put_db(conn)
return jsonify({'error': '交易记录不存在'}), 404
# 根据修改同步调整可用资金:先回滚旧交易,再应用新交易
def trade_amount(t):
p, q = (t.get('price') or 0), (t.get('quantity') or 0)
return round(float(p) * int(q), 2) if p and q else 0
old_amt = trade_amount(old_trade)
new_amt = trade_amount(trade)
old_type = (old_trade.get('trade_type') or '').lower()
new_type = (trade.get('trade_type') or '').lower()
delta = 0
if old_type == 'buy':
delta += old_amt
elif old_type == 'sell':
delta -= old_amt
if new_type == 'buy':
delta -= new_amt
elif new_type == 'sell':
delta += new_amt
old_delta = _calc_cash_delta(old_trade.get('trade_type'), old_trade.get('price'), old_trade.get('quantity'))
new_delta = _calc_cash_delta(trade.get('trade_type'), trade.get('price'), trade.get('quantity'))
delta = new_delta - old_delta
new_cash = None
if delta != 0:
current = db_get_available_cash(user_id)
cur.execute("SELECT available_cash FROM users WHERE id = %s FOR UPDATE", (user_id,))
row = cur.fetchone()
current = float(row['available_cash'] or 0) if row else 0
new_cash = max(0, round(current + delta, 2))
ok, _ = db_update_available_cash(user_id, new_cash)
if ok:
return jsonify({'success': True, 'trade': trade, 'available_cash': new_cash})
return jsonify({'success': True, 'trade': trade})
cur.execute("UPDATE users SET available_cash = %s WHERE id = %s", (new_cash, user_id))
conn.commit()
resp = {'success': True, 'trade': trade}
if new_cash is not None:
resp['available_cash'] = new_cash
return jsonify(resp)
except Exception as e:
return jsonify({'error': str(e)}), 400
conn.rollback()
return jsonify({'success': False, 'error': str(e)}), 400
finally:
put_db(conn)
@bp.route('/trades/<int:trade_id>', methods=['DELETE'])
@login_required
def delete_trade(trade_id):
"""删除交易记录"""
user_id = get_current_user_id()
old_trade = db_get_trade(user_id, trade_id)
if not old_trade:
return jsonify({'success': False, 'error': '交易记录不存在'}), 404
success = db_delete_trade(user_id, trade_id)
if not success:
return jsonify({'success': False}), 400
# 回滚该交易对可用资金的影响
t_type = (old_trade.get('trade_type') or '').lower()
amount = round(float(old_trade.get('price') or 0) * int(old_trade.get('quantity') or 0), 2)
delta = amount if t_type == 'buy' else -amount
if delta != 0:
current = db_get_available_cash(user_id)
new_cash = max(0, round(current + delta, 2))
db_update_available_cash(user_id, new_cash)
return jsonify({'success': True, 'available_cash': new_cash})
return jsonify({'success': True})
"""删除交易记录(同一事务内删除 + 回滚资金)"""
conn = get_db()
if not conn:
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
try:
user_id = get_current_user_id()
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT trade_type, price, quantity FROM trades WHERE id = %s AND user_id = %s
""", (trade_id, user_id))
old_trade = cur.fetchone()
if not old_trade:
put_db(conn)
return jsonify({'success': False, 'error': '交易记录不存在'}), 404
cur.execute("DELETE FROM trades WHERE id = %s AND user_id = %s", (trade_id, user_id))
delta = -_calc_cash_delta(old_trade.get('trade_type'), old_trade.get('price'), old_trade.get('quantity'))
new_cash = None
if delta != 0:
cur.execute("SELECT available_cash FROM users WHERE id = %s FOR UPDATE", (user_id,))
row = cur.fetchone()
current = float(row['available_cash'] or 0) if row else 0
new_cash = max(0, round(current + delta, 2))
cur.execute("UPDATE users SET available_cash = %s WHERE id = %s", (new_cash, user_id))
conn.commit()
resp = {'success': True}
if new_cash is not None:
resp['available_cash'] = new_cash
return jsonify(resp)
except Exception as e:
conn.rollback()
return jsonify({'success': False, 'error': str(e)}), 400
finally:
put_db(conn)
@bp.route('/available_cash', methods=['GET'])
+2 -1
View File
@@ -5,9 +5,10 @@
import requests
import json
from config import Config
# API配置
API_KEY = "9fd8383f-5776-4366-855d-c6f40e867940"
API_KEY = Config.DOUBAO_API_KEY or "9fd8383f-5776-4366-855d-c6f40e867940"
API_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
MODEL = "doubao-seed-1-6-251015"
+545
View File
@@ -0,0 +1,545 @@
"""
外部因素分析模块P2/P3/P4/P7
包含
- P2: 北向资金外资动向
- P3: 美股隔夜板块变化
- P4: 大宗商品价格
- P7: 汇率变化
数据源AKShare开源免费
所有数据采集均带超时和异常处理失败时返回中性评分不影响主流程
"""
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# 缓存(当日有效)
_cache = {}
_cache_date = {}
def _get_cache(key):
"""获取当日缓存"""
today = datetime.now().strftime('%Y-%m-%d')
if _cache_date.get(key) == today:
return _cache.get(key)
return None
def _set_cache(key, value):
"""设置当日缓存"""
today = datetime.now().strftime('%Y-%m-%d')
_cache[key] = value
_cache_date[key] = today
# ═══════════════════════════════════════════════
# P2: 北向资金
# ═══════════════════════════════════════════════
def get_northbound_capital():
"""
获取北向资金净流入数据
返回:
dict: {
'net_inflow': float, # 今日净流入(亿)
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
}
"""
cached = _get_cache('northbound')
if cached:
return cached
try:
import akshare as ak
# 获取北向资金净流入数据
df = ak.stock_hsgt_north_net_flow_in_em(symbol="北向")
if df is None or df.empty:
return _neutral_result('北向资金数据为空')
# 取最近5个交易日
recent = df.tail(5)
today_inflow = float(recent.iloc[-1].get('当日净流入', 0) or 0)
# 连续流入/流出天数
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
# 评分
score = 0
reasons = []
summary_parts = []
if today_inflow > 50:
score += 5
reasons.append(f'北向今日净流入{today_inflow:.1f}亿(+5)')
summary_parts.append(f'外资今日大幅买入{today_inflow:.1f}亿元')
elif today_inflow > 20:
score += 3
reasons.append(f'北向今日净流入{today_inflow:.1f}亿(+3)')
summary_parts.append(f'外资今日净流入{today_inflow:.1f}亿元')
elif today_inflow < -50:
score -= 5
reasons.append(f'北向今日净流出{abs(today_inflow):.1f}亿(-5)')
summary_parts.append(f'外资今日大幅卖出{abs(today_inflow):.1f}亿元')
elif today_inflow < -20:
score -= 3
reasons.append(f'北向今日净流出{abs(today_inflow):.1f}亿(-3)')
summary_parts.append(f'外资今日净流出{abs(today_inflow):.1f}亿元')
else:
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}天买入')
if consecutive_outflow >= 3:
score -= 3
reasons.append(f'北向连续{consecutive_outflow}日净流出(-3)')
summary_parts.append(f'已连续{consecutive_outflow}天卖出')
score = max(-10, min(10, score))
result = {
'net_inflow': round(today_inflow, 2),
'consecutive_inflow': consecutive_inflow,
'consecutive_outflow': consecutive_outflow,
'score': score,
'summary': ''.join(summary_parts),
'reasons': reasons,
}
_set_cache('northbound', result)
return result
except Exception as e:
logger.warning(f"获取北向资金数据失败: {e}")
return _neutral_result('北向资金数据获取失败')
# ═══════════════════════════════════════════════
# P3: 美股隔夜板块变化
# ═══════════════════════════════════════════════
# 美股板块 → A股板块映射
US_A_SECTOR_MAP = {
'科技': ['半导体', '软件', '消费电子', '芯片', 'IT服务'],
'新能源车': ['锂电池', '汽车零部件', '新能源车', '充电桩'],
'金融': ['银行', '保险', '券商'],
'能源': ['石油开采', '化工', '页岩气'],
'医药': ['创新药', '医疗器械', '生物制品', 'CXO'],
'消费': ['白酒', '食品饮料', '零售', '免税'],
'房地产': ['房地产', '建材', '家居'],
'工业': ['机械', '军工', '工业4.0'],
'材料': ['有色金属', '钢铁', '化工新材料'],
'公用事业': ['电力', '环保', '水务'],
}
def get_us_market_overview():
"""
获取美股隔夜收盘数据计算外盘情绪
返回:
dict: {
'indices': dict, # 三大指数涨跌
'sectors': dict, # 主要板块涨跌
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_a_sectors': dict, # 对A股板块的影响
}
"""
cached = _get_cache('us_market')
if cached:
return cached
try:
import akshare as ak
# 获取全球主要指数
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),
}
if not us_indices:
return _neutral_result('未找到美股指数')
# 计算综合涨跌
avg_change = sum(v['change_pct'] for v in us_indices.values()) / len(us_indices)
# 评分
score = 0
reasons = []
summary_parts = []
if avg_change > 2:
score += 5
reasons.append(f'美股三大指数平均涨幅{avg_change:.1f}%(+5)')
summary_parts.append(f'美股大涨,平均涨幅{avg_change:.1f}%')
elif avg_change > 0.5:
score += 2
reasons.append(f'美股偏强,平均涨幅{avg_change:.1f}%(+2)')
summary_parts.append(f'美股小幅上涨,平均{avg_change:.1f}%')
elif avg_change < -2:
score -= 5
reasons.append(f'美股三大指数平均跌幅{abs(avg_change):.1f}%(-5)')
summary_parts.append(f'美股大跌,平均跌幅{abs(avg_change):.1f}%')
elif avg_change < -0.5:
score -= 2
reasons.append(f'美股偏弱,平均跌幅{abs(avg_change):.1f}%(-2)')
summary_parts.append(f'美股小幅下跌,平均{avg_change:.1f}%')
else:
summary_parts.append(f'美股基本平盘,平均变化{avg_change:.1f}%')
# 对A股板块的影响
affected_sectors = {}
if avg_change > 1:
for us_sector, a_sectors in US_A_SECTOR_MAP.items():
affected_sectors[us_sector] = {
'a_sectors': a_sectors,
'direction': '利好',
'note': f'美股{us_sector}板块偏强,A股{"".join(a_sectors[:3])}可能高开',
}
elif avg_change < -1:
for us_sector, a_sectors in US_A_SECTOR_MAP.items():
affected_sectors[us_sector] = {
'a_sectors': a_sectors,
'direction': '利空',
'note': f'美股{us_sector}板块偏弱,A股{"".join(a_sectors[:3])}可能低开',
}
score = max(-10, min(10, score))
result = {
'indices': us_indices,
'avg_change': round(avg_change, 2),
'score': score,
'summary': ''.join(summary_parts),
'reasons': reasons,
'affected_a_sectors': affected_sectors,
}
_set_cache('us_market', result)
return result
except Exception as e:
logger.warning(f"获取美股数据失败: {e}")
return _neutral_result('美股数据获取失败')
# ═══════════════════════════════════════════════
# P4: 大宗商品价格
# ═══════════════════════════════════════════════
# 大宗商品 → A股板块影响映射
COMMODITY_A_SECTOR_MAP = {
'原油': {
'beneficiary': ['石油开采', '油服', '化工'],
'victim': ['航空', '物流', '化工下游'],
'direction': '油价涨→开采受益,航空受损',
},
'黄金': {
'beneficiary': ['黄金股', '珠宝', '有色'],
'victim': [],
'direction': '金价涨→黄金企业受益',
},
'': {
'beneficiary': ['铜矿', '有色', '电缆'],
'victim': [],
'direction': '铜价涨→铜企受益',
},
'螺纹钢': {
'beneficiary': ['钢铁', '钢矿'],
'victim': ['基建', '地产'],
'direction': '钢价涨→钢企受益,基建成本增',
},
'碳酸锂': {
'beneficiary': ['锂矿', '锂电池'],
'victim': ['新能源车'],
'direction': '锂价涨→锂矿受益,新能源车成本增',
},
}
def get_commodity_overview():
"""
获取主要大宗商品价格变化
返回:
dict: {
'commodities': dict, # 各商品涨跌
'score': int, # 评分增减(-5 ~ +5
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_sectors': dict, # 对A股板块影响
}
"""
cached = _get_cache('commodity')
if cached:
return cached
try:
import akshare as ak
# 获取国内商品期货行情
df = ak.futures_main_sina()
if df is None or df.empty:
return _neutral_result('大宗商品数据为空')
# 关注的商品
target_commodities = ['原油', '黄金', '', '螺纹钢', '碳酸锂']
commodities = {}
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
if not commodities:
return _neutral_result('未找到关注的大宗商品')
# 评分和影响
score = 0
reasons = []
summary_parts = []
affected = {}
for name, data in commodities.items():
pct = data['change_pct']
if abs(pct) < 0.5:
continue
mapping = COMMODITY_A_SECTOR_MAP.get(name)
if not mapping:
continue
if pct > 2:
score += 1
reasons.append(f'{name}{pct:.1f}%,利好{"".join(mapping["beneficiary"][:2])}(+1)')
summary_parts.append(f'{name}大涨{pct:.1f}%')
affected[name] = {
'direction': '利好',
'beneficiary': mapping['beneficiary'],
'victim': mapping['victim'],
'note': mapping['direction'],
}
elif pct < -2:
score -= 1
reasons.append(f'{name}{abs(pct):.1f}%,利空{"".join(mapping["beneficiary"][:2])}(-1)')
summary_parts.append(f'{name}大跌{pct:.1f}%')
affected[name] = {
'direction': '利空',
'beneficiary': mapping['victim'],
'victim': mapping['beneficiary'],
'note': mapping['direction'],
}
score = max(-5, min(5, score))
result = {
'commodities': commodities,
'score': score,
'summary': ''.join(summary_parts) if summary_parts else '大宗商品整体平稳',
'reasons': reasons,
'affected_sectors': affected,
}
_set_cache('commodity', result)
return result
except Exception as e:
logger.warning(f"获取大宗商品数据失败: {e}")
return _neutral_result('大宗商品数据获取失败')
# ═══════════════════════════════════════════════
# P7: 汇率变化
# ═══════════════════════════════════════════════
# 汇率 → A股板块影响
FX_SECTOR_MAP = {
'升值': {
'beneficiary': ['航空', '造纸', '房地产'],
'victim': ['纺织', '家电出口', '电子代工'],
},
'贬值': {
'beneficiary': ['纺织', '家电', '电子代工'],
'victim': ['航空', '造纸'],
},
}
def get_fx_overview():
"""
获取人民币汇率变化
返回:
dict: {
'usd_cny': float, # 美元兑人民币汇率
'change_pct': float, # 涨跌幅
'direction': str, # 升值/贬值/稳定
'score': int, # 评分增减(-3 ~ +3
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_sectors': dict, # 对A股板块影响
}
"""
cached = _get_cache('fx')
if cached:
return cached
try:
import akshare as ak
# 获取人民币汇率
df = ak.currency_boc_sina(symbol="美元")
if df is None or df.empty:
return _neutral_result('汇率数据为空')
# 取最近2条计算变化
recent = df.tail(2)
if len(recent) < 2:
return _neutral_result('汇率数据不足')
today_rate = float(recent.iloc[-1].get('中行折算价', 0) or 0)
prev_rate = float(recent.iloc[-2].get('中行折算价', 0) or 0)
if prev_rate == 0:
return _neutral_result('汇率数据异常')
change_pct = round((today_rate / prev_rate - 1) * 100, 3)
# 判断方向(美元兑人民币:涨=人民币贬值,跌=人民币升值)
if change_pct > 0.1:
direction = '贬值'
score = -2
summary = f'人民币贬值{abs(change_pct):.3f}%'
reasons = [f'人民币贬值{abs(change_pct):.3f}%(-2)']
affected = FX_SECTOR_MAP['贬值']
elif change_pct < -0.1:
direction = '升值'
score = 2
summary = f'人民币升值{abs(change_pct):.3f}%'
reasons = [f'人民币升值{abs(change_pct):.3f}%(+2)']
affected = FX_SECTOR_MAP['升值']
else:
direction = '稳定'
score = 0
summary = '人民币汇率基本稳定'
reasons = []
affected = {}
score = max(-3, min(3, score))
result = {
'usd_cny': round(today_rate, 4),
'change_pct': change_pct,
'direction': direction,
'score': score,
'summary': summary,
'reasons': reasons,
'affected_sectors': affected,
}
_set_cache('fx', result)
return result
except Exception as e:
logger.warning(f"获取汇率数据失败: {e}")
return _neutral_result('汇率数据获取失败')
# ═══════════════════════════════════════════════
# 综合外部因素
# ═══════════════════════════════════════════════
def get_all_external_factors():
"""
获取所有外部因素数据返回综合结果
返回:
dict: 包含北向资金美股大宗商品汇率的综合数据
"""
northbound = get_northbound_capital()
us_market = get_us_market_overview()
commodity = get_commodity_overview()
fx = get_fx_overview()
total_score = (
northbound.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(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)]:
s = data.get('summary', '')
if s and '失败' not in s and '为空' not in s:
summaries.append(f'{name}{s}')
return {
'northbound_capital': northbound,
'us_market': us_market,
'commodity': commodity,
'fx': fx,
'total_score': total_score,
'all_reasons': all_reasons,
'summary': ' | '.join(summaries),
}
def _neutral_result(reason):
"""返回中性结果"""
return {
'score': 0,
'summary': reason,
'reasons': [],
}
+257
View File
@@ -0,0 +1,257 @@
"""
主力资金流向分析模块P0
功能
1. 从数据库读取近N日资金流向数据
2. 计算主力连续净流入/流出天数累计净流入额
3. 检测量价背离资金流入+价格不涨 吸筹资金流出+价格不跌 出货
4. 返回资金面评分和信号列表
数据来源stock_fund_flow_history sync_fund_flow.py 每日同步
"""
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
def get_fund_flow_history(stock_code, days=10):
"""
从数据库读取近N日资金流向历史数据
参数:
stock_code: 股票代码
days: 获取天数
返回:
list[dict]: 每日资金流向记录按日期升序排列
"""
from db import get_db, put_db
conn = get_db()
if not conn:
return []
try:
cur = conn.cursor()
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
cur.execute("""
SELECT trade_date, close_price, change_pct,
main_net_inflow, main_net_inflow_pct,
super_net_inflow, super_net_inflow_pct,
big_net_inflow, big_net_inflow_pct,
mid_net_inflow, mid_net_inflow_pct,
small_net_inflow, small_net_inflow_pct
FROM stock_fund_flow_history
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date ASC
""", (stock_code, start_date))
rows = cur.fetchall()
records = []
for row in rows:
records.append({
'date': row[0].strftime('%Y-%m-%d') if row[0] else '',
'close_price': float(row[1] or 0),
'change_pct': float(row[2] or 0),
'main_net_inflow': float(row[3] or 0),
'main_net_inflow_pct': float(row[4] or 0),
'super_net_inflow': float(row[5] or 0),
'super_net_inflow_pct': float(row[6] or 0),
'big_net_inflow': float(row[7] or 0),
'big_net_inflow_pct': float(row[8] or 0),
'mid_net_inflow': float(row[9] or 0),
'mid_net_inflow_pct': float(row[10] or 0),
'small_net_inflow': float(row[11] or 0),
'small_net_inflow_pct': float(row[12] or 0),
})
return records
except Exception as e:
logger.error(f"获取资金流向历史失败({stock_code}): {e}")
return []
finally:
put_db(conn)
def analyze_fund_flow(stock_code, days=5):
"""
分析主力资金流向返回资金面评分和信号
参数:
stock_code: 股票代码
days: 分析最近几天的资金流向
返回:
dict: {
'score': int, # 资金面评分增减(-20 ~ +20)
'signals': list, # 资金信号列表
'summary': str, # 白话总结
'details': dict, # 详细数据
'reasons': list, # 评分原因列表
}
"""
records = get_fund_flow_history(stock_code, days=days + 5)
if len(records) < 2:
return {
'score': 0,
'signals': [],
'summary': '暂无资金流向数据',
'details': {},
'reasons': [],
}
recent = records[-days:] if len(records) >= days else records
# 计算连续净流入/流出天数
consecutive_inflow = 0
consecutive_outflow = 0
for r in reversed(recent):
if r['main_net_inflow'] > 0:
if consecutive_outflow > 0:
break
consecutive_inflow += 1
elif r['main_net_inflow'] < 0:
if consecutive_inflow > 0:
break
consecutive_outflow += 1
# 累计净流入
total_main_inflow = sum(r['main_net_inflow'] for r in recent)
avg_main_pct = sum(r['main_net_inflow_pct'] for r in recent) / len(recent) if recent else 0
# 超大单累计
total_super_inflow = sum(r['super_net_inflow'] for r in recent)
avg_super_pct = sum(r['super_net_inflow_pct'] for r in recent) / len(recent) if recent else 0
# 量价背离检测
# 吸筹:主力净流入但价格不涨(涨幅<2%)
# 出货:主力净流出但价格不跌(跌幅<2%)
accumulation = False
distribution = False
if total_main_inflow > 0:
price_changes = [r['change_pct'] for r in recent]
avg_price_change = sum(price_changes) / len(price_changes) if price_changes else 0
if avg_price_change < 2:
accumulation = True
if total_main_inflow < 0:
price_changes = [r['change_pct'] for r in recent]
avg_price_change = sum(price_changes) / len(price_changes) if price_changes else 0
if avg_price_change > -2:
distribution = True
# 单日超大单突击
big_surge = False
big_surge_day = None
for r in recent:
if r['super_net_inflow_pct'] > 15:
big_surge = True
big_surge_day = r['date']
break
# 评分计算
score = 0
reasons = []
signals = []
if consecutive_inflow >= 3:
score += 10
reasons.append(f'主力连续{consecutive_inflow}日净流入(+10)')
signals.append({
'type': 'fund_continuous_inflow',
'name': '主力持续流入',
'direction': 'buy',
'strength': 80,
'description': f'主力资金连续{consecutive_inflow}日净流入,累计{total_main_inflow/10000:.0f}万元',
})
if consecutive_outflow >= 3:
score -= 10
reasons.append(f'主力连续{consecutive_outflow}日净流出(-10)')
signals.append({
'type': 'fund_continuous_outflow',
'name': '主力持续流出',
'direction': 'sell',
'strength': 75,
'description': f'主力资金连续{consecutive_outflow}日净流出,累计{total_main_inflow/10000:.0f}万元',
})
if accumulation:
score += 8
reasons.append('主力暗中吸筹(+8)')
signals.append({
'type': 'fund_accumulation',
'name': '主力吸筹',
'direction': 'buy',
'strength': 85,
'description': f'主力净流入但价格未涨,暗中吸筹,可能即将拉升',
})
if distribution:
score -= 8
reasons.append('主力暗中出货(-8)')
signals.append({
'type': 'fund_distribution',
'name': '主力出货',
'direction': 'sell',
'strength': 80,
'description': f'主力净流出但价格未跌,暗中出货,需警惕',
})
if big_surge:
score += 5
reasons.append(f'超大单突击流入({big_surge_day})(+5)')
signals.append({
'type': 'fund_big_surge',
'name': '大单突击',
'direction': 'buy',
'strength': 70,
'description': f'{big_surge_day}超大单净流入占比>15%,大机构突击入场',
})
# 主力净流入占比评分
if avg_main_pct > 10:
score += 5
reasons.append(f'主力净流入占比{avg_main_pct:.1f}%(+5)')
elif avg_main_pct < -10:
score -= 5
reasons.append(f'主力净流出占比{abs(avg_main_pct):.1f}%(-5)')
score = max(-20, min(20, score))
# 白话总结
summary_parts = []
if consecutive_inflow >= 3:
summary_parts.append(f'{consecutive_inflow}天主力持续买入,累计流入{total_main_inflow/10000:.0f}万元')
elif consecutive_outflow >= 3:
summary_parts.append(f'{consecutive_outflow}天主力持续卖出,累计流出{abs(total_main_inflow)/10000:.0f}万元')
elif total_main_inflow > 0:
summary_parts.append(f'近期主力总体净流入{total_main_inflow/10000:.0f}万元')
elif total_main_inflow < 0:
summary_parts.append(f'近期主力总体净流出{abs(total_main_inflow)/10000:.0f}万元')
if accumulation:
summary_parts.append('但价格没怎么涨,像是在暗中吸筹')
if distribution:
summary_parts.append('但价格没怎么跌,像是在暗中出货,要小心')
summary = ''.join(summary_parts) if summary_parts else '资金面无明显方向'
return {
'score': score,
'signals': signals,
'summary': summary,
'details': {
'consecutive_inflow': consecutive_inflow,
'consecutive_outflow': consecutive_outflow,
'total_main_inflow': round(total_main_inflow, 2),
'avg_main_pct': round(avg_main_pct, 2),
'total_super_inflow': round(total_super_inflow, 2),
'avg_super_pct': round(avg_super_pct, 2),
'accumulation': accumulation,
'distribution': distribution,
'recent_days': len(recent),
'daily_data': recent,
},
'reasons': reasons,
}
+2 -1
View File
@@ -11,7 +11,8 @@ import time
from datetime import datetime, timedelta
# API配置
LICENCE = "5352ED2F-94E5-4E96-8B7F-B57BA75284E3"
from config import Config
LICENCE = Config.MAIRUI_LICENCE or "5352ED2F-94E5-4E96-8B7F-B57BA75284E3"
BASE_URL = "https://api.mairuiapi.com"
# 缓存配置
+197
View File
@@ -0,0 +1,197 @@
"""
市场情绪指标模块P1
stock_realtime_price 表直接计算市场情绪指标无需额外数据源
指标包括
1. 涨停/跌停家数比
2. 连板高度最高连板数
3. 换手率中位数
4. 两市成交额
"""
import logging
logger = logging.getLogger(__name__)
def calc_market_sentiment():
"""
从数据库实时行情表计算市场情绪指标
返回:
dict: {
'limit_up_count': int, # 涨停家数
'limit_down_count': int, # 跌停家数
'up_down_ratio': float, # 涨跌停比
'sentiment': str, # 情绪标签
'consecutive_board': int, # 最高连板数
'turnover_median': float, # 换手率中位数
'total_amount': float, # 两市成交额(亿)
'market_temp': str, # 市场温度(偏热/偏冷/正常)
'score': int, # 情绪评分增减(-10 ~ +10
'reasons': list, # 评分原因
}
"""
from db import get_db, put_db
conn = get_db()
if not conn:
return _empty_sentiment()
try:
cur = conn.cursor()
# 涨停跌停统计(涨停:涨幅>=9.8%,跌停:跌幅<=-9.8%
cur.execute("""
SELECT
COUNT(*) FILTER (WHERE change_pct >= 9.8) AS limit_up,
COUNT(*) FILTER (WHERE change_pct <= -9.8) AS limit_down,
COUNT(*) FILTER (WHERE change_pct > 0) AS up_count,
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
FROM stock_realtime_price
WHERE volume > 0 AND price > 0
""")
row = cur.fetchone()
if not row:
return _empty_sentiment()
limit_up = int(row[0] or 0)
limit_down = int(row[1] or 0)
up_count = int(row[2] or 0)
down_count = int(row[3] or 0)
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)
# 涨跌停比
up_down_ratio = round(limit_up / limit_down, 1) if limit_down > 0 else float(limit_up)
# 情绪标签
if limit_down == 0 and limit_up > 10:
sentiment = '极度乐观'
elif up_down_ratio >= 5:
sentiment = '乐观'
elif up_down_ratio >= 2:
sentiment = '偏多'
elif up_down_ratio >= 1:
sentiment = '中性'
elif up_down_ratio >= 0.5:
sentiment = '偏空'
else:
sentiment = '悲观'
# 市场温度
if total_amount > 1.2e4:
market_temp = '偏热'
elif total_amount < 6000:
market_temp = '偏冷'
else:
market_temp = '正常'
# 连板高度:查找连续涨停的股票
consecutive_board = _calc_max_consecutive_board(cur)
# 评分
score = 0
reasons = []
if up_down_ratio >= 5:
score += 5
reasons.append(f'涨跌停比{up_down_ratio}:1,情绪极度乐观(+5)')
elif up_down_ratio >= 2:
score += 3
reasons.append(f'涨跌停比{up_down_ratio}:1,情绪偏多(+3)')
elif up_down_ratio < 0.5:
score -= 5
reasons.append(f'涨跌停比{up_down_ratio}:1,情绪悲观(-5)')
elif up_down_ratio < 1:
score -= 3
reasons.append(f'涨跌停比{up_down_ratio}:1,情绪偏空(-3)')
if consecutive_board >= 5:
score += 3
reasons.append(f'最高{consecutive_board}连板,市场热度高(+3)')
if total_amount > 1.2e4:
score += 2
reasons.append(f'两市成交额{total_amount:.0f}亿,交投活跃(+2)')
elif total_amount < 6000:
score -= 2
reasons.append(f'两市成交额仅{total_amount:.0f}亿,交投清淡(-2)')
score = max(-10, min(10, score))
return {
'limit_up_count': limit_up,
'limit_down_count': limit_down,
'up_count': up_count,
'down_count': down_count,
'up_down_ratio': up_down_ratio,
'sentiment': sentiment,
'consecutive_board': consecutive_board,
'turnover_median': round(turnover_median, 2),
'total_amount': round(total_amount, 0),
'market_temp': market_temp,
'score': score,
'reasons': reasons,
}
except Exception as e:
logger.error(f"计算市场情绪指标失败: {e}")
return _empty_sentiment()
finally:
put_db(conn)
def _calc_max_consecutive_board(cur):
"""
计算最高连板数需要历史数据辅助判断
简化版通过查找连续涨幅>=9.8%的股票
由于实时表只有当日数据这里用近似方法
查找涨停股票数量作为市场热度参考
"""
try:
# 查找涨停股票(涨幅>=9.8%)
cur.execute("""
SELECT COUNT(*) FROM stock_realtime_price
WHERE change_pct >= 9.8 AND volume > 0
""")
limit_up_count = int(cur.fetchone()[0] or 0)
# 简化:涨停家数>50视为有高连板可能
if limit_up_count > 50:
return 5
elif limit_up_count > 30:
return 4
elif limit_up_count > 15:
return 3
elif limit_up_count > 5:
return 2
elif limit_up_count > 0:
return 1
return 0
except Exception:
return 0
def _empty_sentiment():
"""返回空情绪数据"""
return {
'limit_up_count': 0,
'limit_down_count': 0,
'up_count': 0,
'down_count': 0,
'up_down_ratio': 0,
'sentiment': '无数据',
'consecutive_board': 0,
'turnover_median': 0,
'total_amount': 0,
'market_temp': '无数据',
'score': 0,
'reasons': [],
}
+584
View File
@@ -0,0 +1,584 @@
"""
新闻/公告/政策分析模块P5/P6
功能
- P5: 上市公司公告采集 + LLM情感分析 + 异动监测
- P6: 政策面新闻监控 + LLM政策分析
数据源
- AKShare 公告数据 (stock_notice_report)
- 豆包LLM 做分类和情感分析
"""
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# 当日缓存
_news_cache = {}
_news_cache_date = {}
def _get_cache(key):
today = datetime.now().strftime('%Y-%m-%d')
if _news_cache_date.get(key) == today:
return _news_cache.get(key)
return None
def _set_cache(key, value):
today = datetime.now().strftime('%Y-%m-%d')
_news_cache[key] = value
_news_cache_date[key] = today
# ═══════════════════════════════════════════════
# P5: 公告/并购消息分析
# ═══════════════════════════════════════════════
# 公告类型关键词映射
ANNOUNCEMENT_KEYWORDS = {
'并购重组': ['收购', '合并', '重组', '并购', '吸收合并'],
'增减持': ['增持', '减持', '股份变动', '股东减持', '股东增持'],
'业绩预告': ['业绩预告', '业绩快报', '盈利预测', '预增', '预减', '预亏', '扭亏'],
'股权激励': ['股权激励', '限制性股票', '股票期权'],
'定增再融资': ['定增', '非公开发行', '配股', '可转债', '再融资'],
'分红送转': ['分红', '送转', '派息', '转增', '利润分配'],
'重大合同': ['重大合同', '中标', '框架协议', '战略合作'],
'停复牌': ['停牌', '复牌', '继续停牌'],
'其他重大事项': ['重大事项', '重大投资', '资产出售', '资产剥离', '商誉减值'],
}
def classify_announcement(title):
"""
根据标题关键词对公告进行分类
参数:
title: 公告标题
返回:
str: 公告类型
"""
for category, keywords in ANNOUNCEMENT_KEYWORDS.items():
for kw in keywords:
if kw in title:
return category
return '其他'
def get_stock_announcements(stock_code, days=7):
"""
获取个股近期公告
参数:
stock_code: 股票代码
days: 获取最近几天的公告
返回:
list[dict]: 公告列表
"""
cached = _get_cache(f'announcements_{stock_code}')
if cached:
return cached
try:
import akshare as ak
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
df = ak.stock_notice_report(symbol=stock_code, date=start_date)
if df is None or df.empty:
# 尝试备用接口
try:
df = ak.stock_zh_a_disclosure_report_cninfo(
symbol=stock_code, market='沪深京',
start_date=start_date, end_date=end_date
)
except Exception:
return []
if df is None or df.empty:
return []
announcements = []
for _, row in df.iterrows():
title = str(row.get('标题', row.get('title', '')))
date_str = str(row.get('公告日期', row.get('date', '')))
category = classify_announcement(title)
announcements.append({
'title': title,
'date': date_str[:10] if date_str else '',
'category': category,
'sentiment': None, # 待LLM分析
})
_set_cache(f'announcements_{stock_code}', announcements)
return announcements
except Exception as e:
logger.warning(f"获取公告数据失败({stock_code}): {e}")
return []
def analyze_announcement_sentiment(stock_name, stock_code, announcements):
"""
使用LLM分析公告情感倾向
参数:
stock_name: 股票名称
stock_code: 股票代码
announcements: 公告列表
返回:
dict: {
'score': int, # 评分增减(-15 ~ +15
'summary': str, # 白话总结
'reasons': list, # 评分原因
'details': list, # 各公告分析结果
}
"""
if not announcements:
return {
'score': 0,
'summary': '近期无重要公告',
'reasons': [],
'details': [],
}
# 先用规则快速分类
positive_keywords = ['收购', '增持', '预增', '扭亏', '重大合同', '中标', '战略合作', '分红', '送转', '股权激励']
negative_keywords = ['减持', '预亏', '预减', '商誉减值', '资产出售', '停牌', '重大事项']
details = []
score = 0
reasons = []
positive_count = 0
negative_count = 0
for ann in announcements:
title = ann['title']
category = ann['category']
is_positive = any(kw in title for kw in positive_keywords)
is_negative = any(kw in title for kw in negative_keywords)
if is_positive and not is_negative:
sentiment = '利好'
ann_score = _get_category_score(category, positive=True)
positive_count += 1
elif is_negative and not is_positive:
sentiment = '利空'
ann_score = _get_category_score(category, positive=False)
negative_count += 1
else:
sentiment = '中性'
ann_score = 0
ann['sentiment'] = sentiment
ann['score'] = ann_score
score += ann_score
details.append(ann)
if ann_score != 0:
reasons.append(f'[{category}]{title[:30]}...({sentiment}{ann_score:+d})')
# 尝试用LLM深度分析(如果有重要公告)
important_categories = ['并购重组', '业绩预告', '增减持', '定增再融资']
important_anns = [a for a in announcements if a['category'] in important_categories]
if important_anns and len(important_anns) <= 5:
try:
llm_result = _llm_analyze_announcements(stock_name, stock_code, important_anns)
if llm_result:
# LLM分析覆盖规则评分
score = llm_result.get('score', score)
reasons = llm_result.get('reasons', reasons)
except Exception as e:
logger.warning(f"LLM公告分析失败: {e}")
score = max(-15, min(15, score))
# 白话总结
if positive_count > negative_count:
summary = f'{len(announcements)}条公告中{positive_count}条利好、{negative_count}条利空,消息面偏多'
elif negative_count > positive_count:
summary = f'{len(announcements)}条公告中{negative_count}条利空、{positive_count}条利好,消息面偏空'
else:
summary = f'{len(announcements)}条公告,消息面中性'
return {
'score': score,
'summary': summary,
'reasons': reasons,
'details': details,
}
def _get_category_score(category, positive=True):
"""根据公告类型和方向返回评分"""
scores = {
'并购重组': 10 if positive else -8,
'业绩预告': 8 if positive else -10,
'增减持': 5 if positive else -5,
'定增再融资': 5 if positive else -3,
'重大合同': 5 if positive else 0,
'分红送转': 3 if positive else 0,
'股权激励': 3 if positive else 0,
'停复牌': 0,
'其他重大事项': 0,
'其他': 0,
}
return scores.get(category, 0)
def _llm_analyze_announcements(stock_name, stock_code, announcements):
"""
调用豆包LLM分析公告情感
参数:
stock_name: 股票名称
stock_code: 股票代码
announcements: 重要公告列表
返回:
dict: LLM分析结果
"""
try:
import requests
import json
from services.doubao_api import API_KEY, API_URL, MODEL
ann_text = '\n'.join([f"- [{a['category']}]{a['title']}" for a in announcements])
prompt = f"""请分析以下{stock_name}({stock_code})的近期公告,判断每条公告是利好还是利空,并给出整体消息面评分。
公告列表
{ann_text}
请按以下JSON格式输出不要输出其他内容
{{"score": <整数,-15+15>, "reasons": ["原因1", "原因2"], "summary": "一句话总结"}}"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
payload = {
"model": MODEL,
"max_completion_tokens": 1024,
"stream": False,
"messages": [
{"role": "user", "content": prompt}
]
}
resp = requests.post(API_URL, headers=headers, json=payload, timeout=30)
if resp.status_code == 200:
data = resp.json()
content = data.get('choices', [{}])[0].get('message', {}).get('content', '')
# 尝试解析JSON
try:
result = json.loads(content)
return result
except json.JSONDecodeError:
# 尝试提取JSON
import re
match = re.search(r'\{.*\}', content, re.DOTALL)
if match:
return json.loads(match.group())
return None
except Exception as e:
logger.warning(f"LLM公告分析失败: {e}")
return None
def detect_price_anomaly(stock_code, df):
"""
检测股价异动可能由消息面驱动
参数:
stock_code: 股票代码
df: K线DataFrame
返回:
dict: 异动检测结果
"""
if df is None or len(df) < 20:
return {'anomaly': False, 'score': 0, 'reasons': []}
try:
import numpy as np
recent = df.tail(5)
vol_20 = float(df['volume'].tail(20).mean())
vol_recent = float(recent['volume'].mean())
vol_ratio = vol_recent / vol_20 if vol_20 > 0 else 1
change_recent = float((recent.iloc[-1]['close'] / recent.iloc[0]['close'] - 1) * 100)
# 异动条件:量比>3 且 涨跌幅>5%
if vol_ratio > 3 and abs(change_recent) > 5:
direction = '利好' if change_recent > 0 else '利空'
score = 5 if change_recent > 0 else -5
return {
'anomaly': True,
'direction': direction,
'vol_ratio': round(vol_ratio, 1),
'change_pct': round(change_recent, 2),
'score': score,
'reasons': [f'近期异动:量比{vol_ratio:.1f}倍+{direction}{abs(change_recent):.1f}%,可能有消息面催化({score:+d})'],
'summary': f'近期量比{vol_ratio:.1f}倍,{"" if change_recent > 0 else ""}{abs(change_recent):.1f}%,可能有消息面催化',
}
return {'anomaly': False, 'score': 0, 'reasons': []}
except Exception as e:
logger.warning(f"异动检测失败({stock_code}): {e}")
return {'anomaly': False, 'score': 0, 'reasons': []}
# ═══════════════════════════════════════════════
# P6: 政策面分析
# ═══════════════════════════════════════════════
# 政策关键词
POLICY_KEYWORDS = {
'行业扶持': ['扶持', '支持', '补贴', '鼓励', '促进', '加快', '推动', '振兴'],
'行业监管': ['监管', '限制', '禁止', '整顿', '规范', '处罚', '约谈'],
'货币政策': ['降准', '降息', '逆回购', 'MLF', 'SLF', '流动性', '存款准备金'],
'财政政策': ['减税', '降费', '基建', '专项债', '财政赤字', '以旧换新'],
'资本市场': ['注册制', '退市', '再融资', 'IPO', '印花税', '减持新规', '分红'],
}
def get_policy_news(days=3):
"""
获取近期财经政策新闻
返回:
list[dict]: 政策新闻列表
"""
cached = _get_cache('policy_news')
if cached:
return cached
try:
import akshare as ak
# 获取财经新闻
df = ak.stock_info_global_em()
if df is None or df.empty:
return []
# 筛选含政策关键词的新闻
policy_news = []
for _, row in df.head(50).iterrows():
title = str(row.get('标题', row.get('title', '')))
content = str(row.get('内容', row.get('content', '')))
date_str = str(row.get('发布时间', row.get('date', '')))
for category, keywords in POLICY_KEYWORDS.items():
if any(kw in title for kw in keywords):
policy_news.append({
'title': title,
'date': date_str[:10] if date_str else '',
'category': category,
'content': content[:200],
'sentiment': None,
})
break
_set_cache('policy_news', policy_news)
return policy_news
except Exception as e:
logger.warning(f"获取政策新闻失败: {e}")
return []
def analyze_policy_impact(policy_news):
"""
分析政策面对市场的影响
参数:
policy_news: 政策新闻列表
返回:
dict: {
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_sectors': dict, # 受影响板块
}
"""
if not policy_news:
return {
'score': 0,
'summary': '近期无明显政策消息',
'reasons': [],
'affected_sectors': {},
}
# 规则评分
sector_impact = {
'行业扶持': {'direction': '利好', 'sectors': ['对应行业板块']},
'行业监管': {'direction': '利空', 'sectors': ['对应行业板块']},
'货币政策': {'direction': '利好', 'sectors': ['全市场']},
'财政政策': {'direction': '利好', 'sectors': ['基建', '消费', '相关板块']},
'资本市场': {'direction': '中性', 'sectors': ['券商', '全市场']},
}
score = 0
reasons = []
affected = {}
positive_count = 0
negative_count = 0
for news in policy_news:
category = news['category']
impact = sector_impact.get(category, {'direction': '中性', 'sectors': []})
if impact['direction'] == '利好':
score += 2
positive_count += 1
news['sentiment'] = '利好'
reasons.append(f'[{category}]{news["title"][:30]}...(利好+2)')
elif impact['direction'] == '利空':
score -= 3
negative_count += 1
news['sentiment'] = '利空'
reasons.append(f'[{category}]{news["title"][:30]}...(利空-3)')
else:
news['sentiment'] = '中性'
affected[category] = impact
# 尝试用LLM深度分析重大政策
major_policies = [n for n in policy_news if n['category'] in ['行业扶持', '行业监管', '货币政策']]
if major_policies and len(major_policies) <= 5:
try:
llm_result = _llm_analyze_policy(major_policies)
if llm_result:
score = llm_result.get('score', score)
reasons = llm_result.get('reasons', reasons)
except Exception as e:
logger.warning(f"LLM政策分析失败: {e}")
score = max(-10, min(10, score))
if positive_count > negative_count:
summary = f'近期{len(policy_news)}条政策消息,偏利好({positive_count}条利好/{negative_count}条利空)'
elif negative_count > positive_count:
summary = f'近期{len(policy_news)}条政策消息,偏利空({negative_count}条利空/{positive_count}条利好)'
else:
summary = f'近期{len(policy_news)}条政策消息,影响中性'
return {
'score': score,
'summary': summary,
'reasons': reasons,
'affected_sectors': affected,
'details': policy_news,
}
def _llm_analyze_policy(policy_news):
"""
调用豆包LLM分析政策影响
参数:
policy_news: 政策新闻列表
返回:
dict: LLM分析结果
"""
try:
import requests
import json
from services.doubao_api import API_KEY, API_URL, MODEL
news_text = '\n'.join([f"- [{n['category']}]{n['title']}" for n in policy_news])
prompt = f"""请分析以下财经政策新闻对A股市场的影响,判断整体是利好还是利空,并给出评分。
政策新闻
{news_text}
请按以下JSON格式输出不要输出其他内容
{{"score": <整数,-10+10>, "reasons": ["原因1", "原因2"], "summary": "一句话总结", "affected_sectors": {{"板块名": "利好/利空"}}}}"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
payload = {
"model": MODEL,
"max_completion_tokens": 1024,
"stream": False,
"messages": [
{"role": "user", "content": prompt}
]
}
resp = requests.post(API_URL, headers=headers, json=payload, timeout=30)
if resp.status_code == 200:
data = resp.json()
content = data.get('choices', [{}])[0].get('message', {}).get('content', '')
try:
return json.loads(content)
except json.JSONDecodeError:
import re
match = re.search(r'\{.*\}', content, re.DOTALL)
if match:
return json.loads(match.group())
return None
except Exception as e:
logger.warning(f"LLM政策分析失败: {e}")
return None
# ═══════════════════════════════════════════════
# 综合消息面分析
# ═══════════════════════════════════════════════
def analyze_news_factors(stock_code, stock_name, df=None):
"""
获取个股消息面 + 政策面综合分析
参数:
stock_code: 股票代码
stock_name: 股票名称
df: K线DataFrame用于异动检测
返回:
dict: 综合消息面分析结果
"""
# 公告分析
announcements = get_stock_announcements(stock_code, days=7)
ann_result = analyze_announcement_sentiment(stock_name, stock_code, announcements)
# 异动检测
anomaly_result = detect_price_anomaly(stock_code, df) if df is not None else {'anomaly': False, 'score': 0, 'reasons': []}
# 政策面
policy_news = get_policy_news(days=3)
policy_result = analyze_policy_impact(policy_news)
total_score = ann_result.get('score', 0) + anomaly_result.get('score', 0) + policy_result.get('score', 0)
total_score = max(-20, min(20, total_score))
all_reasons = []
all_reasons.extend(ann_result.get('reasons', []))
all_reasons.extend(anomaly_result.get('reasons', []))
all_reasons.extend(policy_result.get('reasons', []))
return {
'announcements': ann_result,
'price_anomaly': anomaly_result,
'policy': policy_result,
'total_score': total_score,
'all_reasons': all_reasons,
'summary': f"公告:{ann_result.get('summary', '')} | 政策:{policy_result.get('summary', '')}",
}
+134
View File
@@ -0,0 +1,134 @@
"""
综合评分引擎 整合所有影响因素到统一评分体系
将技术面基础50%+与外部因素加减分项整合为最终评分
权重分配
- 技术面评分compute_deep_analysis 原始分基础分0-100
- P0 资金面±20
- P1 市场情绪±10
- P2 北向资金±10
- P3 美股外盘±10
- P4 大宗商品±5
- P5 公告/异动±15
- P6 政策面±10
- P7 汇率±3
最终评分 = 技术面基础分 + 外部因素加减分上限100下限0
"""
import logging
logger = logging.getLogger(__name__)
def compute_comprehensive_score(stock_code, stock_name, technical_score, df=None):
"""
综合评分引擎 整合技术面和所有外部因素
参数:
stock_code: 股票代码
stock_name: 股票名称
technical_score: float 技术面基础评分0-100来自 compute_deep_analysis
df: K线DataFrame用于异动检测可选
返回:
dict: {
'technical_score': float, # 技术面基础分
'external_score': int, # 外部因素总加减分
'final_score': int, # 最终综合评分(0-100
'verdict': str, # 最终评级
'factors': dict, # 各因素详细数据
'all_reasons': list, # 所有评分原因
'summary': str, # 综合白话总结
}
"""
factors = {}
all_reasons = []
external_score = 0
summaries = []
# ---- P0: 主力资金进出 ----
try:
from services.fund_flow_analyzer import analyze_fund_flow
fund_result = analyze_fund_flow(stock_code, days=5)
factors['fund_flow'] = fund_result
external_score += fund_result.get('score', 0)
all_reasons.extend(fund_result.get('reasons', []))
s = fund_result.get('summary', '')
if s and '暂无' not in s:
summaries.append(f'资金面:{s}')
except Exception as e:
logger.warning(f"P0资金面分析失败: {e}")
factors['fund_flow'] = {'score': 0, 'summary': '分析失败', 'reasons': []}
# ---- P1: 市场情绪指标 ----
try:
from services.market_sentiment import calc_market_sentiment
sentiment_result = calc_market_sentiment()
factors['market_sentiment'] = sentiment_result
external_score += sentiment_result.get('score', 0)
all_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)}:{sentiment_result.get("limit_down_count",0)}')
except Exception as e:
logger.warning(f"P1市场情绪分析失败: {e}")
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()
factors['external'] = ext_result
external_score += ext_result.get('total_score', 0)
all_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}")
factors['external'] = {'total_score': 0, 'summary': '分析失败', 'all_reasons': []}
# ---- P5/P6: 公告/异动/政策 ----
try:
from services.news_analyzer import analyze_news_factors
news_result = analyze_news_factors(stock_code, stock_name, df)
factors['news'] = news_result
external_score += news_result.get('total_score', 0)
all_reasons.extend(news_result.get('all_reasons', []))
s = news_result.get('summary', '')
if s:
summaries.append(s)
except Exception as e:
logger.warning(f"P5/P6消息面分析失败: {e}")
factors['news'] = {'total_score': 0, 'summary': '分析失败', 'all_reasons': []}
# ---- 最终评分 ----
# 外部因素加减分上限:±40(避免喧宾夺主)
external_score = max(-40, min(40, external_score))
final_score = max(0, min(100, int(technical_score + external_score)))
# 最终评级
if final_score >= 80:
verdict = '强烈看多'
elif final_score >= 65:
verdict = '看多'
elif final_score >= 50:
verdict = '中性偏多'
elif final_score >= 35:
verdict = '中性偏空'
else:
verdict = '看空'
# 综合总结
summary = ' | '.join(summaries) if summaries else '暂无外部因素数据'
return {
'technical_score': round(technical_score, 0),
'external_score': external_score,
'final_score': final_score,
'verdict': verdict,
'factors': factors,
'all_reasons': all_reasons,
'summary': summary,
}
+413 -22
View File
@@ -168,22 +168,23 @@ def _get_kline_from_local_db(stock_code, days=120):
"""从本地数据库读取K线(最快,毫秒级)"""
import pandas as pd
try:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
from db import get_db, put_db
conn = get_db()
if not conn:
return None
conn.autocommit = True
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
with conn.cursor() as cur:
cur.execute("""
SELECT trade_date, open, high, low, close, volume
FROM stock_kline_daily
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date
""", (stock_code, start_date))
rows = cur.fetchall()
conn.close()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT trade_date, open, high, low, close, volume
FROM stock_kline_daily
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date
""", (stock_code, start_date))
rows = cur.fetchall()
finally:
put_db(conn)
if rows and len(rows) >= 30:
df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
@@ -625,9 +626,9 @@ def compute_recommend(signal_status, indicators, triggered_count, is_holding):
if has_real_dragon:
return ('watch', '关注', '真龙出现 → 趋势启动,等待龙抬头确认', 65)
# MACD死叉 → 卖出/回避
# MACD死叉 → 回避(非持仓不能卖出,应为回避/观望)
if dif is not None and dea is not None and dif < dea:
return ('sell', '卖出', f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f})", 75)
return ('watch', '回避', f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f}),趋势偏弱", 25)
# 底背离 → 关注(suanfa.md 步骤1: 纳入关注范围)
if has_divergence:
@@ -652,23 +653,23 @@ def get_latest_price(stock_code):
返回:
float: 最新价格, 失败返回 0
"""
from db import get_db, put_db
conn = get_db()
if not conn:
return 0
try:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
cur = conn.cursor()
cur.execute("""
SELECT price FROM stock_realtime_price
WHERE code = %s AND price > 0
""", (stock_code,))
row = cur.fetchone()
conn.close()
if row:
return float(row[0])
except Exception:
pass
finally:
put_db(conn)
return 0
@@ -905,3 +906,393 @@ def find_bull_stocks(scan_rows, holding_codes=None):
'total': total,
'stage_info': BULL_STAGES,
}
# ═══════════════════════════════════════════════
# 9. 单股深度分析(价格位置、压力支撑、量价、空间估算)
# ═══════════════════════════════════════════════
def _generate_plain_summary(price, change_pct, ma_trend, position, supports,
resistances, vol_ratio, vol_trend, patterns,
space, score, verdict, reasons):
"""根据技术分析结果生成通俗易懂的中文解说"""
parts = []
# 1. 当前走势概况
if change_pct > 3:
trend_desc = f'今天涨了{change_pct:.1f}%,涨势比较猛'
elif change_pct > 0:
trend_desc = f'今天小涨{change_pct:.1f}%'
elif change_pct > -3:
trend_desc = f'今天小跌{abs(change_pct):.1f}%'
else:
trend_desc = f'今天跌了{abs(change_pct):.1f}%,跌幅较大'
if ma_trend == 'bullish':
trend_desc += ',均线呈多头排列,说明中短期整体向上'
elif ma_trend == 'bearish':
trend_desc += ',均线呈空头排列,中短期趋势偏弱'
else:
trend_desc += ',均线交叉纠缠,短期方向还不太明确'
parts.append(trend_desc + '')
# 2. 价格位置(用大白话)
pos_20 = position.get('20d', {})
pct_20 = pos_20.get('pct', 50)
if pct_20 > 80:
parts.append(f'当前股价处于近20天的高位区间({pct_20:.0f}%位置),已经涨了不少,追高要小心。')
elif pct_20 > 50:
parts.append(f'股价在近20天的中高位置({pct_20:.0f}%),还有一定上涨空间。')
elif pct_20 > 20:
parts.append(f'股价在近20天的中低位置({pct_20:.0f}%),相对安全。')
else:
parts.append(f'股价处于近20天的低位区间({pct_20:.0f}%),可能存在反弹机会。')
# 3. 上方压力和下方支撑
if resistances:
nearest_r = resistances[0]
r_gap = round((nearest_r['level'] - price) / price * 100, 1) if price > 0 else 0
if r_gap > 0:
parts.append(f'往上最近的压力位在{nearest_r["level"]:.2f}元({nearest_r["name"]}),距离约{r_gap:.1f}%。')
if supports:
nearest_s = supports[0]
s_gap = round((price - nearest_s['level']) / price * 100, 1) if price > 0 else 0
if s_gap > 0:
parts.append(f'往下最近的支撑位在{nearest_s["level"]:.2f}元({nearest_s["name"]}),有{s_gap:.1f}%的安全垫。')
# 4. 成交量情况
if vol_ratio >= 2:
parts.append(f'成交量明显放大(量比{vol_ratio:.1f}倍),市场关注度很高,要留意是主力进场还是出货。')
elif vol_ratio >= 1.3:
parts.append(f'成交量温和放大(量比{vol_ratio:.1f}倍),有资金在活跃参与。')
elif vol_ratio < 0.6:
parts.append(f'成交量萎缩(量比{vol_ratio:.1f}倍),市场比较冷清,短期可能震荡。')
else:
parts.append(f'成交量正常(量比{vol_ratio:.1f}倍)。')
# 5. 形态识别
if patterns:
pattern_names = [p['name'] for p in patterns]
bullish_p = [p['name'] for p in patterns if p.get('bullish') is True]
bearish_p = [p['name'] for p in patterns if p.get('bullish') is False]
if bullish_p:
parts.append(f'发现看涨信号:{"".join(bullish_p)},这是积极的技术形态。')
if bearish_p:
parts.append(f'注意看跌信号:{"".join(bearish_p)},需要警惕。')
# 6. 综合建议(大白话)
action_tip = ''
if score >= 75:
action_tip = '综合来看比较乐观,可以考虑逢低关注或适量参与,但注意控制仓位。'
elif score >= 60:
action_tip = '整体偏积极,可以少量关注,等回调到支撑位附近再考虑。'
elif score >= 45:
action_tip = '目前多空力量比较均衡,建议观望为主,等方向更明确再做决定。'
elif score >= 30:
action_tip = '目前偏弱势,不建议急于买入。如果持有,可以在反弹时适当减仓。'
else:
action_tip = '当前走势比较弱,建议回避。已经持有的可以考虑止损或等待反弹减仓。'
# 7. 空间估算
rr = space.get('risk_reward', 0)
if rr and rr > 0:
if rr >= 2:
parts.append(f'从空间来看,潜在收益是风险的{rr:.1f}倍,性价比不错。')
elif rr >= 1:
parts.append(f'收益风险比{rr:.1f}:1,性价比一般。')
else:
parts.append(f'收益风险比仅{rr:.1f}:1,下行风险大于上涨空间,不太划算。')
summary_text = ''.join(parts)
return {
'text': summary_text,
'action_tip': action_tip,
'confidence': '' if score >= 70 or score <= 30 else '',
}
def compute_deep_analysis(df, signal_result=None, realtime_info=None):
"""
对单只股票进行深度分析返回结构化的分析报告
参数:
df: DataFrame (含技术指标的K线数据)
signal_result: dict (detect_all_signals 返回的结果可选)
realtime_info: dict (stock_realtime_price 行数据可选)
返回:
dict: 完整的深度分析报告
"""
import numpy as np
if df is None or len(df) < 30:
return {'error': 'K线数据不足(需要至少30天)'}
last = df.iloc[-1]
cl = float(last['close'])
n = len(df)
# ---- 1. 均线系统 ----
ma_data = {}
for period in [5, 10, 20, 60]:
col = f'ma{period}'
if col in df.columns and n >= period:
ma_data[f'ma{period}'] = round(float(df[col].iloc[-1]), 2)
ma_list = sorted(ma_data.items(), key=lambda x: x[1], reverse=True)
ma_trend = 'bullish' if all(
ma_data.get(f'ma{a}', 0) >= ma_data.get(f'ma{b}', 0)
for a, b in [(5, 10), (10, 20)]
) else 'bearish' if all(
ma_data.get(f'ma{a}', 0) <= ma_data.get(f'ma{b}', 0)
for a, b in [(5, 10), (10, 20)]
) else 'mixed'
ma_trend_label = {'bullish': '多头排列', 'bearish': '空头排列', 'mixed': '交叉整理'}
# ---- 2. 价格位置分析 ----
position = {}
for days in [20, 60, 120]:
subset = df.tail(days) if n >= days else df
h = float(subset['high'].max())
l = float(subset['low'].min())
rng = h - l
pct = round((cl - l) / rng * 100, 0) if rng > 0 else 50
position[f'd{days}'] = {
'high': round(h, 2), 'low': round(l, 2),
'range_pct': pct,
'up_space': round((h / cl - 1) * 100, 1),
'down_risk': round((1 - l / cl) * 100, 1),
}
# ---- 3. 支撑与压力位 ----
supports = []
resistances = []
for name, val in ma_data.items():
if val < cl:
supports.append({'level': val, 'type': 'ma', 'name': name.upper()})
elif val > cl:
resistances.append({'level': val, 'type': 'ma', 'name': name.upper()})
for days_key in ['d20', 'd60', 'd120']:
p = position.get(days_key, {})
label = days_key.replace('d', '') + ''
if p.get('low', 0) < cl:
supports.append({'level': p['low'], 'type': 'low', 'name': f'{label}低点'})
if p.get('high', 0) > cl:
resistances.append({'level': p['high'], 'type': 'high', 'name': f'{label}高点'})
supports.sort(key=lambda x: x['level'], reverse=True)
resistances.sort(key=lambda x: x['level'])
# ---- 4. 成交量分析 ----
vol = float(last['volume'])
vol_5 = float(df['volume'].tail(5).mean()) if n >= 5 else vol
vol_20 = float(df['volume'].tail(20).mean()) if n >= 20 else vol
vol_ratio = round(vol / vol_20, 1) if vol_20 > 0 else 1.0
vol_trend = '缩量' if vol_ratio < 0.7 else '平量' if vol_ratio < 1.3 else '温和放量' if vol_ratio < 2.0 else '大幅放量'
# ---- 5. 形态识别(增强版) ----
patterns = []
closes_10 = [float(x) for x in df['close'].tail(10)]
if n >= 10:
std_10 = np.std(closes_10)
mean_10 = np.mean(closes_10)
cv_10 = std_10 / mean_10 if mean_10 > 0 else 0
if cv_10 < 0.015 and cl > max(closes_10[:-1]):
patterns.append({'name': '平台突破', 'bullish': True,
'desc': f'近10日波动率仅{cv_10*100:.1f}%,今日突破平台'})
elif cv_10 < 0.015:
patterns.append({'name': '窄幅整理', 'bullish': None,
'desc': f'近10日波动率{cv_10*100:.1f}%,蓄势待变'})
if n >= 20:
h20 = float(df.tail(20)['high'].max())
if cl >= h20 * 0.99:
patterns.append({'name': '创20日新高', 'bullish': True,
'desc': f'触及20日高点{h20:.2f}'})
# 双底形态:近30日内两个低点价格接近(差异<3%),且当前价格高于两低点之间的高点
if n >= 30:
lows_30 = [float(x) for x in df['low'].tail(30)]
# 找最低点和次低点
min_idx = int(np.argmin(lows_30))
min_val = lows_30[min_idx]
# 在最低点之前找次低点
if min_idx > 5:
before_lows = lows_30[:min_idx]
second_min_idx = int(np.argmin(before_lows))
second_min_val = before_lows[second_min_idx]
if abs(min_val - second_min_val) / min_val < 0.03:
# 两低点之间的高点
between_high = max(lows_30[second_min_idx:min_idx])
if cl > between_high:
patterns.append({'name': '双底突破', 'bullish': True,
'desc': f'双底形态(低点{min_val:.2f}{second_min_val:.2f}),已突破颈线{between_high:.2f}'})
# 量价齐升:近5日成交量递增且价格递增
if n >= 5:
vols_5 = [float(x) for x in df['volume'].tail(5)]
closes_5 = [float(x) for x in df['close'].tail(5)]
if all(vols_5[i] <= vols_5[i+1] for i in range(len(vols_5)-1)) and \
all(closes_5[i] <= closes_5[i+1] for i in range(len(closes_5)-1)):
patterns.append({'name': '量价齐升', 'bullish': True,
'desc': '近5日成交量与价格同步递增,强势特征'})
# 均线粘合后发散:MA5/10/20 三线粘合后开始发散
if n >= 20:
ma5_val = ma_data.get('ma5', 0)
ma10_val = ma_data.get('ma10', 0)
ma20_val = ma_data.get('ma20', 0)
if ma5_val and ma10_val and ma20_val:
ma_spread = max(ma5_val, ma10_val, ma20_val) - min(ma5_val, ma10_val, ma20_val)
ma_pct = ma_spread / cl * 100
if ma_pct < 1.0 and ma5_val > ma10_val > ma20_val:
patterns.append({'name': '均线粘合发散', 'bullish': True,
'desc': f'MA5/10/20粘合(离散{ma_pct:.1f}%)后多头排列'})
# 涨跌幅计算:如果最后一条是今天(可能未收盘),用前一日收盘价计算
from datetime import date
last_date_str = str(df['date'].values[-1])[:10]
today_str = date.today().isoformat()
if last_date_str == today_str and n >= 3:
# 今天未收盘,用倒数第二根K线的收盘价对比倒数第三根
change_today = round((cl / float(df.iloc[-2]['close']) - 1) * 100, 2)
else:
change_today = round((cl / float(df.iloc[-2]['close']) - 1) * 100, 2) if n >= 2 else 0
if change_today >= 5:
patterns.append({'name': '大阳线', 'bullish': True,
'desc': f'涨幅{change_today:.1f}%'})
elif change_today <= -5:
patterns.append({'name': '大阴线', 'bullish': False,
'desc': f'跌幅{change_today:.1f}%'})
# ---- 6. 空间估算 ----
first_resist = resistances[0] if resistances else None
first_support = supports[0] if supports else None
space = {
'nearest_resist': first_resist,
'nearest_support': first_support,
'risk_reward': None,
}
if first_resist and first_support:
upside = first_resist['level'] - cl
downside = cl - first_support['level']
space['risk_reward'] = round(upside / downside, 1) if downside > 0 else 99
# ---- 7. 综合评估 ----
score = 50
reasons = []
if ma_trend == 'bullish':
score += 10
reasons.append('均线多头排列(+10)')
elif ma_trend == 'bearish':
score -= 10
reasons.append('均线空头排列(-10)')
if vol_ratio >= 1.3:
score += 5
reasons.append(f'放量{vol_ratio}倍(+5)')
elif vol_ratio < 0.6:
score -= 3
reasons.append(f'缩量{vol_ratio}倍(-3)')
any_breakout = any(p['name'] == '平台突破' for p in patterns)
if any_breakout:
score += 10
reasons.append('平台突破(+10)')
any_new_high = any(p['name'] == '创20日新高' for p in patterns)
if any_new_high:
score += 5
reasons.append('创20日新高(+5)')
any_double_bottom = any(p['name'] == '双底突破' for p in patterns)
if any_double_bottom:
score += 10
reasons.append('双底突破(+10)')
any_vol_price_rise = any(p['name'] == '量价齐升' for p in patterns)
if any_vol_price_rise:
score += 8
reasons.append('量价齐升(+8)')
any_ma_converge = any(p['name'] == '均线粘合发散' for p in patterns)
if any_ma_converge:
score += 7
reasons.append('均线粘合发散(+7)')
pos_120 = position.get('d120', {}).get('range_pct', 50)
if pos_120 < 30:
score += 5
reasons.append(f'120日位置偏低{pos_120}%(+5)')
elif pos_120 > 80:
score -= 5
reasons.append(f'120日位置偏高{pos_120}%(-5)')
# 20日位置也纳入评分
pos_20 = position.get('d20', {}).get('range_pct', 50)
if pos_20 < 25:
score += 3
reasons.append(f'20日位置偏低{pos_20}%(+3)')
elif pos_20 > 85:
score -= 3
reasons.append(f'20日位置偏高{pos_20}%(-3)')
if signal_result:
sig_count = signal_result.get('signal_summary', {}).get('total_signals', 0)
if sig_count >= 3:
score += 15
reasons.append(f'{sig_count}信号共振(+15)')
elif sig_count >= 2:
score += 10
reasons.append(f'{sig_count}信号叠加(+10)')
elif sig_count >= 1:
score += 5
reasons.append(f'{sig_count}个信号(+5)')
if space.get('risk_reward') and space['risk_reward'] >= 2:
score += 5
reasons.append(f'风险收益比{space["risk_reward"]}:1(+5)')
elif space.get('risk_reward') and space['risk_reward'] < 0.8:
score -= 5
reasons.append(f'风险收益比{space["risk_reward"]}:1(-5)')
score = max(0, min(100, score))
verdict = '强烈看多' if score >= 80 else '看多' if score >= 65 else '中性偏多' if score >= 50 else '中性偏空' if score >= 35 else '看空'
ai_summary = _generate_plain_summary(
cl, change_today, ma_trend, position, supports, resistances,
vol_ratio, vol_trend, patterns, space, score, verdict, reasons
)
return {
'price': cl,
'change_pct': change_today,
'ma': ma_data,
'ma_trend': ma_trend,
'ma_trend_label': ma_trend_label[ma_trend],
'position': position,
'supports': supports[:5],
'resistances': resistances[:5],
'volume': {
'today': vol,
'avg_5': round(vol_5),
'avg_20': round(vol_20),
'ratio': vol_ratio,
'trend': vol_trend,
},
'patterns': patterns,
'space': space,
'deep_score': score,
'verdict': verdict,
'score_reasons': reasons,
'ai_summary': ai_summary,
'kline_days': n,
}
+349
View File
@@ -1530,3 +1530,352 @@
border-radius: 8px;
font-size: 14px;
}
/* ============ 个股深析页面 ============ */
.deep-analysis-page { padding: 0 4px; box-sizing: border-box; max-width: 100%; overflow-x: hidden; }
.deep-input-card {
background: var(--card-bg, #1e1e2e);
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-sizing: border-box;
}
.deep-input-row {
display: flex;
gap: 8px;
width: 100%;
box-sizing: border-box;
}
.deep-code-input {
flex: 1;
min-width: 0;
padding: 10px 12px;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
background: rgba(0,0,0,0.2);
color: #fff;
font-size: 15px;
letter-spacing: 1px;
box-sizing: border-box;
}
.deep-code-input::placeholder { color: rgba(255,255,255,0.3); }
.deep-analyze-btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
background: #2196F3;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.deep-analyze-btn:disabled { opacity: 0.5; }
.deep-report { display: flex; flex-direction: column; gap: 10px; max-width: 100%; overflow-x: hidden; }
.deep-header-card {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--card-bg, #1e1e2e);
border-radius: 12px;
padding: 14px 16px;
}
.deep-stock-name { font-size: 18px; font-weight: 700; color: #fff; }
.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-score-circle {
width: 50px; height: 50px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
margin: 0 auto;
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-verdict { text-align: center; font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 4px; }
.deep-section {
background: var(--card-bg, #1e1e2e);
border-radius: 12px;
padding: 12px 14px;
}
.deep-section-title {
font-size: 13px;
font-weight: 600;
color: rgba(255,255,255,0.5);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
}
.deep-recommend-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
flex-wrap: wrap;
}
.deep-recommend-bar.rec-buy { background: rgba(244,67,54,0.15); }
.deep-recommend-bar.rec-watch { background: rgba(33,150,243,0.12); }
.deep-recommend-bar.rec-sell { background: rgba(76,175,80,0.15); }
.rec-display {
font-size: 16px; font-weight: 700; color: #fff;
background: rgba(255,255,255,0.1);
padding: 2px 10px; border-radius: 4px;
}
.rec-rate { font-size: 14px; color: rgba(255,255,255,0.6); }
.rec-reason-text { font-size: 13px; color: rgba(255,255,255,0.7); }
.deep-signal-list { display: flex; flex-direction: column; gap: 6px; }
.deep-signal-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(255,255,255,0.04);
border-radius: 6px;
flex-wrap: wrap;
}
.deep-sig-name { font-weight: 600; color: #ff9800; font-size: 13px; }
.deep-sig-strength {
font-size: 11px;
background: rgba(255,152,0,0.2);
color: #ffb74d;
padding: 1px 6px;
border-radius: 4px;
}
.deep-sig-desc { font-size: 12px; color: rgba(255,255,255,0.5); }
.deep-position-grid { display: flex; flex-direction: column; gap: 8px; }
.deep-pos-item {
display: grid;
grid-template-columns: 36px 1fr 1fr 80px;
align-items: center;
gap: 6px;
font-size: 12px;
}
.pos-label { font-weight: 600; color: rgba(255,255,255,0.5); }
.pos-range { color: rgba(255,255,255,0.4); font-size: 11px; }
.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-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; }
.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; }
.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-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; }
.deep-vol-info { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.vol-tag {
font-size: 13px; font-weight: 600;
padding: 2px 10px; border-radius: 4px;
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-detail { font-size: 12px; color: rgba(255,255,255,0.4); }
.deep-patterns { display: flex; flex-wrap: wrap; gap: 6px; }
.pattern-tag {
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
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 small { opacity: 0.7; }
.deep-ma-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.ma-trend-tag {
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-val { font-size: 12px; color: rgba(255,255,255,0.4); }
.deep-fundamental {
display: flex;
gap: 12px;
font-size: 12px;
color: rgba(255,255,255,0.5);
}
.deep-score-details { display: flex; flex-wrap: wrap; gap: 6px; }
.score-reason-tag {
font-size: 12px;
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; }
/* 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;
}
.deep-ai-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #64b5f6;
margin-bottom: 10px;
}
.deep-ai-text {
font-size: 14px;
line-height: 1.75;
color: rgba(255,255,255,0.85);
}
.deep-ai-text p {
margin: 0 0 8px 0;
}
.deep-ai-text p:last-child { margin-bottom: 0; }
.ai-action-tip {
margin-top: 10px;
padding: 10px 12px;
background: rgba(255,255,255,0.04);
border-radius: 8px;
font-size: 13px;
color: rgba(255,255,255,0.7);
}
.ai-action-label {
font-weight: 600;
color: #ff9800;
margin-right: 4px;
}
.deep-section { box-sizing: border-box; overflow: hidden; }
@media (max-width: 480px) {
.deep-pos-item { grid-template-columns: 32px 1fr 80px; }
.pos-range { display: none; }
.deep-header-card { flex-wrap: wrap; gap: 8px; }
.deep-section { padding: 10px 12px; }
.deep-recommend-bar { padding: 8px 10px; }
.deep-sr-grid { grid-template-columns: 1fr; gap: 8px; }
.deep-ma-info { gap: 4px; }
.ma-val { font-size: 11px; }
}
/* ============ 买入分析页面 ============ */
.buy-analysis-page { padding: 0 4px; }
.buy-analysis-header {
background: var(--card-bg, #1e1e2e);
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
text-align: center;
}
.buy-analysis-title {
font-size: 16px;
font-weight: 700;
color: #fff;
}
.buy-analysis-desc {
font-size: 12px;
color: rgba(255,255,255,0.4);
margin-top: 4px;
}
.buy-analysis-list { display: flex; flex-direction: column; gap: 8px; }
.buy-analysis-card {
background: var(--card-bg, #1e1e2e);
border-radius: 12px;
overflow: hidden;
}
.buy-card-header {
display: flex;
align-items: center;
padding: 12px 14px;
cursor: pointer;
gap: 8px;
}
.buy-card-header:active { background: rgba(255,255,255,0.03); }
.buy-card-left { flex: 1; min-width: 0; }
.buy-card-name { font-size: 14px; font-weight: 600; color: #fff; }
.buy-card-code { font-size: 11px; color: rgba(255,255,255,0.4); margin-left: 6px; }
.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-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-verdict { font-size: 11px; color: rgba(255,255,255,0.5); }
.buy-card-arrow {
font-size: 14px;
color: rgba(255,255,255,0.3);
transition: transform 0.2s;
flex-shrink: 0;
}
.buy-card-arrow.expanded { transform: rotate(90deg); }
.buy-card-detail {
padding: 0 14px 14px;
border-top: 1px solid rgba(255,255,255,0.05);
}
.buy-detail-grid { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.buy-detail-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
font-size: 13px;
gap: 8px;
}
.buy-detail-label {
color: rgba(255,255,255,0.4);
flex-shrink: 0;
min-width: 60px;
}
.buy-detail-value {
color: rgba(255,255,255,0.8);
text-align: right;
flex: 1;
}
+87 -4
View File
@@ -92,10 +92,24 @@
scanSummaryCollapsed: false,
fullScanSignalDist: [],
// 找牛股
bullStocksData: null, // { stages: {1:[...], 2:[...]}, summary: {}, stage_info: [...] }
// 找牛股(保留兼容)
bullStocksData: null,
bullStocksLoading: false,
bullActiveStage: 2, // 默认显示阶段2=龙抬头(最佳买点)
bullActiveStage: 2,
// 个股深析
deepCode: '',
deepReport: null,
deepLoading: false,
// 模型页子tab
modelSubTab: 'system',
// 买入分析
buyAnalysisList: [],
buyAnalysisLoading: false,
buyAnalysisProgress: 0,
buyAnalysisTotal: 0,
// 交易记录页面
trades: [],
@@ -2239,7 +2253,7 @@
params: holding ? { holdingStocks: holding } : {}
});
if (resp.data.success) {
this.bullStocksData = resp.data; // { stages, summary, total, stage_info }
this.bullStocksData = resp.data;
} else {
this.showToast(resp.data.error || '获取牛股数据失败', 'error');
}
@@ -2251,6 +2265,75 @@
}
},
async fetchDeepAnalysis() {
const code = (this.deepCode || '').trim();
if (!code || code.length < 6) {
this.showToast('请输入6位股票代码', 'error');
return;
}
this.deepLoading = true;
this.deepReport = null;
try {
const resp = await axios.post('/api/deep_analyze', { stock_code: code });
if (resp.data.success) {
this.deepReport = resp.data.report;
} else {
this.showToast(resp.data.error || '分析失败', 'error');
}
} catch (err) {
console.error('深度分析失败:', err);
this.showToast('深度分析失败: ' + (err.response?.data?.error || err.message), 'error');
} finally {
this.deepLoading = false;
}
},
async fetchBuyAnalysis() {
if (this.buyAnalysisLoading) return;
this.buyAnalysisLoading = true;
this.buyAnalysisList = [];
this.buyAnalysisProgress = 0;
try {
const scanResp = await axios.get('/api/scan_results', {
params: { per_page: 200, recommend_text: '买入' }
});
if (!scanResp.data.success) {
this.showToast('获取买入推荐失败', 'error');
return;
}
const buyStocks = scanResp.data.results || [];
this.buyAnalysisTotal = buyStocks.length;
if (buyStocks.length === 0) {
this.showToast('当前无买入推荐股票', 'info');
return;
}
const results = [];
for (let i = 0; i < buyStocks.length; i++) {
this.buyAnalysisProgress = i + 1;
try {
const resp = await axios.post('/api/deep_analyze', {
stock_code: buyStocks[i].code,
skip_llm: true
});
if (resp.data.success) {
const report = resp.data.report;
report._expanded = false;
results.push(report);
}
} catch (e) {
console.warn('分析失败:', buyStocks[i].code, e.message);
}
}
results.sort((a, b) => b.deep_score - a.deep_score);
this.buyAnalysisList = results;
} catch (err) {
console.error('买入分析失败:', err);
this.showToast('买入分析失败: ' + (err.message || '未知错误'), 'error');
} finally {
this.buyAnalysisLoading = false;
}
},
getBullStageStocks(stageNum) {
if (!this.bullStocksData || !this.bullStocksData.stages) return [];
return this.bullStocksData.stages[String(stageNum)] || [];
+12 -4
View File
@@ -260,11 +260,19 @@ def backfill_history(conn, max_days=30):
if not flows:
continue
# 获取当天收盘价
# 获取当天收盘价和涨跌幅(通过前一日收盘价计算)
cur.execute("""
SELECT code, close, change_pct
FROM stock_kline_daily
WHERE trade_date = %s AND code = ANY(%s)
SELECT k.code, k.close,
CASE WHEN prev.close > 0
THEN ROUND((k.close - prev.close) / prev.close * 100, 2)
ELSE 0 END AS change_pct
FROM stock_kline_daily k
LEFT JOIN LATERAL (
SELECT close FROM stock_kline_daily
WHERE code = k.code AND trade_date < k.trade_date
ORDER BY trade_date DESC LIMIT 1
) prev ON true
WHERE k.trade_date = %s AND k.code = ANY(%s)
""", (d, list(flows.keys())))
price_map = {r[0]: {'close': float(r[1] or 0), 'change_pct': float(r[2] or 0)}
for r in cur.fetchall()}
+8 -2
View File
@@ -54,9 +54,15 @@ def get_db_conn():
def get_all_stock_codes(conn):
"""获取所有股票代码"""
"""获取可交易的股票列表(排除退市、停牌等无效股票)"""
with conn.cursor() as cur:
cur.execute("SELECT code, name FROM stock_realtime_price ORDER BY code")
cur.execute("""
SELECT code, name FROM stock_realtime_price
WHERE volume > 0 AND price > 0
AND name NOT LIKE '%%退%%'
AND name NOT LIKE 'PT%%'
ORDER BY code
""")
return cur.fetchall()
+345 -97
View File
@@ -24,7 +24,7 @@
<link rel="stylesheet" href="/static/css/base.css?v=20260318v2">
<link rel="stylesheet" href="/static/css/components.css?v=20260318v2">
<link rel="stylesheet" href="/static/css/auth.css?v=20260318v1">
<link rel="stylesheet" href="/static/css/pages.css?v=20260318v2">
<link rel="stylesheet" href="/static/css/pages.css?v=20260511v3">
<link rel="stylesheet" href="/static/css/scan.css?v=20260318v2">
<link rel="stylesheet" href="/static/css/responsive.css?v=20260318v2">
</head>
@@ -668,11 +668,11 @@
<button :class="['sub-tab', { active: analysisSubTab === 'scan' }]" @click="analysisSubTab = 'scan'; fetchFullScanResults(1)">
全景扫描
</button>
<button :class="['sub-tab', { active: analysisSubTab === 'bull' }]" @click="analysisSubTab = 'bull'; fetchBullStocks()">
找牛股
<button :class="['sub-tab', { active: analysisSubTab === 'bull' }]" @click="analysisSubTab = 'bull'">
个股深析
</button>
<button :class="['sub-tab', { active: analysisSubTab === 'sim' }]" @click="analysisSubTab = 'sim'; loadSimData()">
模拟交易
<button :class="['sub-tab', { active: analysisSubTab === 'buyanalysis' }]" @click="analysisSubTab = 'buyanalysis'; fetchBuyAnalysis()">
买入分析
</button>
</div>
@@ -1100,114 +1100,272 @@
</div><!-- 全景扫描子页面结束 -->
<!-- 找牛股子页面 -->
<div v-show="analysisSubTab === 'bull'" class="bull-stocks-page">
<!-- 个股深析子页面 -->
<div v-show="analysisSubTab === 'bull'" class="deep-analysis-page">
<!-- 牛股流程说明 -->
<div class="bull-flow-card">
<div class="bull-flow-title">标准牛股启动信号流程</div>
<div class="bull-flow-steps">
<span class="bull-step step-divergence" :class="{ active: bullActiveStage === 1 }" @click="bullActiveStage = 1">
<span class="step-label">底部探测</span>
<span class="step-action">关注</span>
<span class="step-count" v-if="bullStocksData">{{ getBullStageStocks(1).length }}</span>
</span>
<span class="bull-flow-arrow"></span>
<span class="bull-step step-dragon" :class="{ active: bullActiveStage === 2 }" @click="bullActiveStage = 2">
<span class="step-label">资金进场</span>
<span class="step-action">买入</span>
<span class="step-count" v-if="bullStocksData">{{ getBullStageStocks(2).length }}</span>
</span>
<span class="bull-flow-arrow"></span>
<span class="bull-step step-true-dragon" :class="{ active: bullActiveStage === 3 }" @click="bullActiveStage = 3">
<span class="step-label">趋势确立</span>
<span class="step-action">持有</span>
<span class="step-count" v-if="bullStocksData">{{ getBullStageStocks(3).length }}</span>
</span>
<span class="bull-flow-arrow"></span>
<span class="bull-step step-main-wave" :class="{ active: bullActiveStage === 4 }" @click="bullActiveStage = 4">
<span class="step-label">加速拉升</span>
<span class="step-action">加仓</span>
<span class="step-count" v-if="bullStocksData">{{ getBullStageStocks(4).length }}</span>
</span>
<span class="bull-flow-arrow"></span>
<span class="bull-step step-rebound" :class="{ active: bullActiveStage === 5 }" @click="bullActiveStage = 5">
<span class="step-label">回调补涨</span>
<span class="step-action">观察</span>
<span class="step-count" v-if="bullStocksData">{{ getBullStageStocks(5).length }}</span>
</span>
</div>
<div class="bull-flow-hint">
<template v-if="bullStocksData"><b>{{ bullStocksData.total }}</b> 只有信号股票 · 数据来自最新全景扫描</template>
<template v-else>点击各阶段查看对应股票列表 · 数据来自最新全景扫描</template>
<!-- 输入区 -->
<div class="deep-input-card">
<div class="deep-input-row">
<input v-model="deepCode" placeholder="输入股票代码,如 601609"
class="deep-code-input" @keyup.enter="fetchDeepAnalysis()" maxlength="6">
<button class="deep-analyze-btn" @click="fetchDeepAnalysis()" :disabled="deepLoading">
{{ deepLoading ? '分析中...' : '深度分析' }}
</button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="bullStocksLoading" class="bull-loading">
<span class="loading-dot"></span> 正在分析牛股阶段...
<div v-if="deepLoading" class="bull-loading">
<span class="loading-dot"></span> 正在进行深度分析...
</div>
<!-- 阶段股票列表 -->
<div v-if="bullStocksData && !bullStocksLoading" class="bull-stage-section">
<div class="bull-stage-header">
<template v-if="getBullStageInfo(bullActiveStage)">
<span class="bull-stage-icon">{{ getBullStageInfo(bullActiveStage).icon }}</span>
<span class="bull-stage-name">{{ getBullStageInfo(bullActiveStage).name }} — {{ getBullStageInfo(bullActiveStage).desc }}</span>
</template>
<span class="bull-stage-count">{{ getBullStageStocks(bullActiveStage).length }} 只</span>
</div>
<!-- 分析报告 -->
<div v-if="deepReport && !deepLoading" class="deep-report">
<div v-if="getBullStageStocks(bullActiveStage).length === 0" class="bull-empty">
当前阶段暂无符合条件的股票
<!-- 头部:股票信息+综合评分 -->
<div class="deep-header-card">
<div class="deep-header-left">
<div class="deep-stock-name">{{ deepReport.stock_name }}</div>
<div class="deep-stock-code">{{ deepReport.stock_code }}</div>
</div>
<div class="deep-header-center" v-if="deepReport.realtime">
<div class="deep-price">¥{{ deepReport.realtime.price.toFixed(2) }}</div>
<div class="deep-change" :class="deepReport.realtime.change_pct >= 0 ? 'up' : 'down'">
{{ deepReport.realtime.change_pct >= 0 ? '+' : '' }}{{ deepReport.realtime.change_pct.toFixed(2) }}%
</div>
</div>
<div class="deep-header-right">
<div class="deep-score-circle" :class="'score-' + (deepReport.deep_score >= 65 ? 'high' : deepReport.deep_score >= 50 ? 'mid' : 'low')">
<span class="score-num">{{ deepReport.deep_score }}</span>
</div>
<div class="deep-verdict">{{ deepReport.verdict }}</div>
</div>
</div>
<div v-else class="bull-stock-list">
<div v-for="stock in getBullStageStocks(bullActiveStage)" :key="stock.code"
class="bull-stock-card" :class="'stage-s' + bullActiveStage"
@click="techSignalCode = stock.code; analysisSubTab = 'scan'; fetchTechSignals()">
<div class="bull-card-left">
<div class="bull-card-code">{{ stock.code }}</div>
<div class="bull-card-name">{{ stock.name }}</div>
<!-- AI通俗解说 -->
<div v-if="deepReport.ai_summary" class="deep-ai-summary">
<div class="deep-ai-title">💡 AI 解读</div>
<div class="deep-ai-text">
<p>{{ deepReport.ai_summary.text }}</p>
</div>
<div class="ai-action-tip">
<span class="ai-action-label">操作建议:</span>{{ deepReport.ai_summary.action_tip }}
</div>
</div>
<!-- 推荐操作 -->
<div class="deep-section">
<div class="deep-section-title">系统推荐</div>
<div class="deep-recommend-bar" :class="'rec-' + deepReport.recommend.signal_type">
<span class="rec-display">{{ deepReport.recommend.display }}</span>
<span class="rec-rate">{{ deepReport.recommend.rate }}分</span>
<span class="rec-reason-text">{{ deepReport.recommend.reason }}</span>
</div>
</div>
<!-- 信号列表 -->
<div class="deep-section" v-if="deepReport.signals && deepReport.signals.length">
<div class="deep-section-title">触发信号 ({{ deepReport.signals.length }}个)</div>
<div class="deep-signal-list">
<div v-for="sig in deepReport.signals" :key="sig.type" class="deep-signal-item">
<span class="deep-sig-name">{{ sig.name }}</span>
<span class="deep-sig-strength">{{ sig.strength }}%</span>
<span class="deep-sig-desc">{{ sig.description }}</span>
</div>
<div class="bull-card-center">
<div class="bull-card-signals">
<span v-for="sig in stock.signals_active" :key="sig" class="bull-signal-tag">{{ sig }}</span>
</div>
<div class="bull-card-recommend">
<span :class="['rec-tag', 'rec-' + stock.recommend_type]">{{ stock.recommend_text }}</span>
<span class="rec-reason">{{ stock.recommend_reason }}</span>
</div>
</div>
<div class="bull-card-right">
<div class="bull-card-price" v-if="stock.price">¥{{ stock.price.toFixed(2) }}</div>
<div class="bull-card-change" v-if="stock.change_pct != null"
:class="stock.change_pct >= 0 ? 'up' : 'down'">
{{ stock.change_pct >= 0 ? '+' : '' }}{{ stock.change_pct.toFixed(2) }}%
</div>
<div class="bull-card-progress">
<div class="mini-progress-bar">
<div class="mini-progress-fill" :style="{ width: stock.progress + '%' }"></div>
</div>
</div>
<!-- 价格位置 -->
<div class="deep-section">
<div class="deep-section-title">价格位置</div>
<div class="deep-position-grid">
<div v-for="(pos, key) in deepReport.position" :key="key" class="deep-pos-item">
<div class="pos-label">{{ key.replace('d','') }}日</div>
<div class="pos-range">{{ pos.low }} ~ {{ pos.high }}</div>
<div class="pos-bar-wrap">
<div class="pos-bar-bg">
<div class="pos-bar-fill" :style="{ width: pos.range_pct + '%' }"
:class="pos.range_pct > 80 ? 'high' : pos.range_pct < 30 ? 'low' : 'mid'"></div>
</div>
<span class="progress-label">{{ stock.progress }}%</span>
<span class="pos-pct">{{ pos.range_pct }}%</span>
</div>
<div class="pos-space">
<span class="space-up">↑{{ pos.up_space }}%</span>
<span class="space-down">↓{{ pos.down_risk }}%</span>
</div>
</div>
</div>
</div>
<!-- 支撑压力 -->
<div class="deep-section">
<div class="deep-section-title">支撑与压力</div>
<div class="deep-sr-grid">
<div class="sr-col">
<div class="sr-col-title support-title">支撑位</div>
<div v-for="s in deepReport.supports" :key="s.name" class="sr-item support">
<span class="sr-name">{{ s.name }}</span>
<span class="sr-level">{{ s.level }}</span>
</div>
<div v-if="!deepReport.supports.length" class="sr-empty"></div>
</div>
<div class="sr-col">
<div class="sr-col-title resist-title">压力位</div>
<div v-for="r in deepReport.resistances" :key="r.name" class="sr-item resist">
<span class="sr-name">{{ r.name }}</span>
<span class="sr-level">{{ r.level }}</span>
</div>
<div v-if="!deepReport.resistances.length" class="sr-empty"></div>
</div>
</div>
</div>
<!-- 量价与形态 -->
<div class="deep-section">
<div class="deep-section-title">量价分析</div>
<div class="deep-vol-info">
<span class="vol-tag" :class="deepReport.volume.ratio >= 1.3 ? 'vol-up' : deepReport.volume.ratio < 0.7 ? 'vol-dn' : ''">
{{ deepReport.volume.trend }}
</span>
<span class="vol-detail">量比 {{ deepReport.volume.ratio }}x · 5日均量 {{ (deepReport.volume.avg_5/10000).toFixed(0) }}万</span>
</div>
<div v-if="deepReport.patterns.length" class="deep-patterns">
<span v-for="p in deepReport.patterns" :key="p.name"
class="pattern-tag" :class="p.bullish === true ? 'bullish' : p.bullish === false ? 'bearish' : ''">
{{ p.name }} <small>{{ p.desc }}</small>
</span>
</div>
</div>
<!-- 均线与基本面 -->
<div class="deep-section">
<div class="deep-section-title">技术指标</div>
<div class="deep-ma-info">
<span class="ma-trend-tag" :class="deepReport.ma_trend">{{ deepReport.ma_trend_label }}</span>
<span v-for="(val, key) in deepReport.ma" :key="key" class="ma-val">
{{ key.toUpperCase() }}={{ val }}
</span>
</div>
<div v-if="deepReport.realtime" class="deep-fundamental">
<span v-if="deepReport.realtime.pe > 0">PE {{ deepReport.realtime.pe.toFixed(1) }}</span>
<span v-if="deepReport.realtime.pb > 0">PB {{ deepReport.realtime.pb.toFixed(1) }}</span>
<span v-if="deepReport.realtime.total_market_cap > 0">
市值 {{ (deepReport.realtime.total_market_cap/100000000).toFixed(0) }}亿
</span>
</div>
</div>
<!-- 评分明细 -->
<div class="deep-section">
<div class="deep-section-title">评分明细</div>
<div class="deep-score-details">
<span v-for="r in deepReport.score_reasons" :key="r" class="score-reason-tag"
:class="r.includes('+') ? 'positive' : 'negative'">{{ r }}</span>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!bullStocksData && !bullStocksLoading" class="bull-empty-tip">
<div class="empty-icon">找牛股</div>
<p>找牛股 — 按信号流程筛选潜力股</p>
<p class="sub-tip">基于全景扫描数据,按标准牛股启动信号流程分阶段筛选</p>
<button class="scan-action-btn" @click="fetchBullStocks()">开始找牛股</button>
<div v-if="!deepReport && !deepLoading" class="bull-empty-tip">
<div class="empty-icon" style="font-size:28px">个股深析</div>
<p>输入股票代码,获取深度技术分析报告</p>
<p class="sub-tip">包含价格位置、压力支撑、量价分析、空间估算、综合评分</p>
</div>
</div><!-- 找牛股子页面结束 -->
</div><!-- 个股深析子页面结束 -->
<!-- 模拟交易子页面 -->
<div v-show="analysisSubTab === 'sim'" class="sim-trade-page">
<!-- 买入分析子页面 -->
<div v-show="analysisSubTab === 'buyanalysis'" class="buy-analysis-page">
<div class="buy-analysis-header">
<div class="buy-analysis-title">买入股深度分析</div>
<div class="buy-analysis-desc">对全景扫描中推荐买入的股票批量执行深度分析</div>
<button class="deep-analyze-btn" @click="fetchBuyAnalysis()" :disabled="buyAnalysisLoading" style="margin-top:8px;">
{{ buyAnalysisLoading ? '分析中...' : '刷新分析' }}
</button>
</div>
<div v-if="buyAnalysisLoading" class="bull-loading">
<span class="loading-dot"></span> 正在批量分析买入推荐股...{{ buyAnalysisProgress }}/{{ buyAnalysisTotal }}
</div>
<div v-if="!buyAnalysisLoading && buyAnalysisList.length === 0" class="bull-empty-tip">
<div class="empty-icon" style="font-size:28px">📊</div>
<p>暂无买入推荐股票</p>
<p class="sub-tip">当全景扫描中有买入推荐时,会自动进行深度分析</p>
</div>
<div v-if="buyAnalysisList.length > 0" class="buy-analysis-list">
<div v-for="(item, idx) in buyAnalysisList" :key="item.stock_code" class="buy-analysis-card">
<div class="buy-card-header" @click="item._expanded = !item._expanded">
<div class="buy-card-left">
<span class="buy-card-name">{{ item.stock_name }}</span>
<span class="buy-card-code">{{ item.stock_code }}</span>
</div>
<div class="buy-card-center">
<span class="buy-card-price" v-if="item.realtime">¥{{ item.realtime.price.toFixed(2) }}</span>
<span class="buy-card-change" v-if="item.realtime" :class="item.realtime.change_pct >= 0 ? 'up' : 'down'">
{{ item.realtime.change_pct >= 0 ? '+' : '' }}{{ item.realtime.change_pct.toFixed(2) }}%
</span>
</div>
<div class="buy-card-right">
<span class="buy-card-score" :class="'score-' + (item.deep_score >= 65 ? 'high' : item.deep_score >= 50 ? 'mid' : 'low')">
{{ item.deep_score }}分
</span>
<span class="buy-card-verdict">{{ item.verdict }}</span>
</div>
<span class="buy-card-arrow" :class="{ expanded: item._expanded }"></span>
</div>
<div v-show="item._expanded" class="buy-card-detail">
<div v-if="item.ai_summary" class="deep-ai-summary" style="margin-bottom:8px;">
<div class="deep-ai-title">💡 AI 解读</div>
<div class="deep-ai-text"><p>{{ item.ai_summary.text }}</p></div>
<div class="ai-action-tip">
<span class="ai-action-label">操作建议:</span>{{ item.ai_summary.action_tip }}
</div>
</div>
<div class="buy-detail-grid">
<div class="buy-detail-item">
<span class="buy-detail-label">系统推荐</span>
<span class="buy-detail-value" style="color:#f44336;font-weight:600;">{{ item.recommend.display }} {{ item.recommend.rate }}分</span>
</div>
<div class="buy-detail-item">
<span class="buy-detail-label">推荐理由</span>
<span class="buy-detail-value">{{ item.recommend.reason }}</span>
</div>
<div class="buy-detail-item" v-if="item.signals && item.signals.length">
<span class="buy-detail-label">触发信号</span>
<span class="buy-detail-value">
<span v-for="sig in item.signals" :key="sig.type" class="deep-sig-name" style="margin-right:6px;">{{ sig.name }}</span>
</span>
</div>
<div class="buy-detail-item" v-if="item.supports && item.supports.length">
<span class="buy-detail-label">支撑位</span>
<span class="buy-detail-value">{{ item.supports.map(s => s.level.toFixed(2)).join(' / ') }}</span>
</div>
<div class="buy-detail-item" v-if="item.resistances && item.resistances.length">
<span class="buy-detail-label">压力位</span>
<span class="buy-detail-value">{{ item.resistances.map(r => r.level.toFixed(2)).join(' / ') }}</span>
</div>
<div class="buy-detail-item">
<span class="buy-detail-label">量能</span>
<span class="buy-detail-value">{{ item.volume ? item.volume.trend + ' (量比' + item.volume.ratio + 'x)' : '-' }}</span>
</div>
<div class="buy-detail-item">
<span class="buy-detail-label">均线</span>
<span class="buy-detail-value">{{ item.ma_trend_label }}</span>
</div>
</div>
<button class="deep-analyze-btn" style="margin-top:8px;font-size:12px;padding:6px 12px;"
@click="deepCode = item.stock_code; analysisSubTab = 'bull'; fetchDeepAnalysis();">
查看完整报告 →
</button>
</div>
</div>
</div>
</div><!-- 买入分析子页面结束 -->
<!-- (模拟交易已移至模型tab) -->
<div style="display:none">
<!-- 模拟交易统计卡片 -->
<div class="sim-stats-card">
<div class="sim-stats-header">
@@ -1533,14 +1691,24 @@
</div>
</div>
</div><!-- 模拟交易子页面结束 -->
</div><!-- 模拟交易占位结束 -->
</div><!-- 分析页面结束 -->
<!-- 模型页面 - 交易信号实战体系 -->
<!-- 模型页面 -->
<div v-show="activeTab === 'model'" class="model-page">
<!-- 模型页子导航 -->
<div class="sub-tabs">
<button :class="['sub-tab', { active: modelSubTab === 'system' }]" @click="modelSubTab = 'system'">
信号体系
</button>
<button :class="['sub-tab', { active: modelSubTab === 'sim' }]" @click="modelSubTab = 'sim'; loadSimData()">
模拟交易
</button>
</div>
<!-- 信号体系子页面 -->
<div v-show="modelSubTab === 'system'">
<!-- 信号胜率排行 -->
<div class="signal-system-section">
<h4 class="signal-system-title">交易信号胜率排行</h4>
@@ -1652,6 +1820,86 @@
<div class="signal-summary-item"><span class="summary-tag aux">短底背离 / 反弹</span>仅作辅助参考,不单独作为核心决策依据</div>
</div>
</div>
</div><!-- 信号体系子页面结束 -->
<!-- 模拟交易子页面 -->
<div v-show="modelSubTab === 'sim'" class="sim-trade-page">
<!-- 模拟交易统计卡片 -->
<div class="sim-stats-card">
<div class="sim-stats-header">
<h4>智能交易 <span v-if="smartAlgoConfig" style="font-size:12px;color:#888;">{{ smartAlgoConfig.algo_name }}</span></h4>
<div class="sim-actions">
<button class="sim-btn auto" @click="executeAutoTrade" :disabled="simAutoTrading">
{{ simAutoTrading ? '执行中...' : '执行' }}
</button>
<button class="sim-btn reset" @click="confirmResetSim">重置</button>
</div>
</div>
<div class="sim-stats-grid">
<div class="sim-stat-item">
<div class="sim-stat-value">{{ formatMoney(smartAlgoConfig?.total_capital || simStats.initial_capital || 200000) }}</div>
<div class="sim-stat-label">总本金</div>
</div>
<div class="sim-stat-item">
<div class="sim-stat-value">{{ formatMoney(simStats.total_market_value || 0) }}</div>
<div class="sim-stat-label">持仓市值</div>
</div>
<div class="sim-stat-item">
<div class="sim-stat-value">{{ formatMoney(simStats.cash || (smartAlgoConfig?.total_capital || 200000)) }}</div>
<div class="sim-stat-label">可用资金</div>
</div>
<div class="sim-stat-item" :class="(simStats.total_profit || 0) >= 0 ? 'profit' : 'loss'">
<div class="sim-stat-value">{{ (simStats.total_profit || 0) >= 0 ? '+' : '' }}{{ formatMoney(simStats.total_profit || 0) }}</div>
<div class="sim-stat-label">总盈亏</div>
</div>
<div class="sim-stat-item" :class="(simStats.profit_rate || 0) >= 0 ? 'profit' : 'loss'">
<div class="sim-stat-value">{{ (simStats.profit_rate || 0) >= 0 ? '+' : '' }}{{ (simStats.profit_rate || 0).toFixed(2) }}%</div>
<div class="sim-stat-label">收益率</div>
</div>
<div class="sim-stat-item">
<div class="sim-stat-value">{{ simStats.total_trades || 0 }}</div>
<div class="sim-stat-label">交易次数</div>
</div>
</div>
<div class="sim-stats-detail">
<span :class="(simStats.unrealized_profit || 0) >= 0 ? 'profit' : 'loss'">
浮动 {{ (simStats.unrealized_profit || 0) >= 0 ? '+' : '' }}{{ formatMoney(simStats.unrealized_profit || 0) }}
</span>
<span :class="(simStats.realized_profit || 0) >= 0 ? 'profit' : 'loss'">
已实现 {{ (simStats.realized_profit || 0) >= 0 ? '+' : '' }}{{ formatMoney(simStats.realized_profit || 0) }}
</span>
<button class="sim-btn refresh" @click="updateSimPrices" :disabled="simPositions.length === 0 || simPriceRefreshing" v-if="simPositions.length > 0" style="margin-left:auto;font-size:11px;padding:2px 8px;">
{{ simPriceRefreshing ? '刷新中...' : '刷新现价' }}
</button>
</div>
</div>
<!-- 模拟持仓和交易历史直接复用现有逻辑 -->
<div class="sim-section collapsible-section" v-if="simPositions.length > 0">
<div class="sim-section-header" style="cursor:pointer;" @click="simPositionsExpanded = !simPositionsExpanded">
<h4 class="section-title">持仓 <span class="badge">{{ simPositions.length }}</span></h4>
<span class="expand-arrow" :class="{ expanded: simPositionsExpanded }"></span>
</div>
<div v-show="simPositionsExpanded" class="sim-positions-list">
<div v-for="pos in simPositions" :key="pos.stock_code" class="sim-position-item" @click="loadFundamental(pos.stock_code, pos.stock_name)" style="cursor:pointer;">
<div class="sim-pos-header">
<span class="sim-pos-stock">{{ pos.stock_code }} {{ pos.stock_name }}</span>
<span class="sim-pos-profit" :class="(pos.unrealized_profit || 0) >= 0 ? 'profit' : 'loss'">
{{ (pos.unrealized_profit || 0) >= 0 ? '+' : '' }}{{ formatMoney(pos.unrealized_profit || 0) }}
({{ (pos.profit_rate || 0) >= 0 ? '+' : '' }}{{ (pos.profit_rate || 0).toFixed(2) }}%)
</span>
</div>
<div class="sim-pos-detail">
<span>{{ pos.quantity }}股 @ ¥{{ (pos.avg_cost || 0).toFixed(2) }}</span>
<span v-if="pos.current_price">现价 ¥{{ pos.current_price.toFixed(2) }}</span>
</div>
</div>
</div>
</div>
</div><!-- 模拟交易子页面结束 -->
</div><!-- 模型页面结束 -->
<!-- 交易记录页面 -->
@@ -1944,6 +2192,6 @@
</div>
{% endraw %}
<script src="/static/js/app.js?v=20260318v2"></script>
<script src="/static/js/app.js?v=20260511v3"></script>
</body>
</html>