mirror of
https://github.com/starriv/worth-buy-stocks.git
synced 2026-11-02 07:46:12 +00:00
Add scripts/options_engine with strategy modules for Bollinger Bull Put/Bear Call credits, near-delta-neutral long calendars, Iron Condors, and Iron Butterflies, driven by options_seller.py (analyze/scan) over a two-phase setup->chain flow with NY trading-day boundaries and a strict offline contract. options_position_manager.py plus the ledger/lifecycle modules keep a local SQLite audit ledger of user-confirmed fills and run read-only, leg-by-leg lifecycle checks against Alpaca positions with 14/7 DTE exits, Chinese Markdown/JSON reports, and opt-in Telegram alerts. Supporting primitives: bollinger_series/bollinger_bands in metrics.py and fetch_economic_calendar (CPI/PCE/employment/FOMC/GDP) in finnhub.py for the macro entry gate. backtest.py gains pragmatic-entry-validate and pragmatic-entry-v1-validate, replaying two rejected research-only staged-entry experiments on non-overlapping pre-registered holdouts; both remain reject and do not change the production v1.2 verdict, positions, or trade plans. Makefile adds options-analyze/scan/manage/report/performance/ledger-list and the two pragmatic validate targets; SKILL.md, README, agents/openai.yaml, and scripts/README document the options workflow. New tests cover the engine, strategies, four-leg lifecycle, ledger, bollinger primitives, economic calendar, and Makefile gateway. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015PYZq6XJV4HFzYRLyqnTiE
721 lines
26 KiB
Python
721 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""纯指标计算原语:均线、EMA、MACD、RSI、KDJ、收益率、周线聚合。
|
||
|
||
只依赖 Python 标准库,不触网、不读写文件,便于单元测试与复用。
|
||
所有函数对输入做确定性计算;历史不足时返回 None(而非抛错或退化为 0)。
|
||
|
||
含两类原语:
|
||
- 经典指标:均线、EMA、MACD、RSI、KDJ、收益率、周线聚合。
|
||
- 量化因子:年化波动率、最大回撤、ATR、效率比、对数价格回归(斜率/R²)、12-1 动量。
|
||
"""
|
||
import datetime
|
||
import math
|
||
|
||
TRADING_DAYS = 252 # 年化用交易日数
|
||
|
||
|
||
def ema_series(values, n, seed="first"):
|
||
"""递归 EMA,返回与输入等长的序列。
|
||
|
||
seed="first"(默认):首值做种子,需约 3×n 根衰减偏置(历史行为)。
|
||
seed="sma":前 n 根 SMA 做种子,大幅缩短预热需求——MACD 用此模式。
|
||
"""
|
||
if not values:
|
||
return []
|
||
k = 2.0 / (n + 1)
|
||
if seed == "sma" and len(values) >= n:
|
||
sma = sum(values[:n]) / n
|
||
out = [sma] * n # 前 n 根填 SMA 种子,对齐时间索引
|
||
for v in values[n:]:
|
||
out.append(v * k + out[-1] * (1 - k))
|
||
return out
|
||
out = [values[0]]
|
||
for v in values[1:]:
|
||
out.append(v * k + out[-1] * (1 - k))
|
||
return out
|
||
|
||
|
||
def macd(closes, fast=12, slow=26, signal=9):
|
||
"""返回 DIF / DEA / 柱状(2*(DIF-DEA)) 的末值与最近交叉信息。
|
||
|
||
三条 EMA 均用 SMA-seeded 初始化(前 N 根 SMA 做种子),将预热需求从
|
||
~3×slow 降到 ~slow+signal+5 根。
|
||
"""
|
||
min_bars = slow + signal + 5 # SMA seed 后仅需 DIF → DEA 的递推长度
|
||
if len(closes) < min_bars:
|
||
return None
|
||
ema_fast = ema_series(closes, fast, seed="sma")
|
||
ema_slow = ema_series(closes, slow, seed="sma")
|
||
dif = [f - s for f, s in zip(ema_fast, ema_slow)]
|
||
dea = ema_series(dif, signal, seed="sma")
|
||
hist = [2 * (d - e) for d, e in zip(dif, dea)]
|
||
cross = _recent_cross(dif, dea, lookback=30)
|
||
same_sign = len(hist) >= 2 and (hist[-1] >= 0) == (hist[-2] >= 0)
|
||
return {
|
||
"DIF": round(dif[-1], 4),
|
||
"DEA": round(dea[-1], 4),
|
||
"hist": round(hist[-1], 4),
|
||
"above_zero": dif[-1] > 0 and dea[-1] > 0,
|
||
"bull": dif[-1] > dea[-1],
|
||
"hist_expanding": same_sign and abs(hist[-1]) > abs(hist[-2]),
|
||
"recent_cross": cross,
|
||
}
|
||
|
||
|
||
def _recent_cross(a, b, lookback=30):
|
||
"""检测序列 a 上穿/下穿 b 的最近一次交叉(金叉/死叉),在 lookback 根内。"""
|
||
n = len(a)
|
||
start = max(1, n - lookback)
|
||
last = None
|
||
for i in range(start, n):
|
||
prev, cur = a[i - 1] - b[i - 1], a[i] - b[i]
|
||
if prev <= 0 < cur:
|
||
last = {"type": "golden", "bars_ago": n - 1 - i}
|
||
elif prev >= 0 > cur:
|
||
last = {"type": "death", "bars_ago": n - 1 - i}
|
||
return last
|
||
|
||
|
||
def rsi(closes, n=14):
|
||
"""Wilder RSI。返回末值(0-100)。"""
|
||
if len(closes) < n + 1:
|
||
return None
|
||
gains, losses = [], []
|
||
for i in range(1, len(closes)):
|
||
ch = closes[i] - closes[i - 1]
|
||
gains.append(max(ch, 0.0))
|
||
losses.append(max(-ch, 0.0))
|
||
avg_gain = sum(gains[:n]) / n
|
||
avg_loss = sum(losses[:n]) / n
|
||
for i in range(n, len(gains)):
|
||
avg_gain = (avg_gain * (n - 1) + gains[i]) / n
|
||
avg_loss = (avg_loss * (n - 1) + losses[i]) / n
|
||
if avg_gain == 0 and avg_loss == 0:
|
||
return 50.0
|
||
if avg_loss == 0:
|
||
return 100.0
|
||
rs = avg_gain / avg_loss
|
||
return round(100 - 100 / (1 + rs), 2)
|
||
|
||
|
||
def kdj(highs, lows, closes, n=9, k_smooth=3, d_smooth=3):
|
||
"""KDJ(n,k_smooth,d_smooth)。K/D 分别按各自平滑系数迭代,J=3K-2D。返回末值。"""
|
||
if len(closes) < n:
|
||
return None
|
||
k_val, d_val = 50.0, 50.0
|
||
ak, ad = 1.0 / k_smooth, 1.0 / d_smooth
|
||
for i in range(n - 1, len(closes)):
|
||
ll = min(lows[i - n + 1:i + 1])
|
||
hh = max(highs[i - n + 1:i + 1])
|
||
rsv = 50.0 if hh == ll else (closes[i] - ll) / (hh - ll) * 100
|
||
k_val = (1 - ak) * k_val + ak * rsv
|
||
d_val = (1 - ad) * d_val + ad * k_val
|
||
j_val = 3 * k_val - 2 * d_val
|
||
return {
|
||
"K": round(k_val, 2),
|
||
"D": round(d_val, 2),
|
||
"J": round(j_val, 2),
|
||
"bull": k_val > d_val,
|
||
"above_50": k_val > 50 and d_val > 50,
|
||
}
|
||
|
||
|
||
def ma(values, n):
|
||
return round(sum(values[-n:]) / n, 4) if len(values) >= n else None
|
||
|
||
|
||
def bollinger_series(closes, n=20, deviations=2.0):
|
||
"""Return a time-aligned Bollinger Band series using population stddev.
|
||
|
||
Rows before the first complete window are ``None``. Keeping the primitive
|
||
here makes stock and option workflows share one deterministic definition.
|
||
"""
|
||
if n < 2:
|
||
raise ValueError("bollinger window must be >= 2")
|
||
if deviations <= 0:
|
||
raise ValueError("bollinger deviations must be > 0")
|
||
out = []
|
||
for idx in range(len(closes)):
|
||
if idx + 1 < n:
|
||
out.append(None)
|
||
continue
|
||
window = [float(v) for v in closes[idx - n + 1:idx + 1]]
|
||
middle = sum(window) / n
|
||
variance = sum((v - middle) ** 2 for v in window) / n
|
||
std = math.sqrt(variance)
|
||
upper = middle + deviations * std
|
||
lower = middle - deviations * std
|
||
width = upper - lower
|
||
percent_b = (window[-1] - lower) / width if width else 0.5
|
||
bandwidth = width / middle * 100 if middle else None
|
||
zscore = (window[-1] - middle) / std if std else 0.0
|
||
out.append({
|
||
"middle": round(middle, 4),
|
||
"upper": round(upper, 4),
|
||
"lower": round(lower, 4),
|
||
"stddev": round(std, 4),
|
||
"bandwidth_pct": round(bandwidth, 4) if bandwidth is not None else None,
|
||
"percent_b": round(percent_b, 4),
|
||
"zscore": round(zscore, 4),
|
||
})
|
||
return out
|
||
|
||
|
||
def bollinger_bands(closes, n=20, deviations=2.0, slope_lookback=5):
|
||
"""Return the latest Bollinger values plus middle-band slope.
|
||
|
||
The slope is the percentage change from the middle band ``slope_lookback``
|
||
sessions ago. Insufficient history returns ``None`` instead of silently
|
||
shortening either window.
|
||
"""
|
||
if slope_lookback < 1:
|
||
raise ValueError("bollinger slope_lookback must be >= 1")
|
||
series = bollinger_series(closes, n=n, deviations=deviations)
|
||
if not series or series[-1] is None:
|
||
return None
|
||
latest = dict(series[-1])
|
||
prior_idx = len(series) - 1 - slope_lookback
|
||
prior = series[prior_idx] if prior_idx >= 0 else None
|
||
prior_middle = prior.get("middle") if isinstance(prior, dict) else None
|
||
latest["middle_slope_pct"] = (
|
||
round((latest["middle"] / prior_middle - 1) * 100, 4)
|
||
if prior_middle else None
|
||
)
|
||
latest["window"] = n
|
||
latest["deviations"] = float(deviations)
|
||
return latest
|
||
|
||
|
||
def pct_return(closes, bars_ago):
|
||
if len(closes) <= bars_ago or closes[-1 - bars_ago] == 0:
|
||
return None
|
||
return round((closes[-1] / closes[-1 - bars_ago] - 1) * 100, 2)
|
||
|
||
|
||
def to_weekly(bars):
|
||
"""按 ISO 周聚合日线:周开=首根,周收=末根,周高=max,周低=min,量=sum。"""
|
||
weeks = {}
|
||
order = []
|
||
for b in bars:
|
||
# t 形如 2026-06-12T04:00:00Z;用日期算 (iso_year, iso_week)
|
||
y, m, d = (int(x) for x in b["t"][:10].split("-"))
|
||
key = datetime.date(y, m, d).isocalendar()[:2]
|
||
if key not in weeks:
|
||
weeks[key] = {"o": b["o"], "h": b["h"], "l": b["l"], "c": b["c"], "v": b["v"]}
|
||
order.append(key)
|
||
else:
|
||
w = weeks[key]
|
||
w["h"] = max(w["h"], b["h"])
|
||
w["l"] = min(w["l"], b["l"])
|
||
w["c"] = b["c"]
|
||
w["v"] += b["v"]
|
||
return [weeks[k] for k in order]
|
||
|
||
|
||
# ---- 量化因子原语 ----
|
||
|
||
def daily_returns(closes):
|
||
"""简单日收益率序列(剔除基准为 0 的点)。"""
|
||
return [closes[i] / closes[i - 1] - 1
|
||
for i in range(1, len(closes)) if closes[i - 1]]
|
||
|
||
|
||
def _sample_std(xs):
|
||
"""样本标准差(n-1);样本数 < 2 返回 None。"""
|
||
n = len(xs)
|
||
if n < 2:
|
||
return None
|
||
m = sum(xs) / n
|
||
return math.sqrt(sum((x - m) ** 2 for x in xs) / (n - 1))
|
||
|
||
|
||
def annualized_vol(closes, window=63):
|
||
"""窗口内日收益率的年化波动率(百分比)。需要 ≥ window+1 根。"""
|
||
if len(closes) < window + 1:
|
||
return None
|
||
s = _sample_std(daily_returns(closes[-(window + 1):]))
|
||
return round(s * math.sqrt(TRADING_DAYS) * 100, 2) if s is not None else None
|
||
|
||
|
||
def annualized_return(closes, window=126):
|
||
"""窗口内复合年化收益率(百分比)。需要 ≥ window+1 根。"""
|
||
if len(closes) < window + 1 or closes[-(window + 1)] <= 0:
|
||
return None
|
||
total = closes[-1] / closes[-(window + 1)]
|
||
return round((total ** (TRADING_DAYS / window) - 1) * 100, 2)
|
||
|
||
|
||
def max_drawdown(closes, window=126):
|
||
"""窗口内最大回撤(负百分比;峰值到谷值的最深跌幅)。"""
|
||
if len(closes) < window:
|
||
return None
|
||
seg = closes[-window:]
|
||
if len(seg) < 2:
|
||
return None
|
||
peak, mdd = seg[0], 0.0
|
||
for c in seg:
|
||
if c > peak:
|
||
peak = c
|
||
if peak:
|
||
mdd = min(mdd, c / peak - 1)
|
||
return round(mdd * 100, 2)
|
||
|
||
|
||
def atr(highs, lows, closes, n=14):
|
||
"""Wilder ATR(绝对价格单位)。需要 ≥ n+1 根。"""
|
||
if len(closes) < n + 1:
|
||
return None
|
||
trs = []
|
||
for i in range(1, len(closes)):
|
||
trs.append(max(highs[i] - lows[i],
|
||
abs(highs[i] - closes[i - 1]),
|
||
abs(lows[i] - closes[i - 1])))
|
||
a = sum(trs[:n]) / n
|
||
for i in range(n, len(trs)):
|
||
a = (a * (n - 1) + trs[i]) / n
|
||
return round(a, 4)
|
||
|
||
|
||
def efficiency_ratio(closes, n=30):
|
||
"""Kaufman 效率比:净位移/路径总长,∈[0,1]。1=单边干净趋势,0=纯震荡。"""
|
||
if len(closes) < n + 1:
|
||
return None
|
||
seg = closes[-(n + 1):]
|
||
net = abs(seg[-1] - seg[0])
|
||
path = sum(abs(seg[i] - seg[i - 1]) for i in range(1, len(seg)))
|
||
return round(net / path, 3) if path else None
|
||
|
||
|
||
def _linreg(values):
|
||
"""对 values 关于索引 0..n-1 做最小二乘,返回 (slope, r2);退化时 None。"""
|
||
n = len(values)
|
||
if n < 3:
|
||
return None
|
||
xs = range(n)
|
||
mx = (n - 1) / 2.0
|
||
my = sum(values) / n
|
||
sxx = sum((x - mx) ** 2 for x in xs)
|
||
sxy = sum((x - mx) * (values[x] - my) for x in xs)
|
||
syy = sum((v - my) ** 2 for v in values)
|
||
# 用相对阈值判退化:近乎零方差的序列趋势无定义(浮点噪声会让 syy 是 1e-28 量级
|
||
# 而非精确 0,绝对 ==0 判定挡不住),返回 None 而非给出虚假的 r2=0。
|
||
scale = max((abs(v) for v in values), default=0.0)
|
||
if sxx == 0 or syy <= (scale * scale + 1.0) * 1e-18 * n:
|
||
return None
|
||
slope = sxy / sxx
|
||
r2 = (sxy * sxy) / (sxx * syy)
|
||
return slope, r2
|
||
|
||
|
||
def trend_regression(closes, window=63):
|
||
"""对数价格线性回归:年化斜率(%)与 R²(趋势平滑度)。需要 ≥3 个正价。"""
|
||
seg = [c for c in closes[-window:] if c > 0]
|
||
if len(seg) < 3:
|
||
return None
|
||
res = _linreg([math.log(c) for c in seg])
|
||
if not res:
|
||
return None
|
||
slope, r2 = res
|
||
ann = (math.exp(slope * TRADING_DAYS) - 1) * 100
|
||
return {"ann_slope_pct": round(ann, 2), "r2": round(r2, 3)}
|
||
|
||
|
||
def momentum_12_1(closes):
|
||
"""学术口径 12-1 动量:t-252 到 t-21 的收益(跳过最近一月,避开短期反转)。
|
||
|
||
closes[-1] = P(t),则 P(t-21) = closes[-22]、P(t-252) = closes[-253]。
|
||
历史不足 253 根时返回 None,而非用 closes[0] 近似——后者会把窗口悄悄
|
||
变成「全历史-1」,得到与真 12-1 动量不可比的读数,污染跨股截面排序/IC。
|
||
历史短的标的应缺这一读数(_f_momentum 会按可用权重重归一),不应给假值。
|
||
"""
|
||
if len(closes) < 253:
|
||
return None
|
||
recent, base = closes[-22], closes[-253]
|
||
if base <= 0:
|
||
return None
|
||
return round((recent / base - 1) * 100, 2)
|
||
|
||
|
||
def obv(closes, volumes, n=30):
|
||
"""On-Balance Volume(能量潮)与背离检测。
|
||
|
||
计算累积 OBV 序列,对最近 n 根做线性回归,比较价格与 OBV 的趋势方向:
|
||
- bearish divergence:价格趋势向上但 OBV 趋势向下(顶背离 = 派发预警)
|
||
- bullish divergence:价格趋势向下但 OBV 趋势向上(底背离 = 累积信号)
|
||
|
||
需要 ≥ n+1 根。返回 dict 或 None。
|
||
"""
|
||
if len(closes) < n + 1:
|
||
return None
|
||
# 累积 OBV
|
||
vals = [0.0]
|
||
for i in range(1, len(closes)):
|
||
if closes[i] > closes[i - 1]:
|
||
vals.append(vals[-1] + volumes[i])
|
||
elif closes[i] < closes[i - 1]:
|
||
vals.append(vals[-1] - volumes[i])
|
||
else:
|
||
vals.append(vals[-1])
|
||
|
||
# OBV 最近 n 根的趋势方向
|
||
obv_reg = _linreg(vals[-n:])
|
||
obv_dir = None
|
||
if obv_reg is not None:
|
||
obv_dir = "rising" if obv_reg[0] > 0 else "falling"
|
||
|
||
# 价格最近 n 根的趋势方向(对数回归)
|
||
price_reg = _linreg([math.log(c) for c in closes[-n:] if c > 0])
|
||
price_dir = None
|
||
if price_reg is not None:
|
||
price_dir = "rising" if price_reg[0] > 0 else "falling"
|
||
|
||
divergence = None
|
||
if price_dir == "rising" and obv_dir == "falling":
|
||
divergence = "bearish"
|
||
elif price_dir == "falling" and obv_dir == "rising":
|
||
divergence = "bullish"
|
||
|
||
return {
|
||
"value": round(vals[-1], 1),
|
||
"trend_30d": obv_dir,
|
||
"divergence": divergence,
|
||
}
|
||
|
||
|
||
def volume_ratio_ma(volumes, short_window=5, long_window=20):
|
||
"""近 short_window 天平均量能比(vs long_window 日均量)。
|
||
|
||
比单日 ratio_vs_ma20 更稳健,消除单日噪声。
|
||
需要 ≥ long_window+short_window 根。
|
||
"""
|
||
total = long_window + short_window
|
||
if len(volumes) < total:
|
||
return None
|
||
ma_long = sum(volumes[-(total):-short_window]) / long_window
|
||
if not ma_long:
|
||
return None
|
||
recent = volumes[-short_window:]
|
||
return round(sum(recent) / len(recent) / ma_long, 2)
|
||
|
||
|
||
def volume_trend_direction(volumes, window=10):
|
||
"""量能线性趋势方向。返回 "rising" / "falling" / None。"""
|
||
if len(volumes) < window:
|
||
return None
|
||
reg = _linreg(volumes[-window:])
|
||
if reg is None:
|
||
return None
|
||
return "rising" if reg[0] > 0 else "falling"
|
||
|
||
|
||
def up_day_volume_ratio(closes, volumes, window=10):
|
||
"""上涨日 vs 下跌日的平均量比。
|
||
|
||
>1 = 上涨放量(健康),<1 = 下跌放量(派发)。
|
||
需要 ≥ window+1 根且 window 内有涨有跌。
|
||
"""
|
||
if len(closes) < window + 1:
|
||
return None
|
||
seg_c = closes[-(window + 1):]
|
||
seg_v = volumes[-(window + 1):]
|
||
up_vols, dn_vols = [], []
|
||
for i in range(1, len(seg_c)):
|
||
if seg_c[i] > seg_c[i - 1]:
|
||
up_vols.append(seg_v[i])
|
||
elif seg_c[i] < seg_c[i - 1]:
|
||
dn_vols.append(seg_v[i])
|
||
if not dn_vols or not up_vols:
|
||
return None # 全部上涨或全部下跌 → 无法比较
|
||
avg_up = sum(up_vols) / len(up_vols)
|
||
avg_dn = sum(dn_vols) / len(dn_vols)
|
||
return round(avg_up / avg_dn, 2) if avg_dn else None
|
||
|
||
|
||
# ---- 入场时机(过热度 / 趋势内回调拐点)----
|
||
|
||
# 过热判定:距 MA20 的 ATR 倍数与 RSI14 超买(硬条件二选一,软条件需同时满足)
|
||
ENTRY_EXT_ATR_HARD = 3.0
|
||
ENTRY_EXT_RSI_HARD = 80.0
|
||
ENTRY_EXT_ATR_SOFT = 2.2
|
||
ENTRY_EXT_RSI_SOFT = 75.0
|
||
# 回调拐点:近 10 日低点较 30 日高点的最小回撤深度(%),或 5 日内触及 MA20
|
||
ENTRY_PULLBACK_MIN_DEPTH_PCT = -4.0
|
||
# 反弹确认:反转日之外还需的独立触发信号数
|
||
ENTRY_BOUNCE_MIN_SIGNALS = 2
|
||
# 趋势破坏后的高置信超跌反转:与强趋势内回调分开,全部是硬条件。
|
||
RECOVERY_MIN_DEPTH_PCT = -12.0
|
||
RECOVERY_MIN_DAILY_RETURN_PCT = 3.0
|
||
RECOVERY_MIN_CLOSE_STRENGTH = 0.75
|
||
RECOVERY_MIN_VOL_RATIO = 1.5
|
||
RECOVERY_MIN_SIGNALS = 4
|
||
RECOVERY_MAX_EXT_ATR = 0.75
|
||
|
||
|
||
def _ma_reclaim(closes, n, lookback=2):
|
||
"""近 lookback 根内收盘价由 n 日均线下方收复到上方,且当前仍在上方。"""
|
||
total = len(closes)
|
||
if total < n + lookback + 1:
|
||
return None
|
||
m_now = ma(closes, n)
|
||
if m_now is None or closes[-1] <= m_now:
|
||
return False
|
||
for back in range(lookback):
|
||
c_prev = closes[-2 - back]
|
||
m_prev = ma(closes[:total - 1 - back], n)
|
||
if m_prev is not None and c_prev <= m_prev:
|
||
return True
|
||
return False
|
||
|
||
|
||
def entry_timing(closes, highs, lows, volumes):
|
||
"""入场时机画像:过热度、趋势内回调与高置信超跌反转。历史 <60 根返回 None。
|
||
|
||
只做时机分类,不判断趋势资格(趋势/相对强度由 ALPHA 因子与闸门负责):
|
||
- overextended:价格显著超出短期均值/RSI 超买 → 追高风险
|
||
- pullback_reversal:趋势完好 + 近期回调 + 最后一根出现放量反转触发
|
||
- recovery_reversal:趋势破坏后深度回撤 + 放量强反转,只提供受限试仓候选
|
||
- pullback_no_trigger:回调中但反转未确认
|
||
- trend_continuation:趋势正常推进、未过热
|
||
- trend_broken:跌破中期均线(资格由闸门另行否决)
|
||
"""
|
||
n = len(closes)
|
||
if n < 60:
|
||
return None
|
||
last = closes[-1]
|
||
ma20 = ma(closes, 20)
|
||
ma60 = ma(closes, 60)
|
||
ma200 = ma(closes, 200)
|
||
atr14 = atr(highs, lows, closes, 14)
|
||
rsi14 = rsi(closes, 14)
|
||
|
||
# 过热度:距 MA20 的 ATR 倍数(均值回归引力的量纲化读数)
|
||
ext_atr = round((last - ma20) / atr14, 2) if (ma20 and atr14) else None
|
||
ext_pct = round((last / ma20 - 1) * 100, 2) if ma20 else None
|
||
overextended = bool(
|
||
(ext_atr is not None and ext_atr >= ENTRY_EXT_ATR_HARD)
|
||
or (rsi14 is not None and rsi14 >= ENTRY_EXT_RSI_HARD)
|
||
or (ext_atr is not None and rsi14 is not None
|
||
and ext_atr >= ENTRY_EXT_ATR_SOFT and rsi14 >= ENTRY_EXT_RSI_SOFT)
|
||
)
|
||
|
||
# 回调结构必须满足「先高点、后低点、再到当前反转」的时间顺序。旧实现直接拿
|
||
# 近 10 日低点除以近 30 日最高点;当最高点就是今天时,会把从旧低点上涨到
|
||
# 新高的幅度误当成回撤。这里先定位近 10 日最低点,再只在它之前找高点。
|
||
win = min(30, n)
|
||
window_start = n - win
|
||
recent_start = n - min(10, n)
|
||
recent_low = min(lows[recent_start:])
|
||
# 并列低点取最后一个,优先解释离当前反转最近的回调低点。
|
||
recent_low_idx = recent_start + max(
|
||
i for i, value in enumerate(lows[recent_start:]) if value == recent_low
|
||
)
|
||
prior_highs = highs[window_start:recent_low_idx]
|
||
if prior_highs:
|
||
hi = max(prior_highs)
|
||
# 并列高点取最后一个,得到距离当前最近且仍早于低点的结构高点。
|
||
hi_idx = window_start + max(i for i, v in enumerate(prior_highs) if v == hi)
|
||
days_since_high = n - 1 - hi_idx
|
||
depth = round((recent_low / hi - 1) * 100, 2) if hi else None
|
||
else:
|
||
hi, hi_idx, days_since_high, depth = None, None, None, None
|
||
|
||
# 过去五日的低点必须与「当时」的 MA20 比较,不能用今天的 MA20 回看历史。
|
||
touched_ma20_days = []
|
||
for idx in range(max(19, n - 5), n):
|
||
ma20_at_idx = ma(closes[:idx + 1], 20)
|
||
if ma20_at_idx is not None and lows[idx] <= ma20_at_idx * 1.01:
|
||
touched_ma20_days.append(idx)
|
||
touched_ma20 = bool(touched_ma20_days)
|
||
had_pullback = bool(
|
||
(depth is not None and depth <= ENTRY_PULLBACK_MIN_DEPTH_PCT) or touched_ma20
|
||
)
|
||
|
||
# 反转日:收阳且收在日内区间上半部,或直接收在前一根最高价上方
|
||
prev_close = closes[-2]
|
||
up_day = last > prev_close
|
||
daily_return_pct = round((last / prev_close - 1) * 100, 2) if prev_close else None
|
||
day_range = highs[-1] - lows[-1]
|
||
close_strength = round((last - lows[-1]) / day_range, 2) if day_range else None
|
||
bounce_day = bool(up_day and (
|
||
(close_strength is not None and close_strength >= 0.55) or last > highs[-2]
|
||
))
|
||
|
||
# 独立触发信号
|
||
vol_ma20 = ma(volumes, 20)
|
||
vol_ratio = round(volumes[-1] / vol_ma20, 2) if vol_ma20 else None
|
||
vol_expand = bool(vol_ratio is not None and vol_ratio >= 1.2)
|
||
reclaim10 = _ma_reclaim(closes, 10)
|
||
reclaim20 = _ma_reclaim(closes, 20)
|
||
reclaim_ma = bool(reclaim10 or reclaim20)
|
||
|
||
kdj_now = kdj(highs, lows, closes)
|
||
kdj_p1 = kdj(highs[:-1], lows[:-1], closes[:-1])
|
||
kdj_p2 = kdj(highs[:-2], lows[:-2], closes[:-2]) if n > 2 else None
|
||
kdj_golden = bool(
|
||
kdj_now and kdj_now.get("bull") and (
|
||
(kdj_p1 and not kdj_p1.get("bull"))
|
||
or (kdj_p1 and kdj_p1.get("bull") and kdj_p2 and not kdj_p2.get("bull"))
|
||
)
|
||
)
|
||
|
||
macd_now = macd(closes)
|
||
macd_p1 = macd(closes[:-1])
|
||
macd_rising = bool(
|
||
macd_now and macd_p1
|
||
and macd_now.get("hist") is not None and macd_p1.get("hist") is not None
|
||
and macd_now["hist"] > macd_p1["hist"]
|
||
)
|
||
|
||
rsi6_now = rsi(closes, 6)
|
||
rsi6_p1 = rsi(closes[:-1], 6)
|
||
rsi6_rebound = bool(
|
||
rsi6_now is not None and rsi6_p1 is not None
|
||
and rsi6_now > rsi6_p1 and rsi6_p1 < 50
|
||
)
|
||
|
||
signal_count = sum((vol_expand, reclaim_ma, kdj_golden, macd_rising, rsi6_rebound))
|
||
|
||
# 趋势是否完好(时机分类的前提;不足以单独构成买入资格)
|
||
trend_intact = bool(
|
||
ma60 is not None and last >= ma60 * 0.97
|
||
and (ma200 is None or last > ma200 * 0.99)
|
||
)
|
||
|
||
recovery_reversal = bool(
|
||
not trend_intact
|
||
and depth is not None and depth <= RECOVERY_MIN_DEPTH_PCT
|
||
and bounce_day
|
||
and daily_return_pct is not None and daily_return_pct >= RECOVERY_MIN_DAILY_RETURN_PCT
|
||
and close_strength is not None and close_strength >= RECOVERY_MIN_CLOSE_STRENGTH
|
||
and vol_ratio is not None and vol_ratio >= RECOVERY_MIN_VOL_RATIO
|
||
and signal_count >= RECOVERY_MIN_SIGNALS
|
||
and ext_atr is not None and ext_atr <= RECOVERY_MAX_EXT_ATR
|
||
)
|
||
|
||
if recovery_reversal:
|
||
cls = "recovery_reversal"
|
||
timing_score = 95
|
||
elif not trend_intact:
|
||
cls = "trend_broken"
|
||
timing_score = 10
|
||
elif overextended:
|
||
cls = "overextended"
|
||
timing_score = 20
|
||
elif had_pullback and bounce_day and signal_count >= ENTRY_BOUNCE_MIN_SIGNALS:
|
||
cls = "pullback_reversal"
|
||
timing_score = min(75 + 5 * signal_count, 95)
|
||
elif had_pullback and (ext_atr is None or ext_atr < 1.0):
|
||
# 回调中但反转未确认(含反弹日信号不足);已收复到 MA20 上方 1 ATR 以上的
|
||
# 视为回调结束、回到趋势推进
|
||
cls = "pullback_no_trigger"
|
||
timing_score = 45
|
||
else:
|
||
cls = "trend_continuation"
|
||
timing_score = 65
|
||
if ext_atr is not None and ext_atr >= 2.0:
|
||
timing_score = 45
|
||
elif ext_atr is not None and ext_atr >= 1.5:
|
||
timing_score = 55
|
||
|
||
return {
|
||
"ext_atr_above_ma20": ext_atr,
|
||
"ext_pct_above_ma20": ext_pct,
|
||
"rsi14": rsi14,
|
||
"overextended": overextended,
|
||
"trend_intact": trend_intact,
|
||
"pullback": {
|
||
"high_30d": round(hi, 4) if hi is not None else None,
|
||
"days_since_30d_high": days_since_high,
|
||
"recent_low_10d": round(recent_low, 4),
|
||
"bars_since_recent_low": n - 1 - recent_low_idx,
|
||
"depth_from_30d_high_pct": depth,
|
||
"touched_ma20_5d": touched_ma20,
|
||
"had_pullback": had_pullback,
|
||
},
|
||
"bounce": {
|
||
"bounce_day": bounce_day,
|
||
"up_day": up_day,
|
||
"daily_return_pct": daily_return_pct,
|
||
"close_strength": close_strength,
|
||
"vol_ratio_vs_ma20": vol_ratio,
|
||
"vol_expand": vol_expand,
|
||
"reclaim_ma10": reclaim10,
|
||
"reclaim_ma20": reclaim20,
|
||
"kdj_golden_3d": kdj_golden,
|
||
"macd_hist_rising": macd_rising,
|
||
"rsi6_rebound": rsi6_rebound,
|
||
"signal_count": signal_count,
|
||
"recovery_hard_pass": recovery_reversal,
|
||
},
|
||
"classification": cls,
|
||
"timing_score_pct": timing_score,
|
||
}
|
||
|
||
|
||
def adx(highs, lows, closes, n=14):
|
||
"""Average Directional Index(Wilder 平滑)。
|
||
|
||
返回 ADX 值、+DI、-DI 与趋势判定:
|
||
- ADX > 25:强趋势市,> 40:极强趋势
|
||
- ADX < 20:震荡/无趋势
|
||
- +DI > -DI:上涨趋势,反之下跌趋势
|
||
|
||
需要 ≥ n*2+1 根。与 efficiency_ratio 互补——
|
||
efficiency_ratio 衡量趋势"干净度",ADX 衡量趋势"力度"。
|
||
"""
|
||
if len(closes) < n * 2 + 1:
|
||
return None
|
||
|
||
# True Range
|
||
trs = []
|
||
for i in range(1, len(closes)):
|
||
trs.append(max(
|
||
highs[i] - lows[i],
|
||
abs(highs[i] - closes[i - 1]),
|
||
abs(lows[i] - closes[i - 1]),
|
||
))
|
||
|
||
# Directional Movements
|
||
plus_dm, minus_dm = [], []
|
||
for i in range(1, len(highs)):
|
||
up = highs[i] - highs[i - 1]
|
||
dn = lows[i - 1] - lows[i]
|
||
plus_dm.append(up if (up > dn and up > 0) else 0.0)
|
||
minus_dm.append(dn if (dn > up and dn > 0) else 0.0)
|
||
|
||
# Wilder 平滑:alpha = 1/n,首 n 根用 SMA
|
||
def _wilder(values, period):
|
||
if len(values) < period:
|
||
return None
|
||
k = 1.0 / period
|
||
out = [sum(values[:period]) / period]
|
||
for v in values[period:]:
|
||
out.append(v * k + out[-1] * (1 - k))
|
||
return out
|
||
|
||
atr14 = _wilder(trs, n)
|
||
pdi14 = _wilder(plus_dm, n)
|
||
mdi14 = _wilder(minus_dm, n)
|
||
|
||
if not all((atr14, pdi14, mdi14)):
|
||
return None
|
||
|
||
# +DI / -DI(百分比)与 DX
|
||
pdi = [round(p / t * 100, 2) if t else 0.0 for p, t in zip(pdi14, atr14)]
|
||
mdi = [round(m / t * 100, 2) if t else 0.0 for m, t in zip(mdi14, atr14)]
|
||
dx = [abs(p - m) / (p + m) * 100 if (p + m) > 0 else 0.0
|
||
for p, m in zip(pdi, mdi)]
|
||
|
||
# ADX = Wilder 平滑的 DX
|
||
adx14 = _wilder(dx, n)
|
||
if not adx14:
|
||
return None
|
||
|
||
return {
|
||
"ADX": round(adx14[-1], 2),
|
||
"plus_DI": pdi[-1],
|
||
"minus_DI": mdi[-1],
|
||
"trend_strong": adx14[-1] >= 25,
|
||
"bull_trend": adx14[-1] >= 20 and pdi[-1] > mdi[-1],
|
||
}
|