update fix bug
This commit is contained in:
parent
5205fbd8a8
commit
5075fcc588
Binary file not shown.
@ -80,11 +80,11 @@ def classify_entry_signal(df: pd.DataFrame) -> dict:
|
||||
|
||||
|
||||
def detect_breakout(df: pd.DataFrame) -> dict | None:
|
||||
"""突破型检测
|
||||
"""突破型检测(严格版:必须真正突破 + 显著放量)
|
||||
|
||||
条件:
|
||||
1. 价格突破或贴近 20 日阻力位(close >= resist * 0.98)
|
||||
2. 量能放大 (vol > vol_ma5 * 1.2)
|
||||
1. 收盘价真正突破 20 日高点(close > resist_20d),不再允许"贴近"
|
||||
2. 量能显著放大 (vol > vol_ma5 * 1.5)
|
||||
3. MA5 > MA20(短期偏多)
|
||||
4. 收盘偏强 (close 在当日上半部)
|
||||
"""
|
||||
@ -100,13 +100,12 @@ def detect_breakout(df: pd.DataFrame) -> dict | None:
|
||||
return None
|
||||
resist_20d = df["high"].iloc[-21:-1].max()
|
||||
|
||||
# 放宽:贴近阻力位 2% 即视为突破前兆
|
||||
if last["close"] < resist_20d * 0.98:
|
||||
# 严格:必须真正突破(收盘价 > 20日高点),不允许"贴近未突破"
|
||||
if last["close"] <= resist_20d:
|
||||
return None
|
||||
|
||||
# 量能放大
|
||||
min_vol_ratio = getattr(settings, "breakout_min_volume_ratio", 1.2)
|
||||
if last["vol"] <= last["vol_ma5"] * min_vol_ratio:
|
||||
# 量能显著放大(从 1.2 提高到 1.5)
|
||||
if last["vol"] <= last["vol_ma5"] * 1.5:
|
||||
return None
|
||||
|
||||
# MA5 > MA20
|
||||
@ -124,6 +123,7 @@ def detect_breakout(df: pd.DataFrame) -> dict | None:
|
||||
"signal_type": EntrySignal.BREAKOUT,
|
||||
"details": {
|
||||
"resist_level": round(resist_20d, 2),
|
||||
"resistance_price": round(resist_20d, 2), # 阻力位价格,供止损计算用
|
||||
"breakout_pct": round(breakout_pct, 2),
|
||||
"volume_ratio": round(last["vol"] / last["vol_ma5"], 2),
|
||||
},
|
||||
@ -404,8 +404,7 @@ def _score_breakout(df: pd.DataFrame, signal: dict) -> float:
|
||||
score += 18
|
||||
elif breakout_pct > 0:
|
||||
score += 12
|
||||
else:
|
||||
score += 6 # 贴近未突破
|
||||
# 不再有"贴近未突破"分支,只有真正突破才到这里
|
||||
|
||||
vol_ratio = signal["details"].get("volume_ratio", 1)
|
||||
if vol_ratio > 2.5:
|
||||
|
||||
Binary file not shown.
@ -51,6 +51,7 @@ async def get_latest():
|
||||
"reasons": r.reasons,
|
||||
"risk_note": r.risk_note,
|
||||
"llm_analysis": r.llm_analysis,
|
||||
"entry_timing": r.entry_timing,
|
||||
"llm_score": r.llm_score,
|
||||
"strategy": r.strategy,
|
||||
"entry_signal_type": r.entry_signal_type,
|
||||
|
||||
@ -30,8 +30,8 @@ class Settings(BaseSettings):
|
||||
# 筛选参数
|
||||
top_sector_count: int = 5 # 关注板块数量
|
||||
top_stock_count: int = 20 # 进入技术面筛选的个股数
|
||||
min_turnover_rate: float = 3.0 # 最小换手率 %
|
||||
max_turnover_rate: float = 15.0 # 最大换手率 %
|
||||
min_turnover_rate: float = 2.0 # 最小换手率 %
|
||||
max_turnover_rate: float = 30.0 # 最大换手率 %
|
||||
min_circ_mv: float = 50.0 # 最小流通市值(亿)
|
||||
max_circ_mv: float = 500.0 # 最大流通市值(亿)
|
||||
min_list_days: int = 60 # 最小上市天数
|
||||
|
||||
Binary file not shown.
Binary file not shown.
201
backend/app/data/eastmoney_client.py
Normal file
201
backend/app/data/eastmoney_client.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""东方财富分钟K线数据客户端
|
||||
|
||||
通过 push2his.eastmoney.com 获取 A 股分钟级 K 线数据,
|
||||
用于盘中量能分布分析和短线进场时机判断。
|
||||
|
||||
免费接口,无需认证,注意限频。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import httpx
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
|
||||
from app.data.cache import cache
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 东方财富分钟K线接口
|
||||
EASTMONEY_KLINE_URL = "http://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
HEADERS = {
|
||||
"Referer": "http://finance.eastmoney.com",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
}
|
||||
|
||||
|
||||
def _ts_code_to_eastmoney(ts_code: str) -> str:
|
||||
"""600519.SH -> 1.600519 (上海=1, 深圳=0)"""
|
||||
code, market = ts_code.split(".")
|
||||
prefix = "1" if market == "SH" else "0"
|
||||
return f"{prefix}.{code}"
|
||||
|
||||
|
||||
async def get_min_kline(
|
||||
ts_code: str,
|
||||
period: str = "5",
|
||||
count: int = 48,
|
||||
) -> pd.DataFrame:
|
||||
"""获取分钟级 K 线数据
|
||||
|
||||
Args:
|
||||
ts_code: Tushare 格式代码,如 600519.SH
|
||||
period: 分钟周期 ("1", "5", "15", "30", "60")
|
||||
count: 请求条数(5分钟×48=4小时≈一个交易日)
|
||||
|
||||
Returns:
|
||||
DataFrame with columns: time, open, close, high, low, volume, amount
|
||||
空 DataFrame if failed
|
||||
"""
|
||||
cache_key = f"min_kline:{ts_code}:{period}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
secid = _ts_code_to_eastmoney(ts_code)
|
||||
|
||||
params = {
|
||||
"secid": secid,
|
||||
"fields1": "f1,f2,f3,f4,f5,f6",
|
||||
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61",
|
||||
"klt": period, # 分钟周期
|
||||
"fqt": "1", # 前复权
|
||||
"beg": "0",
|
||||
"end": "20500101",
|
||||
"lmt": str(count), # 返回条数限制
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
EASTMONEY_KLINE_URL,
|
||||
params=params,
|
||||
headers=HEADERS,
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
klines = data.get("data", {}).get("klines", [])
|
||||
if not klines:
|
||||
logger.debug(f"东方财富分钟K线 {ts_code} 无数据")
|
||||
return pd.DataFrame()
|
||||
|
||||
# 解析:格式 "2026-04-15 09:30,5.71,5.73,5.74,5.70,12345,70000.00"
|
||||
rows = []
|
||||
for line in klines:
|
||||
parts = line.split(",")
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
rows.append({
|
||||
"time": parts[0],
|
||||
"open": float(parts[1]),
|
||||
"close": float(parts[2]),
|
||||
"high": float(parts[3]),
|
||||
"low": float(parts[4]),
|
||||
"volume": float(parts[5]),
|
||||
"amount": float(parts[6]),
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
# 缓存(分钟数据盘中3分钟过期,盘后30分钟过期)
|
||||
ttl = 180 if _is_trading_hours() else 1800
|
||||
cache.set(cache_key, df, ttl)
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"东方财富分钟K线获取失败 {ts_code}: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def analyze_intraday_volume_distribution(min_df: pd.DataFrame) -> dict:
|
||||
"""分析盘中量能分布(基于5分钟K线)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"morning_volume_ratio": float, # 上午成交额占比
|
||||
"afternoon_volume_ratio": float, # 下午成交额占比
|
||||
"opening_strength": float, # 开盘30分钟成交额/全天占比
|
||||
"closing_strength": float, # 尾盘30分钟成交额/全天占比
|
||||
"volume_trend": str, # "morning_dominant" / "afternoon_dominant" / "balanced"
|
||||
"key_periods": list[str], # 关键放量时段描述
|
||||
}
|
||||
"""
|
||||
if min_df.empty or len(min_df) < 4:
|
||||
return {
|
||||
"morning_volume_ratio": 0.5,
|
||||
"afternoon_volume_ratio": 0.5,
|
||||
"opening_strength": 0.25,
|
||||
"closing_strength": 0.25,
|
||||
"volume_trend": "balanced",
|
||||
"key_periods": [],
|
||||
}
|
||||
|
||||
total_amount = min_df["amount"].sum()
|
||||
if total_amount == 0:
|
||||
return {
|
||||
"morning_volume_ratio": 0.5,
|
||||
"afternoon_volume_ratio": 0.5,
|
||||
"opening_strength": 0.25,
|
||||
"closing_strength": 0.25,
|
||||
"volume_trend": "balanced",
|
||||
"key_periods": [],
|
||||
}
|
||||
|
||||
# 按时间段分割
|
||||
morning = min_df[min_df["time"].str.contains("09:|10:|11:0", na=False)]
|
||||
afternoon = min_df[min_df["time"].str.contains("13:|14:0|14:1|14:2|14:3|14:4|14:5", na=False)]
|
||||
|
||||
morning_ratio = morning["amount"].sum() / total_amount if not morning.empty else 0.5
|
||||
afternoon_ratio = afternoon["amount"].sum() / total_amount if not afternoon.empty else 0.5
|
||||
|
||||
# 开盘30分钟(9:30-10:00)
|
||||
opening_30 = min_df[min_df["time"].str.contains("09:3|09:4|09:5|10:0", na=False)]
|
||||
opening_strength = opening_30["amount"].sum() / total_amount if not opening_30.empty else 0.25
|
||||
|
||||
# 尾盘30分钟(14:30-15:00)
|
||||
closing_30 = min_df[min_df["time"].str.contains("14:3|14:4|14:5|15:0", na=False)]
|
||||
closing_strength = closing_30["amount"].sum() / total_amount if not closing_30.empty else 0.25
|
||||
|
||||
# 量能趋势判断
|
||||
if morning_ratio > 0.6:
|
||||
volume_trend = "morning_dominant"
|
||||
elif afternoon_ratio > 0.6:
|
||||
volume_trend = "afternoon_dominant"
|
||||
else:
|
||||
volume_trend = "balanced"
|
||||
|
||||
# 关键放量时段
|
||||
key_periods = []
|
||||
avg_amount = min_df["amount"].mean()
|
||||
hot_bars = min_df[min_df["amount"] > avg_amount * 2]
|
||||
for _, bar in hot_bars.iterrows():
|
||||
time_str = bar["time"].split(" ")[1] if " " in bar["time"] else bar["time"]
|
||||
pct = bar["amount"] / total_amount * 100
|
||||
key_periods.append(f"{time_str}放量(占比{pct:.0f}%)")
|
||||
|
||||
return {
|
||||
"morning_volume_ratio": round(morning_ratio, 2),
|
||||
"afternoon_volume_ratio": round(afternoon_ratio, 2),
|
||||
"opening_strength": round(opening_strength, 2),
|
||||
"closing_strength": round(closing_strength, 2),
|
||||
"volume_trend": volume_trend,
|
||||
"key_periods": key_periods[:3],
|
||||
}
|
||||
|
||||
|
||||
def _is_trading_hours() -> bool:
|
||||
"""简单判断是否在交易时间"""
|
||||
now = datetime.now()
|
||||
if now.weekday() >= 5: # 周六周日
|
||||
return False
|
||||
hour = now.hour
|
||||
minute = now.minute
|
||||
# 9:30 - 11:30 或 13:00 - 15:00
|
||||
if (hour == 9 and minute >= 30) or (hour == 10) or (hour == 11 and minute <= 30):
|
||||
return True
|
||||
if (hour == 13) or (hour == 14) or (hour == 15 and minute == 0):
|
||||
return True
|
||||
return False
|
||||
@ -129,6 +129,7 @@ class Recommendation(BaseModel):
|
||||
level: str = "" # 强烈推荐/推荐/观望/回避
|
||||
strategy: str = "trend_breakout" # trend_breakout / momentum(旧) / potential(旧)
|
||||
entry_signal_type: str = "none" # breakout / pullback / launch / none
|
||||
entry_timing: str = "" # 进场时机建议(盘中适用)
|
||||
llm_analysis: str = "" # LLM 深度分析
|
||||
llm_score: float | None = None # AI 评分 1-10
|
||||
scan_session: str = ""
|
||||
|
||||
@ -60,6 +60,9 @@ class TushareClient:
|
||||
return cached
|
||||
result = self._fetch_with_retry(func, *args, **kwargs)
|
||||
if not result.empty:
|
||||
# Tushare 返回降序(最新在前),排序为升序(最旧在前)以便分析
|
||||
if "trade_date" in result.columns:
|
||||
result = result.sort_values("trade_date").reset_index(drop=True)
|
||||
cache.set(cache_key, result, ttl)
|
||||
return result
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -10,7 +10,6 @@ from app.engine.screener import run_screening
|
||||
from app.data.models import Recommendation, MarketTemperature, SectorInfo
|
||||
from app.db.database import get_db
|
||||
from app.db import tables
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -37,12 +36,6 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
||||
# 更新历史推荐跟踪(检查之前推荐的后续表现)
|
||||
await _update_tracking()
|
||||
|
||||
# 异步 AI 深度分析(不阻塞返回)
|
||||
if settings.deepseek_api_key:
|
||||
import asyncio
|
||||
from app.llm.analysis_agent import analyze_recommendations
|
||||
asyncio.create_task(analyze_recommendations(result))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
"""趋势突破统一筛选器(自上而下方案)
|
||||
"""趋势突破统一筛选器(自上而下方案,中短线交易定位)
|
||||
|
||||
三阶段管道:
|
||||
Step 1: 板块定位 — 找到有资金流入的热门板块 (3-5个)
|
||||
Step 2: 板块内选股 — 在热门板块成分股中筛出有资金流入的候选 (30-50只)
|
||||
Step 3: 深度分析 — 供需 + 价格行为 + 趋势 (10-15只推荐)
|
||||
Step 3: 深度分析 — 供需 + 价格行为 + 趋势 + LLM (10-15只推荐)
|
||||
|
||||
评分公式:供需关系 40% + 价格行为 35% + 趋势 25%
|
||||
板块和资金流作为前置过滤条件,不参与评分。
|
||||
评分公式:供需关系 50% + 价格行为 40% + 趋势 10%
|
||||
板块和资金流作为前置过滤条件,板块涨停数作为情绪奖励。
|
||||
|
||||
风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。
|
||||
|
||||
数据源:
|
||||
- 盘中模式:Tushare 日线 + 腾讯实时行情混合
|
||||
- 盘中模式:Tushare 日线 + 腾讯实时行情 + 东方财富5分钟K线
|
||||
- 盘后模式:Tushare 当日完整数据
|
||||
|
||||
止损止盈:基于市场结构(阻力位/支撑MA/近期低点),而非固定百分比。
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -78,7 +82,10 @@ async def run_screening(trade_date: str = None) -> dict:
|
||||
|
||||
# ── Step 2: 板块内选股 ──
|
||||
logger.info("=== Step 2: 板块内选股 ===")
|
||||
candidates = await _select_from_hot_sectors(hot_sectors, trade_date, intraday)
|
||||
if intraday:
|
||||
candidates = await intraday_filter_stocks(hot_sectors)
|
||||
else:
|
||||
candidates = await _select_from_hot_sectors(hot_sectors, trade_date, intraday)
|
||||
|
||||
if not candidates:
|
||||
logger.info("=== Step 2 无候选,回退到全市场扫描 ===")
|
||||
@ -98,9 +105,22 @@ async def run_screening(trade_date: str = None) -> dict:
|
||||
"scan_mode": scan_mode,
|
||||
}
|
||||
|
||||
# ── Step 3 之前:注入腾讯实时价格(防止 Tushare 日线数据过时) ──
|
||||
if candidates:
|
||||
try:
|
||||
from app.data.tencent_client import get_realtime_quotes_batch
|
||||
codes = [c["ts_code"] for c in candidates if "ts_code" in c]
|
||||
quotes = await get_realtime_quotes_batch(codes)
|
||||
for c in candidates:
|
||||
q = quotes.get(c["ts_code"])
|
||||
if q and q.price > 0:
|
||||
c["price"] = q.price
|
||||
except Exception as e:
|
||||
logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}")
|
||||
|
||||
# ── Step 3: 供需 + 价格行为 + 趋势评分 ──
|
||||
logger.info("=== Step 3: 深度分析 ===")
|
||||
recommendations = _build_recommendations(
|
||||
recommendations = await _build_recommendations(
|
||||
candidates, market_temp, hot_sectors, market_temp_score, intraday,
|
||||
)
|
||||
|
||||
@ -194,6 +214,16 @@ async def _select_from_hot_sectors(
|
||||
(basic["turnover_rate"] <= settings.max_turnover_rate)
|
||||
].copy()
|
||||
|
||||
# 严格过滤为空时,放宽换手率条件重试
|
||||
if filtered_basic.empty:
|
||||
logger.info("Step 2 严格过滤无结果,放宽换手率重试")
|
||||
filtered_basic = basic[
|
||||
(basic["ts_code"].isin(sector_member_codes)) &
|
||||
(~basic["ts_code"].isin(exclude_codes)) &
|
||||
(basic["circ_mv"] >= settings.min_circ_mv) &
|
||||
(basic["circ_mv"] <= settings.max_circ_mv)
|
||||
].copy()
|
||||
|
||||
logger.info(f"Step 2 基本面过滤: {len(sector_member_codes)} 只 → {len(filtered_basic)} 只")
|
||||
|
||||
if filtered_basic.empty:
|
||||
@ -277,7 +307,7 @@ async def _select_from_hot_sectors(
|
||||
return candidates
|
||||
|
||||
|
||||
def _build_recommendations(
|
||||
async def _build_recommendations(
|
||||
candidates: list[dict],
|
||||
market_temp: MarketTemperature,
|
||||
hot_sectors: list[SectorInfo],
|
||||
@ -310,6 +340,7 @@ def _build_recommendations(
|
||||
industry_map[row["ts_code"]] = row.get("industry", "")
|
||||
|
||||
recommendations = []
|
||||
llm_candidates = [] # 收集候选摘要供 LLM 分析
|
||||
total = len(candidates)
|
||||
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
|
||||
|
||||
@ -327,6 +358,14 @@ def _build_recommendations(
|
||||
if df.empty or len(df) < 30:
|
||||
continue
|
||||
|
||||
# 数据新鲜度校验:最后一行必须是近 10 天内的数据
|
||||
from datetime import datetime, timedelta
|
||||
last_date = str(df.iloc[-1]["trade_date"])
|
||||
cutoff = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d")
|
||||
if last_date < cutoff:
|
||||
logger.warning(f"K线数据过时 {ts_code}: 最新={last_date}, 需≥{cutoff}, 跳过")
|
||||
continue
|
||||
|
||||
# 添加技术指标
|
||||
df = add_all_indicators(df)
|
||||
|
||||
@ -340,45 +379,64 @@ def _build_recommendations(
|
||||
|
||||
# ── 三维度评分 ──
|
||||
|
||||
# 1. 供需关系评分 (40%)
|
||||
# 1. 供需关系评分 (50%) — 短线核心
|
||||
supply_demand_score = score_supply_demand(df)
|
||||
|
||||
# 2. 价格行为评分 (35%)
|
||||
# 2. 价格行为评分 (40%) — 形态质量
|
||||
price_action_score = _score_price_action(df, entry_signal)
|
||||
|
||||
# 3. 趋势评分 (25%)
|
||||
# 3. 趋势评分 (10%) — 短线趋势权重低,偏空直接过滤
|
||||
trend_score = _score_trend(df)
|
||||
|
||||
# 综合评分
|
||||
# 趋势偏空门槛过滤:MA5<MA10<MA20(空头排列)直接跳过
|
||||
last = df.iloc[-1]
|
||||
if all(c in df.columns for c in ["ma5", "ma10", "ma20"]):
|
||||
if not any(pd.isna(last[c]) for c in ["ma5", "ma10", "ma20"]):
|
||||
if last["ma5"] < last["ma10"] < last["ma20"]:
|
||||
signal_counts["none"] += 1
|
||||
continue
|
||||
|
||||
# 综合评分(短线交易:供需最关键,趋势只做门槛)
|
||||
final_score = (
|
||||
supply_demand_score * 0.40 +
|
||||
price_action_score * 0.35 +
|
||||
trend_score * 0.25
|
||||
supply_demand_score * 0.50 +
|
||||
price_action_score * 0.40 +
|
||||
trend_score * 0.10
|
||||
)
|
||||
|
||||
# 风险乘数
|
||||
# ── 风险乘数:惩罚取最大而非叠加(避免过度惩罚),奖励可叠加 ──
|
||||
tech_signal = generate_signals(ts_code, name)
|
||||
|
||||
# 收集所有惩罚因子(取最大,而非叠加)
|
||||
penalties = []
|
||||
if tech_signal:
|
||||
if tech_signal.rally_pct_5d > 20:
|
||||
final_score *= 0.65
|
||||
penalties.append(0.65)
|
||||
elif tech_signal.rally_pct_5d > 15:
|
||||
final_score *= 0.80
|
||||
penalties.append(0.80)
|
||||
|
||||
# 板块末期惩罚(板块信息来自 hot_sectors)
|
||||
sector_stage = _get_sector_stage(sector, hot_sectors)
|
||||
if sector_stage == "end":
|
||||
final_score *= 0.70
|
||||
penalties.append(0.70)
|
||||
elif sector_stage == "late":
|
||||
final_score *= 0.88
|
||||
penalties.append(0.88)
|
||||
|
||||
# 市场温度风控
|
||||
if market_temp_score < 30:
|
||||
final_score *= 0.75
|
||||
penalties.append(0.75)
|
||||
elif market_temp_score < 50:
|
||||
final_score *= 0.88
|
||||
penalties.append(0.88)
|
||||
|
||||
# 取最大惩罚(1.0 = 无惩罚)
|
||||
if penalties:
|
||||
final_score *= min(penalties)
|
||||
|
||||
# 奖励可叠加(奖励之间互不矛盾)
|
||||
sector_limit_up = _get_sector_limit_up(sector, hot_sectors)
|
||||
sector_member_count = _get_sector_member_count(sector, hot_sectors)
|
||||
if sector_limit_up >= 5:
|
||||
final_score *= 1.20 # 板块5+涨停,情绪极强
|
||||
elif sector_limit_up >= 3:
|
||||
final_score *= 1.10 # 板块3涨停,情绪较强
|
||||
|
||||
# 高置信度入场信号奖励
|
||||
if entry_signal.get("signal_score", 0) >= 80:
|
||||
final_score *= 1.10
|
||||
|
||||
@ -397,29 +455,93 @@ def _build_recommendations(
|
||||
and final_score >= 60):
|
||||
signal = "BUY"
|
||||
|
||||
# 价格参考
|
||||
# 价格参考 — 结构化止损止盈(基于市场结构而非固定百分比)
|
||||
entry_price = None
|
||||
target_price = None
|
||||
stop_loss = None
|
||||
if tech_signal:
|
||||
entry_price = tech_signal.support_price
|
||||
target_price = tech_signal.resist_price
|
||||
stop_loss = tech_signal.stop_loss_price
|
||||
|
||||
details = entry_signal.get("details", {})
|
||||
current_close = stock.get("price") or float(df.iloc[-1]["close"])
|
||||
st = signal_type.value
|
||||
if st in ("breakout", "breakout_confirm") and details.get("resist_level"):
|
||||
entry_price = details["resist_level"]
|
||||
target_price = round(entry_price * 1.05, 2)
|
||||
elif st == "pullback" and details.get("support_price"):
|
||||
entry_price = details["support_price"]
|
||||
target_price = round(entry_price * 1.05, 2)
|
||||
elif st == "launch" and details.get("resist_level"):
|
||||
entry_price = round(details["resist_level"] * 1.01, 2)
|
||||
target_price = round(details["resist_level"] * 1.08, 2)
|
||||
elif st == "reversal" and tech_signal and tech_signal.support_price:
|
||||
entry_price = tech_signal.support_price
|
||||
target_price = round(entry_price * 1.08, 2)
|
||||
details = entry_signal.get("details", {})
|
||||
|
||||
# ── 入场价:统一为当前价(短线看盘即时进场) ──
|
||||
entry_price = round(current_close, 2)
|
||||
|
||||
# ── 止损价:基于市场结构 ──
|
||||
if st == "breakout":
|
||||
# 突破型:止损在突破点(被突破的阻力位)下方1%
|
||||
resistance = details.get("resistance_price", 0)
|
||||
if resistance and resistance > 0:
|
||||
stop_loss = round(resistance * 0.99, 2)
|
||||
else:
|
||||
# fallback: 近20日低点下方1%
|
||||
low_20 = float(df.tail(20)["low"].min())
|
||||
stop_loss = round(low_20 * 0.99, 2)
|
||||
elif st == "pullback":
|
||||
# 回踩型:止损在支撑均线下方1.5%
|
||||
support_ma = details.get("support_ma", "MA20")
|
||||
support_price = 0
|
||||
if support_ma == "MA20" and not pd.isna(last.get("ma20")):
|
||||
support_price = last["ma20"]
|
||||
elif support_ma == "MA10" and not pd.isna(last.get("ma10")):
|
||||
support_price = last["ma10"]
|
||||
if support_price > 0:
|
||||
stop_loss = round(support_price * 0.985, 2)
|
||||
else:
|
||||
stop_loss = round(current_close * 0.97, 2)
|
||||
elif st == "reversal":
|
||||
# 反转型:止损在近5日最低点下方1%
|
||||
low_5 = float(df.tail(5)["low"].min())
|
||||
stop_loss = round(low_5 * 0.99, 2)
|
||||
elif st == "launch":
|
||||
# 启动型:止损在MA20下方2%
|
||||
if not pd.isna(last.get("ma20")) and last["ma20"] > 0:
|
||||
stop_loss = round(last["ma20"] * 0.98, 2)
|
||||
else:
|
||||
stop_loss = round(current_close * 0.97, 2)
|
||||
else:
|
||||
# breakout_confirm / 其他:近20日低点下方1%
|
||||
low_20 = float(df.tail(20)["low"].min())
|
||||
stop_loss = round(min(low_20 * 0.99, current_close * 0.97), 2)
|
||||
|
||||
# ── 止盈价:基于下一个阻力位 ──
|
||||
# 近20日高点作为第一阻力
|
||||
high_20 = float(df.tail(20)["high"].max())
|
||||
# 近60日高点作为第二阻力
|
||||
high_60 = float(df.tail(60)["high"].max()) if len(df) >= 60 else high_20
|
||||
|
||||
if st == "breakout":
|
||||
# 突破型:刚突破20日高点,目标看60日高点附近
|
||||
if high_60 > current_close:
|
||||
target_price = round(min(high_60 * 0.98, entry_price * 1.08), 2)
|
||||
else:
|
||||
target_price = round(entry_price * 1.05, 2)
|
||||
elif st == "launch":
|
||||
# 启动型:整理后启动,目标看整理区间上方+8%
|
||||
target_price = round(min(high_20 * 1.03, entry_price * 1.08), 2)
|
||||
elif st == "reversal":
|
||||
# 反转型:从低位反转,目标看近20日高点
|
||||
target_price = round(min(high_20 * 0.98, entry_price * 1.08), 2)
|
||||
elif st == "pullback":
|
||||
# 回踩型:目标看前高附近
|
||||
target_price = round(min(high_20 * 0.98, entry_price * 1.05), 2)
|
||||
else:
|
||||
# breakout_confirm / 其他
|
||||
target_price = round(min(high_20 * 0.98, entry_price * 1.05), 2)
|
||||
|
||||
# 保底:止损不超过入场价-8%(防止结构化止损太远)
|
||||
max_stop_pct = 0.08
|
||||
if stop_loss < entry_price * (1 - max_stop_pct):
|
||||
stop_loss = round(entry_price * (1 - max_stop_pct), 2)
|
||||
# 止损不低于入场价-2%(止损太近没有意义)
|
||||
min_stop_pct = 0.02
|
||||
if stop_loss > entry_price * (1 - min_stop_pct):
|
||||
stop_loss = round(entry_price * (1 - min_stop_pct), 2)
|
||||
|
||||
# 保底:止盈不低于入场价+3%(空间太小不值得做)
|
||||
min_target_pct = 0.03
|
||||
if target_price < entry_price * (1 + min_target_pct):
|
||||
target_price = round(entry_price * (1 + min_target_pct), 2)
|
||||
|
||||
# 生成推荐理由
|
||||
reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday)
|
||||
@ -428,6 +550,9 @@ def _build_recommendations(
|
||||
# 量价模式
|
||||
vol_pattern = analyze_volume_pattern(df)
|
||||
|
||||
# 进场时机建议(盘中适用)
|
||||
entry_timing = _generate_entry_timing(signal_type.value, intraday)
|
||||
|
||||
rec = Recommendation(
|
||||
ts_code=ts_code,
|
||||
name=name,
|
||||
@ -448,11 +573,45 @@ def _build_recommendations(
|
||||
level=level,
|
||||
strategy="trend_breakout",
|
||||
entry_signal_type=signal_type.value,
|
||||
entry_timing=entry_timing,
|
||||
)
|
||||
recommendations.append(rec)
|
||||
|
||||
if len(recommendations) >= settings.top_stock_count:
|
||||
break
|
||||
# 收集 LLM 分析所需的候选摘要(不含 signal_type,让 LLM 独立判断)
|
||||
llm_candidate = {
|
||||
"ts_code": ts_code,
|
||||
"name": name,
|
||||
"sector": sector,
|
||||
"quant_score": round(final_score, 1),
|
||||
"position_score": round(position_score, 1),
|
||||
"current_price": stock.get("price") or float(df.iloc[-1]["close"]),
|
||||
"kline_summary": _summarize_for_llm(df, entry_signal, tech_signal),
|
||||
"capital_flow_summary": (
|
||||
f"主力净流入{stock.get('main_net_inflow', 0):.0f}万, "
|
||||
f"占比{stock.get('inflow_ratio', 0):.1f}%"
|
||||
),
|
||||
}
|
||||
|
||||
# 盘中模式:补充分时量能分布数据
|
||||
if intraday:
|
||||
try:
|
||||
from app.data.eastmoney_client import get_min_kline, analyze_intraday_volume_distribution
|
||||
min_df = await get_min_kline(ts_code, period="5", count=48)
|
||||
if not min_df.empty:
|
||||
vol_dist = analyze_intraday_volume_distribution(min_df)
|
||||
llm_candidate["intraday_volume"] = (
|
||||
f"上午量占比{vol_dist['morning_volume_ratio']}%, "
|
||||
f"下午{vol_dist['afternoon_volume_ratio']}%, "
|
||||
f"开盘30分{vol_dist['opening_strength']}%, "
|
||||
f"尾盘30分{vol_dist['closing_strength']}%, "
|
||||
f"趋势={vol_dist['volume_trend']}"
|
||||
)
|
||||
if vol_dist["key_periods"]:
|
||||
llm_candidate["intraday_volume"] += f", 放量时段: {'; '.join(vol_dist['key_periods'])}"
|
||||
except Exception as e:
|
||||
logger.debug(f"分时量能数据获取失败 {ts_code}: {e}")
|
||||
|
||||
llm_candidates.append(llm_candidate)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"深度分析 {ts_code} 失败: {e}")
|
||||
@ -469,6 +628,61 @@ def _build_recommendations(
|
||||
f"(共分析{total}只)"
|
||||
)
|
||||
|
||||
# ── LLM 逐股深度分析 ──
|
||||
if settings.deepseek_api_key and llm_candidates:
|
||||
try:
|
||||
from app.llm.batch_screener import analyze_candidates_individually
|
||||
|
||||
# 只对量化评分 Top N 做LLM分析,减少API调用
|
||||
llm_candidates.sort(key=lambda c: c["quant_score"], reverse=True)
|
||||
llm_top = llm_candidates[:settings.top_stock_count]
|
||||
|
||||
market_summary = (
|
||||
f"市场温度: {market_temp.temperature}/100, "
|
||||
f"涨跌比: {market_temp.up_count}涨/{market_temp.down_count}跌, "
|
||||
f"涨停: {market_temp.limit_up_count}家"
|
||||
)
|
||||
llm_results = await analyze_candidates_individually(llm_top, market_summary)
|
||||
|
||||
# 综合量化 + LLM 判断
|
||||
for rec in recommendations:
|
||||
llm_data = llm_results.get(rec.ts_code)
|
||||
if llm_data:
|
||||
rec.llm_analysis = llm_data.get("analysis", "")
|
||||
|
||||
# LLM 信号强度转换为分数调整
|
||||
strength = llm_data.get("strength", "中")
|
||||
llm_signal = llm_data.get("signal", "HOLD")
|
||||
|
||||
# 基础调整:量化评分 * 调整系数
|
||||
if llm_signal == "SKIP":
|
||||
adjustment = 0.50
|
||||
elif llm_signal == "HOLD":
|
||||
adjustment = 0.85
|
||||
elif llm_signal == "BUY" and strength == "强":
|
||||
adjustment = 1.15
|
||||
elif llm_signal == "BUY" and strength == "中":
|
||||
adjustment = 1.05
|
||||
else: # BUY + 弱
|
||||
adjustment = 1.00
|
||||
|
||||
rec.score = round(rec.score * adjustment, 1)
|
||||
rec.level = _score_to_level(rec.score)
|
||||
|
||||
# 用 LLM 给出的价格替代硬编码价格
|
||||
if llm_data.get("entry_price"):
|
||||
rec.entry_price = llm_data["entry_price"]
|
||||
if llm_data.get("target_price"):
|
||||
rec.target_price = llm_data["target_price"]
|
||||
if llm_data.get("stop_loss"):
|
||||
rec.stop_loss = llm_data["stop_loss"]
|
||||
|
||||
recommendations.sort(key=lambda r: r.score, reverse=True)
|
||||
recommendations = recommendations[:settings.top_stock_count]
|
||||
logger.info(f"LLM 逐股分析完成, 综合评分后保留 {len(recommendations)} 只")
|
||||
except Exception as e:
|
||||
logger.error(f"LLM 逐股分析失败, 仅使用量化评分: {e}")
|
||||
|
||||
return recommendations
|
||||
|
||||
|
||||
@ -685,6 +899,22 @@ def _get_sector_heat(sector_name: str, hot_sectors: list[SectorInfo]) -> float:
|
||||
return 30.0
|
||||
|
||||
|
||||
def _get_sector_limit_up(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
|
||||
"""获取板块涨停数"""
|
||||
for s in hot_sectors:
|
||||
if s.sector_name == sector_name:
|
||||
return s.limit_up_count
|
||||
return 0
|
||||
|
||||
|
||||
def _get_sector_member_count(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
|
||||
"""获取板块成分股数量"""
|
||||
for s in hot_sectors:
|
||||
if s.sector_name == sector_name:
|
||||
return s.member_count
|
||||
return 0
|
||||
|
||||
|
||||
def _score_capital_simple(stock: dict) -> float:
|
||||
"""资金流简单评分(仅基于已有数据,不额外调 API)"""
|
||||
main_net = stock.get("main_net_inflow", 0) or 0
|
||||
@ -712,6 +942,21 @@ def _score_capital_simple(stock: dict) -> float:
|
||||
return min(score, 100)
|
||||
|
||||
|
||||
def _generate_entry_timing(signal_type: str, intraday: bool) -> str:
|
||||
"""根据信号类型生成进场时机建议"""
|
||||
if not intraday:
|
||||
return "" # 盘后模式不需要时机建议
|
||||
|
||||
timing_map = {
|
||||
"breakout": "开盘观察是否站稳突破位,午后14:00确认不回落再进场",
|
||||
"breakout_confirm": "突破已确认,盘中放量时可直接进场",
|
||||
"pullback": "盘中靠近支撑位时分批进场,尾盘14:30确认支撑有效可加仓",
|
||||
"launch": "早盘放量确认后即可进场,注意开盘9:30-10:00量能",
|
||||
"reversal": "午后13:30确认不回落再进场,避免早盘追高",
|
||||
}
|
||||
return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场")
|
||||
|
||||
|
||||
def _score_to_level(score: float) -> str:
|
||||
if score >= 80:
|
||||
return "强烈推荐"
|
||||
@ -823,3 +1068,141 @@ def _generate_risk_note(
|
||||
if not notes:
|
||||
return "注意设好止损,控制仓位"
|
||||
return ";".join(notes)
|
||||
|
||||
|
||||
def _summarize_for_llm(df, entry_signal: dict, tech_signal: TechnicalSignal | None) -> str:
|
||||
"""生成 K 线分析结论供 LLM 判断(输出结论而非原始数据)"""
|
||||
import pandas as pd
|
||||
|
||||
last = df.iloc[-1]
|
||||
parts = []
|
||||
|
||||
# ── 趋势结论 ──
|
||||
ma_fields = ["ma5", "ma10", "ma20", "ma60"]
|
||||
ma_vals = {m: last.get(m) for m in ma_fields}
|
||||
|
||||
trend_desc = "趋势不明"
|
||||
all_ma_valid = all(ma_vals.get(m) is not None and not pd.isna(ma_vals[m]) for m in ma_fields)
|
||||
if all_ma_valid:
|
||||
if ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"] > ma_vals["ma60"]:
|
||||
trend_desc = "强势多头排列(MA5>MA10>MA20>MA60)"
|
||||
elif ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"]:
|
||||
trend_desc = "中短期多头(MA5>MA10>MA20)"
|
||||
elif ma_vals["ma5"] > ma_vals["ma20"]:
|
||||
trend_desc = "偏多(MA5在MA20上方)"
|
||||
elif ma_vals["ma5"] < ma_vals["ma10"] < ma_vals["ma20"]:
|
||||
trend_desc = "空头排列,趋势偏弱"
|
||||
else:
|
||||
trend_desc = "均线交织,趋势震荡"
|
||||
|
||||
# MA20 方向
|
||||
if len(df) >= 5 and not pd.isna(last.get("ma20")) and not pd.isna(df.iloc[-5].get("ma20")):
|
||||
ma20_now = last["ma20"]
|
||||
ma20_5d = df.iloc[-5]["ma20"]
|
||||
if ma20_5d > 0:
|
||||
ma20_pct = (ma20_now - ma20_5d) / ma20_5d * 100
|
||||
if ma20_pct > 2:
|
||||
trend_desc += ",MA20快速上扬"
|
||||
elif ma20_pct > 0:
|
||||
trend_desc += ",MA20缓慢上行"
|
||||
else:
|
||||
trend_desc += ",MA20走平或下行"
|
||||
parts.append(trend_desc)
|
||||
|
||||
# ── 量价结论 ──
|
||||
if len(df) >= 10:
|
||||
recent = df.tail(10)
|
||||
up_days = recent[recent["pct_chg"] > 0]
|
||||
down_days = recent[recent["pct_chg"] <= 0]
|
||||
vol_conclusion = ""
|
||||
if len(up_days) > 0 and len(down_days) > 0:
|
||||
avg_up_vol = up_days["vol"].mean()
|
||||
avg_down_vol = down_days["vol"].mean()
|
||||
if avg_down_vol > 0:
|
||||
ratio = avg_up_vol / avg_down_vol
|
||||
if ratio > 1.5:
|
||||
vol_conclusion = f"量价健康(上涨均量/下跌均量={ratio:.1f},需求主导)"
|
||||
elif ratio > 1.0:
|
||||
vol_conclusion = f"量价尚可(量比={ratio:.1f},需求略强)"
|
||||
else:
|
||||
vol_conclusion = f"量价偏弱(量比={ratio:.1f},供给主导)"
|
||||
if not vol_conclusion:
|
||||
vol_conclusion = "量价数据不足"
|
||||
|
||||
# 最近5日量能变化
|
||||
recent_5 = df.tail(5)
|
||||
vol_ma5 = recent_5["vol"].mean()
|
||||
vol_ma10 = df.tail(10)["vol"].mean()
|
||||
if vol_ma10 > 0:
|
||||
vol_ratio = vol_ma5 / vol_ma10
|
||||
if vol_ratio > 1.5:
|
||||
vol_conclusion += ",近5日明显放量"
|
||||
elif vol_ratio < 0.7:
|
||||
vol_conclusion += ",近5日缩量"
|
||||
parts.append(vol_conclusion)
|
||||
|
||||
# ── MACD 结论 ──
|
||||
dif = last.get("dif", 0) or 0
|
||||
dea = last.get("dea", 0) or 0
|
||||
macd_desc = ""
|
||||
if len(df) >= 3:
|
||||
prev_dif = df.iloc[-2].get("dif", 0) or 0
|
||||
prev_dea = df.iloc[-2].get("dea", 0) or 0
|
||||
if dif > dea and prev_dif <= prev_dea:
|
||||
macd_desc = "MACD刚金叉"
|
||||
elif dif > dea:
|
||||
macd_desc = "MACD金叉运行中"
|
||||
elif dif < dea and prev_dif >= prev_dea:
|
||||
macd_desc = "MACD刚死叉"
|
||||
elif dif < dea:
|
||||
macd_desc = "MACD死叉运行中"
|
||||
|
||||
if dif > 0:
|
||||
macd_desc += ",零轴上方(偏多)"
|
||||
else:
|
||||
macd_desc += ",零轴下方(偏空)"
|
||||
parts.append(macd_desc or "MACD数据不足")
|
||||
|
||||
# ── RSI 结论 ──
|
||||
rsi = last.get("rsi14", 50)
|
||||
if not pd.isna(rsi):
|
||||
if rsi > 80:
|
||||
parts.append(f"RSI14={rsi:.0f},超买区,回调风险大")
|
||||
elif rsi > 70:
|
||||
parts.append(f"RSI14={rsi:.0f},偏高,注意追高风险")
|
||||
elif rsi >= 40:
|
||||
parts.append(f"RSI14={rsi:.0f},健康区间")
|
||||
else:
|
||||
parts.append(f"RSI14={rsi:.0f},偏低,可能超卖")
|
||||
|
||||
# ── 价格位置结论 ──
|
||||
if tech_signal:
|
||||
pos_parts = []
|
||||
if tech_signal.rally_pct_5d > 15:
|
||||
pos_parts.append(f"5日已涨{tech_signal.rally_pct_5d}%,追高风险大")
|
||||
elif tech_signal.rally_pct_5d > 8:
|
||||
pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,短期有一定涨幅")
|
||||
elif tech_signal.rally_pct_5d > 0:
|
||||
pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,涨幅温和")
|
||||
else:
|
||||
pos_parts.append(f"5日跌{abs(tech_signal.rally_pct_5d)}%,回调中")
|
||||
|
||||
if tech_signal.distance_from_high >= 0:
|
||||
pos_parts.append("处于60日新高附近")
|
||||
elif tech_signal.distance_from_high > -5:
|
||||
pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%")
|
||||
else:
|
||||
pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%,位置较低")
|
||||
|
||||
parts.append("位置: " + ",".join(pos_parts))
|
||||
|
||||
# ── 近5日价格走势简述 ──
|
||||
if len(df) >= 5:
|
||||
recent_5 = df.tail(5)
|
||||
closes = recent_5["close"].tolist()
|
||||
first_c = closes[0]
|
||||
last_c = closes[-1]
|
||||
pct_5d = (last_c - first_c) / first_c * 100
|
||||
parts.append(f"当前价: {last_c:.2f},5日{'涨' if pct_5d >= 0 else '跌'}{abs(pct_5d):.1f}%")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
Binary file not shown.
170
backend/app/llm/batch_screener.py
Normal file
170
backend/app/llm/batch_screener.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""LLM 逐股深度分析
|
||||
|
||||
量化筛选完成后,对每只候选股票单独调用 LLM 做深度分析,
|
||||
让 AI 独立判断入场时机并给出具体买卖价格。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
||||
"""对单只股票做 LLM 深度分析
|
||||
|
||||
candidate: 包含 ts_code, name, sector, quant_score, position_score,
|
||||
kline_summary, capital_flow_summary
|
||||
market_summary: 市场环境摘要
|
||||
|
||||
返回: {
|
||||
"signal": "BUY"/"HOLD"/"SKIP",
|
||||
"strength": "强"/"中"/"弱",
|
||||
"entry_price": float or None,
|
||||
"target_price": float or None,
|
||||
"stop_loss": float or None,
|
||||
"analysis": str,
|
||||
}
|
||||
"""
|
||||
from app.llm.prompts import SINGLE_STOCK_ANALYSIS_PROMPT
|
||||
from app.llm.client import get_client
|
||||
|
||||
# 构建 prompt — 不传 signal_type,让 LLM 独立判断
|
||||
stock_text = f"""\
|
||||
股票: {candidate['name']}({candidate['ts_code']})
|
||||
板块: {candidate.get('sector', '未知')}
|
||||
量化评分: {candidate.get('quant_score', 0)}/100
|
||||
位置安全: {candidate.get('position_score', 50)}/100
|
||||
当前价: {candidate.get('current_price', '未知')}"""
|
||||
|
||||
if candidate.get("kline_summary"):
|
||||
stock_text += f"\n\n## 技术分析结论\n{candidate['kline_summary']}"
|
||||
|
||||
if candidate.get("capital_flow_summary"):
|
||||
stock_text += f"\n\n## 资金流向\n{candidate['capital_flow_summary']}"
|
||||
|
||||
user_msg = f"{SINGLE_STOCK_ANALYSIS_PROMPT}\n\n## 市场环境\n{market_summary}\n\n{stock_text}\n\n请给出你的分析。"
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.deepseek_model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是一位专业的A股趋势交易分析师,专注于中短线(1-5日)交易。"
|
||||
"你根据技术分析结论独立判断入场时机,给出具体的买卖价格建议。"
|
||||
"不要被量化评分束缚,给出你真实的判断。"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
max_tokens=800,
|
||||
temperature=0.3,
|
||||
)
|
||||
content = response.choices[0].message.content.strip()
|
||||
return _parse_single_response(content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}")
|
||||
return {
|
||||
"signal": "HOLD",
|
||||
"strength": "弱",
|
||||
"entry_price": None,
|
||||
"target_price": None,
|
||||
"stop_loss": None,
|
||||
"analysis": "AI分析暂不可用",
|
||||
}
|
||||
|
||||
|
||||
def _parse_single_response(text: str) -> dict:
|
||||
"""解析单只股票的 LLM 返回"""
|
||||
# 提取信号
|
||||
signal = "HOLD"
|
||||
signal_match = re.search(r"信号[:\s]*(BUY|HOLD|SKIP)", text)
|
||||
if signal_match:
|
||||
signal = signal_match.group(1)
|
||||
|
||||
# 提取信号强度
|
||||
strength = "中"
|
||||
strength_match = re.search(r"信号强度[:\s]*(强|中|弱)", text)
|
||||
if strength_match:
|
||||
strength = strength_match.group(1)
|
||||
|
||||
# 提取买入价
|
||||
entry_price = None
|
||||
entry_match = re.search(r"买入价[:\s]*(\d+(?:\.\d+)?)", text)
|
||||
if entry_match:
|
||||
entry_price = float(entry_match.group(1))
|
||||
|
||||
# 提取止盈价
|
||||
target_price = None
|
||||
target_match = re.search(r"止盈价[:\s]*(\d+(?:\.\d+)?)", text)
|
||||
if target_match:
|
||||
target_price = float(target_match.group(1))
|
||||
|
||||
# 提取止损价
|
||||
stop_loss = None
|
||||
stop_match = re.search(r"止损价[:\s]*(\d+(?:\.\d+)?)", text)
|
||||
if stop_match:
|
||||
stop_loss = float(stop_match.group(1))
|
||||
|
||||
# 提取分析
|
||||
analysis = ""
|
||||
analysis_match = re.search(r"分析[:\s]*(.+)", text, re.DOTALL)
|
||||
if analysis_match:
|
||||
analysis = analysis_match.group(1).strip()
|
||||
|
||||
return {
|
||||
"signal": signal,
|
||||
"strength": strength,
|
||||
"entry_price": entry_price,
|
||||
"target_price": target_price,
|
||||
"stop_loss": stop_loss,
|
||||
"analysis": analysis or "暂无分析",
|
||||
}
|
||||
|
||||
|
||||
async def analyze_candidates_individually(
|
||||
candidates: list[dict], market_summary: str, max_concurrent: int = 3
|
||||
) -> dict[str, dict]:
|
||||
"""对候选股票逐个做 LLM 分析(控制并发数)
|
||||
|
||||
返回: {ts_code: {"signal", "strength", "entry_price", ...}}
|
||||
"""
|
||||
if not settings.deepseek_api_key or not candidates:
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def _analyze_with_semaphore(c: dict):
|
||||
async with semaphore:
|
||||
ts_code = c["ts_code"]
|
||||
logger.info(f"LLM 分析: {c.get('name', ts_code)}")
|
||||
result = await analyze_single_stock(c, market_summary)
|
||||
logger.info(
|
||||
f"LLM 结果: {c.get('name', ts_code)} → "
|
||||
f"信号={result['signal']} 强度={result['strength']} "
|
||||
f"买入={result.get('entry_price')} 止盈={result.get('target_price')} "
|
||||
f"止损={result.get('stop_loss')}"
|
||||
)
|
||||
return ts_code, result
|
||||
|
||||
tasks = [_analyze_with_semaphore(c) for c in candidates]
|
||||
|
||||
completed = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for item in completed:
|
||||
if isinstance(item, Exception):
|
||||
logger.error(f"LLM 分析任务异常: {item}")
|
||||
continue
|
||||
ts_code, result = item
|
||||
results[ts_code] = result
|
||||
|
||||
logger.info(f"LLM 逐股分析完成: {len(results)}/{len(candidates)} 只")
|
||||
return results
|
||||
@ -113,3 +113,37 @@ ANALYSIS_USER_TEMPLATE = """\
|
||||
- 涨停: {limit_up_count}家
|
||||
|
||||
请使用工具获取该股票的K线、资金流向、技术信号等数据,然后按照指定格式输出深度分析报告。"""
|
||||
|
||||
|
||||
# ── AI 逐股筛选 Prompt ──
|
||||
|
||||
SINGLE_STOCK_ANALYSIS_PROMPT = """\
|
||||
你是一位专业的A股趋势交易分析师,专注于中短线交易(持仓1-5个交易日)。
|
||||
|
||||
量化系统已通过多轮筛选认定该股票具备投资价值,请你基于以下技术分析结论,独立判断入场时机。
|
||||
|
||||
你的任务:
|
||||
1. 综合趋势、量价、技术指标和位置,判断当前是否适合介入
|
||||
2. 如果适合介入,给出具体的买入价位、止盈价和止损价
|
||||
3. 评估信号强度
|
||||
|
||||
注意:
|
||||
- 你看到的是量化系统对K线和技术指标的分析结论,不是原始数据
|
||||
- 请独立判断,不要被量化评分影响太多
|
||||
- 止盈空间通常3-8%,止损空间通常3-5%
|
||||
- 中短线交易,重点关注1-5日的走势
|
||||
|
||||
请严格按以下格式输出:
|
||||
|
||||
信号: BUY/HOLD/SKIP
|
||||
信号强度: 强/中/弱
|
||||
买入价: XX.XX(建议入场价位,基于当前价微调)
|
||||
止盈价: XX.XX(目标价位,给出合理空间)
|
||||
止损价: XX.XX(跌破此价离场)
|
||||
分析: 3-5句话,说明核心逻辑、入场理由和主要风险
|
||||
|
||||
说明:
|
||||
- BUY: 看好,建议在买入价附近介入
|
||||
- HOLD: 观望,等待更好的时机
|
||||
- SKIP: 不看好,风险大于收益
|
||||
- 信号强度"强"表示把握较大,"弱"表示不确定性较高"""
|
||||
|
||||
Binary file not shown.
@ -1,9 +1,9 @@
|
||||
{
|
||||
"pages": {
|
||||
"/diagnose/page": [
|
||||
"/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/diagnose/page.js"
|
||||
"static/chunks/app/page.js"
|
||||
],
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
@ -11,15 +11,10 @@
|
||||
"static/css/app/layout.css",
|
||||
"static/chunks/app/layout.js"
|
||||
],
|
||||
"/page": [
|
||||
"/stock/[code]/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/page.js"
|
||||
],
|
||||
"/sectors/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/sectors/page.js"
|
||||
"static/chunks/app/stock/[code]/page.js"
|
||||
],
|
||||
"/monitor/page": [
|
||||
"static/chunks/webpack.js",
|
||||
@ -31,10 +26,15 @@
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/recommendations/page.js"
|
||||
],
|
||||
"/_not-found/page": [
|
||||
"/sectors/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/_not-found/page.js"
|
||||
"static/chunks/app/sectors/page.js"
|
||||
],
|
||||
"/diagnose/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/diagnose/page.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,9 @@
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
@ -13,7 +15,16 @@
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_app": []
|
||||
"/_app": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_app.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_error.js"
|
||||
]
|
||||
},
|
||||
"ampFirstPages": []
|
||||
}
|
||||
@ -4,5 +4,17 @@
|
||||
"files": [
|
||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
||||
]
|
||||
},
|
||||
"components/capital-flow.tsx -> echarts": {
|
||||
"id": "components/capital-flow.tsx -> echarts",
|
||||
"files": [
|
||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
||||
]
|
||||
},
|
||||
"components/kline-chart.tsx -> echarts": {
|
||||
"id": "components/kline-chart.tsx -> echarts",
|
||||
"files": [
|
||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"/_not-found/page": "app/_not-found/page.js",
|
||||
"/diagnose/page": "app/diagnose/page.js",
|
||||
"/page": "app/page.js",
|
||||
"/sectors/page": "app/sectors/page.js",
|
||||
"/stock/[code]/page": "app/stock/[code]/page.js",
|
||||
"/monitor/page": "app/monitor/page.js",
|
||||
"/recommendations/page": "app/recommendations/page.js"
|
||||
"/recommendations/page": "app/recommendations/page.js",
|
||||
"/sectors/page": "app/sectors/page.js",
|
||||
"/diagnose/page": "app/diagnose/page.js"
|
||||
}
|
||||
@ -2,7 +2,9 @@ self.__BUILD_MANIFEST = {
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [
|
||||
@ -10,7 +12,16 @@ self.__BUILD_MANIFEST = {
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_app": []
|
||||
"/_app": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_app.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_error.js"
|
||||
]
|
||||
},
|
||||
"ampFirstPages": []
|
||||
};
|
||||
|
||||
@ -1 +1 @@
|
||||
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
|
||||
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
|
||||
@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"/_app": "pages/_app.js",
|
||||
"/_error": "pages/_error.js",
|
||||
"/_document": "pages/_document.js"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "2X6fu/eJ5clwy441ELew8wl+I0z3kZMpUonbMY8jvw8="
|
||||
"encryptionKey": "ivfiGgdCRmC7fJWUxiqW3o7IY5TIF27ivPa+HF5AgdE="
|
||||
}
|
||||
@ -125,7 +125,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("4eddf011ceda7fdd")
|
||||
/******/ __webpack_require__.h = () => ("53866692304f1888")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -2,12 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { fetchAPI, postAPI } from "@/lib/api";
|
||||
import type { DiagnosisResult } from "@/lib/api";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import { getScoreColor } from "@/lib/utils";
|
||||
import KlineChart from "@/components/kline-chart";
|
||||
import CapitalFlowChart from "@/components/capital-flow";
|
||||
import ScoreRadar from "@/components/score-radar";
|
||||
|
||||
interface StockSignals {
|
||||
ts_code: string;
|
||||
@ -71,8 +69,6 @@ export default function StockDetailPage() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [kline, setKline] = useState<any[]>([]);
|
||||
const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]);
|
||||
const [diagnosis, setDiagnosis] = useState<string | null>(null);
|
||||
const [diagnosing, setDiagnosing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
@ -113,11 +109,10 @@ export default function StockDetailPage() {
|
||||
返回
|
||||
</a>
|
||||
|
||||
{/* Quote + Radar */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 animate-fade-in-up">
|
||||
{/* Quote header */}
|
||||
{/* Quote header */}
|
||||
<div className="animate-fade-in-up">
|
||||
{quote && (
|
||||
<div className="glass-card-static p-5 md:col-span-2">
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold tracking-tight">{quote.name}</span>
|
||||
@ -155,9 +150,9 @@ export default function StockDetailPage() {
|
||||
<div className="grid grid-cols-4 gap-2 mt-4">
|
||||
<MiniStat
|
||||
label="开盘"
|
||||
value={quote.open?.toFixed(2) ?? "-"}
|
||||
value={quote.open && quote.open > 0 ? quote.open.toFixed(2) : "-"}
|
||||
color={
|
||||
quote.open && quote.pre_close
|
||||
quote.open && quote.open > 0 && quote.pre_close
|
||||
? quote.open >= quote.pre_close
|
||||
? "text-red-400"
|
||||
: "text-emerald-400"
|
||||
@ -166,12 +161,12 @@ export default function StockDetailPage() {
|
||||
/>
|
||||
<MiniStat
|
||||
label="最高"
|
||||
value={quote.high?.toFixed(2) ?? "-"}
|
||||
value={quote.high && quote.high > 0 ? quote.high.toFixed(2) : "-"}
|
||||
color="text-red-400"
|
||||
/>
|
||||
<MiniStat
|
||||
label="最低"
|
||||
value={quote.low?.toFixed(2) ?? "-"}
|
||||
value={quote.low && quote.low > 0 ? quote.low.toFixed(2) : "-"}
|
||||
color="text-emerald-400"
|
||||
/>
|
||||
<MiniStat
|
||||
@ -215,13 +210,6 @@ export default function StockDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Radar chart - desktop */}
|
||||
{signals && (
|
||||
<div className="hidden md:block">
|
||||
<ScoreRadar signals={signals} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position Safety + Capital Flow Breakdown */}
|
||||
@ -324,61 +312,6 @@ export default function StockDetailPage() {
|
||||
{kline.length > 0 && <KlineChart data={kline} />}
|
||||
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
|
||||
</div>
|
||||
|
||||
{/* AI Diagnosis */}
|
||||
<div className="animate-fade-in-up delay-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">AI 诊断</h2>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setDiagnosing(true);
|
||||
setDiagnosis(null);
|
||||
try {
|
||||
const res = await postAPI<DiagnosisResult>(`/api/stocks/${code}/diagnose`);
|
||||
if (res.status === "ok" && res.diagnosis) {
|
||||
setDiagnosis(res.diagnosis);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDiagnosing(false);
|
||||
}
|
||||
}}
|
||||
disabled={diagnosing}
|
||||
className="text-xs px-4 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium"
|
||||
>
|
||||
{diagnosing ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
诊断中...
|
||||
</span>
|
||||
) : (
|
||||
"AI 诊断"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{diagnosing && (
|
||||
<div className="glass-card-static p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
|
||||
<div className="text-xs text-text-muted">AI 正在分析 {quote?.name || code} ...</div>
|
||||
</div>
|
||||
)}
|
||||
{diagnosis && !diagnosing && (
|
||||
<div className="glass-card-static p-5">
|
||||
<div
|
||||
className="text-xs text-text-secondary leading-relaxed [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_p]:text-text-secondary [&_p]:mb-2 [&_ul]:text-text-secondary [&_li]:mb-1"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(diagnosis) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile radar */}
|
||||
{signals && (
|
||||
<div className="md:hidden animate-fade-in-up delay-225">
|
||||
<ScoreRadar signals={signals} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -540,23 +473,3 @@ function formatFlowAmount(val: number): string {
|
||||
if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿";
|
||||
return val.toFixed(0) + "万";
|
||||
}
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
|
||||
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/^- (.+)$/gm, "<li>$1</li>")
|
||||
.replace(/(<li>.*<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`)
|
||||
.replace(/\n{2,}/g, "</p><p>")
|
||||
.replace(/^(?!<[hulo])/gm, "<p>")
|
||||
.replace(/(?<![>])$/gm, "</p>")
|
||||
.replace(/<p><\/p>/g, "")
|
||||
.replace(/<p>(<h[23]>)/g, "$1")
|
||||
.replace(/(<\/h[23]>)<\/p>/g, "$1")
|
||||
.replace(/<p>(<ul>)/g, "$1")
|
||||
.replace(/(<\/ul>)<\/p>/g, "$1");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user