Files
big-qmt/py-client/strategy/etf/engine.py
2026-09-17 00:56:05 +08:00

203 lines
11 KiB
Python

"""串行 ETF 决策:先核对成交,再止盈,最后低吸并预留本轮资金。"""
from datetime import datetime
import logging as log
import math
from libs.grid_take_profit import GridState, GridTrailingTracker
from libs.order import OrderBook, PlaceOrderRequest
from libs.watch import DipWatch
from sdk import OP_BUY, OP_SELL, Portfolio, PositionItem, Tick
from .config import ETFConfig
from .indicators import Indicators
from .state import Store, SymbolState
class Engine:
def __init__(self, client, cfg: ETFConfig, store: Store, min_cash_ratio: float, excluded=()):
if not math.isfinite(min_cash_ratio) or not 0 <= min_cash_ratio <= 1:
raise ValueError('ETF min_cash_ratio 必须在 0 到 1 之间')
self.client, self.cfg, self.store = client, cfg, store
if any(state.pending and code not in cfg.codes for code, state in store.symbols.items()):
raise ValueError('存在已从配置移除的 ETF 待确认委托,请保留该标的直到核对完成')
self.min_cash_ratio, self.excluded = min_cash_ratio, set(excluded)
self.orders = OrderBook(cancel_timeout_sec=120)
self.watch = DipWatch(cfg.watch_seconds, cfg.rebound_pct)
def fee(self, amount: float) -> float:
return max(self.cfg.min_commission, amount * self.cfg.commission_rate)
def reconcile(self, code: str, state: SymbolState, position: PositionItem, orders) -> bool:
"""必须同时得到终态委托与匹配的持仓快照,才解除本地待确认锁。"""
pending = state.pending
# 柜台清仓记录可能保留旧成本;零持仓应统一视为零成本,避免每轮清空低吸观察。
current_cost = position.open_price if position.volume > 0 else 0.0
if pending:
matches = [o for o in orders if o.local_order_id == pending['id'] and o.stock_code == code]
if len(matches) != 1:
log.warning('[ETF待确认] %s 订单=%s 回报缺失或不唯一,暂停该标的', code, pending['id'])
return False
order = matches[0]
status = str(order.order_status)
if status not in {'53', '54', '56', '57'}:
log.info('[ETF待确认] %s 订单=%s 状态=%s 成交=%s', code, pending['id'], status, order.volume_traded)
return False
filled = order.volume_traded
if (order.side != pending['side'] or type(filled) is not int or not 0 <= filled <= pending['volume']
or (status == '56' and filled != pending['volume'])):
log.warning('[ETF待确认] %s 委托方向或成交数量不一致', code)
return False
expected = pending['base_volume'] + (filled if pending['side'] == 'BUY' else -filled)
if position.volume != expected:
log.warning('[ETF待确认] %s 持仓=%s 预期=%s,等待快照同步', code, position.volume, expected)
return False
if filled and pending['side'] == 'BUY':
if not math.isfinite(order.traded_price) or order.traded_price <= 0:
log.warning('[ETF待确认] %s 缺少实际成交均价', code)
return False
state.last_buy = order.traded_price
if filled:
state.reset_profit()
log.info('[ETF回报] %s 订单=%s 状态=%s 成交=%s 持仓=%s',
code, pending['id'], status, filled, position.volume)
state.pending = {}
# 终态已被快照证实,可清理共用委托簿的短期方向缓存。
self.orders.busy_cache.delete(f"{pending['side']}-{code}")
elif position.volume != state.volume or not math.isclose(current_cost, state.cost, abs_tol=1e-8):
# 配置内已有仓位一并管理;人工改变仓位时重新建立止盈和加仓基准。
state.reset_profit()
state.last_buy = position.open_price if position.volume > 0 else 0.0
self.watch.forget(code)
state.volume, state.cost = position.volume, current_cost
if position.volume == 0:
state.last_buy = 0.0
state.reset_profit()
return True
def run(self, portfolio: Portfolio, ticks: dict[str, Tick], indicators: dict[str, Indicators], now: datetime):
# 防重看全账户,自动撤单只针对 ETF 前缀。
self.orders.refresh(self.client, portfolio.orders, cancel_prefix='ETF-')
assets = portfolio.assets
if not all(math.isfinite(v) and v >= 0 for v in (assets.total, assets.available)):
raise ValueError('账户资金无效')
# 先扣除所有未确认买单,不能等遍历到后面的标的才预留。
pending_cash = sum(s.pending['reserved'] for s in self.store.symbols.values()
if s.pending.get('side') == 'BUY')
cash = max(0.0, assets.available - assets.total * self.min_cash_ratio - pending_cash)
for code in self.cfg.codes:
state = self.store.get(code)
had_pending = bool(state.pending)
position = portfolio.positions.get(code, PositionItem(stock_code=code))
try:
if (type(position.volume) is not int or position.volume < 0
or type(position.can_use_volume) is not int or position.can_use_volume < 0
or type(position.on_road_volume) is not int or position.on_road_volume < 0
or not math.isfinite(position.open_price)):
raise ValueError('持仓数量或成本无效')
if not self.reconcile(code, state, position, portfolio.orders):
continue
self.store.save()
if code in self.excluded:
self.watch.forget(code)
continue
if self.orders.busy(code, 'BUY') or self.orders.busy(code, 'SELL'):
continue
if position.on_road_volume > 0 or any(
o.stock_code == code and str(o.order_status) not in {'53', '54', '56', '57'}
for o in portfolio.orders
):
log.info('[ETF跳过] %s 存在在途份额或未知委托状态', code)
continue
tick, ind = ticks.get(code), indicators.get(code)
if ind is None or not self.fresh_tick(tick, now):
self.watch.forget(code)
log.info('[ETF跳过] %s 日线或实时行情无效/过期', code)
continue
price = round(tick.last_price, 3)
if position.volume > 0 and position.open_price <= 0:
raise ValueError('非空持仓缺少有效成本')
if self.sell(code, state, position, price, ind):
continue
cash -= self.buy(code, state, position, price, ind, cash, now)
except Exception:
# 异常后不允许其他标的重复使用可能已提交的资金。
if not had_pending and state.pending.get('side') == 'BUY':
cash = max(0, cash - state.pending['reserved'])
log.exception('[ETF异常] %s 本轮跳过', code)
def fresh_tick(self, tick: Tick | None, now: datetime) -> bool:
if tick is None or not math.isfinite(tick.last_price) or tick.last_price <= 0:
return False
try:
stamp = datetime.strptime(tick.raw['timetag'], '%Y%m%d %H:%M:%S')
return stamp.date() == now.date() and 0 <= (now - stamp).total_seconds() <= self.cfg.max_tick_age_seconds
except (KeyError, TypeError, ValueError):
return False
def sell(self, code: str, state: SymbolState, position: PositionItem, price: float, ind: Indicators) -> bool:
if position.volume <= 0:
return False
cost = position.open_price
volume = min(position.volume, position.can_use_volume)
volume = volume // 100 * 100
# 即使 T+1 当天不可卖,也持续记录高位与峰值;翌日可卖时继续判断。
estimate_volume = volume or position.volume
profit = (price - cost) * estimate_volume
enough_profit = ((price - cost) / cost * 100 >= self.cfg.min_profit_pct
and profit > self.fee(cost * estimate_volume) + self.fee(price * estimate_volume))
if not state.armed:
if price < max(ind.upper, ind.ma60 + ind.grid, cost + ind.grid) or not enough_profit:
return False
state.armed, state.sell_grid = True, ind.grid
state.peak = math.floor((price - cost) / state.sell_grid)
self.store.save()
log.info('[ETF止盈] %s 高位启动,峰值格=%d 格距=%.3f', code, state.peak, state.sell_grid)
return True
# 通过公开 observe 接口恢复跨日峰值,复用现有网格回撤算法。
tracker = GridTrailingTracker(1.0)
tracker.observe(code, state.peak)
observation = tracker.observe(code, (price - cost) / state.sell_grid)
state.peak = observation.peak_grid
self.store.save()
if observation.state == GridState.RETREAT and enough_profit and volume > 0:
self.submit(code, state, position, 'SELL', volume, price, 0.0)
# 止盈已启动时不同时补仓,避免同一轮买卖冲突。
return True
def buy(self, code: str, state: SymbolState, position: PositionItem, price: float,
ind: Indicators, cash: float, now: datetime) -> float:
volume = self.cfg.buy_hands * 100
if position.volume + volume > self.cfg.max_hands * 100:
self.watch.forget(code)
return 0.0
# 首次进入 BOLL 下轨且低于均线一格;加仓须比上次实际买入再低至少一格。
ceiling = min(ind.ma60, state.last_buy - ind.grid) if state.last_buy else ind.ma60
entry = min(ind.lower, ind.ma60 - ind.grid, ceiling)
if price > ceiling:
self.watch.forget(code)
return 0.0
if code not in self.watch.data and price > entry:
return 0.0
amount = round(price, 3) * volume
reserved = amount + self.fee(amount)
if reserved > cash:
return 0.0
if not self.watch.triggered('ETF低吸', code, price, now):
return 0.0
self.submit(code, state, position, 'BUY', volume, price, reserved)
return reserved
def submit(self, code: str, state: SymbolState, position: PositionItem, side: str,
volume: int, price: float, reserved: float):
order_id = self.orders.new_order_id('ETF', side)
# 先持久化再提交;超时、异常、进程重启均不会丢失未确认的意图。
state.pending = dict(id=order_id, side=side, volume=volume,
base_volume=position.volume, reserved=reserved)
self.store.save()
request = PlaceOrderRequest(OP_BUY if side == 'BUY' else OP_SELL,
code, volume, order_id, 'etf', price=round(price, 3))
accepted = self.orders.place(self.client, request)
log.info('[ETF委托] %s %s 数量=%d 限价=%.3f 接口返回=%s 订单=%s,等待柜台核对',
code, side, volume, price, accepted, order_id)