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

491 lines
19 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 网格策略持仓管理:整仓止盈、单档止盈、百分比补仓。
规则见 ``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