"""仅用已收盘日线计算指标,避免把盘中未完成的日线混入信号。""" from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal, ROUND_CEILING import math from statistics import fmean, pstdev from .config import ETFConfig @dataclass(frozen=True) class Indicators: day: str ma60: float atr: float lower: float middle: float upper: float grid: float def calculate(rows: list[dict], today: date, cfg: ETFConfig) -> Indicators: """MA60 + Wilder ATR + BOLL(总体标准差),格距向上取整到 0.001 元。""" bars = {} for row in rows: day = datetime.strptime(str(row['date']), '%Y%m%d').date() if day >= today: continue if day in bars: raise ValueError('日线包含重复日期') high, low, close = (float(row[key]) for key in ('high', 'low', 'close')) if not all(math.isfinite(v) and v > 0 for v in (high, low, close)) or not low <= close <= high: raise ValueError('日线价格无效') bars[day] = (high, low, close) days = sorted(bars) if len(days) < max(60, cfg.atr_period + 1, cfg.boll_period): raise ValueError('已收盘日线不足,至少需要 60 根且能计算 ATR') # 长期停牌或历史缓存未补齐时不使用过期信号;春节等长假允许 15 个自然日。 if (today - days[-1]).days > 15: raise ValueError('最近日线超过 15 个自然日,需补齐行情') values = [bars[d] for d in days] closes = [v[2] for v in values] tr = [max(h - l, abs(h - closes[i - 1]), abs(l - closes[i - 1])) for i, (h, l, _) in enumerate(values) if i > 0] n = cfg.atr_period atr = fmean(tr[:n]) for value in tr[n:]: atr = (atr * (n - 1) + value) / n ma = fmean(closes[-60:]) window = closes[-cfg.boll_period:] middle, width = fmean(window), cfg.boll_std * pstdev(window) raw_grid = max(atr * cfg.atr_multiplier, ma * cfg.min_grid_pct / 100, 0.001) grid = float(Decimal(str(raw_grid)).quantize(Decimal('0.001'), rounding=ROUND_CEILING)) return Indicators(days[-1].strftime('%Y%m%d'), ma, atr, middle - width, middle, middle + width, grid)