astock-agent/backend/app/analysis/intraday.py
2026-04-10 23:38:37 +08:00

330 lines
11 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.

"""盘中实时扫描
盘中 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_basicPE/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]:
"""盘中板块热度更新:用腾讯实时行情刷新板块涨幅和涨停数
基于前一日的板块列表(来自 Tushare用成分股的实时行情
重新计算板块涨跌幅和涨停家数。
"""
if not prev_sectors:
return prev_sectors
# 收集所有板块的成分股
sector_members: dict[str, list[str]] = {}
all_codes = set()
for sector in prev_sectors:
members = tushare_client.get_ths_members(sector.sector_code)
if members.empty or "con_code" not in members.columns:
continue
codes = [c for c in members["con_code"].tolist() if "." in str(c)]
sector_members[sector.sector_code] = codes
all_codes.update(codes)
if not all_codes:
return prev_sectors
# 批量获取实时行情
quotes = await tencent_client.get_realtime_quotes_batch(list(all_codes))
if not quotes:
return prev_sectors
# 构建涨停集合
limit_up_codes = set()
for code, q in quotes.items():
if q.limit_up and q.price >= q.limit_up * 0.995:
limit_up_codes.add(code)
# 更新每个板块的数据
for sector in prev_sectors:
codes = sector_members.get(sector.sector_code, [])
if not codes:
continue
sector_quotes = [quotes[c] for c in codes if c in quotes]
if not sector_quotes:
continue
# 实时涨跌幅(成分股均值)
pct_changes = [q.pct_chg for q in sector_quotes if q.pct_chg is not None]
if pct_changes:
sector.pct_change = round(sum(pct_changes) / len(pct_changes), 2)
# 实时涨停家数
sector.limit_up_count = len([c for c in codes if c in limit_up_codes])
logger.info(
f"盘中板块实时更新: {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