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