optz
This commit is contained in:
317
py-client/strategy/etf/signal.py
Normal file
317
py-client/strategy/etf/signal.py
Normal 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
|
||||
Reference in New Issue
Block a user