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,250 @@
"""ETF 网格策略开仓:观察 → 反弹确认 → 底仓挂单。
信号由 ``strategy/etf/signal.py`` 的 ``gen_signals`` 生成:白名单里的每个标的
一条信号,``tech_indicator`` 里带着已收盘指标(``etf_entry``、``etf_price`` 等)。
本模块只负责"能不能建网 / 按哪个价挂底仓",补仓与卖出见 ``positions.py``。
底仓规则(``docs/etf.md`` §2、§3.5
1. 现价必须落在入场门槛以内(``min(区间下沿 + 通道幅度×channel_pct%, MA60)``
2. 用 ``rt.open_watch````DipWatch``)确认从观察低点反弹 ``rebound_pct%``
3. 反弹确认价就是锚点,按该价挂限价单买一档 ``buy_shares`` 股;
4. 资金不足或挂单失败时撤销锚点,下一轮重新触发,不留"死锚点"
"""
from datetime import datetime
import logging as log
import math
from typing import Any, Mapping
from libs.calc import trading_time
from libs.order import PlaceOrderRequest
from libs.runtime import Runtime
from libs.signal import SignalItem
from sdk import OP_BUY
from .signal import IND_ENTRY, IND_PRICE
def entry_prices(item: SignalItem) -> tuple[float, float]:
"""返回 (入场门槛, 最近收盘价);缺失时对应项为 0。"""
values = getattr(item, "tech_indicator", None)
if not isinstance(values, Mapping):
values = {}
entry = _positive(values.get(IND_ENTRY) or values.get("entry"))
price = _positive(values.get(IND_PRICE) or getattr(item, "last_close", 0.0))
return entry, price
def classify_entry(item: SignalItem, runtime: Runtime, price: float) -> tuple[bool, str]:
"""判定现价是否处于入场区,并维护 ``open_watch`` 的观察状态。
Returns:
(是否已确认可建网, 说明)。价格在入场区之上时清除观察点,
防止用"陈旧低点 + 现价"拼出虚假反弹。
"""
entry, _ = entry_prices(item)
if entry <= 0:
return False, "缺少入场门槛指标"
if price > entry:
# 价格回到入场区上方:旧观察低点作废,必须重新形成低点。
runtime.open_watch.forget(item.code)
return False, f"未进入入场区(现价{price:.3f}>门槛{entry:.3f})"
if not runtime.open_watch.triggered("建网", item.code, price):
return False, f"入场区内等待反弹确认(门槛{entry:.3f})"
return True, f"反弹已确认,锚点={price:.3f}"
def open_signal(run: Runtime, ticks, open_signals) -> None:
"""逐个验证开仓信号,按锚点价挂出底仓限价单。"""
if not trading_time(datetime.now()):
return
for item in open_signals:
code = item.code
try:
symbol = _symbol(run, code)
if symbol is None:
log.info("[ETF开仓] %s 跳过:不在 _etf.yaml 白名单内", code)
continue
if code in (getattr(run.account_cfg, "excluded_codes", None) or []):
log.info("[ETF开仓] %s 跳过:已配置为排除证券", code)
continue
price = _tick_price(run, code, (ticks or {}).get(code))
if price <= 0:
continue
if run.orders.busy(code, "BUY"):
log.info("[ETF开仓] %s 跳过:买入委托处理中", code)
continue
confirmed, reason = classify_entry(item, run, price)
if not confirmed:
log.info("[ETF开仓] %s 跳过:%s", code, reason)
continue
volume = _entry_volume(run, code)
if volume <= 0:
run.open_watch.forget(code) # 不留挂不出单的死锚点
continue
if not _budget_ok(run, price * volume):
run.open_watch.forget(code)
log.info(
"[ETF开仓] %s 跳过:本轮预算不足,锚点作废,现价=%.3f,需要=%.2f",
code,
price,
price * volume,
)
continue
do_open(run, code, volume, price, reason)
except Exception as exc:
log.exception("[ETF开仓] %s 处理异常:%s", code, exc)
def do_open(run: Runtime, code: str, volume: int, price: float, reason: str = "") -> bool:
"""按锚点价挂底仓买入委托;成功返回 True。"""
request = PlaceOrderRequest(
op=OP_BUY,
code=code,
volume=int(volume),
order_id=run.orders.new_order_id("ETF", "BUY"),
strategy_name=strategy_name(run),
kind="base",
price=price,
)
if not run.orders.place(run.client, request):
run.open_watch.forget(code)
log.warning("[ETF开仓] %s 底仓挂单失败,撤销锚点:%s", code, reason)
return False
run.open_watch.forget(code)
log.info(
"[ETF开仓] %s 建网底仓 %d 股,锚点=%.3f%s", code, request.volume, price, reason
)
return True
def strategy_name(run: Runtime) -> str:
"""委托上的策略名:与账户 ``strategy`` 一致,便于按策略过滤委托与日志。"""
return str(getattr(run.account_cfg, "strategy", "") or "etf").strip().lower() or "etf"
def _symbol(run: Runtime, code: str) -> Any | None:
"""取标的配置;不在白名单内返回 None。"""
symbols = getattr(getattr(run, "etf_cfg", None), "symbols", None)
if not isinstance(symbols, Mapping):
return None
return symbols.get(code)
def _tick_price(run: Runtime, code: str, tick) -> float:
"""校验实时行情:有限正数、时间戳为当天且未超过 ``max_tick_age_seconds``。"""
price = _positive(getattr(tick, "last_price", 0.0)) if tick is not None else 0.0
if price <= 0:
log.info("[ETF开仓] %s 跳过:价格无效", code)
return 0.0
now = datetime.now()
stamp = _tick_stamp(getattr(tick, "raw", None))
if stamp is None:
log.info("[ETF开仓] %s 跳过:行情时间戳缺失", code)
return 0.0
if stamp.date() != now.date():
log.info("[ETF开仓] %s 跳过:行情时间戳非当天(%s)", code, stamp)
return 0.0
limit = _max_tick_age(run)
age = (now - stamp).total_seconds()
if age > limit:
log.info("[ETF开仓] %s 跳过:行情已过期 %.0f 秒>%d", code, age, limit)
return 0.0
return price
def _tick_stamp(raw: Any) -> datetime | None:
"""解析行情时间戳(``20260916103000`` / ``2026-09-16 10:30:00``)。"""
if not isinstance(raw, Mapping):
return None
text = str(raw.get("timetag") or raw.get("time") or raw.get("stime") or "")
digits = "".join(char for char in text if char.isdigit())
if len(digits) < 14:
return None
try:
return datetime.strptime(digits[:14], "%Y%m%d%H%M%S")
except ValueError:
return None
def _max_tick_age(run: Runtime) -> int:
defaults = getattr(getattr(run, "etf_cfg", None), "defaults", None)
value = getattr(defaults, "max_tick_age_seconds", 0)
return value if type(value) is int and value > 0 else 90
def _entry_volume(run: Runtime, code: str) -> int:
"""底仓股数:配置的 ``buy_shares``,按整手与单标的上限裁剪。"""
volume = getattr(_symbol(run, code), "buy_shares", 0)
if type(volume) is not int or volume <= 0:
log.info("[ETF开仓] %s 跳过buy_shares 配置无效", code)
return 0
volume -= volume % 100
if volume <= 0:
return 0
max_shares = getattr(_symbol(run, code), "max_shares", None)
if type(max_shares) is int and max_shares > 0:
volume = min(volume, max_shares - max_shares % 100)
return volume
def _budget_ok(run: Runtime, amount: float) -> bool:
"""本轮可用预算 = 券商可用资金 现金安全线 所有在途买单预留。"""
assets = _latest_assets(run)
available = getattr(assets, "available", None)
if isinstance(available, bool) or not isinstance(available, (int, float)):
# 拿不到资金快照时不阻拦,最终由柜台与在途委托锁把关。
return True
total = _positive(getattr(assets, "total", 0.0))
ratio = getattr(run.account_cfg, "min_cash_ratio", 0.0)
if isinstance(ratio, bool) or not isinstance(ratio, (int, float)):
ratio = 0.0
budget = float(available) - total * float(ratio) - pending_buy_amount(run)
return amount <= max(0.0, budget)
def pending_buy_amount(run: Runtime) -> float:
"""所有未确认买单的预留金额(不是只算当前标的)。"""
reserved = 0.0
for order in getattr(run.orders, "data", None) or []:
if getattr(order, "side", "") != "BUY":
continue
remaining = getattr(order, "volume_total_original", 0) - getattr(
order, "volume_traded", 0
)
price = getattr(order, "limit_price", 0.0) or getattr(order, "traded_price", 0.0)
if remaining > 0 and _positive(price) > 0:
reserved += float(remaining) * float(price)
return reserved
def _latest_assets(run: Runtime) -> Any:
"""读取最新资金快照:优先用 Runtime 上缓存的,其次问一次客户端。"""
cached = getattr(run, "assets", None)
if cached is not None:
return cached
try:
return run.client.assets()
except Exception:
return None
def _positive(value: Any) -> float:
"""把配置/指标值转成有限正浮点数;不合法时返回 0。"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return 0.0
value = float(value)
return value if math.isfinite(value) and value > 0 else 0.0