"""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