251 lines
9.5 KiB
Python
251 lines
9.5 KiB
Python
"""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
|