167 lines
7.0 KiB
Python
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")
|