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