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

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

算法文档重构:
- 章节重排: 技术面(二三)→外部因素(四)→买卖决策(五)→数据源(六)→性能(七)
- 架构图更新为五层,标注章节对应
- 5.1/5.2标注纯技术面,5.3整合外部因素修正推荐
2026-07-17 23:50:42 +08:00

470 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
全量股票技术信号扫描脚本
对数据库中的全部股票进行7个技术信号检测,结果存入 stock_signal_scan 表
支持断点续扫、并发处理、进度报告
"""
import sys
import os
import time
import json
import threading
import signal as sig_module
from datetime import datetime, date, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pandas as pd
import psycopg2
from psycopg2.extras import Json
from config import Config
from services.signal_detector import detect_all_signals
from services.stock_algorithms import (
get_kline_from_local_db_threaded,
get_ali_session, get_tencent_session,
code_to_ali_symbol, code_to_tencent_symbol, is_bj_stock,
ALICLOUD_KLINE_URL, TENCENT_KLINE_URL,
)
WORKERS = 8
BATCH_SIZE = 80
BATCH_SAVE_SIZE = 40
K_DAYS = 120
LOOKBACK = 5
_shutdown = False
def signal_handler(signum, frame):
global _shutdown
print("\n⚠️ 收到中断信号,正在优雅退出...")
_shutdown = True
sig_module.signal(sig_module.SIGINT, signal_handler)
sig_module.signal(sig_module.SIGTERM, signal_handler)
def get_db_conn():
return psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
def get_all_stock_codes(conn):
"""获取可交易的股票列表(排除退市、停牌、ST等无效股票)"""
with conn.cursor() as cur:
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()
def get_scanned_codes(conn, scan_date):
with conn.cursor() as cur:
cur.execute(
"SELECT code FROM stock_signal_scan WHERE scan_date = %s",
(scan_date,),
)
return {row[0] for row in cur.fetchall()}
def get_kline_data(stock_code, days=K_DAYS):
"""获取K线数据:优先本地DB → 阿里云API → 腾讯API → 麦蕊API → AKShare
(使用 services.stock_algorithms 统一的API会话和工具函数)"""
from services.mairui_api import get_kline as _mairui_get_kline
# 1. 优先从本地数据库读取(多线程安全版本)
df = get_kline_from_local_db_threaded(stock_code, days)
if df is not None:
return df
# 2. 阿里云K线API(北交所直接跳过,不支持)
if not is_bj_stock(stock_code):
try:
session = get_ali_session()
symbol = code_to_ali_symbol(stock_code)
resp = session.post(ALICLOUD_KLINE_URL, data={
'symbol': symbol, 'type': '240',
'limit': str(min(days, 300)), 'ma': '5',
}, timeout=10)
if resp.status_code == 200:
data = resp.json()
if data.get('success') and data.get('data', {}).get('list'):
records = []
for item in data['data']['list']:
day_str = item.get('day', '')
if not day_str or len(day_str) < 10:
continue
records.append({
'date': day_str[:10],
'open': float(item.get('open', 0)),
'high': float(item.get('high', 0)),
'low': float(item.get('low', 0)),
'close': float(item.get('close', 0)),
'volume': float(item.get('volume', 0)),
})
if len(records) >= 30:
return pd.DataFrame(records)
except Exception:
pass
# 3. 腾讯K线API(全市场,含北交所)
try:
session = get_tencent_session()
tencent_symbol = code_to_tencent_symbol(stock_code)
start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d')
resp = session.get(TENCENT_KLINE_URL, params={
'param': f'{tencent_symbol},day,{start_date},,{min(days, 300)},qfq',
}, timeout=15)
if resp.status_code == 200:
data = resp.json()
stock_data = data.get('data', {}).get(tencent_symbol, {})
klines = stock_data.get('qfqday') or stock_data.get('day') or []
if len(klines) >= 30:
records = []
for item in klines:
if len(item) < 6:
continue
records.append({
'date': item[0][:10],
'open': float(item[1]),
'high': float(item[3]),
'low': float(item[4]),
'close': float(item[2]),
'volume': float(item[5]),
})
if len(records) >= 30:
return pd.DataFrame(records)
except Exception:
pass
# 4. 回退到麦蕊API
try:
result = _mairui_get_kline(stock_code, period='d', days=days, adjust='f')
if result['success'] and result['data']:
df = pd.DataFrame(result['data'])
df.rename(columns={
'date': 'date', 'open': 'open', 'high': 'high',
'low': 'low', 'close': 'close', 'volume': 'volume',
}, inplace=True)
if len(df) >= 30:
return df
except Exception:
pass
# 5. 最后回退到AKShare (支持自动降级到腾讯数据源)
try:
from utils.data_fetcher import fetch_stock_hist
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
df = fetch_stock_hist(
stock_code=stock_code, period='daily',
start_date=start_date, end_date=end_date, adjust='qfq',
)
if df is not None and not df.empty:
df = df.rename(columns={
'日期': 'date', '开盘': 'open', '最高': 'high',
'最低': 'low', '收盘': 'close', '成交量': 'volume',
})
df = df[['date', 'open', 'high', 'low', 'close', 'volume']]
return df
except Exception:
pass
return None
def scan_single_stock(code, name):
try:
df = get_kline_data(code)
if df is None or df.empty or len(df) < 30:
return None
result = detect_all_signals(df, lookback=LOOKBACK)
signal_status = result.get('signal_status', [])
triggered_count = sum(1 for s in signal_status if s.get('triggered'))
return {
'code': code,
'name': name,
'triggered_count': triggered_count,
'signal_status': signal_status,
'indicators': result.get('indicators', {}),
'latest_signals': result.get('latest_signals', []),
}
except Exception:
return None
def _fix_sequence(conn):
"""修复序列号,确保不会产生主键冲突"""
try:
with conn.cursor() as cur:
cur.execute("""
SELECT setval('stock_signal_scan_id_seq',
COALESCE((SELECT max(id) FROM stock_signal_scan), 0) + 1, false
)
""")
conn.commit()
except Exception as e:
conn.rollback()
print(f"⚠️ 修复序列失败: {e}", flush=True)
def save_batch(conn, results, scan_date):
if not results:
return
# 每批次开始前确保序列正确
_fix_sequence(conn)
saved = 0
for r in results:
try:
with conn.cursor() as cur:
cur.execute("SAVEPOINT sp_insert")
cur.execute("""
INSERT INTO stock_signal_scan
(code, name, scan_date, triggered_count, signal_status, indicators, latest_signals)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (code, scan_date) DO UPDATE SET
name = EXCLUDED.name,
triggered_count = EXCLUDED.triggered_count,
signal_status = EXCLUDED.signal_status,
indicators = EXCLUDED.indicators,
latest_signals = EXCLUDED.latest_signals,
created_at = CURRENT_TIMESTAMP
""", (
r['code'], r['name'], scan_date, r['triggered_count'],
Json(r['signal_status']), Json(r['indicators']), Json(r['latest_signals']),
))
cur.execute("RELEASE SAVEPOINT sp_insert")
saved += 1
except Exception as e:
# 主键冲突时回滚到 savepoint,修复序列后重试
with conn.cursor() as cur:
cur.execute("ROLLBACK TO SAVEPOINT sp_insert")
if 'UniqueViolation' in type(e).__name__ or 'duplicate key' in str(e):
_fix_sequence(conn)
try:
with conn.cursor() as cur:
cur.execute("SAVEPOINT sp_insert")
cur.execute("""
INSERT INTO stock_signal_scan
(code, name, scan_date, triggered_count, signal_status, indicators, latest_signals)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (code, scan_date) DO UPDATE SET
name = EXCLUDED.name,
triggered_count = EXCLUDED.triggered_count,
signal_status = EXCLUDED.signal_status,
indicators = EXCLUDED.indicators,
latest_signals = EXCLUDED.latest_signals,
created_at = CURRENT_TIMESTAMP
""", (
r['code'], r['name'], scan_date, r['triggered_count'],
Json(r['signal_status']), Json(r['indicators']), Json(r['latest_signals']),
))
cur.execute("RELEASE SAVEPOINT sp_insert")
saved += 1
except Exception as e2:
with conn.cursor() as cur:
cur.execute("ROLLBACK TO SAVEPOINT sp_insert")
print(f"⚠️ 重试保存失败 {r.get('code','?')}: {e2}", flush=True)
else:
print(f"⚠️ 保存失败 {r.get('code','?')}: {e}", flush=True)
conn.commit()
def main():
global _shutdown
scan_date = date.today()
force_rescan = os.environ.get('FORCE_RESCAN', '').strip() == '1'
print(f"{'='*60}", flush=True)
print(f"📊 全量股票技术信号扫描(高速版)", flush=True)
print(f"📅 扫描日期: {scan_date}", flush=True)
if force_rescan:
print(f"⚠️ 强制重新扫描模式", flush=True)
print(f"⚙️ 并发数: {WORKERS}, 批保存: {BATCH_SAVE_SIZE}, K线天数: {K_DAYS}", flush=True)
print(f"{'='*60}", flush=True)
conn = get_db_conn()
if force_rescan:
with conn.cursor() as cur:
cur.execute("DELETE FROM stock_signal_scan WHERE scan_date = %s", (scan_date,))
deleted = cur.rowcount
conn.commit()
print(f"🗑️ 已清除今日 {deleted} 条扫描记录", flush=True)
all_stocks = get_all_stock_codes(conn)
total = len(all_stocks)
print(f"📈 数据库股票总数: {total}", flush=True)
scanned = get_scanned_codes(conn, scan_date)
if scanned:
print(f"✅ 今日已扫描: {len(scanned)} 只(续扫模式)", flush=True)
# 过滤退市/ST股票 — 不参与扫描
_SKIP_TAGS = ('退', 'ST', '*ST', '退市')
pending = [(code, name) for code, name in all_stocks
if code not in scanned and not any(tag in name for tag in _SKIP_TAGS)]
skipped_st = len([1 for _, name in all_stocks if any(tag in name for tag in _SKIP_TAGS)])
pending_count = len(pending)
if skipped_st > 0:
print(f"🚫 已过滤退市/ST股票: {skipped_st}", flush=True)
print(f"⏳ 待扫描: {pending_count}", flush=True)
if pending_count == 0:
print("🎉 今日扫描已全部完成!", flush=True)
show_summary(conn, scan_date)
conn.close()
return
# 检测本地K线数据是否可用
with conn.cursor() as cur:
cur.execute("SELECT count(DISTINCT code) FROM stock_kline_daily WHERE trade_date >= CURRENT_DATE - INTERVAL '7 days'")
local_kline_count = cur.fetchone()[0]
if local_kline_count > 0:
print(f"💾 本地K线数据: {local_kline_count} 只股票可用(优先使用本地数据)", flush=True)
else:
print(f"⚠️ 本地无K线数据,将通过API获取(较慢)", flush=True)
start_time = time.time()
done_count = len(scanned)
error_count = 0
triggered_total = 0
total_to_scan = total
save_buffer = []
last_report_time = time.time()
print(f"\n🚀 开始扫描...", flush=True)
print(f"-" * 60, flush=True)
# 使用全局线程池 — 避免反复创建/销毁线程池开销
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
futures = {}
# 提交所有任务
for code, name in pending:
if _shutdown:
break
futures[executor.submit(scan_single_stock, code, name)] = (code, name)
for future in as_completed(futures):
if _shutdown:
print("⏹️ 用户中断,正在保存当前进度...", flush=True)
break
code, name = futures[future]
done_count += 1
try:
result = future.result()
except Exception:
result = None
if result:
save_buffer.append(result)
if result['triggered_count'] > 0:
triggered_total += 1
names = [s['name'] for s in result['signal_status'] if s.get('triggered')]
print(f" 🔔 {result['code']} {result['name']}: {', '.join(names)}", flush=True)
else:
error_count += 1
# 攒够一批就保存(减少DB写入频率)
if len(save_buffer) >= BATCH_SAVE_SIZE:
save_batch(conn, save_buffer, scan_date)
save_buffer = []
# 每3秒报告一次进度(避免刷屏)
now = time.time()
if now - last_report_time >= 3:
elapsed = now - start_time
scanned_this_run = done_count - len(scanned)
speed = scanned_this_run / elapsed if elapsed > 0 else 0
remaining_stocks = total_to_scan - done_count
remaining_time = remaining_stocks / speed if speed > 0 else 0
pct = done_count / total_to_scan * 100
print(f" [{pct:5.1f}%] {done_count}/{total_to_scan} "
f"| 速度: {speed:.1f}只/秒 | 剩余: {remaining_time/60:.1f}分钟 "
f"| 触发: {triggered_total} | 失败: {error_count}", flush=True)
last_report_time = now
# 保存剩余结果
if save_buffer:
save_batch(conn, save_buffer, scan_date)
elapsed = time.time() - start_time
print(f"\n{'='*60}", flush=True)
print(f"✅ 扫描{'中断' if _shutdown else '完成'}", flush=True)
print(f" 扫描: {done_count} 只 | 耗时: {elapsed/60:.1f}分钟", flush=True)
print(f" 触发信号: {triggered_total} 只 | 失败: {error_count}", flush=True)
final_speed = (done_count - len(scanned)) / elapsed if elapsed > 0 else 0
print(f" 平均速度: {final_speed:.1f} 只/秒", flush=True)
print(f"{'='*60}", flush=True)
show_summary(conn, scan_date)
conn.close()
def show_summary(conn, scan_date):
print(f"\n📊 扫描结果摘要({scan_date}", flush=True)
print(f"-" * 60, flush=True)
with conn.cursor() as cur:
cur.execute("""
SELECT count(*),
coalesce(sum(case when triggered_count > 0 then 1 else 0 end), 0)
FROM stock_signal_scan WHERE scan_date = %s
""", (scan_date,))
total, triggered = cur.fetchone()
print(f" 总扫描: {total} 只 | 有信号: {triggered}", flush=True)
cur.execute("""
SELECT code, name, triggered_count, signal_status
FROM stock_signal_scan
WHERE scan_date = %s AND triggered_count > 0
ORDER BY triggered_count DESC
LIMIT 30
""", (scan_date,))
rows = cur.fetchall()
if rows:
print(f"\n🔔 触发信号TOP30", flush=True)
for code, name, tc, status in rows:
signals = status if isinstance(status, list) else json.loads(status) if status else []
names = [s['name'] for s in signals if s.get('triggered')]
print(f" {code} {name:8s} | {tc}个信号: {', '.join(names)}", flush=True)
else:
print(f"\n 暂无触发信号的股票", flush=True)
cur.execute("""
SELECT
s.value->>'name' as signal_name,
count(*) as cnt
FROM stock_signal_scan, jsonb_array_elements(signal_status) s
WHERE scan_date = %s AND (s.value->>'triggered')::boolean = true
GROUP BY s.value->>'name'
ORDER BY cnt DESC
""", (scan_date,))
signal_dist = cur.fetchall()
if signal_dist:
print(f"\n📈 信号分布:", flush=True)
for name, cnt in signal_dist:
print(f" {name}: {cnt}", flush=True)
if __name__ == '__main__':
main()