"""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