as-event/backend/app/signals.py

131 lines
4.7 KiB
Python
Raw Permalink Normal View History

2026-07-27 09:05:52 +08:00
"""顶底「市场温度」透明启发式(可调,非黑盒)。
温度 0100越高越过热/顶部风险越低越冰点/底部机会
四个分量缺哪个自动剔除并重新归一权重
vol_pct 量能分位天量高温()地量低温()
price_pct 价格分位高位高温低位低温
sentiment 情绪普涨/涨停潮亢奋高温普跌/跌停潮恐慌低温
etf_heat ETF出货热度= (-净流入) 分位大额净流出高温()大额净流入(国家队)低温()
权重与阈值来自 configW_VOL/W_PRICE/W_SENTIMENT/W_ETFHOT/COLD_THRESHOLD
初值仅供起步请用你自己的历史数据回测校准
"""
from __future__ import annotations
from datetime import date
from typing import Dict, List, Optional
from .config import get_settings
settings = get_settings()
def _rolling_pct(values: List[Optional[float]], window: int) -> List[Optional[float]]:
"""滚动百分位排名(0-100):当前值在最近 window 个有效值中的分位。"""
out: List[Optional[float]] = []
for i in range(len(values)):
cur = values[i]
if cur is None:
out.append(None)
continue
lo = max(0, i - window + 1)
base = [v for v in values[lo : i + 1] if v is not None]
if len(base) < max(5, window // 10): # 样本太少不给分位,避免早期噪声
out.append(None)
continue
le = sum(1 for v in base if v <= cur)
out.append(round(le / len(base) * 100, 2))
return out
def _norm_sentiment(sr) -> Optional[float]:
"""把一条情绪原始值折算到 0-100 的「亢奋度」粗分(缺字段则 None交给分位再平滑
这里只做方向性折算涨跌家数比越高涨幅>5%家数越多 越亢奋
真正的相对高低由外层 _rolling_pct 处理
"""
if sr is None:
return None
parts = []
if sr.get("up_down_ratio") is not None:
parts.append(sr["up_down_ratio"])
if sr.get("pct_chg_gt_5_count") is not None:
parts.append(sr["pct_chg_gt_5_count"])
if not parts:
return None
return float(sum(parts)) # 量纲无所谓,后续走分位
def compute_signals(
bars: List[dict],
sentiment_by_date: Dict[date, dict],
etf_net_by_date: Dict[date, float],
window: Optional[int] = None,
) -> List[dict]:
"""bars: [{trade_date, close, amount}, ...] 升序。返回逐日温度与标记。"""
window = window or settings.vol_window
dates = [b["trade_date"] for b in bars]
closes = [b.get("close") for b in bars]
amounts = [b.get("amount") for b in bars]
# 情绪与 ETF 原始序列(对齐 bars 日期)
senti_raw = [_norm_sentiment(sentiment_by_date.get(d)) for d in dates]
etf_out_raw = [
(-etf_net_by_date[d]) if d in etf_net_by_date and etf_net_by_date[d] is not None else None
for d in dates
]
vol_pct = _rolling_pct(amounts, window)
price_pct = _rolling_pct(closes, window)
senti_pct = _rolling_pct(senti_raw, window)
etf_pct = _rolling_pct(etf_out_raw, window)
W = {
"vol": settings.w_vol,
"price": settings.w_price,
"sentiment": settings.w_sentiment,
"etf": settings.w_etf,
}
out: List[dict] = []
for i, d in enumerate(dates):
comps = {
"vol": vol_pct[i],
"price": price_pct[i],
"sentiment": senti_pct[i],
"etf": etf_pct[i],
}
avail = {k: v for k, v in comps.items() if v is not None}
if avail:
wsum = sum(W[k] for k in avail)
temp = round(sum(comps[k] * W[k] for k in avail) / wsum, 2) if wsum else None
else:
temp = None
flags = []
if vol_pct[i] is not None and price_pct[i] is not None:
if vol_pct[i] >= 95 and price_pct[i] >= 80:
flags.append("TOP_VOLUME_PRICE") # 天量见天价
if vol_pct[i] is not None and vol_pct[i] <= 5:
flags.append("BOTTOM_VOLUME_DRY") # 地量见地价
if senti_pct[i] is not None:
if senti_pct[i] >= 95:
flags.append("SENTIMENT_EUPHORIA")
elif senti_pct[i] <= 5:
flags.append("SENTIMENT_PANIC")
if temp is not None:
if temp >= settings.hot_threshold:
flags.append("OVERHEAT")
elif temp <= settings.cold_threshold:
flags.append("FREEZE")
out.append(
{
"trade_date": d,
"temperature": temp,
"components": comps,
"flags": flags,
}
)
return out