1
This commit is contained in:
parent
d07af5508a
commit
9250627dc0
@ -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)
|
||||||
|
|||||||
@ -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.
@ -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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
@ -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
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user