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

251 lines
9.5 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 网格策略开仓:观察 → 反弹确认 → 底仓挂单。
信号由 ``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