This commit is contained in:
2026-09-19 19:45:43 +08:00
parent 7183cb45f8
commit 8131b158b4
60 changed files with 7669 additions and 909 deletions

View File

@@ -0,0 +1,317 @@
"""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