Files
big-qmt/py-client/strategy/zt/boot.py
2026-09-15 20:02:05 +08:00

526 lines
24 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.
"""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