320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""盘中实时扫描
|
||
|
||
盘中 Tushare 的 daily / moneyflow / limit_list_d 等接口尚无当日数据,
|
||
因此盘中扫描采用混合策略:
|
||
- 板块热度:使用前一日 Tushare 数据作为基线(哪些板块是近期热点)
|
||
- 个股筛选:用腾讯实时行情替代 Tushare 的当日 daily_basic / moneyflow
|
||
- 技术面:历史 K 线(Tushare) + 当日实时价格(腾讯)拼接后计算
|
||
|
||
盘中重点捕捉:
|
||
1. 热门板块成分股中,盘中放量拉升的个股
|
||
2. 量比 > 1.5、换手率适中、涨幅在 2%-7% 的活跃股
|
||
3. 用实时价格更新支撑/压力位和技术信号
|
||
"""
|
||
|
||
import logging
|
||
import pandas as pd
|
||
from datetime import datetime
|
||
|
||
from app.data.tushare_client import tushare_client
|
||
from app.data import tencent_client
|
||
from app.data.models import SectorInfo, Recommendation, MarketTemperature, StockQuote
|
||
from app.analysis.sector_scanner import scan_hot_sectors
|
||
from app.analysis.technical import add_all_indicators
|
||
from app.analysis.signals import generate_signals
|
||
from app.analysis.capital_flow import _score_valuation
|
||
from app.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTemperature:
|
||
"""盘中市场温度:用腾讯实时行情计算真实涨跌数据"""
|
||
index_data = await tencent_client.get_index_realtime()
|
||
sh_index = index_data.get("000001.SH")
|
||
|
||
if not sh_index:
|
||
return prev_temp
|
||
|
||
# ── 用腾讯实时行情计算真实涨跌数量 ──
|
||
up_count = prev_temp.up_count
|
||
down_count = prev_temp.down_count
|
||
limit_up_count = prev_temp.limit_up_count
|
||
limit_down_count = prev_temp.limit_down_count
|
||
|
||
try:
|
||
stock_basic = tushare_client.get_stock_basic()
|
||
if not stock_basic.empty:
|
||
# 过滤 ST 股票
|
||
all_codes = stock_basic[~stock_basic["name"].str.contains("ST", na=False)]["ts_code"].tolist()
|
||
|
||
if all_codes:
|
||
quotes = await tencent_client.get_realtime_quotes_batch(all_codes)
|
||
up_count = sum(1 for q in quotes.values() if q.pct_chg > 0)
|
||
down_count = sum(1 for q in quotes.values() if q.pct_chg < 0)
|
||
limit_up_count = sum(
|
||
1 for q in quotes.values()
|
||
if q.limit_up and q.price >= q.limit_up * 0.995
|
||
)
|
||
limit_down_count = sum(
|
||
1 for q in quotes.values()
|
||
if q.limit_down and q.price <= q.limit_down * 1.005
|
||
)
|
||
logger.info(
|
||
f"盘中实时涨跌统计: 上涨={up_count} 下跌={down_count} "
|
||
f"涨停={limit_up_count} 跌停={limit_down_count} (共{len(quotes)}只)"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"获取盘中实时涨跌统计失败,使用上一日数据: {e}")
|
||
|
||
# ── 温度分数:基于实时涨跌比重新计算 ──
|
||
ratio = up_count / max(down_count, 1)
|
||
temp_from_ratio = min(ratio / 3.0 * 25, 25) # 涨跌比维度 (0-25)
|
||
temp_from_limit_up = min(limit_up_count / 2, 25) # 涨停数维度 (0-25)
|
||
|
||
# 指数方向调整 (0-25)
|
||
pct = sh_index.get("pct_chg", 0)
|
||
temp_from_index = min(max(pct * 8 + 12.5, 0), 25)
|
||
|
||
new_temp = round(temp_from_ratio + temp_from_limit_up + temp_from_index + 25, 1)
|
||
new_temp = min(max(new_temp, 0), 100)
|
||
|
||
return MarketTemperature(
|
||
trade_date=datetime.now().strftime("%Y%m%d"),
|
||
up_count=up_count,
|
||
down_count=down_count,
|
||
limit_up_count=limit_up_count,
|
||
limit_down_count=limit_down_count,
|
||
max_streak=prev_temp.max_streak,
|
||
broken_rate=prev_temp.broken_rate,
|
||
index_above_ma20=pct > 0 if sh_index else prev_temp.index_above_ma20,
|
||
temperature=new_temp,
|
||
)
|
||
|
||
|
||
async def intraday_filter_stocks(
|
||
hot_sectors: list[SectorInfo],
|
||
) -> list[dict]:
|
||
"""盘中个股筛选:从热门板块成分股中用腾讯实时行情筛选
|
||
|
||
替代 capital_flow.py 中依赖 Tushare 日级数据的逻辑。
|
||
盘中无法获取资金流向,改用 量比 + 换手率 + 涨幅 + 市值 筛选。
|
||
"""
|
||
# 收集热门板块成分股
|
||
stock_sectors: dict[str, list[str]] = {}
|
||
for sector in hot_sectors:
|
||
members = tushare_client.get_ths_members(sector.sector_code)
|
||
if members.empty or "con_code" not in members.columns:
|
||
continue
|
||
for _, m in members.iterrows():
|
||
code = m.get("con_code", "")
|
||
if not code or "." not in str(code):
|
||
continue
|
||
if code not in stock_sectors:
|
||
stock_sectors[code] = []
|
||
stock_sectors[code].append(sector.sector_name)
|
||
|
||
if not stock_sectors:
|
||
logger.warning("盘中筛选: 热门板块成分股为空")
|
||
return []
|
||
|
||
all_codes = list(stock_sectors.keys())
|
||
logger.info(f"盘中筛选: {len(all_codes)} 只成分股,开始获取实时行情")
|
||
|
||
# 过滤 ST 和次新
|
||
stock_basic = tushare_client.get_stock_basic()
|
||
st_codes = set()
|
||
new_codes = set()
|
||
if not stock_basic.empty:
|
||
st_codes = set(stock_basic[stock_basic["name"].str.contains("ST", na=False)]["ts_code"])
|
||
from datetime import timedelta
|
||
cutoff = (datetime.now() - timedelta(days=settings.min_list_days)).strftime("%Y%m%d")
|
||
new_codes = set(stock_basic[stock_basic["list_date"] > cutoff]["ts_code"])
|
||
|
||
eligible = [c for c in all_codes if c not in st_codes and c not in new_codes]
|
||
|
||
# 获取前一日 daily_basic(PE/PB 估值数据)
|
||
prev_date = tushare_client.get_latest_trade_date()
|
||
basic_df = tushare_client.get_daily_basic(prev_date)
|
||
|
||
# 批量获取腾讯实时行情
|
||
quotes = await tencent_client.get_realtime_quotes_batch(eligible)
|
||
|
||
results = []
|
||
for ts_code, quote in quotes.items():
|
||
# 硬性条件
|
||
if quote.pct_chg <= 0:
|
||
continue # 盘中只关注上涨股
|
||
if quote.pct_chg > 9.8:
|
||
continue # 已涨停,无买入空间
|
||
if quote.turnover_rate < settings.min_turnover_rate:
|
||
continue
|
||
if quote.turnover_rate > settings.max_turnover_rate:
|
||
continue
|
||
if quote.circ_mv is not None:
|
||
if quote.circ_mv < settings.min_circ_mv or quote.circ_mv > settings.max_circ_mv:
|
||
continue
|
||
|
||
# 评分
|
||
score = _score_intraday(quote)
|
||
|
||
# 估值评分(用前一日 PE/PB)
|
||
pe = None
|
||
pb = None
|
||
if not basic_df.empty:
|
||
b_row = basic_df[basic_df["ts_code"] == ts_code]
|
||
if not b_row.empty:
|
||
b = b_row.iloc[0]
|
||
pe = float(b["pe_ttm"]) if pd.notna(b.get("pe_ttm")) else None
|
||
pb = float(b["pb"]) if pd.notna(b.get("pb")) else None
|
||
valuation_score = _score_valuation(pe, pb)
|
||
|
||
# 名称
|
||
name = quote.name or ts_code
|
||
|
||
results.append({
|
||
"ts_code": ts_code,
|
||
"name": name,
|
||
"sector": stock_sectors[ts_code][0],
|
||
"sectors": stock_sectors[ts_code],
|
||
"price": quote.price,
|
||
"pct_chg": quote.pct_chg,
|
||
"turnover_rate": quote.turnover_rate,
|
||
"volume_ratio": quote.volume_ratio,
|
||
"circ_mv": quote.circ_mv,
|
||
"capital_score": round(score, 1),
|
||
"valuation_score": round(valuation_score, 1),
|
||
# 盘中无资金流向数据
|
||
"main_net_inflow": 0,
|
||
"inflow_ratio": 0,
|
||
})
|
||
|
||
results.sort(key=lambda x: x["capital_score"], reverse=True)
|
||
top = results[:settings.top_stock_count]
|
||
|
||
for r in top:
|
||
logger.info(
|
||
f"盘中筛选: {r['name']}({r['ts_code']}) 板块={r['sector']} "
|
||
f"涨幅={r['pct_chg']}% 量比={r['volume_ratio']} "
|
||
f"换手率={r['turnover_rate']}% 评分={r['capital_score']}"
|
||
)
|
||
|
||
return top
|
||
|
||
|
||
def _score_intraday(quote: StockQuote) -> float:
|
||
"""盘中评分逻辑(替代资金流向评分)
|
||
|
||
盘中无法获取大单/特大单数据,改用以下指标:
|
||
- 涨幅区间 (25%): 2%-7% 为活跃区间
|
||
- 量比 (30%): > 1.5 说明资金活跃
|
||
- 换手率 (20%): 5%-10% 最佳
|
||
- 振幅 (10%): 适度振幅说明活跃
|
||
- 市值适配 (15%): 100-300亿最佳
|
||
"""
|
||
score = 0.0
|
||
|
||
# 涨幅区间 (25分)
|
||
pct = quote.pct_chg
|
||
if 3 <= pct <= 7:
|
||
score += 25
|
||
elif 2 <= pct < 3:
|
||
score += 18
|
||
elif 7 < pct <= 9:
|
||
score += 15
|
||
elif 0 < pct < 2:
|
||
score += 8
|
||
|
||
# 量比 (30分)
|
||
vr = quote.volume_ratio
|
||
if vr is not None:
|
||
if vr > 3.0:
|
||
score += 30
|
||
elif vr > 2.0:
|
||
score += 24
|
||
elif vr > 1.5:
|
||
score += 18
|
||
elif vr > 1.0:
|
||
score += 10
|
||
|
||
# 换手率 (20分)
|
||
tr = quote.turnover_rate
|
||
if 5 <= tr <= 10:
|
||
score += 20
|
||
elif 3 <= tr < 5:
|
||
score += 14
|
||
elif 10 < tr <= 15:
|
||
score += 10
|
||
|
||
# 振幅 (10分) - 适度振幅
|
||
amp = quote.amplitude or 0
|
||
if 3 <= amp <= 8:
|
||
score += 10
|
||
elif 2 <= amp < 3:
|
||
score += 6
|
||
elif amp > 8:
|
||
score += 3
|
||
|
||
# 市值适配 (15分)
|
||
if quote.circ_mv is not None:
|
||
mv = quote.circ_mv
|
||
if 100 <= mv <= 300:
|
||
score += 15
|
||
elif settings.min_circ_mv <= mv <= settings.max_circ_mv:
|
||
score += 10
|
||
elif mv > 0:
|
||
score += 3
|
||
|
||
return score
|
||
|
||
|
||
async def intraday_sector_scan(prev_sectors: list[SectorInfo]) -> list[SectorInfo]:
|
||
"""盘中板块热度更新:用东方财富实时板块数据刷新涨幅和涨停数
|
||
|
||
一次请求替代之前腾讯批量获取数千只成分股的方式。
|
||
"""
|
||
if not prev_sectors:
|
||
return prev_sectors
|
||
|
||
# 从东方财富获取实时板块排名(1次 HTTP 请求)
|
||
try:
|
||
from app.data.eastmoney_client import get_sector_realtime_ranking
|
||
em_sectors = await get_sector_realtime_ranking()
|
||
except Exception as e:
|
||
logger.warning(f"东方财富板块实时数据获取失败: {e}")
|
||
return prev_sectors
|
||
|
||
if not em_sectors:
|
||
return prev_sectors
|
||
|
||
# 按板块名称匹配更新数据
|
||
matched = 0
|
||
for sector in prev_sectors:
|
||
em_data = None
|
||
# 先精确匹配
|
||
for em_s in em_sectors:
|
||
if em_s["sector_name"] == sector.sector_name:
|
||
em_data = em_s
|
||
break
|
||
# 模糊匹配
|
||
if not em_data:
|
||
for em_s in em_sectors:
|
||
em_name = em_s["sector_name"].rstrip("行业").rstrip("板块").strip()
|
||
ts_name = sector.sector_name.rstrip("行业").rstrip("板块").strip()
|
||
if em_name == ts_name or (len(em_name) <= len(ts_name) and em_name in ts_name) or (len(ts_name) <= len(em_name) and ts_name in em_name):
|
||
em_data = em_s
|
||
break
|
||
|
||
if em_data:
|
||
matched += 1
|
||
sector.pct_change = em_data["pct_change"]
|
||
# 涨停数保留 Tushare 数据(东方财富此字段不可用)
|
||
|
||
logger.info(
|
||
f"盘中板块实时更新: {matched}/{len(prev_sectors)} 匹配成功, "
|
||
f"涨幅最高={max(prev_sectors, key=lambda s: s.pct_change).sector_name} "
|
||
f"({max(s.pct_change for s in prev_sectors):.1f}%)"
|
||
)
|
||
|
||
return prev_sectors
|