astock-agent/backend/app/llm/strategy_selector.py
2026-04-22 11:02:19 +08:00

212 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""动态策略选择器
在固定筛选引擎前增加一层“先选打法,再选股票”的策略决策。
规则负责稳定分类LLM 负责补充语义判断和操作建议。
"""
import json
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
notes: list[str] = []
generated_by: str = "rules"
async def select_strategy_profile(
market_temp: MarketTemperature | None,
hot_sectors: list[SectorInfo],
intraday: bool,
) -> StrategyProfile:
profile = _select_rule_profile(market_temp, hot_sectors, intraday)
if settings.deepseek_api_key:
llm_profile = await _select_llm_profile(market_temp, hot_sectors, intraday, profile)
if llm_profile:
profile = llm_profile
return profile
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 StrategyProfile(
strategy_id="breakout_attack",
name="主线突破",
description="市场偏强,优先寻找主线板块内的突破和突破确认。",
entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"],
score_weights={"supply_demand": 0.45, "price_action": 0.35, "trend": 0.20},
min_score=62,
buy_threshold=66,
max_position_pct=30,
notes=["优先做主线早中期板块", "放量突破优先于回踩低吸"],
)
if temp >= 45 and late_count < 2:
return StrategyProfile(
strategy_id="pullback_rotation",
name="回踩轮动",
description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。",
entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"],
score_weights={"supply_demand": 0.40, "price_action": 0.30, "trend": 0.30},
min_score=60,
buy_threshold=63,
max_position_pct=20,
notes=["降低追高仓位", "更看重位置安全和回踩承接"],
)
if temp >= 30:
return StrategyProfile(
strategy_id="launch_probe",
name="启动试错",
description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。",
entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"],
score_weights={"supply_demand": 0.35, "price_action": 0.35, "trend": 0.30},
min_score=58,
buy_threshold=61,
max_position_pct=10,
notes=["仅做小仓位试错", "突破型需要更强板块一致性才可介入"],
)
return StrategyProfile(
strategy_id="defensive_watch",
name="防守观察",
description="市场退潮,系统以观察池为主,不主动扩大出手。",
entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"],
score_weights={"supply_demand": 0.35, "price_action": 0.40, "trend": 0.25},
min_score=56,
buy_threshold=64,
max_position_pct=5,
notes=["原则上只保留观察池", "等待市场温度修复后再转入主动进攻"],
)
async def _select_llm_profile(
market_temp: MarketTemperature | None,
hot_sectors: list[SectorInfo],
intraday: bool,
fallback: StrategyProfile,
) -> StrategyProfile | None:
from app.llm.client import chat_completion
sector_text = "\n".join(
f"- {s.sector_name}: 涨幅{s.pct_change}%, 热度{s.heat_score}, 阶段{s.stage}, 涨停{s.limit_up_count}"
for s in hot_sectors[:5]
) or "暂无板块数据"
user_msg = f"""你需要为今日A股环境选择一个短线策略模板。
市场温度: {market_temp.temperature if market_temp else 0}
上涨家数: {market_temp.up_count if market_temp else 0}
下跌家数: {market_temp.down_count if market_temp else 0}
涨停数: {market_temp.limit_up_count if market_temp else 0}
炸板率: {market_temp.broken_rate if market_temp else 0}
盘中模式: {'' if intraday else ''}
热门板块:
{sector_text}
规则候选策略:
- breakout_attack: 主线突破
- pullback_rotation: 回踩轮动
- launch_probe: 启动试错
- defensive_watch: 防守观察
请输出 JSON格式:
{{
"strategy_id": "上面四选一",
"notes": ["两条以内理由"],
"buy_threshold_delta": -3到3之间的整数
}}
"""
resp = await chat_completion([
{"role": "system", "content": "你是一位A股短线策略研究员只能在给定策略模板中选择不要发明新策略。回复必须是 JSON。"},
{"role": "user", "content": user_msg},
])
if not resp or not resp.content:
return None
try:
data = json.loads(resp.content)
strategy_id = data.get("strategy_id")
if strategy_id not in {"breakout_attack", "pullback_rotation", "launch_probe", "defensive_watch"}:
return None
selected = _select_rule_profile(market_temp, hot_sectors, intraday)
if selected.strategy_id != strategy_id:
selected = {
"breakout_attack": StrategyProfile(
strategy_id="breakout_attack",
name="主线突破",
description="市场偏强,优先寻找主线板块内的突破和突破确认。",
entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"],
score_weights={"supply_demand": 0.45, "price_action": 0.35, "trend": 0.20},
min_score=62,
buy_threshold=66,
max_position_pct=30,
),
"pullback_rotation": StrategyProfile(
strategy_id="pullback_rotation",
name="回踩轮动",
description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。",
entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"],
score_weights={"supply_demand": 0.40, "price_action": 0.30, "trend": 0.30},
min_score=60,
buy_threshold=63,
max_position_pct=20,
),
"launch_probe": StrategyProfile(
strategy_id="launch_probe",
name="启动试错",
description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。",
entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"],
score_weights={"supply_demand": 0.35, "price_action": 0.35, "trend": 0.30},
min_score=58,
buy_threshold=61,
max_position_pct=10,
),
"defensive_watch": StrategyProfile(
strategy_id="defensive_watch",
name="防守观察",
description="市场退潮,系统以观察池为主,不主动扩大出手。",
entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"],
score_weights={"supply_demand": 0.35, "price_action": 0.40, "trend": 0.25},
min_score=56,
buy_threshold=64,
max_position_pct=5,
),
}[strategy_id]
delta = int(data.get("buy_threshold_delta", 0))
delta = max(-3, min(3, delta))
selected.buy_threshold += delta
selected.notes.extend(data.get("notes", [])[:2])
selected.generated_by = "rules+llm"
return selected
except Exception as e:
logger.debug(f"LLM 策略选择解析失败: {e}")
return fallback