This commit is contained in:
aaron 2026-04-14 20:51:38 +08:00
parent 27c6794aeb
commit 0902091f08
14 changed files with 640 additions and 170 deletions

View File

@ -245,9 +245,9 @@ async def _load_today_from_db() -> dict:
from sqlalchemy import text from sqlalchemy import text
import json import json
# 加载市场温度 # 加载市场温度(按 trade_date 取最新交易日)
result = await db.execute( result = await db.execute(
text("SELECT * FROM market_temperature ORDER BY created_at DESC LIMIT 1") text("SELECT * FROM market_temperature ORDER BY trade_date DESC LIMIT 1")
) )
mt_row = result.fetchone() mt_row = result.fetchone()
market_temp = None market_temp = None
@ -264,12 +264,14 @@ async def _load_today_from_db() -> dict:
temperature=m["temperature"], temperature=m["temperature"],
) )
# 加载推荐(按 ts_code 去重,取最新一条 # 加载推荐(取最近一个有数据的日期,按 ts_code 去重)
result = await db.execute( result = await db.execute(
text("SELECT * FROM recommendations WHERE date(created_at) = :today " text("SELECT * FROM recommendations "
"AND id IN (SELECT MAX(id) FROM recommendations WHERE date(created_at) = :today GROUP BY ts_code) " "WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
"ORDER BY score DESC"), "AND id IN (SELECT MAX(id) FROM recommendations "
{"today": today} " WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
" GROUP BY ts_code) "
"ORDER BY score DESC")
) )
rows = result.fetchall() rows = result.fetchall()
recommendations = [] recommendations = []

View File

