This commit is contained in:
2026-09-19 19:45:43 +08:00
parent 7183cb45f8
commit 8131b158b4
60 changed files with 7669 additions and 909 deletions

View File

@@ -0,0 +1,490 @@
"""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