908 lines
34 KiB
Python
908 lines
34 KiB
Python
"""
|
||
统一算法模块 — 全部核心算法的唯一定义处(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,
|
||
}
|