astock-agent/backend/app/llm/strategy_selector.py
2026-06-01 21:29:26 +08:00

167 lines
7.0 KiB
Python

"""动态策略选择器
根据市场温度和板块状态,纯规则选择当日策略 profile。
不再使用 LLM 或数据库配置覆盖。
"""
import logging
from pydantic import BaseModel
from app.config import settings
from app.data.models import MarketTemperature, SectorInfo
logger = logging.getLogger(__name__)
class StrategyProfile(BaseModel):
strategy_id: str
name: str
description: str
entry_signal_priority: list[str]
score_weights: dict[str, float]
min_score: float
buy_threshold: float
max_position_pct: float
allow_trading: bool = True
actionable_limit: int = 2
watch_limit: int = 4
target_focus_sectors: int = 2
market_stance: str = ""
decision_note: str = ""
notes: list[str] = []
feedback_applied: bool = False
feedback_notes: list[str] = []
generated_by: str = "rules"
def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
normalized = strategy_id or "defensive_watch"
if normalized == "trend_breakout":
normalized = "breakout_attack"
actionable_cap = max(0, settings.actionable_limit)
watch_cap = max(0, settings.watch_limit)
profiles = {
"pre_market_ambush": StrategyProfile(
strategy_id="pre_market_ambush",
name="盘前埋伏",
description="盘前选出缩量整理到位、回踩支撑、尚未启动的埋伏标的。",
entry_signal_priority=["launch", "pullback", "breakout_confirm", "reversal", "breakout"],
score_weights={"catalyst": 0.25, "theme_money": 0.22, "stock_money": 0.18, "emotion_role": 0.10, "timing": 0.25},
min_score=55,
buy_threshold=58,
max_position_pct=25,
allow_trading=True,
actionable_limit=min(3, actionable_cap),
watch_limit=min(5, watch_cap),
target_focus_sectors=3,
market_stance="埋伏待发",
decision_note="盘前选票重点看整理充分度和催化预期,不追已启动标的。",
notes=["优先缩量整理到位+催化预期", "回踩支撑位附近的主线成分优先"],
),
"breakout_attack": StrategyProfile(
strategy_id="breakout_attack",
name="主线突破",
description="市场偏强,优先寻找主线板块内的突破和突破确认。",
entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"],
score_weights={"catalyst": 0.30, "theme_money": 0.25, "stock_money": 0.20, "emotion_role": 0.15, "timing": 0.10},
min_score=62,
buy_threshold=66,
max_position_pct=30,
allow_trading=True,
actionable_limit=min(3, actionable_cap),
watch_limit=min(4, watch_cap),
target_focus_sectors=2,
market_stance="主线进攻",
decision_note="只处理最强主线前排,不扩散到跟风和后排。",
notes=["优先做主线早中期板块", "放量突破优先于回踩低吸"],
),
"pullback_rotation": StrategyProfile(
strategy_id="pullback_rotation",
name="回踩轮动",
description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。",
entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"],
score_weights={"catalyst": 0.25, "theme_money": 0.28, "stock_money": 0.20, "emotion_role": 0.12, "timing": 0.15},
min_score=60,
buy_threshold=63,
max_position_pct=20,
allow_trading=True,
actionable_limit=min(2, actionable_cap),
watch_limit=min(5, watch_cap),
target_focus_sectors=2,
market_stance="轮动低吸",
decision_note="先等回踩承接和板块回流,再决定是否出手。",
notes=["降低追高仓位", "更看重位置安全和回踩承接"],
),
"launch_probe": StrategyProfile(
strategy_id="launch_probe",
name="启动试错",
description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。",
entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"],
score_weights={"catalyst": 0.28, "theme_money": 0.22, "stock_money": 0.20, "emotion_role": 0.12, "timing": 0.18},
min_score=58,
buy_threshold=61,
max_position_pct=10,
allow_trading=True,
actionable_limit=min(1, actionable_cap),
watch_limit=min(4, watch_cap),
target_focus_sectors=1,
market_stance="轻仓试错",
decision_note="只有极少数启动确认标的值得小仓试错。",
notes=["仅做小仓位试错", "突破型需要更强板块一致性才可介入"],
),
"defensive_watch": StrategyProfile(
strategy_id="defensive_watch",
name="防守观察",
description="市场退潮,系统以观察池为主,不主动扩大出手。",
entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"],
score_weights={"catalyst": 0.22, "theme_money": 0.25, "stock_money": 0.18, "emotion_role": 0.15, "timing": 0.20},
min_score=56,
buy_threshold=64,
max_position_pct=5,
allow_trading=False,
actionable_limit=0,
watch_limit=min(3, watch_cap),
target_focus_sectors=1,
market_stance="防守观察",
decision_note="今天不主动出手,只保留少量观察名单。",
notes=["原则上只保留观察池", "等待市场温度修复后再转入主动进攻"],
),
}
return profiles.get(normalized, profiles["defensive_watch"]).model_copy(deep=True)
async def select_strategy_profile(
market_temp: MarketTemperature | None,
hot_sectors: list[SectorInfo],
intraday: bool,
scan_session: str = "",
) -> StrategyProfile:
"""纯规则策略选择。盘前埋伏 session 走独立 profile。"""
if scan_session == "pre_market_ambush" or scan_session == "pre_market":
return get_strategy_profile_by_id("pre_market_ambush")
return _select_rule_profile(market_temp, hot_sectors, intraday)
def _select_rule_profile(
market_temp: MarketTemperature | None,
hot_sectors: list[SectorInfo],
intraday: bool,
) -> StrategyProfile:
temp = market_temp.temperature if market_temp else 0
early_count = sum(1 for s in hot_sectors[:5] if s.stage == "early")
late_count = sum(1 for s in hot_sectors[:5] if s.stage in ("late", "end"))
if temp >= 65 and early_count >= 1:
return get_strategy_profile_by_id("breakout_attack")
if temp >= 45 and late_count < 2:
return get_strategy_profile_by_id("pullback_rotation")
if temp >= 30:
return get_strategy_profile_by_id("launch_probe")
return get_strategy_profile_by_id("defensive_watch")