Initial commit

This commit is contained in:
freedakgmail
2026-07-17 18:49:07 +08:00
commit 9c7d7abdd4
100 changed files with 41337 additions and 0 deletions
+907
View File
@@ -0,0 +1,907 @@
"""
统一算法模块 — 全部核心算法的唯一定义处(Single Source of Truth
包含:
1. compute_recommend — 统一推荐逻辑(买入/卖出/加仓/观望等,严格遵循suanfa.md)
2. get_kline_data — 获取K线数据并返回 DataFrame(优先本地DB → 阿里云 → 腾讯 → 麦蕊 → AKShare)
3. fetch_kline_rows — 获取K线数据并返回 tuple 行列表(用于写入DB同步)
4. get_latest_price — 获取股票最新价格
5. code_to_market — 股票代码→市场判断(SH/SZ/BJ)
6. 各 API session 管理
7. compute_bull_stage — 牛股阶段识别(底部→起爆→确立→加速→补涨)
8. find_bull_stocks — 从扫描结果中找出潜在牛股
调用方:
- routes/analysis.py → compute_recommend, get_kline_data
- services/scheduler.py → compute_recommend, get_latest_price
- full_signal_scan.py → get_kline_data, API sessions
- sync_kline.py → fetch_kline_rows, API sessions
- routes/market.py → get_kline_data
"""
import threading
from datetime import datetime, timedelta
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from config import Config
# ═══════════════════════════════════════════════
# 1. 股票代码 → 市场 工具函数
# ═══════════════════════════════════════════════
def code_to_market(code):
"""6位股票代码 → 市场代码(SH/SZ/BJ)"""
if code.startswith(('0', '3')):
return 'SZ'
elif code.startswith(('8', '9')):
return 'BJ'
else:
return 'SH'
def is_bj_stock(code):
"""是否是北交所股票"""
return code.startswith(('8', '9'))
def code_to_ali_symbol(code):
"""6位股票代码 → 阿里云API格式(SH600519 / SZ000001 / BJ920720"""
return f'{code_to_market(code)}{code}'
def code_to_tencent_symbol(code):
"""6位股票代码 → 腾讯API格式(sh600519 / sz000001 / bj920720"""
return f'{code_to_market(code).lower()}{code}'
# ═══════════════════════════════════════════════
# 2. API Session 管理(线程安全,连接池复用)
# ═══════════════════════════════════════════════
_ali_session = None
_ali_lock = threading.Lock()
_tencent_session = None
_tencent_lock = threading.Lock()
def get_ali_session():
"""获取阿里云API专用 Session(线程安全,单例)"""
global _ali_session
if _ali_session is None:
with _ali_lock:
if _ali_session is None:
s = requests.Session()
retry = Retry(total=2, backoff_factor=0.3,
status_forcelist=[500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retry,
pool_connections=20, pool_maxsize=20)
s.mount('https://', adapter)
s.headers.update({
'Authorization': f'APPCODE {Config.ALICLOUD_APPCODE}',
'Content-Type': 'application/x-www-form-urlencoded',
})
_ali_session = s
return _ali_session
def get_tencent_session():
"""获取腾讯K线API专用 Session(线程安全,单例)"""
global _tencent_session
if _tencent_session is None:
with _tencent_lock:
if _tencent_session is None:
s = requests.Session()
retry = Retry(total=2, backoff_factor=0.3,
status_forcelist=[500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retry,
pool_connections=20, pool_maxsize=20)
s.mount('https://', adapter)
s.headers.update({
'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36'),
})
_tencent_session = s
return _tencent_session
# API URL 常量
ALICLOUD_KLINE_URL = Config.ALICLOUD_KLINE_URL
TENCENT_KLINE_URL = 'https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get'
# ═══════════════════════════════════════════════
# 3. K线数据获取 — DataFrame 格式(供信号检测/分析用)
# ═══════════════════════════════════════════════
def get_kline_data(stock_code, days=120, use_local_db=True):
"""
获取K线数据,返回 pandas DataFrame (columns: date, open, high, low, close, volume)
数据源优先级: 本地DB → 阿里云API → 腾讯API → 麦蕊API → AKShare
参数:
stock_code: 6位股票代码
days: 获取天数
use_local_db: 是否优先使用本地DB(全景扫描时为True, 实时分析时可为False)
返回:
DataFrame 或 None
"""
import pandas as pd
# 1. 优先从本地数据库读取
if use_local_db:
df = _get_kline_from_local_db(stock_code, days)
if df is not None:
return df
# 2. 阿里云K线API(沪深最稳定,北交所可能不支持)
if not is_bj_stock(stock_code):
df = _fetch_ali_kline_df(stock_code, days)
if df is not None and len(df) >= 30:
return df
# 3. 腾讯K线API(全市场,含北交所)
df = _fetch_tencent_kline_df(stock_code, days)
if df is not None and len(df) >= 30:
return df
# 4. 麦蕊API
df = _fetch_mairui_kline_df(stock_code, days)
if df is not None and len(df) >= 30:
return df
# 5. AKShare
df = _fetch_akshare_kline_df(stock_code, days)
if df is not None and len(df) >= 30:
return df
return None
def _get_kline_from_local_db(stock_code, days=120):
"""从本地数据库读取K线(最快,毫秒级)"""
import pandas as pd
try:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
conn.autocommit = True
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
with conn.cursor() as cur:
cur.execute("""
SELECT trade_date, open, high, low, close, volume
FROM stock_kline_daily
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date
""", (stock_code, start_date))
rows = cur.fetchall()
conn.close()
if rows and len(rows) >= 30:
df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
df['date'] = df['date'].astype(str)
for col in ('open', 'high', 'low', 'close', 'volume'):
df[col] = df[col].astype(float)
return df
except Exception:
pass
return None
# ---- 线程本地连接(供多线程扫描时使用,避免频繁建连) ----
_thread_local = threading.local()
def get_kline_from_local_db_threaded(stock_code, days=120):
"""多线程扫描专用:使用线程本地连接从本地DB读取K线"""
import pandas as pd
try:
conn = getattr(_thread_local, 'kline_conn', None)
if conn is None or conn.closed:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
conn.autocommit = True
_thread_local.kline_conn = conn
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
with conn.cursor() as cur:
cur.execute("""
SELECT trade_date, open, high, low, close, volume
FROM stock_kline_daily
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date
""", (stock_code, start_date))
rows = cur.fetchall()
if rows and len(rows) >= 30:
df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
df['date'] = df['date'].astype(str)
for col in ('open', 'high', 'low', 'close', 'volume'):
df[col] = df[col].astype(float)
return df
except Exception:
pass
return None
def _fetch_ali_kline_df(stock_code, days=120):
"""阿里云K线API → DataFrame"""
import pandas as pd
try:
session = get_ali_session()
symbol = code_to_ali_symbol(stock_code)
resp = session.post(ALICLOUD_KLINE_URL, data={
'symbol': symbol,
'type': '240',
'limit': str(min(days, 300)),
'ma': '5',
}, timeout=10)
if resp.status_code == 200:
data = resp.json()
if data.get('success') and data.get('data', {}).get('list'):
records = []
for item in data['data']['list']:
day_str = item.get('day', '')
if not day_str or len(day_str) < 10:
continue
records.append({
'date': day_str[:10],
'open': float(item.get('open', 0)),
'high': float(item.get('high', 0)),
'low': float(item.get('low', 0)),
'close': float(item.get('close', 0)),
'volume': float(item.get('volume', 0)),
})
if records:
return pd.DataFrame(records)
except Exception:
pass
return None
def _fetch_tencent_kline_df(stock_code, days=120):
"""腾讯K线API → DataFrame(全市场含北交所)"""
import pandas as pd
try:
session = get_tencent_session()
symbol = code_to_tencent_symbol(stock_code)
start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d')
resp = session.get(TENCENT_KLINE_URL, params={
'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq',
}, timeout=15)
if resp.status_code == 200:
data = resp.json()
stock_data = data.get('data', {}).get(symbol, {})
klines = stock_data.get('qfqday') or stock_data.get('day') or []
if klines:
records = []
for item in klines:
if len(item) < 6:
continue
# 腾讯格式: [date, open, close, high, low, volume, ...]
records.append({
'date': item[0][:10],
'open': float(item[1]),
'high': float(item[3]), # high = position 3
'low': float(item[4]), # low = position 4
'close': float(item[2]), # close = position 2
'volume': float(item[5]),
})
if records:
return pd.DataFrame(records)
except Exception:
pass
return None
def _fetch_mairui_kline_df(stock_code, days=120):
"""麦蕊API → DataFrame"""
import pandas as pd
try:
from services.mairui_api import get_kline
result = get_kline(stock_code, period='d', days=days, adjust='f')
if result['success'] and result['data']:
df = pd.DataFrame(result['data'])
df.rename(columns={
'date': 'date', 'open': 'open', 'high': 'high',
'low': 'low', 'close': 'close', 'volume': 'volume',
}, inplace=True)
if len(df) >= 30:
return df
except Exception:
pass
return None
def _fetch_akshare_kline_df(stock_code, days=120):
"""AKShare → DataFrame (支持自动降级到腾讯数据源)"""
import pandas as pd
try:
from utils.data_fetcher import fetch_stock_hist
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
df = fetch_stock_hist(
stock_code=stock_code, period='daily',
start_date=start_date, end_date=end_date, adjust='qfq',
)
if df is not None and not df.empty:
df = df.rename(columns={
'日期': 'date', '开盘': 'open', '最高': 'high',
'最低': 'low', '收盘': 'close', '成交量': 'volume',
})
df = df[['date', 'open', 'high', 'low', 'close', 'volume']]
return df
except Exception:
pass
return None
# ═══════════════════════════════════════════════
# 4. K线数据获取 — tuple行格式(供 sync_kline.py 写入DB用)
# ═══════════════════════════════════════════════
def fetch_kline_rows(code, days):
"""
获取K线数据,返回 list of tuple: (code, date, open, high, low, close, volume, amount)
数据源优先级: 阿里云API → 腾讯API → 麦蕊API → AKShare
北交所(8XX/9XX)直接走腾讯API
供 sync_kline.py 同步到本地数据库使用。
"""
bj = is_bj_stock(code)
# 北交所:直接用腾讯API(阿里云/Mairui不支持BJ
if bj:
rows = _fetch_tencent_kline_rows(code, days)
if rows:
return rows
return None
# 1. 首选:阿里云K线API
rows = _fetch_ali_kline_rows(code, days)
if rows:
return rows
# 2. 回退:腾讯API
rows = _fetch_tencent_kline_rows(code, days)
if rows:
return rows
# 3. 回退:Mairui API
rows = _fetch_mairui_kline_rows(code, days)
if rows:
return rows
# 4. 最后回退:AKShare
rows = _fetch_akshare_kline_rows(code, days)
if rows:
return rows
return None
def _fetch_ali_kline_rows(code, days):
"""阿里云K线API → tuple rows"""
try:
session = get_ali_session()
symbol = code_to_ali_symbol(code)
resp = session.post(ALICLOUD_KLINE_URL, data={
'symbol': symbol,
'type': '240',
'limit': str(min(days, 300)),
'ma': '5',
}, timeout=10)
if resp.status_code == 200:
data = resp.json()
if data.get('success') and data.get('data', {}).get('list'):
rows = []
for item in data['data']['list']:
day_str = item.get('day', '')
if not day_str or len(day_str) < 10:
continue
rows.append((
code,
day_str[:10],
float(item.get('open', 0)),
float(item.get('high', 0)),
float(item.get('low', 0)),
float(item.get('close', 0)),
int(item.get('volume', 0)),
float(item.get('amount', 0)),
))
if rows:
return rows
except Exception:
pass
return None
def _fetch_tencent_kline_rows(code, days):
"""腾讯K线API → tuple rows(全市场含北交所)"""
try:
symbol = code_to_tencent_symbol(code)
session = get_tencent_session()
start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d')
resp = session.get(TENCENT_KLINE_URL, params={
'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq',
}, timeout=15)
if resp.status_code == 200:
data = resp.json()
stock_data = data.get('data', {}).get(symbol, {})
klines = stock_data.get('qfqday') or stock_data.get('day') or []
if klines:
rows = []
for item in klines:
if len(item) < 6:
continue
date_str = item[0]
if not date_str or len(date_str) < 10:
continue
rows.append((
code,
date_str[:10],
float(item[1]), # open
float(item[3]), # high (position 3)
float(item[4]), # low (position 4)
float(item[2]), # close (position 2)
int(float(item[5])), # volume
float(item[8]) * 10000 if len(item) > 8 and item[8] else 0,
))
if rows:
return rows
except Exception:
pass
return None
def _fetch_mairui_kline_rows(code, days):
"""麦蕊API → tuple rows"""
try:
from services.mairui_api import get_kline
result = get_kline(code, period='d', days=days, adjust='f')
if result['success'] and result['data']:
rows = []
for item in result['data']:
date_str = item.get('date', '')
if not date_str:
continue
rows.append((
code,
date_str,
item.get('open', 0),
item.get('high', 0),
item.get('low', 0),
item.get('close', 0),
int(item.get('volume', 0)),
item.get('amount', 0),
))
if rows:
return rows
except Exception:
pass
return None
def _fetch_akshare_kline_rows(code, days):
"""AKShare → tuple rows (支持自动降级到腾讯数据源)"""
try:
from utils.data_fetcher import fetch_stock_hist
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
df = fetch_stock_hist(
stock_code=code, period='daily',
start_date=start_date, end_date=end_date, adjust='qfq',
)
if df is not None and not df.empty:
rows = []
for _, r in df.iterrows():
rows.append((
code,
str(r['日期']),
float(r['开盘']),
float(r['最高']),
float(r['最低']),
float(r['收盘']),
int(r['成交量']),
float(r.get('成交额', 0)),
))
if rows:
return rows
except Exception:
pass
return None
# ═══════════════════════════════════════════════
# 5. 统一推荐算法
# ═══════════════════════════════════════════════
def compute_recommend(signal_status, indicators, triggered_count, is_holding):
"""
统一推荐逻辑 — 全局唯一定义(严格遵循 suanfa.md 体系最强战法)。
体系最强战法流程(suanfa.md):
1. 日线底背离 → 纳入关注范围
2. 龙抬头出现 → 执行买入操作(实操核心买点)
3. 真龙/主升浪 → 持有仓位+加仓(不是新买入!)
4. 不见主升浪 → 不出场
参数:
signal_status: list[dict] 信号状态列表(来自 signal_detector._check_all_signal_status
indicators: dict 最新技术指标(含 macd.dif, macd.dea 等)
triggered_count: int 触发信号数量
is_holding: bool 当前是否持仓该股票
返回:
tuple: (signal_type, display_text, reason, recommend_rate)
- signal_type: 'buy' | 'sell' | 'watch'
- display_text: '买入' | '卖出' | '加仓' | '持有' | '关注' | '观察' | '观望'
- reason: str 推荐理由
- recommend_rate: int 推荐评分 0-100
使用场景:
- 全景扫描结果推荐列
- 策略建议分档
- 提醒 tab 买卖推荐
- 模拟交易自动买卖决策
"""
if not signal_status:
return ('watch', '观望', '暂无信号数据', 0)
ss = signal_status
sig_map = {}
for s in ss:
sig_map[s.get('type', '')] = s
has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False)
has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False)
has_dragon = sig_map.get('dragon_head', {}).get('triggered', False)
has_real_dragon = sig_map.get('true_dragon', {}).get('triggered', False)
macd = (indicators or {}).get('macd', {})
dif = macd.get('dif', 0)
dea = macd.get('dea', 0)
triggered_signals = [s.get('name', s.get('type', '')) for s in ss if s.get('triggered')]
# ════════════════════════════════════════════
# 持仓逻辑(suanfa.md 步骤3-4
# ════════════════════════════════════════════
if is_holding:
# 卖出条件: MACD死叉 + 无主升浪 → 趋势走弱,不见主升浪则出场
if dif < dea and not has_main_wave:
return ('sell', '卖出',
f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f})+主升浪消失", 75)
# 主升浪 → 加仓(suanfa.md: 主升浪=持有仓位+加仓)
if has_main_wave:
return ('buy', '加仓', '主升浪信号 → 加速拉升阶段,建议加仓', 90)
# 真龙 → 持有(suanfa.md: 真龙=趋势确认,持有)
if has_real_dragon:
return ('buy', '持有', '真龙信号 → 趋势确立,继续持有', 70)
return ('watch', '观望', '持仓中,等待主升浪信号', 50)
# ════════════════════════════════════════════
# 非持仓逻辑(suanfa.md 步骤1-2
# ════════════════════════════════════════════
# 最佳买入: 底背离+龙抬头(suanfa.md 步骤1→2 的理想组合)
# MACD 死叉时降级,避免与持仓卖出信号矛盾
if has_divergence and has_dragon:
macd_golden = (dif is None or dea is None or dif >= dea)
if macd_golden:
return ('buy', '买入', '日线底背离+龙抬头 → 最佳买入信号', 95)
return ('watch', '关注',
f'底背离+龙抬头但MACD死叉(DIF={dif:.3f}<DEA={dea:.3f}) → 等待MACD金叉确认', 65)
# 核心买入: 龙抬头(suanfa.md: "龙抬头出现 → 执行买入操作")
# 但需要 MACD 配合:如果 MACD 死叉则信号冲突,降级为关注
if has_dragon:
macd_ok = (dif is None or dea is None or dif >= dea) # MACD 金叉或无数据
if has_main_wave and macd_ok:
return ('buy', '买入', '龙抬头+主升浪 → 强势买入信号', 90)
if macd_ok:
return ('buy', '买入', '龙抬头出现 → 短线起爆点,执行买入', 80)
# MACD死叉 + 龙抬头 → 信号冲突,降级为关注
return ('watch', '关注',
f'龙抬头出现但MACD死叉(DIF={dif:.3f}<DEA={dea:.3f}) → 信号冲突,谨慎观望', 55)
# 主升浪(非持仓)→ 不是新买入!suanfa.md: 主升浪=加速段,属于持有/加仓信号
if has_main_wave:
return ('watch', '关注', '主升浪(加速段) → 已过最佳买点,关注回调机会', 75)
# 真龙 → 关注(趋势刚启动,可跟踪)
if has_real_dragon:
return ('watch', '关注', '真龙出现 → 趋势启动,等待龙抬头确认', 65)
# MACD死叉 → 卖出/回避
if dif is not None and dea is not None and dif < dea:
return ('sell', '卖出', f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f})", 75)
# 底背离 → 关注(suanfa.md 步骤1: 纳入关注范围)
if has_divergence:
return ('watch', '关注', '日线底背离出现 → 纳入关注,等待龙抬头', 60)
# 其他信号 → 观察
if triggered_count and triggered_count > 0:
sigs = ''.join(triggered_signals[:3])
return ('watch', '观察', f"触发{triggered_count}个信号: {sigs}", 40)
return ('watch', '观望', '无核心信号触发', 0)
# ═══════════════════════════════════════════════
# 6. 获取最新价格
# ═══════════════════════════════════════════════
def get_latest_price(stock_code):
"""
获取股票最新价格(从本地 stock_realtime_price 表)
返回:
float: 最新价格, 失败返回 0
"""
try:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
cur = conn.cursor()
cur.execute("""
SELECT price FROM stock_realtime_price
WHERE code = %s AND price > 0
""", (stock_code,))
row = cur.fetchone()
conn.close()
if row:
return float(row[0])
except Exception:
pass
return 0
# ═══════════════════════════════════════════════
# 7. 牛股阶段识别(suanfa.md 标准牛股启动信号先后顺序)
# ═══════════════════════════════════════════════
# 标准牛股启动流程(底部→拉升):
# 阶段1 → 日线底背离/短底背离(跌到底部,停止下跌)
# 阶段2 → 龙抬头(资金进场,短线起爆)
# 阶段3 → 真龙(趋势正式确立)
# 阶段4 → ★主升浪(进入加速段,利润兑现最快)
# 阶段5 → 反弹(中途回调后的补涨信号)
# 补充 → 老鼠仓可在底部任意位置提前出现
BULL_STAGES = {
1: {'name': '底部探测', 'icon': '', 'color': '#2196F3',
'desc': '日线底背离/短底背离 → 跌到底部,停止下跌'},
2: {'name': '资金进场', 'icon': '', 'color': '#4CAF50',
'desc': '龙抬头 → 资金进场,短线起爆点(最佳买入时机)'},
3: {'name': '趋势确立', 'icon': '', 'color': '#FF9800',
'desc': '真龙 → 中期趋势正式确立'},
4: {'name': '加速拉升', 'icon': '', 'color': '#F44336',
'desc': '★主升浪 → 进入加速段,利润兑现最快'},
5: {'name': '回调补涨', 'icon': '', 'color': '#9C27B0',
'desc': '反弹 → 中途回调后的补涨信号'},
}
def compute_bull_stage(signal_status):
"""
识别股票在标准牛股启动流程中的阶段。
参数:
signal_status: list[dict] 信号状态列表
返回:
dict: {
'stage': int (0-5, 0=未进入流程),
'stage_name': str,
'stage_icon': str,
'stage_color': str,
'stage_desc': str,
'signals_active': list[str], # 当前活跃的信号名称
'progress': int (0-100), # 牛股流程进度百分比
'next_signal': str, # 下一个期待的信号
'investment_advice': str, # 投资建议
'has_rat_trading': bool, # 是否有老鼠仓(提前埋伏信号)
}
"""
if not signal_status:
return {
'stage': 0, 'stage_name': '观望', 'stage_icon': '',
'stage_color': '#9E9E9E', 'stage_desc': '无信号触发',
'signals_active': [], 'progress': 0,
'next_signal': '等待底背离/短底背离', 'investment_advice': '暂无操作机会',
'has_rat_trading': False,
}
sig_map = {}
for s in signal_status:
sig_map[s.get('type', '')] = s
has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False)
has_short_div = sig_map.get('short_bottom_divergence', {}).get('triggered', False)
has_dragon = sig_map.get('dragon_head', {}).get('triggered', False)
has_true_dragon = sig_map.get('true_dragon', {}).get('triggered', False)
has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False)
has_rebound = sig_map.get('rebound', {}).get('triggered', False)
has_rat = sig_map.get('rat_trading', {}).get('triggered', False)
signals_active = []
if has_divergence:
signals_active.append('日线底背离')
if has_short_div:
signals_active.append('短底背离')
if has_dragon:
signals_active.append('龙抬头')
if has_true_dragon:
signals_active.append('真龙')
if has_main_wave:
signals_active.append('主升浪')
if has_rebound:
signals_active.append('反弹')
if has_rat:
signals_active.append('老鼠仓')
# 确定阶段(按最高阶段判定)
stage = 0
if has_main_wave:
stage = 4
elif has_true_dragon:
stage = 3
elif has_dragon:
stage = 2
elif has_divergence or has_short_div:
stage = 1
elif has_rebound:
stage = 5
elif has_rat:
stage = 1 # 老鼠仓归入底部阶段
if stage == 0:
return {
'stage': 0, 'stage_name': '观望', 'stage_icon': '',
'stage_color': '#9E9E9E', 'stage_desc': '无核心信号触发',
'signals_active': signals_active, 'progress': 0,
'next_signal': '等待底背离/短底背离',
'investment_advice': '暂无操作机会',
'has_rat_trading': has_rat,
}
info = BULL_STAGES[stage]
# 计算流程进度(越靠后越高)
# 加分项:多信号叠加说明流程更完整
base_progress = {1: 20, 2: 45, 3: 65, 4: 85, 5: 50}
progress = base_progress.get(stage, 0)
if stage <= 2 and has_divergence:
progress += 10 # 有底背离做基础更好
if stage >= 2 and has_dragon:
progress += 5
if stage >= 3 and has_true_dragon:
progress += 5
if has_rat:
progress += 5 # 老鼠仓加分
progress = min(progress, 100)
# 下一步信号期待
next_signals = {
1: '等待龙抬头(资金进场信号)',
2: '等待真龙(趋势确认信号)',
3: '等待主升浪(加速拉升信号)',
4: '持有!不见主升浪消失不出场',
5: '等待龙抬头/真龙确认趋势',
}
# 投资建议
advices = {
1: '纳入关注池,等待龙抬头出现后买入',
2: '最佳买入时机!龙抬头=实操核心买点',
3: '趋势已确立,可以追入,建议等回调买入',
4: '已在加速段,持仓者加仓/持有,新入者谨慎追高',
5: '回调中可关注,但需确认不是假反弹',
}
return {
'stage': stage,
'stage_name': info['name'],
'stage_icon': info['icon'],
'stage_color': info['color'],
'stage_desc': info['desc'],
'signals_active': signals_active,
'progress': progress,
'next_signal': next_signals.get(stage, ''),
'investment_advice': advices.get(stage, ''),
'has_rat_trading': has_rat,
}
def find_bull_stocks(scan_rows, holding_codes=None):
"""
从扫描结果中找出潜在牛股,按阶段分组排序。
参数:
scan_rows: list[dict] 扫描结果列表 (含 code, name, signal_status, indicators, triggered_count)
holding_codes: set 持仓代码集合
返回:
dict: {
'stages': {1: [...], 2: [...], ...}, # 按阶段分组的股票列表
'summary': {1: count, 2: count, ...}, # 各阶段数量统计
'total': int, # 有信号的总数
}
"""
if holding_codes is None:
holding_codes = set()
stages = {1: [], 2: [], 3: [], 4: [], 5: []}
summary = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
for row in scan_rows:
signal_status = row.get('signal_status') or []
if not signal_status:
summary[0] += 1
continue
# 判断牛股阶段
bull = compute_bull_stage(signal_status)
stage = bull['stage']
summary[stage] += 1
if stage == 0:
continue
# 计算推荐
is_holding = row.get('code', '') in holding_codes
st, disp, reason, rate = compute_recommend(
signal_status, row.get('indicators'),
row.get('triggered_count'), is_holding,
)
item = {
'code': row.get('code', ''),
'name': row.get('name', ''),
'stage': stage,
'stage_name': bull['stage_name'],
'stage_icon': bull['stage_icon'],
'stage_color': bull['stage_color'],
'signals_active': bull['signals_active'],
'progress': bull['progress'],
'next_signal': bull['next_signal'],
'investment_advice': bull['investment_advice'],
'has_rat_trading': bull['has_rat_trading'],
'recommend_type': st,
'recommend_text': disp,
'recommend_reason': reason,
'recommend_rate': rate,
'is_holding': is_holding,
'triggered_count': row.get('triggered_count', 0),
}
stages[stage].append(item)
# 每个阶段内按推荐评分降序排序
for stage_num in stages:
stages[stage_num].sort(key=lambda x: (-x['recommend_rate'], -x['progress']))
total = sum(len(v) for v in stages.values())
return {
'stages': stages,
'summary': summary,
'total': total,
'stage_info': BULL_STAGES,
}