"""ETF 网格策略持仓管理:整仓止盈、单档止盈、百分比补仓。 规则见 ``docs/etf.md`` §4、§5,参数全部取自 ``rt.etf_cfg``: - 持仓数量与可用份额**只以券商快照为准**,本地不重算持仓; - 档位由"持仓股数 ÷ 每档 ``buy_shares``"推出,是唯一能跨轮次存活的档位依据; - 上一档成交价优先用券商成本价 ``open_price``,没有可用成本时回落到本模块 记录的上次成交价; - 主出口:盈亏率 ≥ ``min_profit_pct`` 整仓卖出(受 T+1 与 ``min_hold_days`` 限制); - 副出口:单档盈利从峰值回撤(``inner_step`` 网格、峰值已抬到 ``inner_grids``)只卖该档; - 补仓:自上一档再跌 ``add_pct`` 且 ``add_watch`` 反弹确认,最多 ``max_adds`` 次; - 超过 ``max_hold_days`` 只告警不强制平仓,残量留作隔夜持仓。 """ from dataclasses import dataclass, field from datetime import datetime import logging as log import math from threading import Lock from typing import Any, Mapping from libs.calc import trading_time from libs.grid_take_profit import GridState, GridTrailingTracker from libs.order import PlaceOrderRequest from libs.runtime import Runtime from sdk import OP_BUY, OP_SELL, PositionItem, Tick from .open import pending_buy_amount, strategy_name # 单次卖出/补仓委托被拒后的冷却时间,避免同一 tick 反复重试。 REJECT_COOLDOWN_SECONDS = 30 @dataclass(slots=True) class TradeDecision: """一次止盈或补仓判断的统一结果。""" submitted: bool message: str = "" reserved_cash: float = 0.0 @dataclass(slots=True) class SymbolProgress: """单标的的进程内进度:补仓次数、上次成交价与冷却时刻。""" adds: int = 0 last_buy_price: float = 0.0 last_add_day: int = 0 last_sell_at: datetime | None = None warned_hold_days: int = 0 # 主出口当日重试次数:挂单失败或状态未回报时不每轮重试。 failed_sell_day: int = 0 _progress: dict[str, SymbolProgress] = {} _trackers: dict[str, GridTrailingTracker] = {} _state_lock = Lock() def manage_positions( runtime: Runtime, ticks: Mapping[str, Tick], positions: list[PositionItem], market_ok: bool, available: float, ) -> None: """逐只核对持仓并执行卖出与补仓。""" if not trading_time(datetime.now()): return # 所有标的分预算:先扣除全部在途买单,避免轮到后面才发现钱不够。 budget = _available_budget(runtime, available, market_ok) for position in positions: code = position.stock_code try: excluded = getattr(runtime.account_cfg, "excluded_codes", None) or [] if code in excluded: log.info("[ETF持仓] %s 跳过:已配置为排除证券", code) continue symbol = _symbol(runtime, code) if symbol is None: log.info("[ETF持仓] %s 跳过:不在 _etf.yaml 白名单内", code) continue tick = ticks.get(code) price = _tick_price(runtime, code, tick) if price <= 0 or position.volume <= 0: log.warning( "[ETF持仓] %s 跳过:持仓或行情无效,持仓=%d,现价=%.3f", code, position.volume, price, ) continue # 盈亏率口径与主出口一致:以券商成本价为分母。 cost = _positive(position.open_price) pnl_rate = (price - cost) / cost * 100 if cost > 0 else 0.0 level = position_level(runtime, position) progress = _get_progress(code, level) sellable = sellable_volume(symbol, position) # 1. 主出口:整仓止盈,一次清空网格。 exit_decision = handle_exit( runtime, symbol, position, tick, pnl_rate, sellable ) action = exit_decision.message or "未触发" if exit_decision.submitted: _log_position(code, position, price, pnl_rate, action, "已停止") continue # 2. 副出口:单档峰值回撤,只处理当前档。 if not runtime.orders.busy(code, "SELL"): per_level = handle_level_exit( runtime, symbol, position, tick, pnl_rate, level ) action = per_level.message or action # 3. 时间退出:超期只告警,残量留作隔夜持仓。 hold_decision = handle_max_hold(runtime, code, progress, level) add_action = "未启用" if hold_decision.submitted: add_action = hold_decision.message # 4. 补仓:自上一档再跌 add_pct,且反弹确认后才买。 if market_ok: add_decision = handle_add( runtime, symbol, position, tick, price, budget, level ) budget = max(0.0, budget - add_decision.reserved_cash) add_action = add_decision.message or "未触发" else: add_action = "大盘信号不允许" _log_position(code, position, price, pnl_rate, action, add_action) except Exception as exc: log.exception("[ETF持仓] %s 处理异常:%s", code, exc) def handle_exit( runtime: Runtime, symbol: Any, position: PositionItem, tick: Tick, pnl_rate: float, sellable: int, ) -> TradeDecision: """主出口:盈亏率 ≥ ``min_profit_pct`` 时整仓卖出。""" target = _default(runtime, "min_profit_pct", 1.0) minimum = _positive(target) if minimum <= 0 or pnl_rate < minimum: return TradeDecision(False, f"持有中 PNL={pnl_rate:.2f}%(目标{minimum:.2f}%)") code = position.stock_code volume = min(max(0, int(sellable)) - int(sellable) % 100, position.volume) if volume <= 0: return TradeDecision(False, f"无当日可卖整手(可用={position.can_use_volume})") if runtime.orders.busy(code, "SELL"): return TradeDecision(False, "卖出委托处理中") progress = _get_progress(code, position_level(runtime, position)) now = datetime.now() if progress.last_sell_at is not None and ( now - progress.last_sell_at ).total_seconds() < REJECT_COOLDOWN_SECONDS: return TradeDecision(False, "卖出冷却中") if progress.failed_sell_day == now.date().toordinal(): # 当日挂单失败过:等收盘或等仓位变化,避免每轮重复下单。 return TradeDecision(False, "当日整仓止盈挂单未成功,暂停重试") request = PlaceOrderRequest( op=OP_SELL, code=code, volume=volume, order_id=runtime.orders.new_order_id("ETF", "SELL"), strategy_name=strategy_name(runtime), kind="exit", price=_tick_price_or(position.last_price, tick.last_price), ) submitted = runtime.orders.place(runtime.client, request) progress.last_sell_at = now if not submitted: progress.failed_sell_day = now.date().toordinal() return TradeDecision(False, "整仓止盈委托失败") return TradeDecision(True, f"[主出口] 盈亏率={pnl_rate:.2f}% 整仓卖出 {volume} 股") def handle_level_exit( runtime: Runtime, symbol: Any, position: PositionItem, tick: Tick, pnl_rate: float, level: int, ) -> TradeDecision: """副出口:单档盈利从峰值回撤且峰值已抬到 ``inner_grids`` 格时只卖该档。""" code = position.stock_code observation = _tracker(code, symbol).observe(f"etf:{code}:level:{level}", pnl_rate) if observation.state is not GridState.RETREAT: return TradeDecision( False, f"单档网格={observation.current_grid}/峰值={observation.peak_grid}" ) required = _positive(_symbol_value(symbol, "inner_grids", runtime, "inner_grids", 2.0)) if observation.peak_grid < required: return TradeDecision( False, f"峰值未达 {required:g} 格(当前峰值={observation.peak_grid})", ) volume = min(max(0, int(position.can_use_volume)) - int(position.can_use_volume) % 100, position.volume) if volume <= 0: return TradeDecision(False, "该档无当日可卖整仓") if runtime.orders.busy(code, "SELL"): return TradeDecision(False, "卖出委托处理中") request = PlaceOrderRequest( op=OP_SELL, code=code, volume=volume, order_id=runtime.orders.new_order_id("ETF", "SELL"), strategy_name=strategy_name(runtime), kind="profit", price=_tick_price_or(position.last_price, tick.last_price), ) if not runtime.orders.place(runtime.client, request): # 下单失败或撤单时必须保留峰值,等下一轮再试。 return TradeDecision(False, "单档止盈委托失败") # 峰值只能在卖出成功后清除。 _tracker(code, symbol).clear(f"etf:{code}:level:{level}") return TradeDecision(True, f"[副出口] 第{level}档 盈亏率={pnl_rate:.2f}% 卖出 {volume} 股") def handle_max_hold( runtime: Runtime, code: str, progress: SymbolProgress, level: int ) -> TradeDecision: """超过 ``max_hold_days`` 的轮次只告警,不强制平仓(残量留作隔夜持仓)。""" limit = _default(runtime, "max_hold_days", 0) if type(limit) is not int or limit <= 0: return TradeDecision(False) # 本地不记录真实买入日:用"档位 + 当日首见/本次加档"推算持有自然日, # 只为触发一次告警,不参与下单决策。没有记录时退化为"档位 ≈ 已持有天数"。 today = datetime.now().date().toordinal() started = progress.last_buy_day or (today - level) if progress.warned_hold_days == today or today < started + limit: return TradeDecision(False) progress.warned_hold_days = today return TradeDecision(True, f"[超期] 已持有{max(0, today - started)}天,超过 max_hold_days={limit},仅告警不平仓") def handle_add( runtime: Runtime, symbol: Any, position: PositionItem, tick: Tick, price: float, budget: float, level: int, ) -> TradeDecision: """补仓:自上一档再跌 ``add_pct`` 且反弹确认后按现价买入一档。""" code = position.stock_code progress = _get_progress(code, level) max_adds = _default(runtime, "max_adds", 9) if type(max_adds) is not int or max_adds < 0: return TradeDecision(False, "max_adds 配置无效") if progress.adds >= max_adds: return TradeDecision(False, f"已满 {max_adds + 1} 档,只等主出口") add_pct = _positive(_default(runtime, "add_pct", 3.0)) last_price = last_buy_price(symbol, position, progress) if add_pct <= 0 or last_price <= 0: return TradeDecision(False, "缺少上一档成交价") drop = (last_price - price) / last_price * 100 if drop < add_pct: # 跌幅未达门槛时不观察,避免把"没到位的低点"记成观察起点。 runtime.add_watch.forget(code) return TradeDecision(False, f"自上一档跌幅={drop:.2f}%<{add_pct:.2f}%") if progress.last_add_day == datetime.now().date().toordinal(): # 同一交易日每档最多补一次:避免同一个低点被反复确认成多笔加仓。 return TradeDecision(False, "本档当日已补仓,等待下一档") buy_shares = _symbol_value(symbol, "buy_shares", runtime, "buy_shares", 0) if type(buy_shares) is not int or buy_shares <= 0: return TradeDecision(False, "buy_shares 配置无效") volume = buy_shares - buy_shares % 100 max_shares = _symbol_value(symbol, "max_shares", runtime, "max_shares", 0) if type(max_shares) is int and max_shares > 0: room = max_shares - max_shares % 100 - position.volume volume = min(volume, room) if volume <= 0: return TradeDecision(False, "已达单标的上限") amount = price * volume if runtime.orders.busy(code, "BUY"): return TradeDecision(False, "买入委托处理中") # 预算不足时不消耗观察状态:等资金腾出来仍可用同一个观察低点确认。 if amount > budget: return TradeDecision(False, f"本轮预算不足(需要{amount:.2f}>可用{budget:.2f})") if not runtime.add_watch.triggered("补仓", code, price): return TradeDecision(False, "等待价格反弹确认") request = PlaceOrderRequest( op=OP_BUY, code=code, volume=volume, order_id=runtime.orders.new_order_id("ETF", "BUY"), strategy_name=strategy_name(runtime), kind="add", price=price, ) if not runtime.orders.place(runtime.client, request): return TradeDecision(False, "补仓委托失败") progress.adds += 1 progress.last_buy_price = price progress.last_add_day = datetime.now().date().toordinal() runtime.add_watch.forget(code) return TradeDecision( True, f"[补仓] 第{level + 1}档 {volume} 股,跌幅={drop:.2f}%", amount ) def position_level(runtime: Runtime, position: PositionItem) -> int: """由持仓股数推出档位:1 = 只有底仓,2 = 底仓 + 一档补仓……""" buy_shares = _symbol_value( _symbol(runtime, position.stock_code), "buy_shares", runtime, "buy_shares", 0, ) if type(buy_shares) is not int or buy_shares <= 0: return 1 return max(1, -(-int(position.volume) // buy_shares)) def last_buy_price( symbol: Any, position: PositionItem, progress: SymbolProgress ) -> float: """上一档成交价:优先券商成本价,其次本模块记录的上次成交价。""" if progress.last_buy_price > 0: return progress.last_buy_price return _positive(position.open_price) def sellable_volume(symbol: Any, position: PositionItem) -> int: """当日可卖股数:受 T+1 与 ``min_hold_days`` 限制,整手向下取整。""" if _is_t0(symbol) or position.yesterday_volume > 0: # T+0 标的,或已有隔夜持仓:券商可用份额就是上限。 return max(0, int(position.can_use_volume)) return 0 def _available_budget(runtime: Runtime, available: float, market_ok: bool) -> float: """补仓预算 = 调用方传入的可用资金 − 现金安全线 − 全部在途买单预留。 调用方只给 ``assets.available``(见 ``boot.RunOnce``),因此现金安全线按 "可用资金"比例扣除:``available × min_cash_ratio`` 是本模块能保守估计的 安全垫,不会把预留资金算成可加仓的额度。 """ if isinstance(available, bool) or not isinstance(available, (int, float)): return 0.0 ratio = getattr(runtime.account_cfg, "min_cash_ratio", 0.0) if isinstance(ratio, bool) or not isinstance(ratio, (int, float)): ratio = 0.0 budget = float(available) - abs(float(available)) * float(ratio) - pending_buy_amount(runtime) return max(0.0, budget) def _tick_price(runtime: Runtime, code: str, tick: Tick | None) -> float: """校验实时行情:有限正数、当天且未超过 ``max_tick_age_seconds``。""" price = _positive(getattr(tick, "last_price", 0.0)) if tick is not None else 0.0 if price <= 0: return 0.0 stamp = _tick_stamp(getattr(tick, "raw", None)) if stamp is None or stamp.date() != datetime.now().date(): return 0.0 limit = _default(runtime, "max_tick_age_seconds", 90) if type(limit) is not int or limit <= 0: limit = 90 if (datetime.now() - stamp).total_seconds() > limit: return 0.0 return price def _tick_stamp(raw: Any) -> datetime | None: 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 _tick_price_or(fallback: Any, price: Any) -> float: """限价:优先现价,缺失时用持仓快照的最新价。""" return _positive(price) or _positive(fallback) def _symbol(runtime: Runtime, code: str) -> Any | None: symbols = getattr(getattr(runtime, "etf_cfg", None), "symbols", None) if not isinstance(symbols, Mapping): return None return symbols.get(code) def _symbol_value( symbol: Any, attr: str, runtime: Runtime, defaults_attr: str, fallback: Any ) -> Any: """标的覆盖优先,其次全局默认:标的为 None 时按未覆盖处理。""" value = getattr(symbol, attr, None) if value is not None: return value return _default(runtime, defaults_attr, fallback) def _default(runtime: Runtime, name: str, fallback: Any) -> Any: """读取 ``_etf.yaml`` 的全局默认参数。""" defaults = getattr(getattr(runtime, "etf_cfg", None), "defaults", None) value = getattr(defaults, name, None) return fallback if value is None else value def _is_t0(symbol: Any) -> bool: return getattr(symbol, "is_t0", False) is True def _tracker(code: str, symbol: Any) -> GridTrailingTracker: """按标的缓存峰值跟踪器:内层格距是逐标的参数。""" with _state_lock: tracker = _trackers.get(code) if tracker is None: step = _positive(getattr(symbol, "inner_step", 0.0)) or 0.5 tracker = GridTrailingTracker(step) _trackers[code] = tracker return tracker def _get_progress(code: str, level: int) -> SymbolProgress: """取标的进度;首次见到时用券商推出来的档位补齐补仓次数。""" with _state_lock: progress = _progress.get(code) if progress is None: # 档位 N 意味着已经补过 N-1 次,重启后仍能对上 max_adds 上限。 progress = SymbolProgress(adds=max(0, level - 1)) _progress[code] = progress return progress def _log_position( code: str, position: PositionItem, price: float, pnl_rate: float, exit_action: str, add_action: str, ) -> None: log.info( "[ETF持仓] %s %s,现价=%.3f,成本=%.3f,盈亏=%.2f%%,持有=%d,可用=%d,止盈=%s,补仓=%s", code, position.stock_name or "-", price, position.open_price, pnl_rate, position.volume, position.can_use_volume, exit_action, add_action, ) 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