"""ETF 信号层:把 ``_etf.yaml`` 的白名单展开为可交易的信号列表。 一个信号就是一个标的:入场判定所需的指标全部固化在 ``SignalItem.tech_indicator`` 里,引擎不再自己取数、算指标。数据来自外部日线接口(``docs/etf.md`` §7.4), 只使用已收盘日线,见 ``calculate`` 的校验。 """ from datetime import date, datetime, timedelta from decimal import ROUND_CEILING, Decimal import logging as log import math import re from statistics import fmean import httpx from config import HTTP_TIMEOUT, EtfSymbolConfig from libs.runtime import Runtime from libs.signal import SignalItem # 外部日线接口:只接受单个 code,无复权价;不发送 QMT 认证信息。 DAILY_PATH = "/etf/daily" DAILY_URL = "http://139.224.247.176:13499/etf/daily" # 取数失败后的重试间隔,避免接口故障时每轮都打满请求。 RETRY_SECONDS = 300 # 只保留最近的样本;需要 60 根 MA60、61 根 ATR14。 BAR_COUNT = 120 # ATR 需要前一根收盘价,MA60 需要 60 根,取两者的较大值作为样本下限。 MIN_BARS = 61 # MA60 是网的顶部上限,周期固定为 60 个交易日。 MA_PERIOD = 60 # 长期停牌或缓存未补齐时不使用过期指标;春节等长假允许 15 个自然日。 MAX_BAR_AGE_DAYS = 15 # tech_indicator 的键:带 etf_ 前缀,避免与其它策略的信号字段混用。 IND_MA60 = "etf_ma60" IND_ATR = "etf_atr" IND_CHANNEL_LOW = "etf_channel_low" IND_CHANNEL_HIGH = "etf_channel_high" IND_ENTRY = "etf_entry" IND_GRID = "etf_grid" IND_GRID_PCT = "etf_grid_pct" IND_ADD_PRICE = "etf_add_price" IND_PRICE = "etf_price" _STAMP_PATTERN = re.compile(r"[0-9]{8}") _PRICE_FIELDS = ("open", "high", "low", "close") # 行情客户端按需创建:模块只做信号生成,不持有 QMT 客户端。 _history_client: httpx.Client | None = None # 每标的每日只取一次;失败标的按 RETRY_SECONDS 重试。 _daily_cache: dict[str, list[dict]] = {} _fetched: dict[str, date] = {} _retry_at: dict[str, datetime] = {} def gen_signals(rt: Runtime) -> list[SignalItem]: """从 ``_etf.yaml`` 白名单生成信号列表,顺序即资金优先级。 每个标的独立取日线并计算指标;取数或计算失败的标的本轮直接跳过, 不允许退化使用旧数据,也不允许替换成别的证券。返回的 ``SignalItem.tech_indicator`` 携带引擎需要的全部已收盘指标。 """ etf_cfg = getattr(rt, "etf_cfg", None) if etf_cfg is None: log.error("[ETF信号] 缺少 _etf.yaml 配置,本轮无可交易标的") return [] today = datetime.now().date() _reset_daily(today) endpoint = _api_endpoint(rt) cfg = etf_cfg.defaults signals: list[SignalItem] = [] for code in etf_cfg.codes: try: symbol = etf_cfg.symbols[code] bars = _daily_bars(code, today, endpoint) if not bars: continue indicators = calculate(bars, symbol, cfg, today) except Exception as exc: log.warning("[ETF信号] %s 跳过:%s", code, exc) continue signals.append( SignalItem( signal_key=etf_cfg_key(rt), code=code, name=code, desc=f"ETF网格 档位={symbol.buy_shares}股 上限={symbol.max_shares}股", last_close=indicators[IND_PRICE], tech_indicator=indicators, ) ) if signals: log.info( "[ETF信号] 生成完毕,可交易=%d/%d,来源=%s", len(signals), len(etf_cfg.codes), endpoint, ) else: log.warning("[ETF信号] 本轮没有可用信号,白名单=%d", len(etf_cfg.codes)) return signals def etf_cfg_key(rt: Runtime) -> str: """信号的品种标识:ETF 全部标的共用 ``etf``,便于按策略名过滤委托与日志。""" strategy = str(getattr(rt.account_cfg, "strategy", "") or "").strip().lower() return strategy or "etf" def calculate( bars: list[dict], symbol: EtfSymbolConfig, defaults, today: date ) -> dict[str, float]: """用已收盘日线算出引擎需要的全部指标。 ATR 走 Wilder 平滑;格距向上取整到 0.001 元(ETF 最小报价单位)。 样本不足或日线过期时抛 ValueError,由调用方放弃该标的当轮交易。 """ if len(bars) < MIN_BARS: raise ValueError(f"已收盘日线不足 {MIN_BARS} 根") ordered = sorted(bars, key=lambda bar: bar["date"]) last_day = datetime.strptime(ordered[-1]["date"], "%Y%m%d").date() # 长期停牌或历史缓存未补齐时不使用过期数据;春节等长假允许 15 个自然日。 if (today - last_day).days > MAX_BAR_AGE_DAYS: raise ValueError( f"最近日线 {ordered[-1]['date']} 超过 {MAX_BAR_AGE_DAYS} 个自然日" ) period = defaults.atr_period if type(period) is not int or period < 2: raise ValueError("atr_period 必须是大于 1 的整数") closes = [float(bar["close"]) for bar in ordered] highs = [float(bar["high"]) for bar in ordered] lows = [float(bar["low"]) for bar in ordered] ranges = [ max(high - low, abs(high - closes[index - 1]), abs(low - closes[index - 1])) for index, (high, low) in enumerate(zip(highs, lows)) if index > 0 ] if len(ranges) < period: raise ValueError(f"日线不足 {period + 1} 根,无法计算 ATR") atr = fmean(ranges[:period]) for value in ranges[period:]: atr = (atr * (period - 1) + value) / period window = int(defaults.channel_period) if len(highs) < max(window, MA_PERIOD): raise ValueError(f"日线不足 {max(window, MA_PERIOD)} 根,无法计算通道或 MA60") ma60 = fmean(closes[-MA_PERIOD:]) channel_low = min(lows[-window:]) channel_high = max(highs[-window:]) # 入场门槛 = min(距区间下沿 channel_pct% 的价位, MA60):不在均线上方建网。 entry = min( channel_low + (channel_high - channel_low) * defaults.channel_pct / 100, ma60 ) # 格距 = max(ATR × 倍数, MA60 × 格距下限百分比, 0.001),向上取整到 0.001 元。 raw_grid = max(atr * symbol.atr_multiplier, ma60 * defaults.min_grid_pct / 100, 0.001) grid = float(Decimal(str(raw_grid)).quantize(Decimal("0.001"), rounding=ROUND_CEILING)) values = (ma60, atr, channel_low, channel_high, entry, grid, closes[-1]) if not all(math.isfinite(value) and value > 0 for value in values): raise ValueError("指标存在非有限正数") if grid <= 0: raise ValueError("格距非正数") return { IND_MA60: ma60, IND_ATR: atr, IND_CHANNEL_LOW: channel_low, IND_CHANNEL_HIGH: channel_high, IND_ENTRY: entry, IND_GRID: grid, IND_GRID_PCT: grid / closes[-1] * 100, IND_ADD_PRICE: closes[-1] * (1 - defaults.add_pct / 100), IND_PRICE: closes[-1], } def daily_bars( client: httpx.Client, code: str, today: date, count: int = BAR_COUNT, endpoint: str = DAILY_URL, ) -> list[dict]: """读取指定证券日线;窗口截取在本地完成(接口只支持单 code)。""" response = client.get(endpoint, params={"code": code}) response.raise_for_status() return parse_daily(response.json(), code, today, count) def parse_daily( payload: object, code: str, today: date, count: int = BAR_COUNT ) -> list[dict]: """校验业务状态、证券归属、OHLC 与日期,返回按日期升序的最近若干根。 线上接口直接返回一维数组(倒序),旧版是 ``{code, message, details}`` 包装, 两种形式都支持。任一校验不通过即抛 ValueError,调用方放弃该标的当轮交易。 """ if type(count) is not int or count <= 0: raise ValueError("日线数量必须为正整数") if isinstance(payload, list): rows = payload elif isinstance(payload, dict): if type(payload.get("code")) is not int or payload["code"] != 0: raise ValueError(f"日线接口业务失败:{payload.get('message', '状态无效')}") rows = payload.get("details") else: rows = None if not isinstance(rows, list) or not rows: raise ValueError(f"{code} 日线接口未返回有效数据列表") bars: dict[str, dict] = {} for row in rows: if not isinstance(row, dict) or row.get("ts_code") != code: raise ValueError(f"{code} 日线证券代码不一致") stamp = str(row.get("trade_date", "")) if not _STAMP_PATTERN.fullmatch(stamp): raise ValueError(f"{code} 日线日期无效:{stamp}") day = datetime.strptime(stamp, "%Y%m%d").date() # 当前日及未来日线不得混入盘中指标,先过滤再截取最近 count 根。 if day >= today: continue if stamp in bars: raise ValueError(f"{code} 日线日期重复:{stamp}") values = {} for name in _PRICE_FIELDS: value = row.get(name) if isinstance(value, bool) or not isinstance(value, (int, float, str)): raise ValueError(f"{code} 日线 {name} 无效") try: number = float(value) except ValueError as exc: raise ValueError(f"{code} 日线 {name} 无效") from exc if not math.isfinite(number) or number <= 0: raise ValueError(f"{code} 日线 {name} 非有限正数") values[name] = number if not ( values["low"] <= values["open"] <= values["high"] and values["low"] <= values["close"] <= values["high"] ): raise ValueError(f"{code} 日线 OHLC 关系异常") bars[stamp] = dict(date=stamp, **values) return [bars[stamp] for stamp in sorted(bars)[-count:]] def _api_endpoint(rt: Runtime) -> str: """日线接口地址:拼接全局 api_host,未配置时用接口默认地址。""" global_cfg = getattr(rt, "global_cfg", None) host = str(getattr(global_cfg, "api_host", "") or "").strip().rstrip("/") return f"{host}{DAILY_PATH}" if host else DAILY_URL def _reset_daily(today: date) -> None: """跨交易日清空日线缓存,保证指标只基于当天可见的已收盘日线。 按标的逐个判断取数日期:失败重试记录带着自己的日期,即使还有标的当天 尚未取数成功也不会被清掉,重试窗口因此始终有效。 """ for code in [code for code, day in _fetched.items() if day != today]: _daily_cache.pop(code, None) _fetched.pop(code, None) for code in [ code for code, retry_at in _retry_at.items() if retry_at.date() != today ]: _retry_at.pop(code, None) def _daily_bars(code: str, today: date, endpoint: str) -> list[dict] | None: """取某个标的的日线:当日成功过就直接复用,失败则等重试间隔。""" if _fetched.get(code) == today: return _daily_cache.get(code) now = datetime.now() # 重试时刻在同一天内才生效;跨日后必须先重新取数。 retry_at = _retry_at.get(code) if retry_at is not None and retry_at.date() == today and now < retry_at: return None try: bars = daily_bars(_history_client_get(), code, today, endpoint=endpoint) except (httpx.HTTPError, ValueError, OSError) as exc: _retry_at[code] = now + timedelta(seconds=RETRY_SECONDS) log.warning( "[ETF日线] %s 获取失败,%d 秒后重试:%s", code, RETRY_SECONDS, exc ) return None _daily_cache[code] = bars _fetched[code] = today _retry_at.pop(code, None) return bars def _history_client_get() -> httpx.Client: """复用外部日线连接池;模块首次取数时才创建。""" global _history_client if _history_client is None: _history_client = httpx.Client(timeout=HTTP_TIMEOUT) return _history_client def reset_history_client() -> None: """关闭并清空外部日线客户端,供进程退出或测试收尾调用。""" global _history_client if _history_client is not None: _history_client.close() _history_client = None