"""涨跌停分析 — 连板股追踪、炸板率统计、敢死队排行、炸板走势统计、涨停原因分类。""" import datetime as dt from typing import List, Dict, Any, Optional from collections import defaultdict, Counter import numpy as np from sqlalchemy import select, and_, func, desc from db import get_session from models import DailyQuote, StockMetric, DragonTiger try: import akshare as ak AK_OK = True except Exception: ak = None AK_OK = False # 涨停原因关键词分类 LIMIT_REASON_MAP = { "题材": ["概念", "题材", "热点", "风口", "赛道"], "业绩": ["业绩", "净利润", "营收", "盈利", "超预期", "预增", "扭亏"], "政策": ["政策", "补贴", "利好", "支持", "规划", "国家", "工信部", "发改委"], "技术突破": ["突破", "新高", "均线", "金叉", "放量"], "重组并购": ["重组", "并购", "收购", "合并", "入股"], "情绪": ["跟风", "连板", "情绪", "氛围", "涨停潮"], } def get_limit_stocks(date: Optional[dt.date] = None, limit_type: str = "up") -> Dict[str, Any]: """获取涨停/跌停股票 Args: date: 日期(None表示最新) limit_type: up涨停/down跌停 Returns: 涨跌停股票列表 """ with get_session() as s: if date is None: date = s.execute(select(func.max(DailyQuote.date))).scalar() if not date: return {"ok": False, "msg": "暂无数据"} # 查询当日股票 quotes = s.execute( select(DailyQuote) .where(DailyQuote.date == date) ).scalars().all() if not quotes: return {"ok": False, "msg": "暂无数据"} # 筛选涨停/跌停股(涨跌幅接近±10%) threshold = 9.8 # 考虑精度问题,用9.8%作为阈值 results = [] for q in quotes: if q.open == 0: continue pct = (float(q.close) - float(q.open)) / float(q.open) * 100 if limit_type == "up" and pct >= threshold: results.append({ "code": q.code, "name": q.name, "close": float(q.close), "pct": round(pct, 2), "volume": float(q.volume), "amount": float(q.amount) }) elif limit_type == "down" and pct <= -threshold: results.append({ "code": q.code, "name": q.name, "close": float(q.close), "pct": round(pct, 2), "volume": float(q.volume), "amount": float(q.amount) }) return { "ok": True, "date": date.isoformat(), "type": limit_type, "count": len(results), "stocks": sorted(results, key=lambda x: x["pct"], reverse=(limit_type == "up")) } def track_consecutive_limits(days: int = 10) -> Dict[str, Any]: """连板股追踪器 Args: days: 追踪天数 Returns: 连板股列表 """ with get_session() as s: latest_date = s.execute(select(func.max(DailyQuote.date))).scalar() if not latest_date: return {"ok": False, "msg": "暂无数据"} start_date = latest_date - dt.timedelta(days=days) # 获取期间所有股票的日线数据 quotes = s.execute( select(DailyQuote) .where(DailyQuote.date >= start_date) .order_by(DailyQuote.code, DailyQuote.date) ).scalars().all() # 按股票分组 stock_data = defaultdict(list) for q in quotes: stock_data[q.code].append(q) # 统计连板 consecutive_limits = [] for code, data in stock_data.items(): if not data: continue # 倒序遍历,统计从最新日期开始的连续涨停天数 data_sorted = sorted(data, key=lambda x: x.date, reverse=True) consecutive_days = 0 for q in data_sorted: if q.open == 0: break pct = (float(q.close) - float(q.open)) / float(q.open) * 100 if pct >= 9.8: # 涨停 consecutive_days += 1 else: break if consecutive_days >= 2: # 至少2连板 latest = data_sorted[0] consecutive_limits.append({ "code": code, "name": latest.name, "consecutive_days": consecutive_days, "close": float(latest.close), "amount": float(latest.amount), "status": f"{consecutive_days}连板" }) # 按连板天数排序 consecutive_limits.sort(key=lambda x: x["consecutive_days"], reverse=True) return { "ok": True, "date": latest_date.isoformat(), "days": days, "count": len(consecutive_limits), "stocks": consecutive_limits } def analyze_limit_break_rate(days: int = 60) -> Dict[str, Any]: """炸板率统计 分析涨停后次日的表现(继续涨停/上涨/下跌/跌停) Args: days: 统计天数 Returns: 炸板率统计 """ with get_session() as s: latest_date = s.execute(select(func.max(DailyQuote.date))).scalar() if not latest_date: return {"ok": False, "msg": "暂无数据"} start_date = latest_date - dt.timedelta(days=days) # 获取期间所有股票的日线数据 quotes = s.execute( select(DailyQuote) .where(DailyQuote.date >= start_date) .order_by(DailyQuote.code, DailyQuote.date) ).scalars().all() # 按股票和日期组织数据 stock_data = defaultdict(dict) for q in quotes: stock_data[q.code][q.date] = q # 统计涨停后次日表现 next_day_stats = { "limit_up": 0, # 继续涨停 "up": 0, # 上涨但未涨停 "down": 0, # 下跌但未跌停 "limit_down": 0, # 跌停 } stock_break_rates = {} # 个股炸板率 for code, data in stock_data.items(): dates = sorted(data.keys()) stock_limits = 0 stock_breaks = 0 for i in range(len(dates) - 1): today = dates[i] tomorrow = dates[i + 1] today_q = data[today] tomorrow_q = data[tomorrow] # 判断今日是否涨停 if today_q.open == 0: continue today_pct = (float(today_q.close) - float(today_q.open)) / float(today_q.open) * 100 if today_pct >= 9.8: # 今日涨停 stock_limits += 1 # 判断次日表现 if tomorrow_q.open == 0: continue tomorrow_pct = (float(tomorrow_q.close) - float(tomorrow_q.open)) / float(tomorrow_q.open) * 100 if tomorrow_pct >= 9.8: next_day_stats["limit_up"] += 1 elif tomorrow_pct > 0: next_day_stats["up"] += 1 stock_breaks += 1 # 炸板 elif tomorrow_pct > -9.8: next_day_stats["down"] += 1 stock_breaks += 1 # 炸板 else: next_day_stats["limit_down"] += 1 stock_breaks += 1 # 炸板 # 计算个股炸板率 if stock_limits > 0: break_rate = stock_breaks / stock_limits * 100 if stock_limits >= 3: # 至少3次涨停才有统计意义 stock_break_rates[code] = { "name": list(data.values())[0].name, "limits": stock_limits, "breaks": stock_breaks, "break_rate": round(break_rate, 2) } total = sum(next_day_stats.values()) if total == 0: return {"ok": False, "msg": "统计样本不足"} # 计算比例 stats_with_pct = { "limit_up": { "count": next_day_stats["limit_up"], "pct": round(next_day_stats["limit_up"] / total * 100, 2) }, "up": { "count": next_day_stats["up"], "pct": round(next_day_stats["up"] / total * 100, 2) }, "down": { "count": next_day_stats["down"], "pct": round(next_day_stats["down"] / total * 100, 2) }, "limit_down": { "count": next_day_stats["limit_down"], "pct": round(next_day_stats["limit_down"] / total * 100, 2) } } # 炸板率 = (上涨未涨停 + 下跌 + 跌停) / 总数 break_rate = (next_day_stats["up"] + next_day_stats["down"] + next_day_stats["limit_down"]) / total * 100 # 个股炸板率排行(从高到低) stock_rankings = sorted( [(code, data) for code, data in stock_break_rates.items()], key=lambda x: x[1]["break_rate"], reverse=True )[:30] return { "ok": True, "days": days, "total_samples": total, "overall_break_rate": round(break_rate, 2), "next_day_stats": stats_with_pct, "stock_rankings": [{ "code": code, "name": data["name"], "limits": data["limits"], "breaks": data["breaks"], "break_rate": data["break_rate"] } for code, data in stock_rankings] } def get_consecutive_calendar(days: int = 60) -> Dict[str, Any]: """连板日历:记录每只股票的连板历史,分析几进几出规律""" with get_session() as s: latest_date = s.execute(select(func.max(DailyQuote.date))).scalar() if not latest_date: return {"ok": False, "msg": "暂无数据"} start_date = latest_date - dt.timedelta(days=days) quotes = s.execute( select(DailyQuote) .where(DailyQuote.date >= start_date) .order_by(DailyQuote.code, DailyQuote.date) ).scalars().all() stock_data = defaultdict(list) for q in quotes: stock_data[q.code].append(q) all_streaks = [] current_streaks = [] for code, data in stock_data.items(): data_sorted = sorted(data, key=lambda x: x.date) name = data_sorted[-1].name streaks = [] current = [] for q in data_sorted: if q.open == 0: if len(current) >= 2: streaks.append(current) current = [] continue pct = (float(q.close) - float(q.open)) / float(q.open) * 100 if pct >= 9.8: current.append(q) else: if len(current) >= 2: streaks.append(current) current = [] if len(current) >= 2: current_streaks.append({ "code": code, "name": name, "days": len(current), "start_date": current[0].date.isoformat(), "latest_date": current[-1].date.isoformat(), "latest_close": float(current[-1].close) }) for streak in streaks: all_streaks.append({ "code": code, "name": name, "days": len(streak), "start_date": streak[0].date.isoformat(), "end_date": streak[-1].date.isoformat() }) distribution = defaultdict(int) for item in all_streaks: distribution[f"{item['days']}板"] += 1 current_streaks.sort(key=lambda x: x["days"], reverse=True) return { "ok": True, "date_range": f"{start_date.isoformat()} ~ {latest_date.isoformat()}", "current_streaks": current_streaks[:30], "streak_distribution": dict(distribution), "total_streaks": len(all_streaks) } def analyze_post_break_performance(days: int = 90) -> Dict[str, Any]: """炸板后走势统计:炸板后 1/3/5 日表现概率分布""" with get_session() as s: latest_date = s.execute(select(func.max(DailyQuote.date))).scalar() if not latest_date: return {"ok": False, "msg": "暂无数据"} start_date = latest_date - dt.timedelta(days=days + 10) quotes = s.execute( select(DailyQuote) .where(DailyQuote.date >= start_date) .order_by(DailyQuote.code, DailyQuote.date) ).scalars().all() stock_data = defaultdict(dict) for q in quotes: stock_data[q.code][q.date] = q # 炸板 = 当日一度涨停但收盘时未涨停(用开高低收近似判断) # 简化:前一日涨停,次日收盘未涨停视为炸板 perf_1d, perf_3d, perf_5d = [], [], [] for code, date_data in stock_data.items(): dates = sorted(date_data.keys()) for i in range(len(dates) - 5): today = dates[i] q_today = date_data[today] if q_today.open == 0: continue pct_today = (float(q_today.close) - float(q_today.open)) / float(q_today.open) * 100 # 判断昨日涨停今日炸板(开高但收盘低) if i == 0: continue yesterday = dates[i - 1] q_yest = date_data[yesterday] if q_yest.open == 0: continue pct_yest = (float(q_yest.close) - float(q_yest.open)) / float(q_yest.open) * 100 # 昨日涨停,今日未涨停(炸板) if pct_yest >= 9.8 and pct_today < 9.8: base = float(q_today.close) # 后续 1/3/5 日表现 for horizon, perf_list in [(1, perf_1d), (3, perf_3d), (5, perf_5d)]: if i + horizon < len(dates): future = dates[i + horizon] q_future = date_data[future] ret = (float(q_future.close) - base) / base * 100 perf_list.append(round(ret, 2)) def summarize(perfs): if not perfs: return {} arr = np.array(perfs) return { "samples": len(perfs), "avg_ret": round(float(arr.mean()), 2), "win_rate": round(float((arr > 0).mean() * 100), 1), "p25": round(float(np.percentile(arr, 25)), 2), "median": round(float(np.median(arr)), 2), "p75": round(float(np.percentile(arr, 75)), 2), } return { "ok": True, "days": days, "after_1d": summarize(perf_1d), "after_3d": summarize(perf_3d), "after_5d": summarize(perf_5d), "conclusion": ( f"炸板后样本 {len(perf_1d)} 条," f"次日平均收益 {summarize(perf_1d).get('avg_ret', 0)}%," f"次日上涨概率 {summarize(perf_1d).get('win_rate', 0)}%" ) if perf_1d else "样本不足" } def classify_limit_reasons(date: Optional[dt.date] = None) -> Dict[str, Any]: """涨停原因分类:情绪、题材、业绩、技术突破等""" with get_session() as s: if date is None: date = s.execute(select(func.max(DragonTiger.date))).scalar() if not date: return {"ok": False, "msg": "暂无龙虎榜数据,请先入库"} lhb_rows = s.execute( select(DragonTiger).where(DragonTiger.date == date) ).scalars().all() # 尝试从 AkShare 获取当日涨停原因 reason_data = {} if AK_OK: try: df = ak.stock_zt_pool_em(date=date.strftime("%Y%m%d")) if df is not None and not df.empty: for _, r in df.iterrows(): code = str(r.get("代码", "")) reason = str(r.get("涨停原因类别", "") or r.get("上榜原因", "")) reason_data[code] = reason except Exception: pass # 合并龙虎榜原因 for row in lhb_rows: if row.code not in reason_data: reason_data[row.code] = row.reason # 分类 classified = defaultdict(list) for code, reason in reason_data.items(): matched = False for category, keywords in LIMIT_REASON_MAP.items(): if any(kw in reason for kw in keywords): classified[category].append({"code": code, "reason": reason}) matched = True break if not matched: classified["其他"].append({"code": code, "reason": reason}) total = sum(len(v) for v in classified.values()) summary = [ { "category": cat, "count": len(items), "pct": round(len(items) / total * 100, 1) if total > 0 else 0, "stocks": items[:10] } for cat, items in sorted(classified.items(), key=lambda x: len(x[1]), reverse=True) ] return { "ok": True, "date": date.isoformat(), "total": total, "categories": summary } def get_limit_squad_rankings(days: int = 30, min_limits: int = 5) -> Dict[str, Any]: """涨停敢死队排行 统计期间内涨停次数最多的股票(俗称"妖股") Args: days: 统计天数 min_limits: 最少涨停次数 Returns: 敢死队排行 """ with get_session() as s: latest_date = s.execute(select(func.max(DailyQuote.date))).scalar() if not latest_date: return {"ok": False, "msg": "暂无数据"} start_date = latest_date - dt.timedelta(days=days) # 获取期间所有股票的日线数据 quotes = s.execute( select(DailyQuote) .where(DailyQuote.date >= start_date) .order_by(DailyQuote.code, DailyQuote.date) ).scalars().all() # 统计每只股票的涨停次数 limit_counts = defaultdict(lambda: { "name": "", "count": 0, "dates": [], "total_days": 0, "max_consecutive": 0 }) # 按股票分组 stock_data = defaultdict(list) for q in quotes: stock_data[q.code].append(q) for code, data in stock_data.items(): if not data: continue data_sorted = sorted(data, key=lambda x: x.date) limit_days = [] for q in data_sorted: if q.open == 0: continue pct = (float(q.close) - float(q.open)) / float(q.open) * 100 if pct >= 9.8: # 涨停 limit_days.append(q.date) if len(limit_days) >= min_limits: # 计算最大连板数 max_consecutive = 1 current_consecutive = 1 for i in range(1, len(limit_days)): if (limit_days[i] - limit_days[i-1]).days == 1: current_consecutive += 1 max_consecutive = max(max_consecutive, current_consecutive) else: current_consecutive = 1 limit_counts[code] = { "name": data_sorted[0].name, "count": len(limit_days), "dates": [d.isoformat() for d in limit_days], "total_days": len(data_sorted), "max_consecutive": max_consecutive, "frequency": round(len(limit_days) / len(data_sorted) * 100, 2) } # 排序 rankings = sorted( [(code, data) for code, data in limit_counts.items()], key=lambda x: (x[1]["count"], x[1]["max_consecutive"]), reverse=True )[:50] return { "ok": True, "days": days, "start_date": start_date.isoformat(), "end_date": latest_date.isoformat(), "count": len(rankings), "rankings": [{ "code": code, "name": data["name"], "limit_count": data["count"], "max_consecutive": data["max_consecutive"], "frequency": data["frequency"], "dates": data["dates"] } for code, data in rankings] }