1
This commit is contained in:
parent
d07af5508a
commit
9250627dc0
@ -41,6 +41,7 @@ async def scan_trend_breakout(
|
||||
"""
|
||||
if not 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}) ===")
|
||||
|
||||
@ -80,8 +81,9 @@ def _bulk_pre_filter(trade_date: str) -> pd.DataFrame:
|
||||
logger.warning("Phase 1: 无法获取交易日历")
|
||||
return pd.DataFrame()
|
||||
|
||||
# 取最近 5 个交易日(含当日)
|
||||
recent_dates = dates[-5:] if len(dates) >= 5 else dates
|
||||
# 取目标日之前最近 5 个交易日,避免早盘把尚无日频行情的当天纳入窗口。
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""使用 daily_basic 过滤市值、换手率,排除 ST 和次新"""
|
||||
basic = tushare_client.get_daily_basic(trade_date)
|
||||
|
||||
@ -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()
|
||||
intraday = should_prefer_realtime_today(latest_trade_date)
|
||||
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 '历史收盘'} ===")
|
||||
|
||||
# ── 市场温度 ──
|
||||
@ -103,8 +104,11 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
||||
# ── Step 1: 主线主题定位 ──
|
||||
logger.info("=== Step 1: 主线主题定位 ===")
|
||||
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:
|
||||
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 = [
|
||||
@ -116,7 +120,7 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
||||
logger.info("无合格主线主题(需要资金/实时强度+非尾声),回退到全部主题")
|
||||
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(
|
||||
scan_session=scan_session,
|
||||
scan_mode=scan_mode,
|
||||
@ -148,7 +152,9 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
||||
|
||||
# 如果主题来自 Tushare 快照,盘中用实时行情更新后再次归一到主题。
|
||||
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)
|
||||
logger.info(
|
||||
@ -172,7 +178,7 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
||||
candidate_metrics: dict = {}
|
||||
candidates = await _build_candidate_pool(
|
||||
hot_sectors=hot_sectors,
|
||||
trade_date=trade_date,
|
||||
trade_date=analysis_trade_date,
|
||||
intraday=intraday,
|
||||
market_temp=market_temp,
|
||||
metrics=candidate_metrics,
|
||||
@ -267,10 +273,15 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
||||
|
||||
before_final_filter = len(recommendations)
|
||||
final_filter_reasons = _build_final_filter_reasons(recommendations, strategy_profile)
|
||||
recommendations = [
|
||||
strict_recommendations = [
|
||||
r for r in recommendations
|
||||
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)
|
||||
|
||||
recommendations = _finalize_battle_plan(
|
||||
@ -366,6 +377,111 @@ async def _apply_catalyst_scores(sectors: list[SectorInfo]) -> list[SectorInfo]:
|
||||
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(
|
||||
hot_sectors: list[SectorInfo],
|
||||
trade_date: str,
|
||||
@ -773,6 +889,45 @@ def _finalize_battle_plan(
|
||||
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(
|
||||
candidates: list[dict],
|
||||
market_temp: MarketTemperature,
|
||||
@ -934,6 +1089,14 @@ async def _build_recommendations(
|
||||
if 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 = []
|
||||
if sector_limit_up >= 5:
|
||||
final_score *= 1.20
|
||||
@ -1093,6 +1256,7 @@ async def _build_recommendations(
|
||||
score=final_score,
|
||||
market_temp=market_temp,
|
||||
sector_stage=sector_stage,
|
||||
position_hint=position_adjustment["hint"],
|
||||
entry_price=entry_price,
|
||||
target_price=target_price,
|
||||
stop_loss=stop_loss,
|
||||
@ -1107,6 +1271,7 @@ async def _build_recommendations(
|
||||
market_temp_score=market_temp_score,
|
||||
sector_stage=sector_stage,
|
||||
hot_theme_match=hot_theme_match,
|
||||
position_adjustment=position_adjustment,
|
||||
)
|
||||
decision_trace = _build_decision_trace(
|
||||
stock=stock,
|
||||
@ -1133,6 +1298,7 @@ async def _build_recommendations(
|
||||
penalties=penalty_notes,
|
||||
risk_tags=risk_tags,
|
||||
hot_theme_match=hot_theme_match,
|
||||
position_adjustment=position_adjustment,
|
||||
)
|
||||
|
||||
rec = Recommendation(
|
||||
@ -1832,11 +1998,60 @@ def _generate_entry_timing(signal_type: str, intraday: bool) -> str:
|
||||
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(
|
||||
signal_type: str,
|
||||
score: float,
|
||||
market_temp: MarketTemperature,
|
||||
sector_stage: str,
|
||||
position_hint: str,
|
||||
entry_price: float | None,
|
||||
target_price: float | None,
|
||||
stop_loss: float | None,
|
||||
@ -1856,9 +2071,14 @@ def _build_trade_plan(
|
||||
"flow_momentum": "资金顺势确认",
|
||||
}.get(signal_type, "资金与量价信号")
|
||||
|
||||
wait_position = position_hint in {"wait_pullback", "wait_confirm"}
|
||||
|
||||
if market_temp.temperature < 40 or sector_stage in ("end",):
|
||||
action_plan = "观察"
|
||||
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":
|
||||
action_plan = "可操作"
|
||||
lifecycle_status = "actionable"
|
||||
@ -1885,10 +2105,17 @@ def _build_trade_plan(
|
||||
base_position -= 5
|
||||
if sector_stage == "late":
|
||||
base_position -= 5
|
||||
if wait_position:
|
||||
base_position = min(base_position, 8)
|
||||
suggested_position_pct = max(0, min(base_position, 30))
|
||||
|
||||
price_part = f"参考价 {entry_price}" if entry_price else "参考当前价"
|
||||
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}"
|
||||
|
||||
invalid_parts = []
|
||||
@ -2213,6 +2440,7 @@ def _build_penalty_notes(
|
||||
market_temp_score: float,
|
||||
sector_stage: str,
|
||||
hot_theme_match: SectorInfo | None,
|
||||
position_adjustment: dict | None = None,
|
||||
) -> list[dict]:
|
||||
notes: list[dict] = []
|
||||
if trend_penalty < 1:
|
||||
@ -2228,6 +2456,13 @@ def _build_penalty_notes(
|
||||
if theme_penalty < 1:
|
||||
label = "未匹配主线" if not hot_theme_match else "非前排主线"
|
||||
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:
|
||||
notes.append({"label": "风险折扣", "value": f"-{round((1 - min(penalties)) * 100)}%", "reason": "存在风险项"})
|
||||
return notes[:4]
|
||||
@ -2258,6 +2493,7 @@ def _build_decision_trace(
|
||||
penalties: list[dict],
|
||||
risk_tags: list[str],
|
||||
hot_theme_match: SectorInfo | None,
|
||||
position_adjustment: dict | None = None,
|
||||
) -> dict:
|
||||
tags = stock.get("recall_tags", []) or []
|
||||
headline = _build_decision_headline(
|
||||
@ -2334,6 +2570,7 @@ def _build_decision_trace(
|
||||
"sector_limit_up": sector_limit_up,
|
||||
"theme_matched": bool(hot_theme_match),
|
||||
"theme_name": hot_theme_match.sector_name if hot_theme_match else "",
|
||||
"position_hint": (position_adjustment or {}).get("hint", "neutral"),
|
||||
},
|
||||
"catalyst": {
|
||||
"score": round(catalyst_score, 1),
|
||||
@ -2341,6 +2578,7 @@ def _build_decision_trace(
|
||||
},
|
||||
"boosts": boosts[:4],
|
||||
"penalties": penalties[:4],
|
||||
"position_adjustment": position_adjustment or {"multiplier": 1.0, "hint": "neutral", "notes": []},
|
||||
"risk_tags": risk_tags,
|
||||
"llm_adjustment": None,
|
||||
}
|
||||
|
||||
Binary file not shown.
@ -1,9 +1,9 @@
|
||||
{
|
||||
"pages": {
|
||||
"/(auth)/dashboard/page": [
|
||||
"/(auth)/recommendations/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/dashboard/page.js"
|
||||
"static/chunks/app/(auth)/recommendations/page.js"
|
||||
],
|
||||
"/(auth)/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
@ -15,16 +15,6 @@
|
||||
"static/chunks/main-app.js",
|
||||
"static/css/app/layout.css",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
{
|
||||
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
||||
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
|
||||
"/(auth)/strategy/page": "app/(auth)/strategy/page.js"
|
||||
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "s0qcbsgwKrVRZ+Vvkywm9IM+ti0wNq5+7QtEBPqabTc="
|
||||
"encryptionKey": "d4VN2R1pidVsk7vdfLIPEMxazAw72u3gfaTtAbGckJc="
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -631,6 +631,7 @@ function PrioritySectorCard({ sector, rank }: { sector: SectorData; rank: number
|
||||
}
|
||||
|
||||
function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
||||
const execution = getExecutionMeta(rec);
|
||||
const stripeClass =
|
||||
rec.action_plan === "可操作"
|
||||
? "bg-red-400"
|
||||
@ -655,6 +656,7 @@ function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
||||
<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={`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>
|
||||
{rec.suggested_position_pct != null ? (
|
||||
<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 }) {
|
||||
const isActionable = rec.action_plan === "可操作";
|
||||
const execution = getExecutionMeta(rec);
|
||||
const badgeClass = isActionable
|
||||
? "border-red-500/15 bg-red-500/[0.08] text-red-400"
|
||||
: rec.action_plan === "重点关注"
|
||||
@ -703,6 +706,11 @@ function LargeFocusStockCard({ rec }: { rec: RecommendationData }) {
|
||||
{rec.action_plan ?? "观察"}
|
||||
</span>
|
||||
</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">
|
||||
<TinyInfo label="评分" value={Math.round(rec.score ?? 0)} />
|
||||
<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">
|
||||
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
|
||||
</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}
|
||||
</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 }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-bg-primary/45 px-2 py-1.5">
|
||||
|
||||
@ -4,6 +4,7 @@ import type { RecommendationData } from "@/lib/api";
|
||||
|
||||
export default function StockCard({ rec, compact = false }: { rec: RecommendationData; compact?: boolean }) {
|
||||
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 risk = rec.invalidation_condition ?? rec.risk_note ?? "暂无明确失效条件";
|
||||
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}`}>
|
||||
{action.label}
|
||||
</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 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>
|
||||
@ -46,10 +52,17 @@ export default function StockCard({ rec, compact = false }: { rec: Recommendatio
|
||||
</div>
|
||||
|
||||
{!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="mt-1 text-xs leading-5 text-text-secondary line-clamp-2">{thesis}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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" };
|
||||
}
|
||||
|
||||
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) {
|
||||
const recallLabels: Record<string, string> = {
|
||||
sector_recall: "主线入选",
|
||||
@ -97,6 +140,10 @@ function buildChips(rec: RecommendationData) {
|
||||
};
|
||||
|
||||
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.review_after_days ? `${rec.review_after_days}日复盘` : null,
|
||||
rec.score != null ? `规则分 ${Math.round(rec.score)}` : null,
|
||||
@ -107,8 +154,22 @@ function buildChips(rec: RecommendationData) {
|
||||
function signalTypeLabel(type: string) {
|
||||
const labels: Record<string, string> = {
|
||||
breakout: "突破",
|
||||
breakout_confirm: "突破确认",
|
||||
pullback: "回踩",
|
||||
launch: "启动",
|
||||
reversal: "反转",
|
||||
flow_momentum: "资金顺势",
|
||||
none: "无信号",
|
||||
};
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
function stageLabel(stage: string) {
|
||||
const labels: Record<string, string> = {
|
||||
early: "启动",
|
||||
mid: "扩散",
|
||||
late: "后段",
|
||||
end: "退潮",
|
||||
};
|
||||
return labels[stage] ?? stage;
|
||||
}
|
||||
|
||||
@ -174,6 +174,19 @@ export interface DecisionTrace {
|
||||
boosts?: Array<{ label: string; value?: string; reason?: string }>;
|
||||
penalties?: Array<{ label: string; value?: string; reason?: 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?: {
|
||||
score?: number;
|
||||
reasons?: string[];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user