Files
big-qmt/py-client/strategy/etf/signal.py
2026-09-19 19:45:43 +08:00

318 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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