526 lines
24 KiB
Python
526 lines
24 KiB
Python
"""ZT 日内做 T:正T/反T 一轮状态机,串行执行,允许隔夜。
|
||
|
||
日志标签(可直接 grep 定位问题):
|
||
|
||
[ZT启动] 启动参数、状态文件、未平轮次,以及"只管自建仓"的说明
|
||
[ZT成交] 成交入账、被忽略的非本策略成交
|
||
[ZT状态] 轮次阶段流转
|
||
[ZT轮次] 一轮结束(结局、买卖均价、价差收益、持有天数)
|
||
[ZT决策] 每只受管证券每轮的价、基准、偏离、敞口、可卖与最终动作
|
||
[ZT下单] 实际提交的委托
|
||
[ZT跳过] 未接管的账户持仓、排除证券、行情无效
|
||
[ZT汇总] 本轮账户与资金概览
|
||
[ZT异常] 被捕获并降级的错误
|
||
"""
|
||
|
||
import logging as log
|
||
import math
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import config
|
||
from libs.calc import trading_time
|
||
from libs.grid_take_profit import GridState, GridTrailingTracker
|
||
from libs.market import market_allow_open
|
||
from libs.order import OrderBook, PlaceOrderRequest
|
||
from libs.overview import Overview
|
||
from libs.runtime import Runtime
|
||
from libs.signal import SignalItem, init_signals
|
||
from libs.snapshot import cache_portfolio
|
||
from libs.watch import DipWatch
|
||
from sdk import OP_BUY, OP_SELL, Client, PositionItem, Tick
|
||
|
||
from . import rules
|
||
from .ownership import owned_deals, owns_local_order_id
|
||
from .rounds import (
|
||
BASE_SOURCE_OPENED,
|
||
KIND_BASE,
|
||
KIND_LONG_T,
|
||
KIND_SHORT_T,
|
||
PHASE_CLOSED,
|
||
PHASE_CLOSING,
|
||
PHASE_OPEN,
|
||
Round,
|
||
RoundStore,
|
||
RoundStoreError,
|
||
advance,
|
||
apply_deals,
|
||
entry_side,
|
||
exit_side,
|
||
expire,
|
||
in_flight_order_ids,
|
||
is_owned_base,
|
||
start_round,
|
||
touch,
|
||
)
|
||
|
||
TICK_INTERVAL = 30
|
||
# 在途委托超过这个时长就撤单重估;撤单不会丢轮次状态,下一轮按新价重新判断。
|
||
CANCEL_TIMEOUT_SEC = 300
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class Decision:
|
||
"""单只证券本轮的处理结果。"""
|
||
|
||
reserved: float = 0.0 # 本轮为该证券预留的资金(买入腿才有)
|
||
reason: str = ""
|
||
submitted: bool = False # 本轮是否真的提交了委托(卖出腿不预留资金)
|
||
|
||
|
||
def StartZT() -> None:
|
||
if config.account_config.zt_open_hands == 0:
|
||
log.info("[ZT启动] zt_open_hands=0,不启动策略")
|
||
return
|
||
|
||
client = Client(
|
||
config.global_config.qmt_base_url,
|
||
config.global_config.qmt_token,
|
||
config.HTTP_TIMEOUT,
|
||
)
|
||
try:
|
||
try:
|
||
store = _open_store()
|
||
except Exception:
|
||
# 状态不可用时宁可不启动,也不能让未处理异常杀掉进程。
|
||
log.exception("[ZT异常] 轮次状态初始化失败,本次不启动策略")
|
||
return
|
||
run = Runtime(
|
||
client=client,
|
||
global_cfg=config.global_config,
|
||
account_cfg=config.account_config,
|
||
orders=OrderBook(cancel_timeout_sec=CANCEL_TIMEOUT_SEC),
|
||
open_watch=DipWatch(),
|
||
add_watch=DipWatch(),
|
||
profit_tracker=GridTrailingTracker(config.account_config.grid_step_pct),
|
||
)
|
||
signals = init_signals(
|
||
config.global_config,
|
||
config.account_config.signal_allow,
|
||
)
|
||
_log_startup(run, store, signals)
|
||
|
||
while True:
|
||
lt = time.localtime()
|
||
if (lt.tm_hour, lt.tm_min, lt.tm_sec) >= (15, 0, 0):
|
||
log.info("[ZT启动] 已到 15:00,结束做 T 策略")
|
||
return
|
||
current_sec = lt.tm_sec
|
||
|
||
# 计算距离下一个目标时间点(0 秒或 30 秒)的等待时间
|
||
if current_sec < TICK_INTERVAL:
|
||
wait_seconds = TICK_INTERVAL - current_sec
|
||
elif current_sec < 60:
|
||
wait_seconds = 60 - current_sec
|
||
else:
|
||
wait_seconds = TICK_INTERVAL
|
||
|
||
time.sleep(wait_seconds)
|
||
|
||
# 单轮失败不能杀死唯一的交易定时线程。
|
||
try:
|
||
RunOnce(run, store, signals)
|
||
except Exception as exc:
|
||
log.error("[ZT异常] 本 tick 执行失败,下一 tick 继续: %s", exc,
|
||
exc_info=True)
|
||
finally:
|
||
client.close()
|
||
|
||
|
||
def _log_startup(run: Runtime, store: RoundStore, signals: list[SignalItem]) -> None:
|
||
cfg = run.account_cfg
|
||
active = [item for item in store.rounds.values() if item.is_active]
|
||
log.info("[ZT启动] 账户=%s 状态文件=%s 轮次=%d 活动轮次=%d 信号=%d",
|
||
cfg.account_id, store.path, len(store.rounds), len(active), len(signals))
|
||
log.info("[ZT启动] 参数 手数=%d 卖出比例=%.2f 买回回落=%.2f%% 中性带=%.2f%% "
|
||
"价格上限=%.2f 最长持有=%d天 网格步长=%.2f%% 撤单超时=%d秒",
|
||
cfg.zt_open_hands, cfg.zt_sell_ratio, cfg.zt_buy_fall_pct,
|
||
cfg.zt_t_band_pct, cfg.zt_max_price, cfg.zt_max_hold_days,
|
||
cfg.grid_step_pct, CANCEL_TIMEOUT_SEC)
|
||
log.info("[ZT启动] 资金安全线=%.2f%% 排除证券=%s",
|
||
cfg.min_cash_ratio * 100, cfg.excluded_codes or '无')
|
||
log.info("[ZT启动] 只管理本策略自己建仓的证券;账户已有持仓一律不接管、不做 T")
|
||
log.info("[ZT启动] 信号=%s", ', '.join(sorted(s.code for s in signals)) or '无')
|
||
for code in sorted(store.rounds):
|
||
item = store.rounds[code]
|
||
if item.is_active:
|
||
log.info("[ZT启动] 未平轮次 %s 类型=%s 阶段=%s 敞口=%d 开仓均价=%.3f "
|
||
"开仓日=%s 委托=%s", code, item.kind, item.phase,
|
||
item.residual_qty, item.entry_avg_price, item.open_date,
|
||
item.entry_order_id)
|
||
|
||
|
||
def _open_store() -> RoundStore:
|
||
"""加载轮次状态;文件损坏时备份并从券商持仓重建,不阻断启动。"""
|
||
path = Path(config.global_config.qmt_data_dir) / (
|
||
f'zt_{config.account_config.account_id}_rounds.json')
|
||
try:
|
||
store = RoundStore(path)
|
||
except RoundStoreError:
|
||
log.exception("[ZT异常] 轮次状态无法解析,改由券商持仓重建基准")
|
||
try:
|
||
path.replace(path.with_name(path.name + '.corrupt'))
|
||
log.warning("[ZT异常] 损坏状态已备份为 %s.corrupt", path.name)
|
||
except OSError:
|
||
log.exception("[ZT异常] 损坏状态备份失败,直接覆盖")
|
||
store = RoundStore(path)
|
||
_drop_foreign_bases(store)
|
||
return store
|
||
|
||
|
||
def _drop_foreign_bases(store: RoundStore) -> int:
|
||
"""清掉旧版本留下的"接管"基准,保证只管理本策略自己建的仓。
|
||
|
||
只删除已结束且基准来源不是 ``opened`` 的记录;仍在进行中的轮次保留,
|
||
以便把未平敞口处理完。
|
||
"""
|
||
dropped = []
|
||
for code, item in list(store.rounds.items()):
|
||
if item.is_active or not item.base_qty:
|
||
continue
|
||
if item.base_source != BASE_SOURCE_OPENED:
|
||
dropped.append(code)
|
||
store.drop(code)
|
||
if dropped:
|
||
log.warning("[ZT启动] 丢弃 %d 条非本策略建仓的旧基准记录(来源=%s):%s",
|
||
len(dropped), '接管', ', '.join(sorted(dropped)))
|
||
return len(dropped)
|
||
|
||
|
||
def RunOnce(run: Runtime, store: RoundStore, signals: list[SignalItem]) -> None:
|
||
now = datetime.now()
|
||
if not trading_time(now):
|
||
return
|
||
today = now.date().isoformat()
|
||
started_at = time.monotonic()
|
||
|
||
# 1. 账户快照:数量与成本的唯一真相。
|
||
try:
|
||
deals = run.client.deals()
|
||
portfolio = run.client.portfolio()
|
||
assets = portfolio.assets
|
||
positions = portfolio.positions
|
||
cache_portfolio(run.account_cfg.account_id, assets,
|
||
list(positions.values()), deals)
|
||
except Exception:
|
||
log.exception("[ZT异常] 刷新账户快照失败,本轮跳过")
|
||
return
|
||
|
||
# 2. 撤单只限本策略前缀;在途集合是唯一的防重依据。
|
||
run.orders.refresh(run.client, portfolio.orders, cancel_prefix='zt-')
|
||
in_flight = in_flight_order_ids(run.orders.data)
|
||
|
||
# 3. 接管基准 + 幂等累计成交 + 推进阶段。
|
||
owned, ignored = owned_deals(deals)
|
||
if ignored:
|
||
foreign = sorted({str(deal.get_local_order_id)
|
||
for deal in deals
|
||
if not owns_local_order_id(deal.get_local_order_id)})
|
||
log.warning("[ZT成交] 忽略 %d 笔非本策略成交(本地编号=%s),"
|
||
"本策略成交 %d 笔", ignored,
|
||
', '.join(repr(name) for name in foreign[:10]), len(owned))
|
||
try:
|
||
_advance_rounds(run, store, positions, owned, in_flight, today)
|
||
except Exception:
|
||
log.exception("[ZT异常] 轮次推进失败,本轮不交易")
|
||
return
|
||
|
||
# 4. 行情:持仓 ∪ 有基准的证券 ∪ 信号候选。
|
||
signal_codes = {item.code for item in signals}
|
||
managed = _managed_codes(store, positions, signal_codes)
|
||
try:
|
||
ticks = run.client.full_tick(sorted(managed))
|
||
except Exception:
|
||
log.exception("[ZT异常] 获取行情失败,代码数量=%d,本轮跳过", len(managed))
|
||
return
|
||
|
||
# 5. 决策:串行执行,开仓与平仓共用同一份剩余资金。
|
||
market_ok = market_allow_open()
|
||
cash_ok = assets.available >= assets.total * run.account_cfg.min_cash_ratio
|
||
remaining = max(0.0, assets.available)
|
||
submitted = 0
|
||
for code in sorted(managed):
|
||
try:
|
||
decision = _manage_code(run, store, code, ticks.get(code),
|
||
positions.get(code), signal_codes, today,
|
||
remaining, market_ok, cash_ok)
|
||
except Exception:
|
||
log.exception("[ZT异常] %s 处理异常,继续后续证券", code)
|
||
continue
|
||
_log_decision(store, code, ticks.get(code), positions.get(code), decision)
|
||
if decision.submitted:
|
||
submitted += 1
|
||
if decision.reserved > 0:
|
||
remaining = max(0.0, remaining - decision.reserved)
|
||
|
||
store.save()
|
||
_log_summary(run, store, assets, positions, managed, submitted, market_ok,
|
||
cash_ok, started_at)
|
||
|
||
|
||
def _log_decision(store: RoundStore, code: str, tick: Tick | None,
|
||
position: PositionItem | None, decision: Decision) -> None:
|
||
item = store.get(code)
|
||
price = tick.last_price if tick is not None else 0.0
|
||
dev = ((price - item.base_cost) / item.base_cost * 100
|
||
if item.base_cost > 0 else 0.0)
|
||
can_use = int(position.can_use_volume) if position is not None else 0
|
||
log.info("[ZT决策] %s 价=%.3f 基准=%.3f(%+.2f%%) 类型=%s 阶段=%s 敞口=%d "
|
||
"可卖=%d 持仓成本=%.3f -> %s",
|
||
code, price, item.base_cost, dev, item.kind or '-', item.phase,
|
||
item.residual_qty, can_use,
|
||
float(position.open_price) if position is not None else 0.0,
|
||
decision.reason)
|
||
|
||
|
||
def _log_summary(run: Runtime, store: RoundStore, assets, positions: dict,
|
||
managed: set[str], submitted: int, market_ok: bool,
|
||
cash_ok: bool, started_at: float) -> None:
|
||
ignored = sorted(set(positions) - managed)
|
||
if ignored:
|
||
log.info("[ZT跳过] 未接管持仓 %d 只,不参与做 T(本策略只管理自己建仓的"
|
||
"证券):%s", len(ignored), ', '.join(ignored))
|
||
log.info("[ZT汇总] 持仓=%d 管理=%d 未接管=%d 新委托=%d 总资产=%.2f 可用=%.2f "
|
||
"大盘=%s 资金=%s 耗时=%d毫秒",
|
||
len(positions), len(managed), len(ignored), submitted, assets.total,
|
||
assets.available, '允许' if market_ok else '禁止',
|
||
'允许' if cash_ok else '不足',
|
||
int((time.monotonic() - started_at) * 1000))
|
||
|
||
|
||
def _managed_codes(store: RoundStore, positions: dict, signal_codes: set[str]) -> set[str]:
|
||
"""只管理有自有基准或未平轮次的证券,以及本轮信号候选(用于建仓)。"""
|
||
codes = set(signal_codes)
|
||
for code, item in store.rounds.items():
|
||
if item.is_active or is_owned_base(item):
|
||
codes.add(code)
|
||
return {code for code in codes if code}
|
||
|
||
|
||
def _advance_rounds(run: Runtime, store: RoundStore, positions: dict,
|
||
owned: list, in_flight: set[str], today: str) -> None:
|
||
"""累计成交、推进阶段、处理超期,最后统一落盘。
|
||
|
||
不接管账户已有持仓:没有自有基准的证券不会出现在轮次表里。
|
||
"""
|
||
for code, item in list(store.rounds.items()):
|
||
before_phase = item.phase
|
||
for leg, deal in apply_deals(item, owned, today):
|
||
filled = item.entry_filled_qty if leg == "entry" else item.exit_filled_qty
|
||
average = item.entry_avg_price if leg == "entry" else item.exit_avg_price
|
||
log.info("[ZT成交] %s %s腿 +%d股@%.3f 成交编号=%s 累计=%d股 均价=%.4f "
|
||
"金额=%.2f", code, '开仓' if leg == 'entry' else '平仓',
|
||
deal.volume, deal.price, deal.order_sys_id, filled, average,
|
||
item.entry_amount if leg == 'entry' else item.exit_amount)
|
||
advance(item, in_flight, today)
|
||
if item.phase != before_phase:
|
||
_log_transition(code, item, before_phase, today)
|
||
if expire(item, today, run.account_cfg.zt_max_hold_days):
|
||
log.warning("[ZT轮次] %s 超期放弃:%s;未平敞口已并回底仓,"
|
||
"基准数量=%d 建仓价=%.4f", code, item.note,
|
||
item.base_qty, item.base_cost)
|
||
touch(item)
|
||
store.put(item)
|
||
|
||
|
||
def _log_transition(code: str, item: Round, before_phase: str, today: str) -> None:
|
||
log.info("[ZT状态] %s %s -> %s 类型=%s 敞口=%d", code, before_phase,
|
||
item.phase, item.kind or '-', item.residual_qty)
|
||
if before_phase == PHASE_CLOSED or item.phase != PHASE_CLOSED:
|
||
return
|
||
held = '当日' if item.open_date == today else f'{item.open_date} 起'
|
||
log.info("[ZT轮次] %s 结束(%s) 开=%d股@%.4f 平=%d股@%.4f 价差收益=%.2f "
|
||
"基准=%d股@%.4f 持有=%s 备注=%s",
|
||
code, item.outcome, item.entry_filled_qty, item.entry_avg_price,
|
||
item.exit_filled_qty, item.exit_avg_price, item.realized_amount,
|
||
item.base_qty, item.base_cost, held, item.note or '无')
|
||
|
||
|
||
def _manage_code(run: Runtime, store: RoundStore, code: str, tick: Tick | None,
|
||
position: PositionItem | None, signal_codes: set[str], today: str,
|
||
remaining: float, market_ok: bool, cash_ok: bool) -> Decision:
|
||
"""处理单只证券,返回预留资金与决策原因。"""
|
||
cfg = run.account_cfg
|
||
item = store.get(code)
|
||
if not item.is_active and item.phase != PHASE_OPEN and item.base_qty <= 0 \
|
||
and code not in signal_codes:
|
||
return Decision(0.0, "无基准无信号,不参与")
|
||
if code in cfg.excluded_codes:
|
||
return Decision(0.0, "已配置为排除股票")
|
||
|
||
price = tick.last_price if tick is not None else 0.0
|
||
if not math.isfinite(price) or price <= 0:
|
||
log.warning("[ZT跳过] %s 行情无效(价=%r),本轮不动作", code, price)
|
||
return Decision(0.0, "行情无效")
|
||
can_use = int(position.can_use_volume) if position is not None else 0
|
||
|
||
if item.phase == PHASE_OPEN:
|
||
return _try_exit(run, store, item, price, can_use, remaining)
|
||
|
||
if not item.can_open(today):
|
||
reason = ("今日已有未平轮次" if item.is_active
|
||
else "今日已完成一轮,不再开新轮")
|
||
return Decision(0.0, reason)
|
||
if item.base_qty <= 0:
|
||
return _try_open_base(run, store, item, price, can_use, remaining,
|
||
today, market_ok, cash_ok)
|
||
return _try_entry(run, store, item, price, can_use, remaining,
|
||
today, market_ok, cash_ok)
|
||
|
||
|
||
def _try_open_base(run: Runtime, store: RoundStore, item: Round, price: float,
|
||
can_use: int, remaining: float, today: str,
|
||
market_ok: bool, cash_ok: bool) -> Decision:
|
||
"""建底仓:需要信号、大盘与资金同时允许,成交均价即建仓价。"""
|
||
cfg = run.account_cfg
|
||
if not market_ok:
|
||
return Decision(0.0, "建仓跳过:大盘信号不允许")
|
||
if not cash_ok:
|
||
return Decision(0.0, f"建仓跳过:可用资金低于安全线({cfg.min_cash_ratio:.0%})")
|
||
if not rules.price_allowed(price, cfg.zt_max_price):
|
||
return Decision(0.0, f"建仓跳过:价格高于上限 {cfg.zt_max_price:.2f}")
|
||
volume = rules.entry_volume(KIND_LONG_T, price=price, open_hands=cfg.zt_open_hands,
|
||
sell_ratio=cfg.zt_sell_ratio, base_qty=0,
|
||
can_use_volume=can_use, available=remaining)
|
||
if volume <= 0:
|
||
return Decision(0.0, f"建仓跳过:剩余资金 {remaining:.2f} 买不起一手")
|
||
if run.orders.busy(item.code, "BUY"):
|
||
return Decision(0.0, "建仓跳过:已有买入委托在途")
|
||
if not run.open_watch.triggered("建仓", item.code, price):
|
||
return Decision(0.0, "建仓等待:尚未确认自低点反弹")
|
||
|
||
order_id = run.orders.new_order_id("zt", "base")
|
||
request = PlaceOrderRequest(OP_BUY, item.code, volume, order_id, cfg.strategy,
|
||
kind="base")
|
||
# 先落盘意图再发请求:进程在请求前后任一时刻退出,下一轮都能自愈——
|
||
# 未受理且无成交的轮次会被判为作废,已受理的委托仍在途,成交照常累计。
|
||
start_round(item, KIND_BASE, today)
|
||
item.entry_order_id = order_id
|
||
item.entry_plan_qty = volume
|
||
touch(item)
|
||
store.put(item)
|
||
store.save()
|
||
if not run.orders.place(run.client, request):
|
||
return Decision(0.0, f"建仓下单未受理,订单={order_id}(下一轮判为作废)")
|
||
run.open_watch.forget(item.code)
|
||
log.info("[ZT下单] %s 建仓买入 %d股 @%.3f 预计金额=%.2f 订单=%s",
|
||
item.code, volume, price, price * volume, order_id)
|
||
return Decision(price * volume, f"建仓已报 {volume}股@{price:.3f}", submitted=True)
|
||
|
||
|
||
def _try_entry(run: Runtime, store: RoundStore, item: Round, price: float,
|
||
can_use: int, remaining: float, today: str,
|
||
market_ok: bool, cash_ok: bool) -> Decision:
|
||
"""在已建立的基准上开一轮正T或反T。"""
|
||
cfg = run.account_cfg
|
||
if not rules.price_allowed(price, cfg.zt_max_price):
|
||
return Decision(0.0, f"跳过:价格高于上限 {cfg.zt_max_price:.2f}")
|
||
kind = rules.choose_kind(price, item.base_cost, cfg.zt_t_band_pct)
|
||
if kind is None:
|
||
return Decision(0.0, f"中性带内不做(±{cfg.zt_t_band_pct:.2f}%)")
|
||
label = "正T低吸" if kind == KIND_LONG_T else "反T高抛"
|
||
# 正T 是加仓,需要大盘与资金允许;反T 是减仓,不受资金限制。
|
||
if kind == KIND_LONG_T and not market_ok:
|
||
return Decision(0.0, f"{label}跳过:大盘信号不允许")
|
||
if kind == KIND_LONG_T and not cash_ok:
|
||
return Decision(0.0, f"{label}跳过:可用资金低于安全线({cfg.min_cash_ratio:.0%})")
|
||
if run.orders.busy(item.code, entry_side(kind)):
|
||
return Decision(0.0, f"{label}跳过:已有{entry_side(kind)}委托在途")
|
||
volume = rules.entry_volume(kind, price=price, open_hands=cfg.zt_open_hands,
|
||
sell_ratio=cfg.zt_sell_ratio, base_qty=item.base_qty,
|
||
can_use_volume=can_use, available=remaining)
|
||
if volume <= 0:
|
||
if kind == KIND_SHORT_T:
|
||
return Decision(0.0, f"{label}跳过:可卖 {can_use} 股不足一手")
|
||
return Decision(0.0, f"{label}跳过:剩余资金 {remaining:.2f} 买不起一手")
|
||
|
||
if kind == KIND_LONG_T:
|
||
if not run.open_watch.triggered("正T低吸", item.code, price):
|
||
return Decision(0.0, f"{label}等待:尚未确认自低点反弹")
|
||
else:
|
||
pnl_rate = (price - item.base_cost) / item.base_cost * 100
|
||
observation = run.profit_tracker.observe(
|
||
f"{cfg.account_id}:{item.code}:{today}", pnl_rate)
|
||
if observation.state != GridState.RETREAT:
|
||
return Decision(0.0, f"{label}等待:网格 {observation.state.value}"
|
||
f"(峰值格={observation.peak_grid} 当前格="
|
||
f"{observation.current_grid})")
|
||
|
||
order_id = run.orders.new_order_id("zt", "entry")
|
||
request = PlaceOrderRequest(_op_of(entry_side(kind)), item.code, volume, order_id,
|
||
cfg.strategy, kind=kind)
|
||
start_round(item, kind, today)
|
||
item.entry_order_id = order_id
|
||
item.entry_plan_qty = volume
|
||
touch(item)
|
||
store.put(item)
|
||
store.save()
|
||
if not run.orders.place(run.client, request):
|
||
return Decision(0.0, f"{label}下单未受理,订单={order_id}(下一轮判为作废)")
|
||
if kind == KIND_LONG_T:
|
||
run.open_watch.forget(item.code)
|
||
log.info("[ZT下单] %s %s %d股 @%.3f 基准=%.4f 订单=%s",
|
||
item.code, label, volume, price, item.base_cost, order_id)
|
||
if kind == KIND_LONG_T:
|
||
return Decision(price * volume, f"{label}已报 {volume}股@{price:.3f}",
|
||
submitted=True)
|
||
return Decision(0.0, f"{label}已报 {volume}股@{price:.3f}", submitted=True)
|
||
|
||
|
||
def _try_exit(run: Runtime, store: RoundStore, item: Round, price: float,
|
||
can_use: int, remaining: float) -> Decision:
|
||
"""平掉轮次敞口:正T 卖出、反T 买回。"""
|
||
cfg = run.account_cfg
|
||
label = "正T高抛" if item.kind == KIND_LONG_T else "反T买回"
|
||
if run.orders.busy(item.code, item.exit_side):
|
||
return Decision(0.0, f"{label}跳过:已有{item.exit_side}委托在途")
|
||
volume = rules.exit_volume(item.kind, residual_qty=item.residual_qty, price=price,
|
||
can_use_volume=can_use, available=remaining)
|
||
if volume <= 0:
|
||
if item.kind == KIND_LONG_T:
|
||
return Decision(0.0, f"{label}暂不可执行:可卖 {can_use} 股不足一手"
|
||
f"(T+1 冻结则留待次日)")
|
||
return Decision(0.0, f"{label}暂不可执行:剩余资金 {remaining:.2f} 买不起一手")
|
||
|
||
# 先判价格条件,再消费反弹观察:DipWatch 触发后会清掉观察点,
|
||
# 若在价格没到位时就调用,会把有效观察点浪费掉,导致买回被系统性错过。
|
||
target = (item.entry_avg_price * (1 - cfg.zt_buy_fall_pct / 100)
|
||
if item.kind == KIND_SHORT_T
|
||
else item.entry_avg_price * (1 + cfg.grid_step_pct / 100))
|
||
if not rules.exit_triggered(item.kind, price, item.entry_avg_price,
|
||
buy_fall_pct=cfg.zt_buy_fall_pct,
|
||
profit_step_pct=cfg.grid_step_pct,
|
||
rebound_confirmed=True):
|
||
return Decision(0.0, f"{label}等待:未达目标价 {target:.3f}"
|
||
f"(开仓均价={item.entry_avg_price:.4f})")
|
||
if item.kind == KIND_SHORT_T and not run.add_watch.triggered("反T买回", item.code,
|
||
price):
|
||
return Decision(0.0, f"{label}等待:尚未确认自低点反弹")
|
||
|
||
order_id = run.orders.new_order_id("zt", "exit")
|
||
request = PlaceOrderRequest(_op_of(exit_side(item.kind)), item.code, volume, order_id,
|
||
cfg.strategy, kind=item.kind)
|
||
item.exit_order_id = order_id
|
||
item.exit_plan_qty = volume
|
||
item.phase = PHASE_CLOSING
|
||
touch(item)
|
||
store.put(item)
|
||
store.save()
|
||
if not run.orders.place(run.client, request):
|
||
return Decision(0.0, f"{label}下单未受理,订单={order_id}(下一轮回到待平仓)")
|
||
if item.kind == KIND_SHORT_T:
|
||
run.add_watch.forget(item.code)
|
||
log.info("[ZT下单] %s %s %d股 @%.3f 开仓均价=%.4f 目标价=%.3f 敞口=%d 订单=%s",
|
||
item.code, label, volume, price, item.entry_avg_price, target,
|
||
item.residual_qty, order_id)
|
||
if item.kind == KIND_SHORT_T:
|
||
return Decision(price * volume, f"{label}已报 {volume}股@{price:.3f}",
|
||
submitted=True)
|
||
return Decision(0.0, f"{label}已报 {volume}股@{price:.3f}", submitted=True)
|
||
|
||
|
||
def _op_of(side: str) -> int:
|
||
return OP_BUY if side == "BUY" else OP_SELL
|