@ -1,11 +1,16 @@
"""趋势突破统一筛选器 """趋势突破统一筛选器(自上而下方案)
三阶段管道全市场批量预筛 资金流过滤 逐股深度分析 三阶段管道
评分公式趋势&时机30% + 资金流25% + 供需20% + 板块共振15% + 市场温度10% Step 1: 板块定位 找到有资金流入的热门板块 (3-5)
Step 2: 板块内选股 在热门板块成分股中筛出有资金流入的候选 (30-50)
Step 3: 深度分析 供需 + 价格行为 + 趋势 (10-15只推荐)
自动检测是否在交易时段 评分公式供需关系 40% + 价格行为 35% + 趋势 25%
- 盘中模式用前一日 Tushare 数据 + 腾讯实时行情混合筛选 板块和资金流作为前置过滤条件不参与评分
- 盘后模式用当日 Tushare 完整数据筛选
数据源
- 盘中模式Tushare 日线 + 腾讯实时行情混合
- 盘后模式Tushare 当日完整数据
""" """
import logging import logging
@ -47,23 +52,40 @@ async def run_screening(trade_date: str = None) -> dict:
market_temp_score = market_temp.temperature market_temp_score = market_temp.temperature
# ── 板块热度(用于板块共振评分) ── # ── Step 1: 板块定位 ──
logger.info("=== 板块热度扫描 ===") logger.info("=== Step 1: 板块定位 ===")
all_sectors = scan_hot_sectors(trade_date) all_sectors = scan_hot_sectors(trade_date)
hot_sectors = all_sectors[:settings.top_sector_count]
# 前置过滤:只保留有资金流入 + 非末期的板块
hot_sectors = [
s for s in all_sectors
if s.capital_inflow > 0 and s.stage not in ("end",)
][:settings.top_sector_count]
if not hot_sectors:
logger.info("无合格热门板块(需要资金流入+非末期),回退到全部板块")
hot_sectors = all_sectors[:settings.top_sector_count]
for s in hot_sectors:
logger.info(f" 目标板块: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow:.0f}"
f"涨停{s.limit_up_count} 阶段={s.stage}")
# 盘中用实时行情更新板块涨幅和涨停数 # 盘中用实时行情更新板块涨幅和涨停数
if intraday: if intraday:
hot_sectors = await intraday_sector_scan(hot_sectors) hot_sectors = await intraday_sector_scan(hot_sectors)
# ── 趋势突破三阶段管道 ── # ── Step 2: 板块内选股 ──
logger.info("=== 趋势突破扫描 ===") logger.info("=== Step 2: 板块内选股 ===")
candidates = await scan_trend_breakout( candidates = await _select_from_hot_sectors(hot_sectors, trade_date, intraday)
trade_date=trade_date,
market_temp=market_temp, if not candidates:
hot_sectors=hot_sectors, logger.info("=== Step 2 无候选,回退到全市场扫描 ===")
intraday=intraday, candidates = await scan_trend_breakout(
) trade_date=trade_date,
market_temp=market_temp,
hot_sectors=hot_sectors,
intraday=intraday,
)
if not candidates: if not candidates:
logger.info("=== 筛选完成: 0 只股票 ===") logger.info("=== 筛选完成: 0 只股票 ===")
@ -74,8 +96,9 @@ async def run_screening(trade_date: str = None) -> dict:
"scan_mode": scan_mode, "scan_mode": scan_mode,
} }
# ── 构建推荐列表 ── # ── Step 3: 供需 + 价格行为 + 趋势评分 ──
recommendations = _build_trend_recommendations( logger.info("=== Step 3: 深度分析 ===")
recommendations = _build_recommendations(
candidates, market_temp, hot_sectors, market_temp_score, intraday, candidates, market_temp, hot_sectors, market_temp_score, intraday,
) )
@ -96,147 +119,498 @@ async def run_screening(trade_date: str = None) -> dict:
} }
def _build_trend_recommendations( async def _select_from_hot_sectors(
hot_sectors: list[SectorInfo],
trade_date: str,
intraday: bool,
) -> list[dict]:
"""Step 2: 从热门板块成分股中选出有资金流入的候选
流程:
1. 收集所有热门板块的成分股代码
2. get_daily_all + get_daily_basic 过滤市值/换手率
3. get_moneyflow_batch 过滤主力净流入 > 0
4. 对候选做入场信号初筛只需满足任一信号类型
"""
from app.data.tushare_client import tushare_client
from datetime import datetime, timedelta
import pandas as pd
if not trade_date:
trade_date = tushare_client.get_latest_trade_date()
# 收集热门板块成分股代码
sector_member_codes: set[str] = set()
sector_code_map: dict[str, str] = {} # ts_code -> sector_name
for s in hot_sectors:
try:
members_df = tushare_client.get_ths_members(s.sector_code)
if not members_df.empty and "con_code" in members_df.columns:
codes = members_df["con_code"].tolist()
sector_member_codes.update(codes)
for c in codes:
sector_code_map[c] = s.sector_name
except Exception as e:
logger.warning(f"获取板块 {s.sector_name} 成分股失败: {e}")
if not sector_member_codes:
logger.info("Step 2: 无板块成分股数据")
return []
logger.info(f"Step 2: 热门板块共 {len(sector_member_codes)} 只成分股")
# 过滤市值/换手率/ST/次新
stock_basic = tushare_client.get_stock_basic()
exclude_codes = set()
if not stock_basic.empty:
st_codes = set(stock_basic[stock_basic["name"].str.contains("ST", na=False)]["ts_code"])
exclude_codes.update(st_codes)
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"])
exclude_codes.update(new_codes)
# 行业映射
industry_map = {}
if not stock_basic.empty:
for _, row in stock_basic.iterrows():
industry_map[row["ts_code"]] = row.get("industry", "")
# 用 daily_basic 过滤
basic = tushare_client.get_daily_basic(trade_date)
if basic.empty:
logger.info("Step 2: daily_basic 无数据")
return []
basic["circ_mv"] = basic["circ_mv"] / 10000 # 万元 → 亿元
filtered_basic = basic[
(basic["ts_code"].isin(sector_member_codes)) &
(~basic["ts_code"].isin(exclude_codes)) &
(basic["circ_mv"] >= settings.min_circ_mv) &
(basic["circ_mv"] <= settings.max_circ_mv) &
(basic["turnover_rate"] >= settings.min_turnover_rate) &
(basic["turnover_rate"] <= settings.max_turnover_rate)
].copy()
logger.info(f"Step 2 基本面过滤: {len(sector_member_codes)} 只 → {len(filtered_basic)}")
if filtered_basic.empty:
return []
# 资金流过滤:主力净流入 > 0
mf = tushare_client.get_moneyflow_batch(trade_date)
if mf.empty:
logger.info("Step 2: 资金流数据为空,跳过资金过滤")
candidate_codes = set(filtered_basic["ts_code"].tolist())
else:
mf["main_net_inflow"] = (
(mf["buy_elg_amount"] - mf["sell_elg_amount"]) +
(mf["buy_lg_amount"] - mf["sell_lg_amount"])
)
total = (
mf["buy_elg_amount"] + mf["sell_elg_amount"] +
mf["buy_lg_amount"] + mf["sell_lg_amount"] +
mf["buy_md_amount"] + mf["sell_md_amount"] +
mf["buy_sm_amount"] + mf["sell_sm_amount"]
)
mf["inflow_ratio"] = (mf["main_net_inflow"] / total.replace(0, float("nan")) * 100).fillna(0)
mf_positive = mf[
(mf["ts_code"].isin(set(filtered_basic["ts_code"]))) &
(mf["main_net_inflow"] > 0)
].sort_values("main_net_inflow", ascending=False)
candidate_codes = set(mf_positive["ts_code"].tolist())
# 构建资金流查找表
mf_lookup = {}
for _, row in mf_positive.iterrows():
mf_lookup[row["ts_code"]] = {
"main_net_inflow": float(row["main_net_inflow"]),
"inflow_ratio": float(row.get("inflow_ratio", 0)),
}
logger.info(f"Step 2 资金流过滤: → {len(candidate_codes)} 只主力净流入 > 0")
if not candidate_codes:
return []
# 构建候选列表
import numpy as np
candidates = []
for ts_code in candidate_codes:
name = ""
if not stock_basic.empty:
row = stock_basic[stock_basic["ts_code"] == ts_code]
if not row.empty:
name = row.iloc[0]["name"]
sector_name = sector_code_map.get(ts_code, industry_map.get(ts_code, ""))
b_row = filtered_basic[filtered_basic["ts_code"] == ts_code]
turnover_rate = float(b_row.iloc[0]["turnover_rate"]) if not b_row.empty else 0
circ_mv = float(b_row.iloc[0]["circ_mv"]) if not b_row.empty else 0
pe = float(b_row.iloc[0]["pe"]) if not b_row.empty and pd.notna(b_row.iloc[0].get("pe")) else None
pb = float(b_row.iloc[0]["pb"]) if not b_row.empty and pd.notna(b_row.iloc[0].get("pb")) else None
volume_ratio = float(b_row.iloc[0]["volume_ratio"]) if not b_row.empty and pd.notna(b_row.iloc[0].get("volume_ratio")) else None
try:
mf_info = mf_lookup.get(ts_code, {})
except NameError:
mf_info = {}
candidates.append({
"ts_code": ts_code,
"name": name,
"sector": sector_name,
"turnover_rate": turnover_rate,
"circ_mv": circ_mv,
"pe": pe,
"pb": pb,
"volume_ratio": volume_ratio,
"main_net_inflow": mf_info.get("main_net_inflow", 0),
"inflow_ratio": mf_info.get("inflow_ratio", 0),
})
logger.info(f"Step 2 候选: {len(candidates)}")
return candidates
def _build_recommendations(
candidates: list[dict], candidates: list[dict],
market_temp: MarketTemperature, market_temp: MarketTemperature,
hot_sectors: list[SectorInfo], hot_sectors: list[SectorInfo],
market_temp_score: float = 0, market_temp_score: float = 0,
intraday: bool = False, intraday: bool = False,
) -> list[Recommendation]: ) -> list[Recommendation]:
"""从趋势突破扫描结果构建推荐列表 """Step 3: 对候选做供需 + 价格行为 + 趋势深度分析
评分公式趋势&时机30% + 资金流25% + 供需20% + 板块共振15% + 市场温度10% 评分公式供需关系 40% + 价格行为 35% + 趋势 25%
板块和资金流已在前置过滤中处理
""" """
from app.data.tushare_client import tushare_client
from app.analysis.technical import add_all_indicators
from app.analysis.breakout_signals import (
classify_entry_signal,
score_supply_demand,
analyze_volume_pattern,
EntrySignal,
)
from app.analysis.signals import generate_signals
from app.analysis.capital_flow import _score_valuation
# 名称和行业映射
stock_basic = tushare_client.get_stock_basic()
name_map = {}
industry_map = {}
if not stock_basic.empty:
for _, row in stock_basic.iterrows():
name_map[row["ts_code"]] = row["name"]
industry_map[row["ts_code"]] = row.get("industry", "")
recommendations = [] recommendations = []
total = len(candidates)
signal_counts = {"breakout": 0, "pullback": 0, "launch": 0, "none": 0}
for stock in candidates: for idx, stock in enumerate(candidates):
ts_code = stock["ts_code"] ts_code = stock.get("ts_code", "")
name = stock["name"] if not ts_code:
sector = stock["sector"] continue
entry_signal_type = stock.get("entry_signal_type", "none")
entry_signal_score = stock.get("entry_signal_score", 0)
tech_signal = stock.get("tech_signal")
# 各维度得分 name = stock.get("name") or name_map.get(ts_code, ts_code)
trend_timing_score = stock.get("trend_timing_score", 50) sector = stock.get("sector") or industry_map.get(ts_code, "")
supply_demand_score = stock.get("supply_demand_score", 50)
capital_score = stock.get("capital_score", 50)
position_score = stock.get("position_score", 50)
valuation_score = stock.get("valuation_score", 50)
# 板块共振评分 try:
sector_score = _score_sector_resonance(sector, hot_sectors) # 获取 120 日 K 线
sector_stage = _get_sector_stage(sector, hot_sectors) df = tushare_client.get_stock_daily(ts_code, 120)
if df.empty or len(df) < 30:
continue
# 综合评分(新权重) # 添加技术指标
final_score = ( df = add_all_indicators(df)
trend_timing_score * 0.30 +
capital_score * 0.25 +
supply_demand_score * 0.20 +
sector_score * 0.15 +
market_temp_score * 0.10
)
# 风险乘数 # ── 入场信号分类 ──
if tech_signal: entry_signal = classify_entry_signal(df)
if tech_signal.rally_pct_5d > 20: signal_type = entry_signal["signal_type"]
final_score *= 0.65 if signal_type == EntrySignal.NONE:
elif tech_signal.rally_pct_5d > 15: signal_counts["none"] += 1
final_score *= 0.80 continue
signal_counts[signal_type.value] += 1
if sector_stage == "end": # ── 三维度评分 ──
final_score *= 0.70
elif sector_stage == "late":
final_score *= 0.88
if market_temp_score < 30: # 1. 供需关系评分 (40%)
final_score *= 0.75 supply_demand_score = score_supply_demand(df)
# 入场信号高置信度奖励 # 2. 价格行为评分 (35%)
if entry_signal_score >= 80: price_action_score = _score_price_action(df, entry_signal)
final_score *= 1.10
# 确定信号和等级 # 3. 趋势评分 (25%)
level = _score_to_level(final_score) trend_score = _score_trend(df)
signal = "HOLD"
if entry_signal_type != "none" and entry_signal_score >= 50 and position_score >= 30 and final_score >= 60:
signal = "BUY"
# 价格参考 # 综合评分
entry_price = None final_score = (
target_price = None supply_demand_score * 0.40 +
stop_loss = None price_action_score * 0.35 +
if tech_signal: trend_score * 0.25
entry_price = tech_signal.support_price )
target_price = tech_signal.resist_price
stop_loss = tech_signal.stop_loss_price
# 根据入场信号类型调整参考价 # 风险乘数
details = stock.get("entry_signal_details", {}) tech_signal = generate_signals(ts_code, name)
if entry_signal_type == "breakout" and details.get("resist_level"):
entry_price = details["resist_level"]
target_price = round(entry_price * 1.05, 2)
elif entry_signal_type == "pullback" and details.get("support_price"):
entry_price = details["support_price"]
target_price = round(entry_price * 1.05, 2)
elif entry_signal_type == "launch" and details.get("resist_level"):
entry_price = round(details["resist_level"] * 1.01, 2)
target_price = round(details["resist_level"] * 1.08, 2)
# 生成推荐理由 if tech_signal:
reasons = _generate_reasons(stock, tech_signal, market_temp, intraday) if tech_signal.rally_pct_5d > 20:
final_score *= 0.65
elif tech_signal.rally_pct_5d > 15:
final_score *= 0.80
# 风险提示 # 板块末期惩罚(板块信息来自 hot_sectors
risk_note = _generate_risk_note(market_temp, tech_signal, stock) sector_stage = _get_sector_stage(sector, hot_sectors)
if sector_stage == "end":
final_score *= 0.70
elif sector_stage == "late":
final_score *= 0.88
rec = Recommendation( # 市场温度风控
ts_code=ts_code, if market_temp_score < 30:
name=name, final_score *= 0.75
sector=sector, elif market_temp_score < 50:
score=round(final_score, 1), final_score *= 0.88
market_temp_score=round(market_temp_score, 1),
sector_score=round(sector_score, 1), # 高置信度入场信号奖励
capital_score=round(capital_score, 1), if entry_signal.get("signal_score", 0) >= 80:
technical_score=round(stock.get("technical_score", 50), 1), final_score *= 1.10
position_score=round(position_score, 1),
valuation_score=round(valuation_score, 1), # 估值评分(辅助参考,不参与主评分)
signal=signal, pe = stock.get("pe")
entry_price=entry_price, pb = stock.get("pb")
target_price=target_price, valuation_score = _score_valuation(pe, pb)
stop_loss=stop_loss,
reasons=reasons, # 确定信号和等级
risk_note=risk_note, level = _score_to_level(final_score)
level=level, signal = "HOLD"
strategy="trend_breakout", position_score = tech_signal.position_score if tech_signal else 50
entry_signal_type=entry_signal_type, if (signal_type != EntrySignal.NONE
) and entry_signal.get("signal_score", 0) >= 50
recommendations.append(rec) and position_score >= 30
and final_score >= 60):
signal = "BUY"
# 价格参考
entry_price = None
target_price = None
stop_loss = None
if tech_signal:
entry_price = tech_signal.support_price
target_price = tech_signal.resist_price
stop_loss = tech_signal.stop_loss_price
details = entry_signal.get("details", {})
st = signal_type.value
if st == "breakout" and details.get("resist_level"):
entry_price = details["resist_level"]
target_price = round(entry_price * 1.05, 2)
elif st == "pullback" and details.get("support_price"):
entry_price = details["support_price"]
target_price = round(entry_price * 1.05, 2)
elif st == "launch" and details.get("resist_level"):
entry_price = round(details["resist_level"] * 1.01, 2)
target_price = round(details["resist_level"] * 1.08, 2)
# 生成推荐理由
reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday)
risk_note = _generate_risk_note(market_temp, tech_signal, stock)
# 量价模式
vol_pattern = analyze_volume_pattern(df)
rec = Recommendation(
ts_code=ts_code,
name=name,
sector=sector,
score=round(final_score, 1),
market_temp_score=round(market_temp_score, 1),
sector_score=round(_get_sector_heat(sector, hot_sectors), 1),
capital_score=round(_score_capital_simple(stock), 1),
technical_score=round(trend_score, 1),
position_score=round(position_score, 1),
valuation_score=round(valuation_score, 1),
signal=signal,
entry_price=entry_price,
target_price=target_price,
stop_loss=stop_loss,
reasons=reasons,
risk_note=risk_note,
level=level,
strategy="trend_breakout",
entry_signal_type=signal_type.value,
)
recommendations.append(rec)
if len(recommendations) >= settings.top_stock_count:
break
except Exception as e:
logger.debug(f"深度分析 {ts_code} 失败: {e}")
continue
# 让出控制权(同步函数中无法 await跳过
# idx % 10 == 0 的让步在 _select_from_hot_sectors 的上层 async 函数中处理
logger.info(
f"Step 3 入场信号分布: "
f"突破={signal_counts['breakout']} 回踩={signal_counts['pullback']} "
f"启动={signal_counts['launch']} 无信号={signal_counts['none']} "
f"(共分析{total}只)"
)
return recommendations return recommendations
def _score_sector_resonance(sector_name: str, hot_sectors: list[SectorInfo]) -> float: # ── 价格行为评分 ──
"""板块共振评分 (0-100)"""
for s in hot_sectors:
if s.sector_name == sector_name:
score = 40 # 在热门板块列表中
score += s.heat_score * 0.3 # 板块热度贡献
if s.stage == "early":
score += 30
elif s.stage == "mid":
score += 20
elif s.stage == "late":
score += 5
return min(score, 100)
return 10.0 # 不在热门板块
def _get_sector_score(sector_name: str, hot_sectors: list[SectorInfo]) -> float: def _score_price_action(df, entry_signal: dict) -> float:
"""获取板块在热门板块中的得分""" """价格行为学评分 (0-100)
for s in hot_sectors:
if s.sector_name == sector_name: 维度
return s.heat_score - 入场信号类型质量 (40): 突破型/回踩型/启动型各自的得分
return 30.0 - K线形态强度 (30): 突破日/回踩日的K线实体占比下影线收盘位置
- 支撑阻力位质量 (30): 关键价格位置的测试情况
"""
score = 0
last = df.iloc[-1]
details = entry_signal.get("details", {})
signal_type = entry_signal.get("signal_type")
# 入场信号类型质量 (40)
signal_score = entry_signal.get("signal_score", 0)
score += signal_score * 0.40
# K线形态强度 (30)
day_range = last["high"] - last["low"]
if day_range > 0:
# 实体占比(实体/全振幅)
body = abs(last["close"] - last["open"])
body_ratio = body / day_range
if body_ratio > 0.7:
score += 20 # 大实体,方向明确
elif body_ratio > 0.4:
score += 12
elif body_ratio > 0.2:
score += 6
# 收盘位置(越接近高点越好)
close_position = (last["close"] - last["low"]) / day_range
if close_position > 0.8:
score += 10 # 收在上部 20%
elif close_position > 0.6:
score += 6
elif close_position > 0.4:
score += 3
# 支撑阻力位质量 (30)
if signal_type and signal_type.value == "breakout":
# 突破型:阻力位被突破的力度
breakout_pct = details.get("breakout_pct", 0)
vol_ratio = details.get("volume_ratio", 1)
if breakout_pct > 2 and vol_ratio > 2:
score += 30 # 强力突破
elif breakout_pct > 1 and vol_ratio > 1.5:
score += 20
elif breakout_pct > 0:
score += 10
elif signal_type and signal_type.value == "pullback":
# 回踩型:支撑位的精确度
support_ma = details.get("support_ma", "")
shrink = details.get("volume_shrink_ratio", 1)
if support_ma == "MA20" and shrink < 0.6:
score += 30 # 精确回踩 MA20 且大幅缩量
elif support_ma == "MA20":
score += 22
elif support_ma == "MA10" and shrink < 0.6:
score += 18
else:
score += 10
elif signal_type and signal_type.value == "launch":
# 启动型:整理的充分度
range_pct = details.get("price_range_pct", 10)
shrink = details.get("volume_shrink_ratio", 1)
if range_pct < 3 and shrink < 0.4:
score += 30 # 极度缩量窄幅整理
elif range_pct < 5 and shrink < 0.6:
score += 20
else:
score += 10
else:
score += 10
return min(score, 100)
# ── 趋势评分 ──
def _score_trend(df) -> float:
"""趋势评分 (0-100)
维度
- 均线排列 (40): MA5>MA10>MA20>MA60
- 更高高点/更高低点结构 (35): 20 日价格结构
- MA20 方向 (25): MA20 是否持续上行
"""
import pandas as pd
score = 0
last = df.iloc[-1]
# 均线排列 (40)
ma_cols = [c for c in ["ma5", "ma10", "ma20", "ma60"] if c in df.columns]
if len(ma_cols) >= 4 and not any(pd.isna(last[c]) for c in ma_cols):
if last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]:
score += 40 # 完美多头
elif last["ma5"] > last["ma10"] > last["ma20"]:
score += 28
elif last["ma5"] > last["ma20"]:
score += 15
elif "ma5" in df.columns and "ma20" in df.columns:
if not pd.isna(last["ma5"]) and not pd.isna(last["ma20"]) and last["ma5"] > last["ma20"]:
score += 15
# 更高高点/更高低点结构 (35)
if len(df) >= 20:
recent = df.tail(20)
# 检查高点抬升
first_10_high = recent["high"].iloc[:10].max()
second_10_high = recent["high"].iloc[10:].max()
# 检查低点抬升
first_10_low = recent["low"].iloc[:10].min()
second_10_low = recent["low"].iloc[10:].min()
if second_10_high > first_10_high and second_10_low > first_10_low:
score += 35 # 既抬高点又抬低点,最健康
elif second_10_high > first_10_high:
score += 20 # 至少高点抬升
elif second_10_low > first_10_low:
score += 12 # 至少低点抬升
# MA20 方向 (25)
if "ma20" in df.columns and len(df) >= 5:
ma20_now = last["ma20"]
ma20_5d = df.iloc[-5]["ma20"]
if not pd.isna(ma20_now) and not pd.isna(ma20_5d) and ma20_5d > 0:
ma20_pct = (ma20_now - ma20_5d) / ma20_5d * 100
if ma20_pct > 2:
score += 25
elif ma20_pct > 1:
score += 18
elif ma20_pct > 0:
score += 10
return min(score, 100)
# ── 辅助函数 ──
def _get_sector_stage(sector_name: str, hot_sectors: list[SectorInfo]) -> str: def _get_sector_stage(sector_name: str, hot_sectors: list[SectorInfo]) -> str:
@ -247,6 +621,41 @@ def _get_sector_stage(sector_name: str, hot_sectors: list[SectorInfo]) -> str:
return "mid" return "mid"
def _get_sector_heat(sector_name: str, hot_sectors: list[SectorInfo]) -> float:
"""获取板块热度得分"""
for s in hot_sectors:
if s.sector_name == sector_name:
return s.heat_score
return 30.0
def _score_capital_simple(stock: dict) -> float:
"""资金流简单评分(仅基于已有数据,不额外调 API"""
main_net = stock.get("main_net_inflow", 0) or 0
inflow_ratio = stock.get("inflow_ratio", 0) or 0
score = 0
if main_net > 10000:
score += 60
elif main_net > 5000:
score += 45
elif main_net > 2000:
score += 30
elif main_net > 0:
score += 15
if inflow_ratio > 15:
score += 40
elif inflow_ratio > 10:
score += 30
elif inflow_ratio > 5:
score += 20
elif inflow_ratio > 0:
score += 10
return min(score, 100)
def _score_to_level(score: float) -> str: def _score_to_level(score: float) -> str:
if score >= 80: if score >= 80:
return "强烈推荐" return "强烈推荐"
@ -259,38 +668,45 @@ def _score_to_level(score: float) -> str:
def _generate_reasons( def _generate_reasons(
stock: dict, tech: TechnicalSignal | None, stock: dict, entry_signal: dict, tech: TechnicalSignal | None,
market: MarketTemperature, intraday: bool = False, df, intraday: bool = False,
) -> list[str]: ) -> list[str]:
"""生成推荐理由""" """生成推荐理由"""
import pandas as pd
reasons = [] reasons = []
entry_type = stock.get("entry_signal_type", "none") signal_type = entry_signal.get("signal_type")
signal_map = {"breakout": "突破型", "pullback": "回踩型", "launch": "启动型"} details = entry_signal.get("details", {})
entry_label = signal_map.get(entry_type, "") signal_map = {EntrySignal.BREAKOUT: "突破型", EntrySignal.PULLBACK: "回踩型", EntrySignal.LAUNCH: "启动型"}
entry_label = signal_map.get(signal_type, "")
# 入场信号 # 入场信号
if entry_label: if entry_label and signal_type:
details = stock.get("entry_signal_details", {}) st = signal_type.value
if entry_type == "breakout": if st == "breakout":
breakout_pct = details.get("breakout_pct", 0) breakout_pct = details.get("breakout_pct", 0)
vol_ratio = details.get("volume_ratio", 0) vol_ratio = details.get("volume_ratio", 0)
reasons.append(f"放量突破20日阻力位涨幅{breakout_pct:.1f}%,量比{vol_ratio:.1f}倍)") reasons.append(f"放量突破20日阻力位涨幅{breakout_pct:.1f}%,量比{vol_ratio:.1f}倍)")
elif entry_type == "pullback": elif st == "pullback":
support = details.get("support_ma", "") support = details.get("support_ma", "")
shrink = details.get("volume_shrink_ratio", 0) shrink = details.get("volume_shrink_ratio", 0)
reasons.append(f"缩量回踩{support}支撑(量能收缩至{shrink:.0%}") reasons.append(f"缩量回踩{support}支撑(量能收缩至{shrink:.0%}")
elif entry_type == "launch": elif st == "launch":
range_pct = details.get("price_range_pct", 0) range_pct = details.get("price_range_pct", 0)
shrink = details.get("volume_shrink_ratio", 0) shrink = details.get("volume_shrink_ratio", 0)
reasons.append(f"高位缩量整理{range_pct:.1f}%后即将变盘(量缩至{shrink:.0%}") reasons.append(f"高位缩量整理{range_pct:.1f}%后即将变盘(量缩至{shrink:.0%}")
# 供需分析 # 供需分析
vol_trend = stock.get("volume_trend", "") if len(df) >= 10:
ds_ratio = stock.get("demand_supply_ratio", 1) recent = df.tail(10)
if ds_ratio > 1.5: up_days = recent[recent["pct_chg"] > 0]
reasons.append(f"需求主导(上涨均量/下跌均量={ds_ratio:.1f}") down_days = recent[recent["pct_chg"] <= 0]
elif vol_trend == "expanding": if len(up_days) > 0 and len(down_days) > 0:
reasons.append("量能逐步放大,资金持续介入") avg_up_vol = up_days["vol"].mean()
avg_down_vol = down_days["vol"].mean()
if avg_down_vol > 0:
ds_ratio = avg_up_vol / avg_down_vol
if ds_ratio > 1.5:
reasons.append(f"需求主导(上涨均量/下跌均量={ds_ratio:.1f}")
# 资金流 # 资金流
main_net = stock.get("main_net_inflow", 0) main_net = stock.get("main_net_inflow", 0)
@ -302,19 +718,7 @@ def _generate_reasons(
# 板块 # 板块
sector = stock.get("sector", "") sector = stock.get("sector", "")
if sector: if sector:
reasons.append(f"所属板块【{sector}") reasons.append(f"所属热门板块【{sector}")
# 技术面
if tech:
tech_reasons = []
if tech.ma_bullish:
tech_reasons.append("均线多头排列")
if tech.macd_golden:
tech_reasons.append("MACD金叉")
if tech.pullback_support:
tech_reasons.append("缩量回踩支撑")
if tech_reasons:
reasons.append("技术面: " + "".join(tech_reasons))
return reasons[:3] return reasons[:3]

View File

@ -42,6 +42,11 @@ CHAT_SYSTEM_PROMPT = """\
3. 搜索股票代码 3. 搜索股票代码
4. 基于数据给出专业的市场分析和投资建议 4. 基于数据给出专业的市场分析和投资建议
重要提醒
- 回答用户关于"今天市场怎么样"之类的问题时必须调用 get_realtime_indices 获取实时指数数据
- 盘中时段9:30-15:00必须使用实时数据盘后时段使用当日收盘数据
- 不要使用过时的数据必须先调用工具获取最新数据再回答
回答要求 回答要求
1. 使用工具获取最新数据后再回答不要凭空编造数据 1. 使用工具获取最新数据后再回答不要凭空编造数据
2. 分析要结合 A 股市场特点资金驱动板块轮动情绪周期 2. 分析要结合 A 股市场特点资金驱动板块轮动情绪周期

View File

@ -33,6 +33,8 @@ async def execute_tool(name: str, arguments: dict) -> str:
return await _get_stock_technical_signal(arguments["ts_code"]) return await _get_stock_technical_signal(arguments["ts_code"])
elif name == "get_sector_performance": elif name == "get_sector_performance":
return await _get_sector_performance(arguments["sector_name"]) return await _get_sector_performance(arguments["sector_name"])
elif name == "get_realtime_indices":
return await _get_realtime_indices()
else: else:
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False) return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
except Exception as e: except Exception as e:
@ -146,3 +148,41 @@ async def _get_sector_performance(sector_name: str) -> str:
return json.dumps({"matched": False, "available_sectors": data}, ensure_ascii=False, default=str) return json.dumps({"matched": False, "available_sectors": data}, ensure_ascii=False, default=str)
data = _clean_for_json([s.model_dump() for s in matched]) data = _clean_for_json([s.model_dump() for s in matched])
return json.dumps({"matched": True, "sectors": data}, ensure_ascii=False, default=str) return json.dumps({"matched": True, "sectors": data}, ensure_ascii=False, default=str)
async def _get_realtime_indices() -> str:
"""获取指数实时行情数据(盘中用腾讯实时数据)"""
from app.data.tencent_client import get_index_realtime
from app.config import is_market_session
is_trading = is_market_session()
try:
index_data = await get_index_realtime()
if not index_data:
return json.dumps({"error": "获取指数数据失败"}, ensure_ascii=False)
# 格式化数据
results = []
for ts_code, data in index_data.items():
results.append({
"ts_code": ts_code,
"name": data.get("name", ts_code),
"price": round(data.get("price", 0), 2),
"pct_chg": round(data.get("pct_chg", 0), 2),
"volume": data.get("volume", 0),
"amount": data.get("amount", 0),
"high": round(data.get("high", 0), 2),
"low": round(data.get("low", 0), 2),
"open": round(data.get("open", 0), 2),
"pre_close": round(data.get("pre_close", 0), 2),
})
return json.dumps({
"is_realtime": is_trading,
"mode": "盘中实时" if is_trading else "盘后收盘",
"indices": results
}, ensure_ascii=False, default=str)
except Exception as e:
logger.error(f"获取实时指数失败: {e}")
return json.dumps({"error": f"获取指数数据失败: {e}"}, ensure_ascii=False)

View File

@ -138,4 +138,16 @@ CHAT_TOOLS = [
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "get_realtime_indices",
"description": "获取指数实时行情数据,包括上证指数、深证成指、创业板指的实时涨跌幅和成交数据。盘中时段返回实时数据,盘后返回当日收盘数据",
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
] ]

View File

@ -20,6 +20,11 @@
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/sectors/page.js" "static/chunks/app/sectors/page.js"
],
"/_not-found/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/_not-found/page.js"
] ]
} }
} }

View File

@ -1,5 +1,6 @@
{ {
"/_not-found/page": "app/_not-found/page.js",
"/page": "app/page.js", "/page": "app/page.js",
"/sectors/page": "app/sectors/page.js", "/recommendations/page": "app/recommendations/page.js",
"/recommendations/page": "app/recommendations/page.js" "/sectors/page": "app/sectors/page.js"
} }

View File

@ -125,7 +125,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ (() => { /******/ (() => {
/******/ __webpack_require__.h = () => ("b931683b67a20916") /******/ __webpack_require__.h = () => ("37ba5b2074c7bffb")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ /* webpack/runtime/hasOwnProperty shorthand */

File diff suppressed because one or more lines are too long