This commit is contained in:
aaron 2026-05-29 10:09:37 +08:00
parent d07af5508a
commit 9250627dc0
10 changed files with 401 additions and 44 deletions

View File

@ -41,6 +41,7 @@ async def scan_trend_breakout(
""" """
if not trade_date: if not trade_date:
trade_date = tushare_client.get_latest_trade_date() trade_date = tushare_client.get_latest_trade_date()
trade_date = _resolve_daily_basic_trade_date(trade_date)
logger.info(f"=== 趋势突破扫描 (trade_date={trade_date}) ===") logger.info(f"=== 趋势突破扫描 (trade_date={trade_date}) ===")
@ -80,8 +81,9 @@ def _bulk_pre_filter(trade_date: str) -> pd.DataFrame:
logger.warning("Phase 1: 无法获取交易日历") logger.warning("Phase 1: 无法获取交易日历")
return pd.DataFrame() return pd.DataFrame()
# 取最近 5 个交易日(含当日) # 取目标日之前最近 5 个交易日,避免早盘把尚无日频行情的当天纳入窗口。
recent_dates = dates[-5:] if len(dates) >= 5 else dates eligible_dates = [d for d in dates if d <= trade_date]
recent_dates = eligible_dates[-5:] if len(eligible_dates) >= 5 else eligible_dates
if trade_date not in recent_dates: if trade_date not in recent_dates:
recent_dates = recent_dates[-4:] + [trade_date] if len(recent_dates) >= 4 else [trade_date] recent_dates = recent_dates[-4:] + [trade_date] if len(recent_dates) >= 4 else [trade_date]
@ -198,6 +200,22 @@ def _bulk_pre_filter(trade_date: str) -> pd.DataFrame:
return _filter_daily_basic(candidates, trade_date) return _filter_daily_basic(candidates, trade_date)
def _resolve_daily_basic_trade_date(preferred: str) -> str:
"""盘中当日日频数据未更新时,回退到最近可用交易日。"""
dates = tushare_client.get_trade_dates()
if preferred and preferred not in dates:
dates = sorted([*dates, preferred])
candidates = [d for d in dates if d <= preferred] if dates else [preferred]
for date in reversed(candidates[-8:]):
basic = tushare_client.get_daily_basic(date)
daily = tushare_client.get_daily_all(date)
if not basic.empty and not daily.empty:
if date != preferred:
logger.info("趋势扫描日频数据回退: %s -> %s", preferred, date)
return date
return preferred
def _filter_daily_basic(candidates: pd.DataFrame, trade_date: str) -> pd.DataFrame: def _filter_daily_basic(candidates: pd.DataFrame, trade_date: str) -> pd.DataFrame:
"""使用 daily_basic 过滤市值、换手率,排除 ST 和次新""" """使用 daily_basic 过滤市值、换手率,排除 ST 和次新"""
basic = tushare_client.get_daily_basic(trade_date) basic = tushare_client.get_daily_basic(trade_date)

View File

@ -64,6 +64,7 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
latest_trade_date = tushare_client.get_latest_trade_date() latest_trade_date = tushare_client.get_latest_trade_date()
intraday = should_prefer_realtime_today(latest_trade_date) intraday = should_prefer_realtime_today(latest_trade_date)
scan_mode = "realtime_today" if intraday else "post_market" scan_mode = "realtime_today" if intraday else "post_market"
analysis_trade_date = _resolve_daily_basic_trade_date(trade_date or latest_trade_date)
logger.info(f"=== 筛选模式: {'今日实时' if intraday else '历史收盘'} ===") logger.info(f"=== 筛选模式: {'今日实时' if intraday else '历史收盘'} ===")
# ── 市场温度 ── # ── 市场温度 ──
@ -103,8 +104,11 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
# ── Step 1: 主线主题定位 ── # ── Step 1: 主线主题定位 ──
logger.info("=== Step 1: 主线主题定位 ===") logger.info("=== Step 1: 主线主题定位 ===")
all_themes = await get_today_realtime_sector_board(limit=30) if intraday else [] all_themes = await get_today_realtime_sector_board(limit=30) if intraday else []
if intraday:
daily_themes = merge_sectors_to_themes(scan_hot_sectors(analysis_trade_date), limit=30)
all_themes = _merge_realtime_and_daily_themes(all_themes, daily_themes)
if not all_themes: if not all_themes:
all_themes = merge_sectors_to_themes(scan_hot_sectors(trade_date), limit=30) all_themes = merge_sectors_to_themes(scan_hot_sectors(analysis_trade_date), limit=30)
# 前置过滤:只保留有资金或实时强度支撑、且非尾声的主题 # 前置过滤:只保留有资金或实时强度支撑、且非尾声的主题
hot_sectors = [ hot_sectors = [
@ -116,7 +120,7 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
logger.info("无合格主线主题(需要资金/实时强度+非尾声),回退到全部主题") logger.info("无合格主线主题(需要资金/实时强度+非尾声),回退到全部主题")
hot_sectors = all_themes[:settings.top_sector_count] hot_sectors = all_themes[:settings.top_sector_count]
hot_sectors = await _apply_catalyst_scores(hot_sectors) hot_sectors = _calibrate_theme_lifecycle(await _apply_catalyst_scores(hot_sectors))
await log_scan_stage( await log_scan_stage(
scan_session=scan_session, scan_session=scan_session,
scan_mode=scan_mode, scan_mode=scan_mode,
@ -148,7 +152,9 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
# 如果主题来自 Tushare 快照,盘中用实时行情更新后再次归一到主题。 # 如果主题来自 Tushare 快照,盘中用实时行情更新后再次归一到主题。
if intraday and hot_sectors and not hot_sectors[0].is_realtime: if intraday and hot_sectors and not hot_sectors[0].is_realtime:
hot_sectors = merge_sectors_to_themes(await intraday_sector_scan(hot_sectors), limit=settings.top_sector_count) hot_sectors = _calibrate_theme_lifecycle(
merge_sectors_to_themes(await intraday_sector_scan(hot_sectors), limit=settings.top_sector_count)
)
strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday) strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday)
logger.info( logger.info(
@ -172,7 +178,7 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
candidate_metrics: dict = {} candidate_metrics: dict = {}
candidates = await _build_candidate_pool( candidates = await _build_candidate_pool(
hot_sectors=hot_sectors, hot_sectors=hot_sectors,
trade_date=trade_date, trade_date=analysis_trade_date,
intraday=intraday, intraday=intraday,
market_temp=market_temp, market_temp=market_temp,
metrics=candidate_metrics, metrics=candidate_metrics,
@ -267,10 +273,15 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
before_final_filter = len(recommendations) before_final_filter = len(recommendations)
final_filter_reasons = _build_final_filter_reasons(recommendations, strategy_profile) final_filter_reasons = _build_final_filter_reasons(recommendations, strategy_profile)
recommendations = [ strict_recommendations = [
r for r in recommendations r for r in recommendations
if _is_main_theme_recommendation(r) and r.score >= strategy_profile.min_score if _is_main_theme_recommendation(r) and r.score >= strategy_profile.min_score
] ]
recommendations = strict_recommendations or _build_empty_pool_fallback(
recommendations=recommendations,
strategy_profile=strategy_profile,
market_temp=market_temp,
)
after_theme_filter = len(recommendations) after_theme_filter = len(recommendations)
recommendations = _finalize_battle_plan( recommendations = _finalize_battle_plan(
@ -366,6 +377,111 @@ async def _apply_catalyst_scores(sectors: list[SectorInfo]) -> list[SectorInfo]:
return sectors return sectors
def _calibrate_theme_lifecycle(sectors: list[SectorInfo]) -> list[SectorInfo]:
"""校准主线生命周期,避免把高潮/尾声板块当作最佳买点。"""
for sector in sectors:
pct = sector.realtime_pct_change if sector.realtime_pct_change is not None else sector.pct_change
limit_up = sector.realtime_limit_up_count if sector.realtime_limit_up_count is not None else sector.limit_up_count
up = sector.realtime_up_count
down = sector.realtime_down_count
breadth = (up - down) if up is not None and down is not None else 0
leader_pcts = [
float(item.get("pct_chg", 0) or 0)
for item in (sector.leading_stocks_realtime or sector.leading_stocks or [])[:5]
if isinstance(item, dict)
]
max_leader_pct = max(leader_pcts) if leader_pcts else 0
if pct < -1.5 or (sector.heat_score < 35 and sector.capital_inflow <= 0 and not sector.is_realtime):
next_stage = "end"
elif sector.days_continuous >= 4 and (pct >= 4 or limit_up >= 6 or max_leader_pct >= 16):
next_stage = "late"
elif sector.days_continuous <= 2 and pct > 0 and sector.heat_score >= 45 and limit_up <= 4:
next_stage = "early"
elif pct > 0 and (limit_up >= 2 or breadth > 0 or sector.heat_score >= 55):
next_stage = "mid"
else:
next_stage = sector.stage or "mid"
sector.stage = next_stage
if next_stage == "early":
sector.heat_score = round(min(sector.heat_score + 6, 100), 1)
elif next_stage == "mid":
sector.heat_score = round(min(sector.heat_score + 2, 100), 1)
elif next_stage == "late":
sector.heat_score = round(max(sector.heat_score - 8, 0), 1)
elif next_stage == "end":
sector.heat_score = round(max(sector.heat_score - 22, 0), 1)
lifecycle_note = f"生命周期={next_stage}, 涨幅={pct:.2f}%, 涨停={limit_up}, 连续={sector.days_continuous}"
sector.source_detail = f"{sector.source_detail}{lifecycle_note}" if sector.source_detail else lifecycle_note
sectors.sort(
key=lambda s: (
{"early": 3, "mid": 2, "late": 1, "end": 0}.get(s.stage, 1),
s.heat_score,
s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change,
),
reverse=True,
)
return sectors
def _merge_realtime_and_daily_themes(realtime: list[SectorInfo], daily: list[SectorInfo]) -> list[SectorInfo]:
"""盘中用实时强度排序,同时保留日频主题成分映射作为兜底。"""
if not realtime:
return daily
merged: dict[str, SectorInfo] = {}
for sector in daily:
merged[sector.sector_name] = sector
for sector in realtime:
existing = merged.get(sector.sector_name)
if existing:
existing.realtime_pct_change = sector.realtime_pct_change
existing.realtime_limit_up_count = sector.realtime_limit_up_count
existing.realtime_amount = sector.realtime_amount
existing.realtime_turnover_rate = sector.realtime_turnover_rate
existing.realtime_up_count = sector.realtime_up_count
existing.realtime_down_count = sector.realtime_down_count
existing.leading_stocks_realtime = sector.leading_stocks_realtime
existing.is_realtime = True
existing.data_mode = sector.data_mode
existing.source = sector.source
existing.source_detail = sector.source_detail or existing.source_detail
existing.heat_score = max(existing.heat_score, sector.heat_score)
else:
merged[sector.sector_name] = sector
return list(merged.values())
def _resolve_daily_basic_trade_date(preferred: str | None = None) -> str:
"""选择最近一个日频行情和 daily_basic 都可用的交易日。"""
dates = tushare_client.get_trade_dates()
if preferred and preferred not in dates:
dates = sorted([*dates, preferred])
if not dates:
return preferred or tushare_client.get_latest_trade_date()
target = preferred or dates[-1]
candidates = [d for d in dates if d <= target]
if not candidates:
candidates = dates
for date in reversed(candidates[-8:]):
try:
basic = tushare_client.get_daily_basic(date)
daily = tushare_client.get_daily_all(date)
except Exception as exc:
logger.debug("日频数据探测失败 %s: %s", date, exc)
continue
if not basic.empty and not daily.empty:
if date != target:
logger.info("日频基础数据回退: %s -> %s", target, date)
return date
return target
async def _select_from_hot_sectors( async def _select_from_hot_sectors(
hot_sectors: list[SectorInfo], hot_sectors: list[SectorInfo],
trade_date: str, trade_date: str,
@ -773,6 +889,45 @@ def _finalize_battle_plan(
return final_list[: settings.top_stock_count] return final_list[: settings.top_stock_count]
def _build_empty_pool_fallback(
recommendations: list[Recommendation],
strategy_profile: StrategyProfile,
market_temp: MarketTemperature,
) -> list[Recommendation]:
"""强市场/主线早期但严格分数线为空时,保留少量低仓位观察名单。"""
if market_temp.temperature < 55:
return []
floor = max(48, strategy_profile.min_score - 14)
candidates = [
rec for rec in recommendations
if _is_main_theme_recommendation(rec) and rec.score >= floor
]
candidates.sort(key=lambda rec: (rec.score, rec.position_score, rec.sector_score), reverse=True)
kept = candidates[: min(6, max(strategy_profile.watch_limit, 3))]
for rec in kept:
pos_hint = (rec.decision_trace or {}).get("position_adjustment", {}).get("hint", "neutral")
rec.action_plan = "重点关注" if rec.entry_signal_type != "none" else "观察"
rec.lifecycle_status = "candidate"
rec.signal = "HOLD"
rec.suggested_position_pct = 5 if rec.action_plan == "重点关注" and pos_hint != "wait_pullback" else 0
prefix = "严格分数线下无可操作票,本条仅作主线前排跟踪"
if rec.trigger_condition:
rec.trigger_condition = f"{prefix}{rec.trigger_condition}"
else:
rec.trigger_condition = f"{prefix};等待板块继续扩散、个股放量或回踩承接确认"
if rec.decision_trace is not None:
rec.decision_trace["action_plan"] = rec.action_plan
rec.decision_trace["empty_pool_fallback"] = True
rec.decision_trace["headline"] = _build_decision_headline(
stock={"stock_role_hint": "主线前排", "sector": rec.sector},
action_plan=rec.action_plan,
entry_signal_type=rec.entry_signal_type,
hot_theme_match=None,
score=rec.score,
)
return kept
async def _build_recommendations( async def _build_recommendations(
candidates: list[dict], candidates: list[dict],
market_temp: MarketTemperature, market_temp: MarketTemperature,
@ -934,6 +1089,14 @@ async def _build_recommendations(
if penalties: if penalties:
final_score *= min(penalties) final_score *= min(penalties)
position_adjustment = _position_execution_adjustment(
tech=tech_signal,
signal_name=signal_name,
sector_stage=sector_stage,
market_temp=market_temp,
)
final_score *= position_adjustment["multiplier"]
boosts = [] boosts = []
if sector_limit_up >= 5: if sector_limit_up >= 5:
final_score *= 1.20 final_score *= 1.20
@ -1093,6 +1256,7 @@ async def _build_recommendations(
score=final_score, score=final_score,
market_temp=market_temp, market_temp=market_temp,
sector_stage=sector_stage, sector_stage=sector_stage,
position_hint=position_adjustment["hint"],
entry_price=entry_price, entry_price=entry_price,
target_price=target_price, target_price=target_price,
stop_loss=stop_loss, stop_loss=stop_loss,
@ -1107,6 +1271,7 @@ async def _build_recommendations(
market_temp_score=market_temp_score, market_temp_score=market_temp_score,
sector_stage=sector_stage, sector_stage=sector_stage,
hot_theme_match=hot_theme_match, hot_theme_match=hot_theme_match,
position_adjustment=position_adjustment,
) )
decision_trace = _build_decision_trace( decision_trace = _build_decision_trace(
stock=stock, stock=stock,
@ -1133,6 +1298,7 @@ async def _build_recommendations(
penalties=penalty_notes, penalties=penalty_notes,
risk_tags=risk_tags, risk_tags=risk_tags,
hot_theme_match=hot_theme_match, hot_theme_match=hot_theme_match,
position_adjustment=position_adjustment,
) )
rec = Recommendation( rec = Recommendation(
@ -1832,11 +1998,60 @@ def _generate_entry_timing(signal_type: str, intraday: bool) -> str:
return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场") return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场")
def _position_execution_adjustment(
tech: TechnicalSignal | None,
signal_name: str,
sector_stage: str,
market_temp: MarketTemperature,
) -> dict:
"""根据位置安全把强势候选转成可交易/等待回踩/只观察。"""
if not tech:
return {"multiplier": 1.0, "hint": "neutral", "notes": []}
notes: list[str] = []
multiplier = 1.0
hint = "neutral"
if tech.position_score < 25 or tech.rally_pct_5d >= 18:
multiplier *= 0.72
hint = "wait_pullback"
notes.append(f"5日涨幅{tech.rally_pct_5d}%,位置偏高,避免追高")
elif tech.position_score < 35 or tech.rally_pct_10d >= 24:
multiplier *= 0.84
hint = "wait_confirm"
notes.append(f"10日涨幅{tech.rally_pct_10d}%,需要承接确认")
if signal_name in {"breakout", "launch", "flow_momentum"} and tech.position_score < 40:
multiplier *= 0.90
hint = "wait_pullback"
notes.append("突破/启动信号叠加高位,优先等回踩")
elif signal_name == "pullback" and tech.position_score >= 45 and sector_stage in {"early", "mid"}:
multiplier *= 1.06
hint = "actionable_pullback"
notes.append("回踩型买点且位置安全,优先级上调")
if sector_stage == "late" and hint != "actionable_pullback":
multiplier *= 0.92
hint = "wait_confirm" if hint == "neutral" else hint
notes.append("板块后段,降低追涨容忍度")
if market_temp.temperature < 50 and hint.startswith("wait"):
multiplier *= 0.94
notes.append("市场温度一般,等待信号要求提高")
return {
"multiplier": round(max(0.55, min(multiplier, 1.12)), 3),
"hint": hint,
"notes": notes[:4],
}
def _build_trade_plan( def _build_trade_plan(
signal_type: str, signal_type: str,
score: float, score: float,
market_temp: MarketTemperature, market_temp: MarketTemperature,
sector_stage: str, sector_stage: str,
position_hint: str,
entry_price: float | None, entry_price: float | None,
target_price: float | None, target_price: float | None,
stop_loss: float | None, stop_loss: float | None,
@ -1856,9 +2071,14 @@ def _build_trade_plan(
"flow_momentum": "资金顺势确认", "flow_momentum": "资金顺势确认",
}.get(signal_type, "资金与量价信号") }.get(signal_type, "资金与量价信号")
wait_position = position_hint in {"wait_pullback", "wait_confirm"}
if market_temp.temperature < 40 or sector_stage in ("end",): if market_temp.temperature < 40 or sector_stage in ("end",):
action_plan = "观察" action_plan = "观察"
lifecycle_status = "candidate" lifecycle_status = "candidate"
elif wait_position and score >= 70:
action_plan = "重点关注"
lifecycle_status = "candidate"
elif score >= 84 and market_temp.temperature >= 62 and sector_stage == "early": elif score >= 84 and market_temp.temperature >= 62 and sector_stage == "early":
action_plan = "可操作" action_plan = "可操作"
lifecycle_status = "actionable" lifecycle_status = "actionable"
@ -1885,10 +2105,17 @@ def _build_trade_plan(
base_position -= 5 base_position -= 5
if sector_stage == "late": if sector_stage == "late":
base_position -= 5 base_position -= 5
if wait_position:
base_position = min(base_position, 8)
suggested_position_pct = max(0, min(base_position, 30)) suggested_position_pct = max(0, min(base_position, 30))
price_part = f"参考价 {entry_price}" if entry_price else "参考当前价" price_part = f"参考价 {entry_price}" if entry_price else "参考当前价"
timing_part = entry_timing or "等待量价确认" timing_part = entry_timing or "等待量价确认"
if position_hint == "wait_pullback":
trigger_condition = f"不追高等待回踩5日/10日线或分时缩量承接后再看{signal_label}重新放量确认,{price_part}附近小仓试错"
elif position_hint == "wait_confirm":
trigger_condition = f"等待次日承接确认,板块前排不退潮且{signal_label}延续后再分批;{price_part}附近关注"
else:
trigger_condition = f"{signal_label}成立且不跌破关键价位,{price_part}附近分批关注;{timing_part}" trigger_condition = f"{signal_label}成立且不跌破关键价位,{price_part}附近分批关注;{timing_part}"
invalid_parts = [] invalid_parts = []
@ -2213,6 +2440,7 @@ def _build_penalty_notes(
market_temp_score: float, market_temp_score: float,
sector_stage: str, sector_stage: str,
hot_theme_match: SectorInfo | None, hot_theme_match: SectorInfo | None,
position_adjustment: dict | None = None,
) -> list[dict]: ) -> list[dict]:
notes: list[dict] = [] notes: list[dict] = []
if trend_penalty < 1: if trend_penalty < 1:
@ -2228,6 +2456,13 @@ def _build_penalty_notes(
if theme_penalty < 1: if theme_penalty < 1:
label = "未匹配主线" if not hot_theme_match else "非前排主线" label = "未匹配主线" if not hot_theme_match else "非前排主线"
notes.append({"label": label, "value": f"-{round((1 - theme_penalty) * 100)}%", "reason": "主题地位不足"}) notes.append({"label": label, "value": f"-{round((1 - theme_penalty) * 100)}%", "reason": "主题地位不足"})
if position_adjustment and position_adjustment.get("multiplier", 1) < 1:
reason = "".join(position_adjustment.get("notes", [])[:2]) or "位置偏高"
notes.append({
"label": "位置/买点折扣",
"value": f"-{round((1 - float(position_adjustment.get('multiplier', 1))) * 100)}%",
"reason": reason,
})
if not notes and penalties: if not notes and penalties:
notes.append({"label": "风险折扣", "value": f"-{round((1 - min(penalties)) * 100)}%", "reason": "存在风险项"}) notes.append({"label": "风险折扣", "value": f"-{round((1 - min(penalties)) * 100)}%", "reason": "存在风险项"})
return notes[:4] return notes[:4]
@ -2258,6 +2493,7 @@ def _build_decision_trace(
penalties: list[dict], penalties: list[dict],
risk_tags: list[str], risk_tags: list[str],
hot_theme_match: SectorInfo | None, hot_theme_match: SectorInfo | None,
position_adjustment: dict | None = None,
) -> dict: ) -> dict:
tags = stock.get("recall_tags", []) or [] tags = stock.get("recall_tags", []) or []
headline = _build_decision_headline( headline = _build_decision_headline(
@ -2334,6 +2570,7 @@ def _build_decision_trace(
"sector_limit_up": sector_limit_up, "sector_limit_up": sector_limit_up,
"theme_matched": bool(hot_theme_match), "theme_matched": bool(hot_theme_match),
"theme_name": hot_theme_match.sector_name if hot_theme_match else "", "theme_name": hot_theme_match.sector_name if hot_theme_match else "",
"position_hint": (position_adjustment or {}).get("hint", "neutral"),
}, },
"catalyst": { "catalyst": {
"score": round(catalyst_score, 1), "score": round(catalyst_score, 1),
@ -2341,6 +2578,7 @@ def _build_decision_trace(
}, },
"boosts": boosts[:4], "boosts": boosts[:4],
"penalties": penalties[:4], "penalties": penalties[:4],
"position_adjustment": position_adjustment or {"multiplier": 1.0, "hint": "neutral", "notes": []},
"risk_tags": risk_tags, "risk_tags": risk_tags,
"llm_adjustment": None, "llm_adjustment": None,
} }

Binary file not shown.

View File

@ -1,9 +1,9 @@
{ {
"pages": { "pages": {
"/(auth)/dashboard/page": [ "/(auth)/recommendations/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/(auth)/dashboard/page.js" "static/chunks/app/(auth)/recommendations/page.js"
], ],
"/(auth)/layout": [ "/(auth)/layout": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
@ -15,16 +15,6 @@
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/css/app/layout.css", "static/css/app/layout.css",
"static/chunks/app/layout.js" "static/chunks/app/layout.js"
],
"/(auth)/recommendations/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/recommendations/page.js"
],
"/(auth)/strategy/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/strategy/page.js"
] ]
} }
} }

View File

@ -1,5 +1,3 @@
{ {
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js", "/(auth)/recommendations/page": "app/(auth)/recommendations/page.js"
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
"/(auth)/strategy/page": "app/(auth)/strategy/page.js"
} }

View File

@ -1,5 +1,5 @@
{ {
"node": {}, "node": {},
"edge": {}, "edge": {},
"encryptionKey": "s0qcbsgwKrVRZ+Vvkywm9IM+ti0wNq5+7QtEBPqabTc=" "encryptionKey": "d4VN2R1pidVsk7vdfLIPEMxazAw72u3gfaTtAbGckJc="
} }

File diff suppressed because one or more lines are too long

View File

@ -631,6 +631,7 @@ function PrioritySectorCard({ sector, rank }: { sector: SectorData; rank: number
} }
function FocusStockCard({ rec }: { rec: RecommendationData }) { function FocusStockCard({ rec }: { rec: RecommendationData }) {
const execution = getExecutionMeta(rec);
const stripeClass = const stripeClass =
rec.action_plan === "可操作" rec.action_plan === "可操作"
? "bg-red-400" ? "bg-red-400"
@ -655,6 +656,7 @@ function FocusStockCard({ rec }: { rec: RecommendationData }) {
<div className="min-w-0 flex items-center gap-2"> <div className="min-w-0 flex items-center gap-2">
<span className="truncate text-sm font-semibold text-text-primary">{rec.name}</span> <span className="truncate text-sm font-semibold text-text-primary">{rec.name}</span>
<span className={`shrink-0 text-[10px] font-medium ${labelClass}`}>{rec.action_plan ?? "观察"}</span> <span className={`shrink-0 text-[10px] font-medium ${labelClass}`}>{rec.action_plan ?? "观察"}</span>
{execution.label ? <span className={`shrink-0 text-[10px] font-medium ${execution.textClass}`}>{execution.label}</span> : null}
</div> </div>
{rec.suggested_position_pct != null ? ( {rec.suggested_position_pct != null ? (
<span className="shrink-0 font-mono text-[11px] tabular-nums text-text-muted">{rec.suggested_position_pct}%</span> <span className="shrink-0 font-mono text-[11px] tabular-nums text-text-muted">{rec.suggested_position_pct}%</span>
@ -687,6 +689,7 @@ function FocusStockCard({ rec }: { rec: RecommendationData }) {
function LargeFocusStockCard({ rec }: { rec: RecommendationData }) { function LargeFocusStockCard({ rec }: { rec: RecommendationData }) {
const isActionable = rec.action_plan === "可操作"; const isActionable = rec.action_plan === "可操作";
const execution = getExecutionMeta(rec);
const badgeClass = isActionable const badgeClass = isActionable
? "border-red-500/15 bg-red-500/[0.08] text-red-400" ? "border-red-500/15 bg-red-500/[0.08] text-red-400"
: rec.action_plan === "重点关注" : rec.action_plan === "重点关注"
@ -703,6 +706,11 @@ function LargeFocusStockCard({ rec }: { rec: RecommendationData }) {
{rec.action_plan ?? "观察"} {rec.action_plan ?? "观察"}
</span> </span>
</div> </div>
{execution.label ? (
<div className={`mt-2 inline-flex rounded-lg border px-2 py-1 text-[10px] font-medium ${execution.className}`}>
{execution.label}
</div>
) : null}
<div className="mt-3 grid grid-cols-3 gap-2"> <div className="mt-3 grid grid-cols-3 gap-2">
<TinyInfo label="评分" value={Math.round(rec.score ?? 0)} /> <TinyInfo label="评分" value={Math.round(rec.score ?? 0)} />
<TinyInfo label="仓位" value={rec.suggested_position_pct != null ? `${rec.suggested_position_pct}%` : "-"} /> <TinyInfo label="仓位" value={rec.suggested_position_pct != null ? `${rec.suggested_position_pct}%` : "-"} />
@ -711,11 +719,44 @@ function LargeFocusStockCard({ rec }: { rec: RecommendationData }) {
<div className="mt-3 line-clamp-2 text-xs leading-5 text-text-secondary"> <div className="mt-3 line-clamp-2 text-xs leading-5 text-text-secondary">
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待新的触发条件。"} {rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
</div> </div>
{execution.note ? (
<div className="mt-2 line-clamp-1 text-[11px] text-cyan-300/85">{execution.note}</div>
) : null}
{rec.sector ? <div className="mt-2 truncate text-[11px] text-text-muted">{rec.sector}</div> : null} {rec.sector ? <div className="mt-2 truncate text-[11px] text-text-muted">{rec.sector}</div> : null}
</a> </a>
); );
} }
function getExecutionMeta(rec: RecommendationData) {
const hint = rec.decision_trace?.position_adjustment?.hint ?? rec.decision_trace?.context?.position_hint ?? "neutral";
const note = rec.decision_trace?.position_adjustment?.notes?.[0] ?? "";
if (hint === "wait_pullback") {
return {
label: "等回踩",
note: note || "位置偏高,先等回踩或缩量承接。",
className: "border-cyan-500/20 bg-cyan-500/10 text-cyan-300",
textClass: "text-cyan-300",
};
}
if (hint === "wait_confirm") {
return {
label: "等确认",
note: note || "等待承接和板块前排确认。",
className: "border-amber-500/20 bg-amber-500/10 text-amber-300",
textClass: "text-amber-300",
};
}
if (hint === "actionable_pullback") {
return {
label: "回踩优先",
note: note || "回踩买点且位置相对安全。",
className: "border-emerald-500/20 bg-emerald-500/10 text-emerald-300",
textClass: "text-emerald-300",
};
}
return { label: "", note: "", className: "", textClass: "" };
}
function TinyInfo({ label, value }: { label: string; value: string | number }) { function TinyInfo({ label, value }: { label: string; value: string | number }) {
return ( return (
<div className="rounded-lg bg-bg-primary/45 px-2 py-1.5"> <div className="rounded-lg bg-bg-primary/45 px-2 py-1.5">

View File

@ -4,6 +4,7 @@ import type { RecommendationData } from "@/lib/api";
export default function StockCard({ rec, compact = false }: { rec: RecommendationData; compact?: boolean }) { export default function StockCard({ rec, compact = false }: { rec: RecommendationData; compact?: boolean }) {
const action = getActionMeta(rec.action_plan); const action = getActionMeta(rec.action_plan);
const execution = getExecutionMeta(rec);
const trigger = rec.trigger_condition ?? rec.entry_timing ?? rec.decision_trace?.headline ?? rec.reasons?.[0] ?? "等待触发条件确认"; const trigger = rec.trigger_condition ?? rec.entry_timing ?? rec.decision_trace?.headline ?? rec.reasons?.[0] ?? "等待触发条件确认";
const risk = rec.invalidation_condition ?? rec.risk_note ?? "暂无明确失效条件"; const risk = rec.invalidation_condition ?? rec.risk_note ?? "暂无明确失效条件";
const thesis = rec.decision_trace?.headline ?? rec.reasons?.[0] ?? rec.focus_points?.[0] ?? "等待更多盘面证据"; const thesis = rec.decision_trace?.headline ?? rec.reasons?.[0] ?? rec.focus_points?.[0] ?? "等待更多盘面证据";
@ -21,6 +22,11 @@ export default function StockCard({ rec, compact = false }: { rec: Recommendatio
<span className={`rounded-lg border px-2 py-0.5 text-[10px] font-medium ${action.className}`}> <span className={`rounded-lg border px-2 py-0.5 text-[10px] font-medium ${action.className}`}>
{action.label} {action.label}
</span> </span>
{execution.label ? (
<span className={`rounded-lg border px-2 py-0.5 text-[10px] font-medium ${execution.className}`}>
{execution.label}
</span>
) : null}
</div> </div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-text-muted"> <div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-text-muted">
<span className="font-mono tabular-nums">{rec.ts_code}</span> <span className="font-mono tabular-nums">{rec.ts_code}</span>
@ -46,10 +52,17 @@ export default function StockCard({ rec, compact = false }: { rec: Recommendatio
</div> </div>
{!compact ? ( {!compact ? (
<div className="mt-3 rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-2"> <div className="mt-3 space-y-2">
{execution.note ? (
<div className={`rounded-xl border px-3 py-2 text-xs leading-5 ${execution.panelClassName}`}>
{execution.note}
</div>
) : null}
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-text-muted"></div> <div className="text-[10px] font-semibold uppercase tracking-wider text-text-muted"></div>
<div className="mt-1 text-xs leading-5 text-text-secondary line-clamp-2">{thesis}</div> <div className="mt-1 text-xs leading-5 text-text-secondary line-clamp-2">{thesis}</div>
</div> </div>
</div>
) : null} ) : null}
<div className="mt-3 flex flex-wrap gap-1.5"> <div className="mt-3 flex flex-wrap gap-1.5">
@ -84,6 +97,36 @@ function getActionMeta(actionPlan?: string | null) {
return { label: "观察", className: "border-border-subtle bg-surface-2 text-text-muted" }; return { label: "观察", className: "border-border-subtle bg-surface-2 text-text-muted" };
} }
function getExecutionMeta(rec: RecommendationData) {
const hint = rec.decision_trace?.position_adjustment?.hint ?? rec.decision_trace?.context?.position_hint ?? "neutral";
const note = rec.decision_trace?.position_adjustment?.notes?.[0] ?? "";
if (hint === "wait_pullback") {
return {
label: "等回踩",
note: note || "位置偏高,先等回踩或缩量承接,不追高。",
className: "border-cyan-500/20 bg-cyan-500/10 text-cyan-300",
panelClassName: "border-cyan-500/15 bg-cyan-500/[0.05] text-cyan-300/90",
};
}
if (hint === "wait_confirm") {
return {
label: "等确认",
note: note || "等待次日承接和板块前排确认后再处理。",
className: "border-amber-500/20 bg-amber-500/10 text-amber-300",
panelClassName: "border-amber-500/15 bg-amber-500/[0.05] text-amber-300/90",
};
}
if (hint === "actionable_pullback") {
return {
label: "回踩优先",
note: note || "回踩买点且位置相对安全,可优先观察触发。",
className: "border-emerald-500/20 bg-emerald-500/10 text-emerald-300",
panelClassName: "border-emerald-500/15 bg-emerald-500/[0.05] text-emerald-300/90",
};
}
return { label: "", note: "", className: "", panelClassName: "" };
}
function buildChips(rec: RecommendationData) { function buildChips(rec: RecommendationData) {
const recallLabels: Record<string, string> = { const recallLabels: Record<string, string> = {
sector_recall: "主线入选", sector_recall: "主线入选",
@ -97,6 +140,10 @@ function buildChips(rec: RecommendationData) {
}; };
return [ return [
rec.decision_trace?.position_adjustment?.hint === "wait_pullback" ? "不追高" : null,
rec.decision_trace?.position_adjustment?.hint === "wait_confirm" ? "确认后再动" : null,
rec.decision_trace?.position_adjustment?.hint === "actionable_pullback" ? "回踩买点" : null,
rec.decision_trace?.context?.sector_stage ? `阶段 ${stageLabel(rec.decision_trace.context.sector_stage)}` : null,
rec.entry_signal_type ? signalTypeLabel(rec.entry_signal_type) : null, rec.entry_signal_type ? signalTypeLabel(rec.entry_signal_type) : null,
rec.review_after_days ? `${rec.review_after_days}日复盘` : null, rec.review_after_days ? `${rec.review_after_days}日复盘` : null,
rec.score != null ? `规则分 ${Math.round(rec.score)}` : null, rec.score != null ? `规则分 ${Math.round(rec.score)}` : null,
@ -107,8 +154,22 @@ function buildChips(rec: RecommendationData) {
function signalTypeLabel(type: string) { function signalTypeLabel(type: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
breakout: "突破", breakout: "突破",
breakout_confirm: "突破确认",
pullback: "回踩", pullback: "回踩",
launch: "启动", launch: "启动",
reversal: "反转",
flow_momentum: "资金顺势",
none: "无信号",
}; };
return labels[type] ?? type; return labels[type] ?? type;
} }
function stageLabel(stage: string) {
const labels: Record<string, string> = {
early: "启动",
mid: "扩散",
late: "后段",
end: "退潮",
};
return labels[stage] ?? stage;
}

View File

@ -174,6 +174,19 @@ export interface DecisionTrace {
boosts?: Array<{ label: string; value?: string; reason?: string }>; boosts?: Array<{ label: string; value?: string; reason?: string }>;
penalties?: Array<{ label: string; value?: string; reason?: string }>; penalties?: Array<{ label: string; value?: string; reason?: string }>;
risk_tags?: string[]; risk_tags?: string[];
context?: {
market_temperature?: number;
sector_stage?: string;
sector_limit_up?: number;
theme_matched?: boolean;
theme_name?: string;
position_hint?: "neutral" | "wait_pullback" | "wait_confirm" | "actionable_pullback" | string;
};
position_adjustment?: {
multiplier?: number;
hint?: "neutral" | "wait_pullback" | "wait_confirm" | "actionable_pullback" | string;
notes?: string[];
};
catalyst?: { catalyst?: {
score?: number; score?: number;
reasons?: string[]; reasons?: string[];