This commit is contained in:
aaron 2026-04-14 22:13:28 +08:00
parent 0902091f08
commit 1db602088d
14 changed files with 633 additions and 317 deletions

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,6 @@ from app.data.tushare_client import tushare_client
from app.analysis.technical import add_all_indicators from app.analysis.technical import add_all_indicators
from app.analysis.breakout_signals import ( from app.analysis.breakout_signals import (
classify_entry_signal, classify_entry_signal,
score_trend_timing,
score_supply_demand, score_supply_demand,
analyze_volume_pattern, analyze_volume_pattern,
EntrySignal, EntrySignal,
@ -330,7 +329,7 @@ async def _deep_analysis(
results = [] results = []
total = len(candidates) total = len(candidates)
signal_counts = {"breakout": 0, "pullback": 0, "launch": 0, "none": 0} signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
for idx, row in candidates.iterrows(): for idx, row in candidates.iterrows():
ts_code = row["ts_code"] if "ts_code" in candidates.columns else "" ts_code = row["ts_code"] if "ts_code" in candidates.columns else ""
@ -358,8 +357,8 @@ async def _deep_analysis(
continue continue
signal_counts[signal_type.value] += 1 signal_counts[signal_type.value] += 1
# 趋势&时机评分 # 趋势评分(内联简化版)
trend_score = score_trend_timing(df, entry_signal) trend_score = _simple_trend_score(df)
# 供需评分 # 供需评分
sd_score = score_supply_demand(df) sd_score = score_supply_demand(df)
@ -419,8 +418,9 @@ async def _deep_analysis(
logger.info( logger.info(
f"Phase 3 入场信号分布: " f"Phase 3 入场信号分布: "
f"突破={signal_counts['breakout']} 回踩={signal_counts['pullback']} " f"突破={signal_counts['breakout']} 确认={signal_counts['breakout_confirm']} "
f"启动={signal_counts['launch']} 无信号={signal_counts['none']} " f"回踩={signal_counts['pullback']} 启动={signal_counts['launch']} "
f"反转={signal_counts['reversal']} 无信号={signal_counts['none']} "
f"(共分析{total}只)" f"(共分析{total}只)"
) )
@ -448,6 +448,31 @@ def _get_multi_day_moneyflow(candidates: pd.DataFrame, trade_date: str) -> dict[
return result return result
def _simple_trend_score(df: pd.DataFrame) -> float:
"""简化趋势评分 (0-100),用于回退扫描路径"""
import numpy as np
score = 0
last = df.iloc[-1]
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 += 50
elif last["ma5"] > last["ma10"] > last["ma20"]:
score += 35
elif last["ma5"] > last["ma20"]:
score += 20
if len(df) >= 20:
recent = df.tail(20)
if recent["high"].iloc[10:].max() > recent["high"].iloc[:10].max():
score += 30
if recent["low"].iloc[10:].min() > recent["low"].iloc[:10].min():
score += 20
return min(score, 100)
def _score_capital(stock_row: dict | pd.Series, mf_history: pd.DataFrame) -> float: def _score_capital(stock_row: dict | pd.Series, mf_history: pd.DataFrame) -> float:
"""资金流评分 (0-100) """资金流评分 (0-100)

View File

@ -79,9 +79,9 @@ def setup_scheduler():
args=["late_session"], id=f"late_{m}", replace_existing=True args=["late_session"], id=f"late_{m}", replace_existing=True
) )
# 收盘总结 15:05 # 收盘总结 16:00Tushare 日线数据通常在 15:30 后更新完成)
scheduler.add_job( scheduler.add_job(
_run_scan, CronTrigger(hour=15, minute=5, day_of_week="mon-fri"), _run_scan, CronTrigger(hour=16, minute=0, day_of_week="mon-fri"),
args=["post_market"], id="post_market", replace_existing=True args=["post_market"], id="post_market", replace_existing=True
) )

View File

@ -15,6 +15,8 @@
import logging import logging
import pandas as pd
from app.analysis.market_temp import calculate_market_temperature from app.analysis.market_temp import calculate_market_temperature
from app.analysis.sector_scanner import scan_hot_sectors from app.analysis.sector_scanner import scan_hot_sectors
from app.analysis.trend_scanner import scan_trend_breakout from app.analysis.trend_scanner import scan_trend_breakout
@ -107,7 +109,7 @@ async def run_screening(trade_date: str = None) -> dict:
logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===") logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
for r in recommendations[:5]: for r in recommendations[:5]:
signal_map = {"breakout": "突破型", "pullback": "回踩型", "launch": "启动"} signal_map = {"breakout": "突破型", "breakout_confirm": "确认型", "pullback": "回踩型", "launch": "启动", "reversal": "反转"}
signal_label = signal_map.get(r.entry_signal_type, r.entry_signal_type) signal_label = signal_map.get(r.entry_signal_type, r.entry_signal_type)
logger.info(f" [{signal_label}] {r.name}({r.ts_code}) {r.level} 评分={r.score} 信号={r.signal}") logger.info(f" [{signal_label}] {r.name}({r.ts_code}) {r.level} 评分={r.score} 信号={r.signal}")
@ -309,7 +311,7 @@ def _build_recommendations(
recommendations = [] recommendations = []
total = len(candidates) total = len(candidates)
signal_counts = {"breakout": 0, "pullback": 0, "launch": 0, "none": 0} signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
for idx, stock in enumerate(candidates): for idx, stock in enumerate(candidates):
ts_code = stock.get("ts_code", "") ts_code = stock.get("ts_code", "")
@ -406,7 +408,7 @@ def _build_recommendations(
details = entry_signal.get("details", {}) details = entry_signal.get("details", {})
st = signal_type.value st = signal_type.value
if st == "breakout" and details.get("resist_level"): if st in ("breakout", "breakout_confirm") and details.get("resist_level"):
entry_price = details["resist_level"] entry_price = details["resist_level"]
target_price = round(entry_price * 1.05, 2) target_price = round(entry_price * 1.05, 2)
elif st == "pullback" and details.get("support_price"): elif st == "pullback" and details.get("support_price"):
@ -415,6 +417,9 @@ def _build_recommendations(
elif st == "launch" and details.get("resist_level"): elif st == "launch" and details.get("resist_level"):
entry_price = round(details["resist_level"] * 1.01, 2) entry_price = round(details["resist_level"] * 1.01, 2)
target_price = round(details["resist_level"] * 1.08, 2) target_price = round(details["resist_level"] * 1.08, 2)
elif st == "reversal" and tech_signal and tech_signal.support_price:
entry_price = tech_signal.support_price
target_price = round(entry_price * 1.08, 2)
# 生成推荐理由 # 生成推荐理由
reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday) reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday)
@ -458,8 +463,9 @@ def _build_recommendations(
logger.info( logger.info(
f"Step 3 入场信号分布: " f"Step 3 入场信号分布: "
f"突破={signal_counts['breakout']} 回踩={signal_counts['pullback']} " f"突破={signal_counts['breakout']} 确认={signal_counts['breakout_confirm']} "
f"启动={signal_counts['launch']} 无信号={signal_counts['none']} " f"回踩={signal_counts['pullback']} 启动={signal_counts['launch']} "
f"反转={signal_counts['reversal']} 无信号={signal_counts['none']} "
f"(共分析{total}只)" f"(共分析{total}只)"
) )
@ -472,21 +478,20 @@ def _build_recommendations(
def _score_price_action(df, entry_signal: dict) -> float: def _score_price_action(df, entry_signal: dict) -> float:
"""价格行为学评分 (0-100) """价格行为学评分 (0-100)
纯粹关注 K 线形态和量价配合不重复评估趋势/均线因素
维度 维度
- 入场信号类型质量 (40): 突破型/回踩型/启动型各自的得分 - K线形态强度 (35): 实体占比收盘位置下影线
- K线形态强度 (30): 突破日/回踩日的K线实体占比下影线收盘位置 - 量价配合 (35): 放量/缩量与价格方向的配合度
- 支撑阻力位质量 (30): 关键价格位置的测试情况 - 入场形态质量 (30): 各信号类型的形态完成度
""" """
import pandas as pd
score = 0 score = 0
last = df.iloc[-1] last = df.iloc[-1]
details = entry_signal.get("details", {}) details = entry_signal.get("details", {})
signal_type = entry_signal.get("signal_type") signal_type = entry_signal.get("signal_type")
# 入场信号类型质量 (40) # K线形态强度 (35)
signal_score = entry_signal.get("signal_score", 0)
score += signal_score * 0.40
# K线形态强度 (30)
day_range = last["high"] - last["low"] day_range = last["high"] - last["low"]
if day_range > 0: if day_range > 0:
# 实体占比(实体/全振幅) # 实体占比(实体/全振幅)
@ -508,24 +513,64 @@ def _score_price_action(df, entry_signal: dict) -> float:
elif close_position > 0.4: elif close_position > 0.4:
score += 3 score += 3
# 支撑阻力位质量 (30) # 下影线(回踩型/启动型利好)
lower_wick = (last["open"] - last["low"]) if last["close"] > last["open"] else (last["close"] - last["low"])
if lower_wick > 0:
wick_ratio = lower_wick / day_range
if signal_type and signal_type.value in ("pullback", "reversal") and wick_ratio > 0.2:
score += 5 # 回踩型/反转型有下影线支撑
# 量价配合 (35)
vol_ma_col = "vol_ma5" if "vol_ma5" in df.columns else None
if vol_ma_col and not pd.isna(last[vol_ma_col]) and last[vol_ma_col] > 0:
vol_ratio = last["vol"] / last[vol_ma_col]
price_up = last["pct_chg"] > 0 if "pct_chg" in df.columns else last["close"] > last["open"]
if price_up and vol_ratio > 2.0:
score += 35 # 放量大阳
elif price_up and vol_ratio > 1.5:
score += 25
elif price_up and vol_ratio > 1.2:
score += 18
elif not price_up and vol_ratio < 0.7:
score += 25 # 缩量回调(良性)
elif not price_up and vol_ratio < 0.9:
score += 15
elif price_up and vol_ratio > 1.0:
score += 10
else:
score += 10
# 入场形态质量 (30) — 只评估形态完成度,不涉及均线/MACD
if signal_type and signal_type.value == "breakout": if signal_type and signal_type.value == "breakout":
# 突破型:阻力位被突破的力度
breakout_pct = details.get("breakout_pct", 0) breakout_pct = details.get("breakout_pct", 0)
vol_ratio = details.get("volume_ratio", 1) vol_ratio = details.get("volume_ratio", 1)
if breakout_pct > 2 and vol_ratio > 2: if breakout_pct > 2 and vol_ratio > 2:
score += 30 # 强力突破 score += 30
elif breakout_pct > 1 and vol_ratio > 1.5: elif breakout_pct > 1 and vol_ratio > 1.5:
score += 20 score += 20
elif breakout_pct > 0: elif breakout_pct > 0:
score += 10 score += 12
else:
score += 6
elif signal_type and signal_type.value == "breakout_confirm":
vol_ratio = details.get("volume_ratio", 1)
confirm_pct = details.get("confirm_pct", 0)
if vol_ratio > 2 and confirm_pct > 2:
score += 30
elif vol_ratio > 1.5 and confirm_pct > 1:
score += 22
elif vol_ratio > 1.0:
score += 14
else:
score += 8
elif signal_type and signal_type.value == "pullback": elif signal_type and signal_type.value == "pullback":
# 回踩型:支撑位的精确度
support_ma = details.get("support_ma", "") support_ma = details.get("support_ma", "")
shrink = details.get("volume_shrink_ratio", 1) shrink = details.get("volume_shrink_ratio", 1)
if support_ma == "MA20" and shrink < 0.6: if support_ma == "MA20" and shrink < 0.6:
score += 30 # 精确回踩 MA20 且大幅缩量 score += 30
elif support_ma == "MA20": elif support_ma == "MA20":
score += 22 score += 22
elif support_ma == "MA10" and shrink < 0.6: elif support_ma == "MA10" and shrink < 0.6:
@ -534,15 +579,26 @@ def _score_price_action(df, entry_signal: dict) -> float:
score += 10 score += 10
elif signal_type and signal_type.value == "launch": elif signal_type and signal_type.value == "launch":
# 启动型:整理的充分度
range_pct = details.get("price_range_pct", 10) range_pct = details.get("price_range_pct", 10)
shrink = details.get("volume_shrink_ratio", 1) if range_pct < 3:
if range_pct < 3 and shrink < 0.4: score += 30
score += 30 # 极度缩量窄幅整理 elif range_pct < 5:
elif range_pct < 5 and shrink < 0.6:
score += 20 score += 20
else: else:
score += 10 score += 10
elif signal_type and signal_type.value == "reversal":
reversal_pct = details.get("reversal_pct", 0)
vol_ratio = details.get("volume_ratio", 1)
if reversal_pct > 5 and vol_ratio > 2.5:
score += 30
elif reversal_pct > 3 and vol_ratio > 2:
score += 22
elif reversal_pct > 3:
score += 14
else:
score += 8
else: else:
score += 10 score += 10
@ -673,10 +729,13 @@ def _generate_reasons(
) -> list[str]: ) -> list[str]:
"""生成推荐理由""" """生成推荐理由"""
import pandas as pd import pandas as pd
from app.analysis.breakout_signals import EntrySignal
reasons = [] reasons = []
signal_type = entry_signal.get("signal_type") signal_type = entry_signal.get("signal_type")
details = entry_signal.get("details", {}) details = entry_signal.get("details", {})
signal_map = {EntrySignal.BREAKOUT: "突破型", EntrySignal.PULLBACK: "回踩型", EntrySignal.LAUNCH: "启动型"} signal_map = {EntrySignal.BREAKOUT: "突破型", EntrySignal.BREAKOUT_CONFIRM: "确认型",
EntrySignal.PULLBACK: "回踩型", EntrySignal.LAUNCH: "启动型",
EntrySignal.REVERSAL: "反转型"}
entry_label = signal_map.get(signal_type, "") entry_label = signal_map.get(signal_type, "")
# 入场信号 # 入场信号
@ -686,14 +745,21 @@ def _generate_reasons(
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 st == "breakout_confirm":
vol_ratio = details.get("volume_ratio", 0)
confirm_pct = details.get("confirm_pct", 0)
reasons.append(f"突破后放量确认(确认日涨{confirm_pct:.1f}%,量比{vol_ratio:.1f}倍)")
elif st == "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 st == "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) reasons.append(f"缩量横盘整理{range_pct:.1f}%后首日放量启动")
reasons.append(f"高位缩量整理{range_pct:.1f}%后即将变盘(量缩至{shrink:.0%}") elif st == "reversal":
reversal_pct = details.get("reversal_pct", 0)
vol_ratio = details.get("volume_ratio", 0)
reasons.append(f"连续下跌后放量长阳反转(涨{reversal_pct:.1f}%,量比{vol_ratio:.1f}倍)")
# 供需分析 # 供需分析
if len(df) >= 10: if len(df) >= 10:
@ -734,10 +800,14 @@ def _generate_risk_note(
if entry_type == "breakout": if entry_type == "breakout":
notes.append("突破型需警惕假突破,关注量能是否持续") notes.append("突破型需警惕假突破,关注量能是否持续")
elif entry_type == "breakout_confirm":
notes.append("确认型需观察后续量能是否跟上,防止冲高回落")
elif entry_type == "pullback": elif entry_type == "pullback":
notes.append("回踩型可能继续下探支撑,注意止损纪律") notes.append("回踩型可能继续下探支撑,注意止损纪律")
elif entry_type == "launch": elif entry_type == "launch":
notes.append("启动型整理可能延长,注意时间成本") notes.append("启动型整理可能延长,注意时间成本")
elif entry_type == "reversal":
notes.append("反转型可能二次探底,确认底部后再加仓")
if market.temperature < 30: if market.temperature < 30:
notes.append("市场情绪偏冷,系统性风险较高") notes.append("市场情绪偏冷,系统性风险较高")

Binary file not shown.

View File

@ -11,6 +11,11 @@
"static/css/app/layout.css", "static/css/app/layout.css",
"static/chunks/app/layout.js" "static/chunks/app/layout.js"
], ],
"/stock/[code]/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/stock/[code]/page.js"
],
"/recommendations/page": [ "/recommendations/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
@ -20,11 +25,6 @@
"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 +1,20 @@
{} {
"components/capital-flow.tsx -> echarts": {
"id": "components/capital-flow.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
},
"components/kline-chart.tsx -> echarts": {
"id": "components/kline-chart.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
},
"components/score-radar.tsx -> echarts": {
"id": "components/score-radar.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
}
}

View File

@ -1,6 +1,6 @@
{ {
"/_not-found/page": "app/_not-found/page.js",
"/page": "app/page.js", "/page": "app/page.js",
"/recommendations/page": "app/recommendations/page.js", "/recommendations/page": "app/recommendations/page.js",
"/stock/[code]/page": "app/stock/[code]/page.js",
"/sectors/page": "app/sectors/page.js" "/sectors/page": "app/sectors/page.js"
} }

View File

@ -1 +1 @@
self.__REACT_LOADABLE_MANIFEST="{}" self.__REACT_LOADABLE_MANIFEST="{\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/score-radar.tsx -> echarts\":{\"id\":\"components/score-radar.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"

View File

@ -1,5 +1,5 @@
{ {
"node": {}, "node": {},
"edge": {}, "edge": {},
"encryptionKey": "t8NqvDMYNuIMnc0JzSsolcrzKZh3QqUqStILpaN7mCE=" "encryptionKey": "xV9IIi0vV+SB9UvVKH8lRLbKebnLKLutavDhD7b36pc="
} }

View File

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

File diff suppressed because one or more lines are too long