diff --git a/stock-html/.env.example b/stock-html/.env.example new file mode 100644 index 0000000..c890474 --- /dev/null +++ b/stock-html/.env.example @@ -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 diff --git a/stock-html/app.py b/stock-html/app.py index d6bf638..e5794f8 100644 --- a/stock-html/app.py +++ b/stock-html/app.py @@ -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() diff --git a/stock-html/config.py b/stock-html/config.py index 6020698..3f29dcd 100644 --- a/stock-html/config.py +++ b/stock-html/config.py @@ -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)) diff --git a/stock-html/deploy/stock-data-service.service b/stock-html/deploy/stock-data-service.service new file mode 100644 index 0000000..a9cef2c --- /dev/null +++ b/stock-html/deploy/stock-data-service.service @@ -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 diff --git a/stock-html/docs/算法分析.md b/stock-html/docs/算法分析.md new file mode 100644 index 0000000..6666048 --- /dev/null +++ b/stock-html/docs/算法分析.md @@ -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/` +- **评分范围**:±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/` +- **评分范围**:±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/` | GET | 个股资金流向分析 | +| `/api/news_analysis/` | GET | 个股消息面分析(公告+政策+异动) | + +### 4.11 数据流 + +``` +deep_analyze 接口调用流程: +1. 获取K线数据 → calc_all_indicators → detect_all_signals +2. compute_deep_analysis(技术面评分 0-100) +3. score_engine.compute_comprehensive_score: + ├─ fund_flow_analyzer.analyze_fund_flow(P0) + ├─ market_sentiment.calc_market_sentiment(P1) + ├─ external_factors.get_all_external_factors(P2-P4,P7) + └─ news_analyzer.analyze_news_factors(P5-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` 单例 + 连接池 + 自动重试 | diff --git a/stock-html/full_signal_scan.py b/stock-html/full_signal_scan.py index 801a4eb..a589721 100644 --- a/stock-html/full_signal_scan.py +++ b/stock-html/full_signal_scan.py @@ -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() diff --git a/stock-html/routes/analysis.py b/stock-html/routes/analysis.py index 38c4613..e160a9c 100644 --- a/stock-html/routes/analysis.py +++ b/stock-html/routes/analysis.py @@ -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/', 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 diff --git a/stock-html/routes/auth.py b/stock-html/routes/auth.py index 234cea6..1980188 100644 --- a/stock-html/routes/auth.py +++ b/stock-html/routes/auth.py @@ -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({ diff --git a/stock-html/routes/market.py b/stock-html/routes/market.py index d769ce7..d7c9f30 100644 --- a/stock-html/routes/market.py +++ b/stock-html/routes/market.py @@ -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/', 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/', 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 diff --git a/stock-html/routes/trades.py b/stock-html/routes/trades.py index b32aca5..25d6a26 100644 --- a/stock-html/routes/trades.py +++ b/stock-html/routes/trades.py @@ -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/', 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/', 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']) diff --git a/stock-html/services/doubao_api.py b/stock-html/services/doubao_api.py index a738057..d2b972e 100644 --- a/stock-html/services/doubao_api.py +++ b/stock-html/services/doubao_api.py @@ -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" diff --git a/stock-html/services/external_factors.py b/stock-html/services/external_factors.py new file mode 100644 index 0000000..458db47 --- /dev/null +++ b/stock-html/services/external_factors.py @@ -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': [], + } diff --git a/stock-html/services/fund_flow_analyzer.py b/stock-html/services/fund_flow_analyzer.py new file mode 100644 index 0000000..d27ec18 --- /dev/null +++ b/stock-html/services/fund_flow_analyzer.py @@ -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, + } diff --git a/stock-html/services/mairui_api.py b/stock-html/services/mairui_api.py index d9fe6ed..a896d5a 100644 --- a/stock-html/services/mairui_api.py +++ b/stock-html/services/mairui_api.py @@ -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" # 缓存配置 diff --git a/stock-html/services/market_sentiment.py b/stock-html/services/market_sentiment.py new file mode 100644 index 0000000..fe21444 --- /dev/null +++ b/stock-html/services/market_sentiment.py @@ -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': [], + } diff --git a/stock-html/services/news_analyzer.py b/stock-html/services/news_analyzer.py new file mode 100644 index 0000000..ff89fb6 --- /dev/null +++ b/stock-html/services/news_analyzer.py @@ -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', '')}", + } diff --git a/stock-html/services/score_engine.py b/stock-html/services/score_engine.py new file mode 100644 index 0000000..9217c7f --- /dev/null +++ b/stock-html/services/score_engine.py @@ -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, + } diff --git a/stock-html/services/stock_algorithms.py b/stock-html/services/stock_algorithms.py index d960811..8422a73 100644 --- a/stock-html/services/stock_algorithms.py +++ b/stock-html/services/stock_algorithms.py @@ -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} 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, + } diff --git a/stock-html/static/css/pages.css b/stock-html/static/css/pages.css index af637ba..1ac8492 100644 --- a/stock-html/static/css/pages.css +++ b/stock-html/static/css/pages.css @@ -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; +} diff --git a/stock-html/static/js/app.js b/stock-html/static/js/app.js index b4da947..4eaf4f9 100644 --- a/stock-html/static/js/app.js +++ b/stock-html/static/js/app.js @@ -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)] || []; diff --git a/stock-html/sync_fund_flow.py b/stock-html/sync_fund_flow.py index 65e8195..3320e42 100755 --- a/stock-html/sync_fund_flow.py +++ b/stock-html/sync_fund_flow.py @@ -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()} diff --git a/stock-html/sync_kline.py b/stock-html/sync_kline.py index a57c62b..563b004 100644 --- a/stock-html/sync_kline.py +++ b/stock-html/sync_kline.py @@ -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() diff --git a/stock-html/templates/index.html b/stock-html/templates/index.html index a61d9f7..1527523 100644 --- a/stock-html/templates/index.html +++ b/stock-html/templates/index.html @@ -24,7 +24,7 @@ - + @@ -668,11 +668,11 @@ - - @@ -1100,114 +1100,272 @@ - -
+ +
- -
-
标准牛股启动信号流程
-
- - 底部探测 - 关注 - {{ getBullStageStocks(1).length }} - - - - 资金进场 - 买入 - {{ getBullStageStocks(2).length }} - - - - 趋势确立 - 持有 - {{ getBullStageStocks(3).length }} - - - - 加速拉升 - 加仓 - {{ getBullStageStocks(4).length }} - - - - 回调补涨 - 观察 - {{ getBullStageStocks(5).length }} - -
-
- - + +
+
+ +
-
- 正在分析牛股阶段... +
+ 正在进行深度分析...
- -
-
- - {{ getBullStageStocks(bullActiveStage).length }} 只 -
+ +
-
- 当前阶段暂无符合条件的股票 + +
+
+
{{ deepReport.stock_name }}
+
{{ deepReport.stock_code }}
+
+
+
¥{{ deepReport.realtime.price.toFixed(2) }}
+
+ {{ deepReport.realtime.change_pct >= 0 ? '+' : '' }}{{ deepReport.realtime.change_pct.toFixed(2) }}% +
+
+
+
+ {{ deepReport.deep_score }} +
+
{{ deepReport.verdict }}
+
- -
-
-
-
{{ stock.code }}
-
{{ stock.name }}
+ + +
+
💡 AI 解读
+
+

{{ deepReport.ai_summary.text }}

+
+
+ 操作建议:{{ deepReport.ai_summary.action_tip }} +
+
+ + +
+
系统推荐
+
+ {{ deepReport.recommend.display }} + {{ deepReport.recommend.rate }}分 + {{ deepReport.recommend.reason }} +
+
+ + +
+
触发信号 ({{ deepReport.signals.length }}个)
+
+
+ {{ sig.name }} + {{ sig.strength }}% + {{ sig.description }}
-
-
- {{ sig }} -
-
- {{ stock.recommend_text }} - {{ stock.recommend_reason }} -
-
-
-
¥{{ stock.price.toFixed(2) }}
-
- {{ stock.change_pct >= 0 ? '+' : '' }}{{ stock.change_pct.toFixed(2) }}% -
-
-
-
+
+
+ + +
+
价格位置
+
+
+
{{ key.replace('d','') }}日
+
{{ pos.low }} ~ {{ pos.high }}
+
+
+
- {{ stock.progress }}% + {{ pos.range_pct }}% +
+
+ ↑{{ pos.up_space }}% + ↓{{ pos.down_risk }}%
+ + +
+
支撑与压力
+
+
+
支撑位
+
+ {{ s.name }} + {{ s.level }} +
+
+
+
+
压力位
+
+ {{ r.name }} + {{ r.level }} +
+
+
+
+
+ + +
+
量价分析
+
+ + {{ deepReport.volume.trend }} + + 量比 {{ deepReport.volume.ratio }}x · 5日均量 {{ (deepReport.volume.avg_5/10000).toFixed(0) }}万 +
+
+ + {{ p.name }} {{ p.desc }} + +
+
+ + +
+
技术指标
+
+ {{ deepReport.ma_trend_label }} + + {{ key.toUpperCase() }}={{ val }} + +
+
+ PE {{ deepReport.realtime.pe.toFixed(1) }} + PB {{ deepReport.realtime.pb.toFixed(1) }} + + 市值 {{ (deepReport.realtime.total_market_cap/100000000).toFixed(0) }}亿 + +
+
+ + +
+
评分明细
+
+ {{ r }} +
+
-
-
找牛股
-

找牛股 — 按信号流程筛选潜力股

-

基于全景扫描数据,按标准牛股启动信号流程分阶段筛选

- +
+
个股深析
+

输入股票代码,获取深度技术分析报告

+

包含价格位置、压力支撑、量价分析、空间估算、综合评分

-
+
- -
+ +
+
+
买入股深度分析
+
对全景扫描中推荐买入的股票批量执行深度分析
+ +
+ +
+ 正在批量分析买入推荐股...({{ buyAnalysisProgress }}/{{ buyAnalysisTotal }}) +
+ +
+
📊
+

暂无买入推荐股票

+

当全景扫描中有买入推荐时,会自动进行深度分析

+
+ +
+
+
+
+ {{ item.stock_name }} + {{ item.stock_code }} +
+
+ ¥{{ item.realtime.price.toFixed(2) }} + + {{ item.realtime.change_pct >= 0 ? '+' : '' }}{{ item.realtime.change_pct.toFixed(2) }}% + +
+
+ + {{ item.deep_score }}分 + + {{ item.verdict }} +
+ +
+
+
+
💡 AI 解读
+

{{ item.ai_summary.text }}

+
+ 操作建议:{{ item.ai_summary.action_tip }} +
+
+
+
+ 系统推荐 + {{ item.recommend.display }} {{ item.recommend.rate }}分 +
+
+ 推荐理由 + {{ item.recommend.reason }} +
+
+ 触发信号 + + {{ sig.name }} + +
+
+ 支撑位 + {{ item.supports.map(s => s.level.toFixed(2)).join(' / ') }} +
+
+ 压力位 + {{ item.resistances.map(r => r.level.toFixed(2)).join(' / ') }} +
+
+ 量能 + {{ item.volume ? item.volume.trend + ' (量比' + item.volume.ratio + 'x)' : '-' }} +
+
+ 均线 + {{ item.ma_trend_label }} +
+
+ +
+
+
+
+ + +
@@ -1533,14 +1691,24 @@
-
- - +
- +
+ +
+ + +
+ + +

交易信号胜率排行

@@ -1652,6 +1820,86 @@
短底背离 / 反弹仅作辅助参考,不单独作为核心决策依据
+
+ + +
+ + +
+
+

智能交易 {{ smartAlgoConfig.algo_name }}

+
+ + +
+
+
+
+
{{ formatMoney(smartAlgoConfig?.total_capital || simStats.initial_capital || 200000) }}
+
总本金
+
+
+
{{ formatMoney(simStats.total_market_value || 0) }}
+
持仓市值
+
+
+
{{ formatMoney(simStats.cash || (smartAlgoConfig?.total_capital || 200000)) }}
+
可用资金
+
+
+
{{ (simStats.total_profit || 0) >= 0 ? '+' : '' }}{{ formatMoney(simStats.total_profit || 0) }}
+
总盈亏
+
+
+
{{ (simStats.profit_rate || 0) >= 0 ? '+' : '' }}{{ (simStats.profit_rate || 0).toFixed(2) }}%
+
收益率
+
+
+
{{ simStats.total_trades || 0 }}
+
交易次数
+
+
+
+ + 浮动 {{ (simStats.unrealized_profit || 0) >= 0 ? '+' : '' }}{{ formatMoney(simStats.unrealized_profit || 0) }} + + + 已实现 {{ (simStats.realized_profit || 0) >= 0 ? '+' : '' }}{{ formatMoney(simStats.realized_profit || 0) }} + + +
+
+ + +
+
+

持仓 {{ simPositions.length }}

+ +
+
+
+
+ {{ pos.stock_code }} {{ pos.stock_name }} + + {{ (pos.unrealized_profit || 0) >= 0 ? '+' : '' }}{{ formatMoney(pos.unrealized_profit || 0) }} + ({{ (pos.profit_rate || 0) >= 0 ? '+' : '' }}{{ (pos.profit_rate || 0).toFixed(2) }}%) + +
+
+ {{ pos.quantity }}股 @ ¥{{ (pos.avg_cost || 0).toFixed(2) }} + 现价 ¥{{ pos.current_price.toFixed(2) }} +
+
+
+
+ +
+
@@ -1944,6 +2192,6 @@
{% endraw %} - +