This commit is contained in:
2026-09-15 20:02:05 +08:00
parent bfe89ba122
commit 04daeff141
37 changed files with 2674 additions and 1426 deletions

View File

@@ -1,29 +1,78 @@
"""ZT 启动与串行调度:成交同步、买回、卖出、建仓。"""
"""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
from concurrent.futures import Future, ThreadPoolExecutor
import config
from libs.calc import trading_time
from .profit import ZTProfitTracker
from libs.grid_take_profit import GridState, GridTrailingTracker
from libs.market import market_allow_open
from libs.order import OrderBook
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.state import State
from libs.watch import DipWatch
from sdk import Client, DealItem, PositionItem
from .open import open_signal
from .positions import manage_positions
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不启动策略")
log.info("[ZT启动] zt_open_hands=0不启动策略")
return
client = Client(
@@ -31,189 +80,446 @@ def StartZT() -> None:
config.global_config.qmt_token,
config.HTTP_TIMEOUT,
)
executor = None
try:
state = State(Path(config.global_config.qmt_data_dir) / f'zt_{config.account_config.account_id}_state.db')
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="zt")
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(), open_watch=DipWatch(), add_watch=DipWatch(),
profit_tracker=ZTProfitTracker(config.account_config.grid_step_pct),
executor=executor
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,
)
initialize = not state.state and not state.deals
deals = client.deals()
portfolio = client.portfolio()
if initialize and {d.order_sys_id: d for d in deals} != {
d.order_sys_id: d for d in client.deals()
}:
raise RuntimeError('ZT 初始化期间成交发生变化,请重新启动')
assets = portfolio.assets
positions = list(portfolio.positions.values())
log.info('[启动] ZT策略已启动账户=%s,信号=%d,持仓=%d',
config.account_config.account_id, len(signals), len(positions))
cache_portfolio(config.account_config.account_id, assets, positions, deals)
_sync_state(state, positions, deals, initialize=initialize)
run.profit_tracker.sync_positions(positions, state)
run.orders.refresh(client, portfolio.orders, cancel_prefix='zt-')
Overview(assets, positions, config.account_config)
DEFAULT_TICK_INTERVAL = 30
_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结束趋势策略")
log.info("[ZT启动] 已到 15:00结束做 T 策略")
return
current_sec = lt.tm_sec
# 计算距离下一个目标时间点0秒或30秒的等待时间
if current_sec < DEFAULT_TICK_INTERVAL:
wait_seconds = DEFAULT_TICK_INTERVAL - current_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 = DEFAULT_TICK_INTERVAL
wait_seconds = TICK_INTERVAL
# 等待到目标时间点
time.sleep(wait_seconds)
# 单轮失败不能杀死唯一的交易定时线程。
try:
RunOnce(run, state, signals)
except Exception as e:
log.error(
f"[ZT] 本 tick 执行失败,下一 tick 继续: {e}", exc_info=True
)
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:
if executor is not None:
executor.shutdown(wait=True)
finally:
client.close()
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 _sync_state(state: State, positions: list[PositionItem], deals: list[DealItem],
*, initialize: bool = False) -> None:
"""使用 State 的独立接口同步,交易前核对归档结果与账户持仓。"""
if initialize:
state.sync_state(positions)
state.sync_deals(deals)
state.archiving()
def _drop_foreign_bases(store: RoundStore) -> int:
"""清掉旧版本留下的"接管"基准,保证只管理本策略自己建的仓。
holdings = {p.stock_code: p.volume for p in positions if p.volume > 0}
blocked = {d.stock_code for d in deals if d.order_sys_id not in state.deals_sys_ids}
blocked.update(d['stock_code'] for d in state.deals.values() if d['is_arch'] != 1)
for code in state.state.keys() | holdings.keys():
row = state.get_by_code(code)
if row.get('base_qty', 0) + row.get('added_qty', 0) != holdings.get(code, 0):
blocked.add(code)
state.blocked_codes = blocked
if blocked:
log.warning('[ZT 同步] 状态待核对,暂停交易:%s', ', '.join(sorted(blocked)))
只删除已结束且基准来源不是 ``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, state: State, signals: list[SignalItem]) -> None:
def RunOnce(run: Runtime, store: RoundStore, signals: list[SignalItem]) -> None:
now = datetime.now()
if not trading_time(now):
return
print(
"=" * 40 + f" Ticker {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " + "=" * 40
)
today = now.date().isoformat()
started_at = time.monotonic()
futures: list[tuple[str, Future]] = []
# 1. 账户快照:数量与成本的唯一真相。
try:
deals = run.client.deals()
portfolio = run.client.portfolio()
assets = portfolio.assets
positions = list(portfolio.positions.values())
position_codes = list(portfolio.positions)
cache_portfolio(run.account_cfg.account_id, assets, positions, deals)
_sync_state(state, positions, deals)
run.profit_tracker.sync_positions(positions, state)
run.orders.refresh(run.client, portfolio.orders, cancel_prefix='zt-')
positions = portfolio.positions
cache_portfolio(run.account_cfg.account_id, assets,
list(positions.values()), deals)
except Exception:
log.exception("[Portfolio] 刷新账户快照失败")
log.exception("[ZT异常] 刷新账户快照失败,本轮跳过")
return
# 2. 验证可用资金;低于资金安全线时禁止开新仓
allow_open_by_cash = (
assets.available >= assets.total * run.account_cfg.min_cash_ratio
)
if not allow_open_by_cash:
log.info(
"[Status] 禁止开仓:可用资金不足,可用=%.2f,总资产=%.2f",
assets.available,
assets.total,
)
# 2. 撤单只限本策略前缀;在途集合是唯一的防重依据
run.orders.refresh(run.client, portfolio.orders, cancel_prefix='zt-')
in_flight = in_flight_order_ids(run.orders.data)
# 3. 获取大盘状态,只有大盘信号允许时才执行开仓
# 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)
# 4. 验证有效开仓信号:排除已有持仓。
allow_open: list[SignalItem] = []
allow_codes: list[str] = []
for signal in signals:
if signal.code not in portfolio.positions and signal.code not in state.blocked_codes:
allow_open.append(signal)
allow_codes.append(signal.code)
store.save()
_log_summary(run, store, assets, positions, managed, submitted, market_ok,
cash_ok, started_at)
if allow_open and not market_ok:
log.info("[开仓] 禁止开仓:大盘信号不允许,候选=%d", len(allow_open))
# 5. 获取持仓和待开仓证券的实时行情 tick。
all_codes = list(dict.fromkeys(position_codes + allow_codes))
try:
ticks = run.client.full_tick(all_codes)
except Exception:
log.exception("[行情] 获取行情失败,代码数量=%d", len(all_codes))
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
log.info(
"[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s",
len(positions),
len(allow_open),
market_ok,
allow_open_by_cash,
)
# 启动线程,开始计算
# 7. 持仓计算。
futures.append(
(
"持仓计算",
run.executor.submit(
manage_positions, run, ticks, positions, market_ok, assets.available, state
),
)
)
# 8. 开仓计算:必须同时存在有效信号且大盘允许开仓。
if allow_open and market_ok and allow_open_by_cash:
futures.append(
("开仓计算", run.executor.submit(open_signal, run, ticks, allow_open))
)
# 9. 开始执行
for name, future in futures:
_wait_worker(name, future)
log.info(
"[RunOnce] 本轮完成,耗时=%d毫秒", int((time.monotonic() - started_at) * 1000)
)
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 _wait_worker(name: str, future: Future) -> None:
"""保留单轮继续运行的语义,分别记录工作线程异常。"""
try:
future.result()
except Exception:
log.exception("[运行] %s线程失败", name)
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

View File

@@ -1,128 +0,0 @@
"""趋势策略开仓逻辑。"""
from datetime import datetime
from functools import lru_cache
import math
from sdk import OP_BUY
from libs.runtime import Runtime
from libs.order import PlaceOrderRequest
import logging as log
def open_signal(run: Runtime, ticks, open_signals) -> None:
"""逐个验证开仓信号并提交买入委托。"""
for item in open_signals:
try:
if not math.isfinite(item.last_close) or item.last_close <= 0:
log.info("[OpenSkip] %s 信号=%s跳过信号无效last_close不是有限正数", item.code, item.signal_key)
continue
if item.code in run.account_cfg.excluded_codes:
log.info("[OpenSkip] %s 信号=%s,跳过:已配置为排除股票", item.code, item.signal_key)
continue
# 1. 验证信号配置允许开仓的时间区间。
signal_config = run.global_cfg.signals.get(item.signal_key)
if signal_config is None:
log.info("[OpenSkip] %s 信号=%s,跳过:未找到信号配置",item.code,item.signal_key)
continue
if not check_timezone(signal_config.timezone):
log.info("[OpenSkip] %s 信号=%s,跳过:不在信号时间段(%s)",item.code,item.signal_key,signal_config.timezone)
continue
# 2. 检查该证券是否已有买入委托锁,防止重复下单。
if run.orders.busy(item.code, "BUY"):
log.info("[OpenSkip] %s 信号=%s,跳过:买入委托处理中", item.code, item.signal_key)
continue
# 3. 验证行情和最新价格是否有效。
tick = ticks.get(item.code)
price = tick.last_price if tick is not None else 0
if not math.isfinite(price) or price <= 0:
log.info("[OpenSkip] %s 信号=%s,跳过:价格无效", item.code, item.signal_key)
continue
# 5. 按配置的固定手数开仓,每手 100 股。
volume = run.account_cfg.zt_open_hands * 100
if volume <= 0:
log.info("[OpenSkip] %s 信号=%s,跳过:数量无效", item.code, item.signal_key)
continue
# 其它信号,均从观察低点反弹,防止直接接下跌中的“飞刀”。
if not run.open_watch.triggered("开仓", item.code, price):
continue
do_open(run, item.code, volume, item.signal_key, price)
except RuntimeError as exc:
log.exception("[OpenRuntimeError] %s 信号=%s,失败:%s",item.code,item.signal_key,exc)
except Exception as err:
log.exception("[OpenExceptionError] %s 信号=%s,异常:%s",item.code,item.signal_key,err)
continue
def do_open(
run: Runtime, code: str, volume: int, signal_key: str, price: float
) -> None:
"""生成本地订单号并按最新价提交开仓委托。"""
order_id = run.orders.new_order_id("zt","base")
request = PlaceOrderRequest(
OP_BUY,
code,
volume,
order_id,
signal_key,
kind="base",
)
if not run.orders.place(run.client, request):
raise RuntimeError("订单提交失败")
run.open_watch.forget(code)
log.info("[Open] %s 信号=%s,买入=%d股,原因=反弹已确认",code,signal_key,volume)
def check_timezone(timezone: str, now: datetime | None = None) -> bool:
"""验证当前时间是否处于配置区间。
``*`` 表示全天允许;多个区间用逗号分隔,例如
``9:30-10:30,13:30-14:30``。同时支持跨午夜区间。
"""
timezone = str(timezone or "").strip()
if timezone == "*":
return True
current = now or datetime.now()
current_minutes = current.hour * 60 + current.minute
for section in timezone.split(","):
bounds = section.strip().split("-")
if len(bounds) != 2:
continue
start = _parse_minutes(bounds[0])
end = _parse_minutes(bounds[1])
if start is None or end is None:
continue
if start <= end and start <= current_minutes <= end:
return True
if start > end and (current_minutes >= start or current_minutes <= end):
return True
return False
@lru_cache(maxsize=256)
def _parse_minutes(value: str) -> int | None:
"""把 ``时:分`` 转换为当天分钟数,无效值返回 None。"""
try:
hour_text, minute_text = value.strip().split(":")
hour, minute = int(hour_text), int(minute_text)
except (TypeError, ValueError):
return None
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
return None
return hour * 60 + minute

View File

@@ -0,0 +1,38 @@
"""ZT 委托与成交的归属判定。
本地订单号由 ``OrderBook.new_order_id`` 生成,形如 ``zt-base-<hex>``、
``zt-added-<hex>``、``zt-SELL-<hex>``。QMT 把提交时传入的 ``userOrderId``
原样写进委托和成交的 ``remark``,客户端的
``OrderItem.local_order_id`` / ``DealItem.local_order_id`` 取 ``remark`` 的
``|`` 前段,因此它们等于当时的本地订单号。
2026-09-15 的生产库 ``zt_86037237_state.db`` 已核实:``remark`` 与
``order_local_id`` 完全相同(``zt-base-8e9da97a42e957408489``),没有
``|策略名`` 后缀。
手工单、IPO 单和其他策略单不带的 ``zt-`` 前缀,属于别人的成交,
绝不能进入本策略账本。
"""
# ZT 本地订单号前缀,与 `OrderBook.new_order_id("zt", ...)` 的调用保持一致。
OWNED_PREFIX = "zt-"
def owns_local_order_id(local_order_id: str) -> bool:
"""判断本地订单号是否属于 ZT 策略。
取严格前缀匹配:本地订单号始终是 ``zt-<角色>-<随机>``。
若上游改动了备注契约,这里会整体判定为"非本策略"
``_sync_state`` 会逐轮打印忽略数量,便于立刻发现。
"""
return str(local_order_id or "").strip().startswith(OWNED_PREFIX)
def owned_deals(deals: list) -> tuple[list, int]:
"""把成交分成"本策略""非本策略"两组。
Returns:
(归属本策略的成交列表, 被忽略的成交笔数)
"""
owned = [deal for deal in deals if owns_local_order_id(deal.get_local_order_id)]
return owned, len(deals) - len(owned)

View File

@@ -1,225 +0,0 @@
"""趋势策略持仓止盈与分级补仓。"""
from dataclasses import dataclass
import math
from libs.calc import calculate_min_profit_rate
from libs.grid_take_profit import GridState
from sdk import OP_BUY, OP_SELL, PositionItem, Tick
from libs.state import State
from libs.order import PlaceOrderRequest
from libs.runtime import Runtime
import logging as log
LOSS_TIERS = -10.0
@dataclass(slots=True)
class TradeDecision:
"""一次止盈或补仓判断的统一结果。"""
submitted: bool
message: str = ""
reserved_cash: float = 0.0
def manage_positions(
runtime: Runtime,
ticks: dict[str, Tick],
positions: list[PositionItem],
market_ok: bool,
available: float,
state:State,
) -> None:
# 遍历处理每个持仓
for position in positions:
try:
available = max(0, available)
code = position.stock_code
tick = ticks.get(code)
if code in runtime.account_cfg.excluded_codes:
log.info(
"[Position - ] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=已配置为排除股票",
code,
position.stock_name,
)
continue
if (
not code
or position.volume <= 0
or tick is None
or not math.isfinite(tick.last_price)
or tick.last_price <= 0
):
log.warning(
"[Position - ] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=持仓或行情数据无效",
code or "未知",
position.stock_name,
)
continue
if code in state.blocked_codes:
log.warning('[Position] %s 状态待核对,暂停该证券交易', code)
continue
posState = state.get_by_code(position.stock_code)
if not posState:
continue
target_qty = posState.get('base_qty', 0)
cost_price = position.open_price
if posState.get('added_qty', 0) > 0:
target_qty = posState['added_qty']
cost_price = posState.get('added_price',0)
# 在途股份不影响已有可卖库存;补仓、底仓均受柜台可卖上限约束。
volume = max(0, min(target_qty, position.can_use_volume, position.volume))
if not math.isfinite(cost_price) or cost_price <= 0:
log.warning('[Position] %s 成本无效,暂停该证券交易', code)
continue
pnl_rate = round(
(tick.last_price - cost_price) / cost_price * 100,
2,
)
minimum_profit = calculate_min_profit_rate(cost_price, 1)
profit_decision = handle_profit(
runtime=runtime,
stock_code=position.stock_code,
volume=volume,
tick=tick,
pnl_rate=pnl_rate,
minimum_profit=minimum_profit,
)
profit_action = profit_decision.message or "未触发"
loss_add_action = "未启用"
if runtime.account_cfg.enable_loss_add_position and market_ok:
loss_decision = handle_loss(
runtime=runtime,
stock_code=position.stock_code,
volume=volume,
tick=tick,
pnl_rate=pnl_rate,
available=available,
)
available = available - loss_decision.reserved_cash
loss_add_action = loss_decision.message or "未触发"
elif runtime.account_cfg.enable_loss_add_position:
loss_add_action = "大盘信号不允许"
strTag = "-"
if pnl_rate >= minimum_profit:
strTag = ""
elif pnl_rate< LOSS_TIERS:
strTag = ""
if strTag != "-":
log.info(
"[Position %s ] %s %s,盈亏=%.2f%%,止盈=%s,补仓=%s",
strTag,
code,
position.stock_name,
pnl_rate,
profit_action,
loss_add_action,
)
except Exception:
log.exception(
"[Position] 持仓处理异常,代码=%s,继续处理后续持仓",
position.stock_code,
)
def handle_profit(
runtime: Runtime,
stock_code: str,
volume:int,
tick: Tick,
pnl_rate: float,
minimum_profit: float,
) -> TradeDecision:
"""基于跨轮保存的最高盈利网格判断是否提交止盈。"""
if pnl_rate < minimum_profit:
return TradeDecision(False)
key = _position_key(runtime, stock_code)
observation = runtime.profit_tracker.observe(key, pnl_rate)
if observation.state == GridState.ARMED:
return TradeDecision(
False,
f"首次, PNL:{pnl_rate:.2f}%,网格={observation.current_grid}",
)
if observation.state == GridState.RAISED:
return TradeDecision(
False,
f"突破, PNL:{pnl_rate:.2f}%,网格={observation.current_grid}",
)
if observation.state == GridState.STEADY:
return TradeDecision(False,f"持平, PNL:{pnl_rate:.2f}%,网格={observation.current_grid}",)
if runtime.orders.busy(stock_code, "SELL"):
return TradeDecision(False, "卖出委托处理中")
if volume <= 0:
return TradeDecision(False, "无可用持仓")
order_id = runtime.orders.new_order_id("zt","SELL")
request = PlaceOrderRequest(
op=OP_SELL,
code=stock_code,
volume=volume,
order_id=order_id,
strategy_name=runtime.account_cfg.strategy,
)
if not runtime.orders.place(runtime.client, request):
return TradeDecision(False, "止盈委托失败")
return TradeDecision(True, f"[止盈卖出] {volume} 股,订单={order_id}")
def handle_loss(
runtime: Runtime,
stock_code: str,
volume:int,
tick: Tick,
pnl_rate: float,
available: float,
) -> TradeDecision:
"""按亏损档位、反弹确认和本轮剩余预算提交补仓。"""
if pnl_rate > LOSS_TIERS:
return TradeDecision(False)
if not runtime.add_watch.triggered("补仓", stock_code, tick.last_price):
return TradeDecision(False, "等待价格反弹确认")
if runtime.orders.busy(stock_code, "BUY"):
return TradeDecision(False, "买入委托处理中")
volume = runtime.account_cfg.zt_open_hands * 100
amount = tick.last_price * volume
if volume <= 0 or amount > available:
return TradeDecision(False, "本轮可用资金不足")
order_id = runtime.orders.new_order_id("zt","added")
request = PlaceOrderRequest(
op=OP_BUY,
code=stock_code,
volume=volume,
order_id=order_id,
strategy_name=runtime.account_cfg.strategy,
kind="add",
)
if not runtime.orders.place(runtime.client, request):
return TradeDecision(False, "补仓订单委托失败")
runtime.add_watch.forget(stock_code)
return TradeDecision(True, f"[补仓买入] {volume} 股,订单={order_id}", amount)
def _position_key(runtime: Runtime, code: str) -> str:
# tracker 为该账户的 ZT Runtime 独享,与 sync_positions 使用同一个键。
return code
def get_add_num(hands: int, market_value: float) -> int:
if market_value > 10000:
return -1
if hands < 2:
return 0
return -1

View File

@@ -1,31 +0,0 @@
"""ZT 按已同步的仓位及实际成本管理止盈峰值。"""
from libs.grid_take_profit import GridTrailingTracker
class ZTProfitTracker(GridTrailingTracker):
def __init__(self, step: float = 1.0):
super().__init__(step)
self._bases: dict[str, tuple] = {}
def sync_positions(self, positions, state) -> None:
# 与交易线程串行执行;提交委托本身不会改变这里的基准。
current = {}
for position in positions:
code = position.stock_code
row = state.get_by_code(code)
if position.volume <= 0 or not row:
continue
bucket = 'added' if row.get('added_qty', 0) > 0 else 'base'
cost = row.get('added_price', 0) if bucket == 'added' else position.open_price
current[code] = (bucket, cost, row.get(f'{bucket}_order_local_id', ''),
row.get(f'{bucket}_created_at', ''))
for code in self._bases.keys() | current.keys():
if code in state.blocked_codes:
continue
if self._bases.get(code) != current.get(code):
self.clear(code)
if code in current:
self._bases[code] = current[code]
else:
self._bases.pop(code, None)

View File

@@ -0,0 +1,379 @@
"""ZT 做 T 轮次状态:一只股票同时最多一轮,允许跨日持有。
正T``LONG_T``与反T``SHORT_T``)共用同一组字段,区别只是两条腿的方向:
正Tentry=BUY exit=SELL 低吸 → 高抛
反Tentry=SELL exit=BUY 高抛 → 低吸
轮次只记录"我打算做什么、做到哪一步",不重算持仓数量:持仓数量永远以
券商 ``positions`` 为准。因此这里没有数量等式,也就没有"数量对不上就冻结"
这条路径;部分成交、分批成交、部分可卖都由 ``entry_filled_qty`` /
``exit_filled_qty`` 自然表达。
成交累计是幂等的:只统计 ``seen_deal_ids`` 里没有的成交编号。QMT 只返回
当日成交,跨日轮次必须靠这份记录才能记住之前已成交多少,所以它必须落盘。
基准只来自本策略自己的建仓成交(``base_source=opened``):程序不接管账户里
已有的持仓,别人的持仓不进轮次、也不参与做 T。
"""
import json
import os
from dataclasses import asdict, dataclass, field, fields
from datetime import date, datetime
from pathlib import Path
from libs.order import BUSY_STATUSES
PHASE_IDLE = "IDLE" # 无活动轮次
PHASE_OPENING = "OPENING" # 开仓腿已提交,等待成交或终态
PHASE_OPEN = "OPEN" # 开仓腿已定局且有余量,等待平仓条件
PHASE_CLOSING = "CLOSING" # 平仓腿已提交
PHASE_CLOSED = "CLOSED" # 本轮结束normal / aborted / expired
KIND_LONG_T = "LONG_T" # 正T先买后卖
KIND_SHORT_T = "SHORT_T" # 反T先卖后买
KIND_BASE = "BASE" # 建底仓:只有买入腿,成交均价即基准成本
ACTIVE_PHASES = (PHASE_OPENING, PHASE_OPEN, PHASE_CLOSING)
_ENTRY_SIDE = {KIND_LONG_T: "BUY", KIND_SHORT_T: "SELL", KIND_BASE: "BUY"}
_EXIT_SIDE = {KIND_LONG_T: "SELL", KIND_SHORT_T: "BUY", KIND_BASE: ""}
OUTCOME_NORMAL = "normal"
OUTCOME_ABORTED = "aborted"
OUTCOME_EXPIRED = "expired"
OUTCOME_BASE = "base"
BASE_SOURCE_OPENED = "opened" # 本策略建仓,成本取实际成交均价
class RoundStoreError(ValueError):
"""轮次状态文件无法解析;调用方据此从券商持仓重建。"""
@dataclass(slots=True)
class Round:
"""单只证券的做 T 轮次记录。"""
code: str = ""
kind: str = ""
phase: str = PHASE_IDLE
open_date: str = "" # 开仓腿提交日;非空且等于今天即视为已用掉当日轮次
close_date: str = ""
outcome: str = ""
# 建仓基准:用户指定用建仓价,不随做 T 买卖摊薄。
base_qty: int = 0
base_cost: float = 0.0
base_date: str = ""
base_source: str = "" # opened / adopted
# 两条腿对称记录便于正T/反T 共用同一套推进逻辑。
entry_order_id: str = ""
entry_plan_qty: int = 0
entry_filled_qty: int = 0
entry_amount: float = 0.0
exit_order_id: str = ""
exit_plan_qty: int = 0
exit_filled_qty: int = 0
exit_amount: float = 0.0
# 已计入的成交编号,保证跨轮重复同步不会重复累加。
seen_deal_ids: list[str] = field(default_factory=list)
# 本股最后一次有腿成交的日期;当天已有成交就不再开新轮。
last_trade_date: str = ""
updated_at: str = ""
note: str = ""
@property
def entry_side(self) -> str:
return _ENTRY_SIDE.get(self.kind, "")
@property
def exit_side(self) -> str:
return _EXIT_SIDE.get(self.kind, "")
@property
def residual_qty(self) -> int:
"""尚未平掉的轮次敞口正T 为待卖反T 为待买回。"""
return self.entry_filled_qty - self.exit_filled_qty
@property
def entry_avg_price(self) -> float:
return self.entry_amount / self.entry_filled_qty if self.entry_filled_qty else 0.0
@property
def exit_avg_price(self) -> float:
return self.exit_amount / self.exit_filled_qty if self.exit_filled_qty else 0.0
@property
def realized_amount(self) -> float:
"""已平部分的价差收益(不含费用),仅用于日志与审计。"""
qty = min(self.entry_filled_qty, self.exit_filled_qty)
if qty <= 0 or self.entry_avg_price <= 0 or self.exit_avg_price <= 0:
return 0.0
if self.kind == KIND_LONG_T:
return (self.exit_avg_price - self.entry_avg_price) * qty
if self.kind == KIND_SHORT_T:
return (self.entry_avg_price - self.exit_avg_price) * qty
return 0.0
@property
def is_active(self) -> bool:
return self.phase in ACTIVE_PHASES
def can_open(self, today: str) -> bool:
"""当日是否还能开新轮。
三个条件缺一不可:没有未平轮次、今天没开过、今天没有腿成交。
最后一条保证"一只股票每天只做一轮"是真正的往返上限:跨日未平的
轮次今天平掉之后,今天也不再开新轮,避免同一天里平旧仓又开新仓。
"""
return (not self.is_active
and self.open_date != today
and self.last_trade_date != today)
_ROUND_FIELDS = {item.name for item in fields(Round)}
def entry_side(kind: str) -> str:
"""该轮次方向的开仓腿买卖方向。"""
return _ENTRY_SIDE.get(kind, "")
def exit_side(kind: str) -> str:
"""该轮次方向的平仓腿买卖方向;建底仓没有平仓腿。"""
return _EXIT_SIDE.get(kind, "")
def in_flight_order_ids(orders: list, *, busy_statuses: set[str] | None = None) -> set[str]:
"""仍可能继续成交的本地订单号集合。
已完成56、已撤54、部撤53、废单57都不在集合内
因此它们一出现就代表对应腿已经定局。
"""
statuses = BUSY_STATUSES if busy_statuses is None else busy_statuses
return {
order.local_order_id
for order in orders
if order.local_order_id and str(order.order_status) in statuses
}
def apply_deals(round: Round, deals: list, today: str) -> list[tuple[str, object]]:
"""把属于本轮两条腿的成交累计进来;同一笔成交只计一次。
去重键是成交编号,不是本地订单号:一个委托拆成多笔成交是常态,
同一本地订单号下可以有多笔成交,各自都要计入。
Returns:
本轮新计入的 ``(腿名, 成交)`` 列表,腿名为 ``entry`` / ``exit``
供调用方逐笔打日志。
"""
applied: list[tuple[str, object]] = []
seen = set(round.seen_deal_ids)
for deal in deals:
local_id = deal.get_local_order_id
if local_id != round.entry_order_id and local_id != round.exit_order_id:
continue
key = deal.order_sys_id or f'{local_id}|{deal.trade_date}|{deal.trade_time}|{deal.volume}'
if key in seen:
continue
if local_id == round.entry_order_id:
round.entry_filled_qty += deal.volume
round.entry_amount += deal.trade_amount
applied.append(("entry", deal))
else:
round.exit_filled_qty += deal.volume
round.exit_amount += deal.trade_amount
applied.append(("exit", deal))
round.seen_deal_ids.append(key)
seen.add(key)
round.last_trade_date = today
return applied
def advance(round: Round, in_flight: set[str], today: str) -> None:
"""按委托是否仍在途推进阶段;只改变本记录,不下单。"""
if round.phase == PHASE_OPENING and round.entry_order_id not in in_flight:
if round.kind == KIND_BASE:
_settle_base(round, today)
elif round.residual_qty > 0:
round.phase = PHASE_OPEN
elif round.residual_qty == 0:
_finish(round, today, OUTCOME_ABORTED, "开仓腿未成交即终态")
else:
_finish(round, today, OUTCOME_ABORTED,
"成交累计异常:平仓量超过开仓量,本轮作废")
elif round.phase == PHASE_CLOSING and round.exit_order_id not in in_flight:
if round.residual_qty > 0:
round.phase = PHASE_OPEN # 平仓腿部分成交或有撤单,余量继续处理
elif round.residual_qty == 0:
_finish(round, today, OUTCOME_NORMAL, "")
else:
_finish(round, today, OUTCOME_NORMAL, "成交累计异常:平仓量超过开仓量")
def _settle_base(round: Round, today: str) -> None:
"""建仓腿定局:以实际成交均价确定基准成本(用户要求用建仓价)。"""
if round.entry_filled_qty <= 0:
_finish(round, today, OUTCOME_ABORTED, "建仓腿未成交即终态")
return
round.base_qty = round.entry_filled_qty
round.base_cost = round.entry_avg_price
round.base_date = round.open_date or today
round.base_source = BASE_SOURCE_OPENED
_finish(round, today, OUTCOME_BASE, "底仓已建立")
def expire(round: Round, today: str, max_hold_days: int) -> bool:
"""轮次持有超过上限则放弃;不强平,残量留作隔夜持仓。"""
if round.phase not in (PHASE_OPEN, PHASE_CLOSING) or not round.open_date:
return False
if _days_between(round.open_date, today) <= max_hold_days:
return False
_finish(round, today, OUTCOME_EXPIRED,
f"持有超过 {max_hold_days} 天,放弃继续平仓")
return True
def _finish(round: Round, today: str, outcome: str, note: str) -> None:
_absorb_residual(round)
round.phase = PHASE_CLOSED
round.close_date = today
round.outcome = outcome
round.exit_plan_qty = 0
if outcome == OUTCOME_ABORTED:
# 没有产生任何持仓的作废轮次不占用当日配额,允许重新判断一次。
round.open_date = ""
if note:
round.note = note
def _absorb_residual(round: Round) -> None:
"""把未平掉的轮次敞口并入底仓数量,成本基准保持建仓价不变。
没有这一步超期放弃的反T 会在"卖出未买回"的敞口上再开一轮,把仓位
越做越偏;并入底仓后基准数量与券商持仓重新对齐,下一轮的下单量才准。
"""
if round.kind == KIND_LONG_T:
round.base_qty = max(0, round.base_qty + round.residual_qty)
elif round.kind == KIND_SHORT_T:
round.base_qty = max(0, round.base_qty - round.residual_qty)
def _days_between(start: str, today: str) -> int:
try:
return (date.fromisoformat(today) - date.fromisoformat(start)).days
except ValueError:
return 0
def start_round(round: Round, kind: str, today: str) -> None:
"""在已有基准上开新一轮,清空上一轮的两条腿与审计字段。
必须走这个入口而不是直接改字段:上一轮的 ``exit_filled_qty`` 若是残留,
``residual_qty`` 会变成负数,``advance`` 会把它当成"作废"并立刻重开一轮。
"""
round.kind = kind
round.phase = PHASE_OPENING
round.open_date = today
round.close_date = ""
round.outcome = ""
round.note = ""
round.entry_order_id = ""
round.entry_plan_qty = 0
round.entry_filled_qty = 0
round.entry_amount = 0.0
round.exit_order_id = ""
round.exit_plan_qty = 0
round.exit_filled_qty = 0
round.exit_amount = 0.0
round.seen_deal_ids = []
def new_round(code: str, kind: str, today: str, base_qty: int, base_cost: float,
base_date: str = "", base_source: str = "") -> Round:
"""构造一条带基准的新轮次记录。"""
record = Round(code=code, base_qty=base_qty, base_cost=base_cost,
base_date=base_date or today, base_source=base_source)
start_round(record, kind, today)
return record
def new_base_round(code: str, today: str, plan_qty: int) -> Round:
"""建底仓:只有买入腿,成交均价随后写入 base_cost。"""
record = Round(code=code)
start_round(record, KIND_BASE, today)
record.entry_plan_qty = plan_qty
return record
def is_owned_base(round: Round) -> bool:
"""基准是否由本策略自己建立。
只有 ``base_source=opened``(建仓腿成交后写入)算自有基准;账户里已有的
持仓不会被接管,因此不会出现别的来源。
"""
return round.base_qty > 0 and round.base_source == BASE_SOURCE_OPENED
def touch(round: Round, now: datetime | None = None) -> None:
round.updated_at = (now or datetime.now()).isoformat(sep=" ", timespec="seconds")
class RoundStore:
"""每账户一个 JSON 文件,整文件原子替换。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.rounds: dict[str, Round] = {}
self.load()
def load(self) -> None:
try:
raw = self.path.read_text(encoding="utf-8")
except FileNotFoundError:
self.rounds = {}
return
except OSError as exc:
raise RoundStoreError(f"读取轮次状态失败: {exc}") from exc
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RoundStoreError(f"解析轮次状态失败: {exc}") from exc
if not isinstance(payload, dict):
raise RoundStoreError("轮次状态根节点必须是对象")
rounds: dict[str, Round] = {}
for code, value in payload.items():
if not isinstance(value, dict):
raise RoundStoreError(f"轮次状态 {code} 必须是对象")
unknown = set(value) - _ROUND_FIELDS
if unknown:
raise RoundStoreError(f"轮次状态 {code} 含未知字段: {sorted(unknown)}")
value["code"] = code
rounds[code] = Round(**value)
self.rounds = rounds
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(self.path.name + ".tmp")
temporary.write_text(
json.dumps({code: asdict(item) for code, item in self.rounds.items()},
ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.replace(temporary, self.path)
def get(self, code: str) -> Round:
return self.rounds.get(code) or Round(code=code)
def put(self, round: Round) -> None:
self.rounds[round.code] = round
def drop(self, code: str) -> None:
self.rounds.pop(code, None)

View File

@@ -0,0 +1,90 @@
"""ZT 正T/反T 的触发与数量规则:纯函数,不碰网络、存储和线程。
设计要点:
* 以建仓价 ``base_cost`` 为中枢,中性带 ``zt_t_band_pct`` 内不做任何动作。
现价低于下沿只考虑正T高于上沿只考虑反T —— 同一时刻只可能命中一种方向,
天然满足"一只股票每天只做一轮",不需要额外的冲突仲裁。
* 数量一律再受券商现实约束封顶:卖出封顶 ``can_use_volume``T+1 只在这里
体现买入封顶可用资金。正T 当天买入的份额当天不可卖,因此它的平仓腿
能卖多少完全由 ``can_use_volume`` 决定,卖不动就自然留成隔夜持仓。
"""
from .rounds import KIND_LONG_T, KIND_SHORT_T
LOT = 100
def choose_kind(price: float, base_cost: float, band_pct: float) -> str | None:
"""按现价相对建仓价的位置决定本轮方向;中性带内返回 None。"""
if price <= 0 or base_cost <= 0 or band_pct < 0:
return None
if price <= base_cost * (1 - band_pct / 100):
return KIND_LONG_T
if price >= base_cost * (1 + band_pct / 100):
return KIND_SHORT_T
return None
def price_allowed(price: float, max_price: float) -> bool:
"""高价股不参与做 T。"""
return 0 < price <= max_price
def entry_volume(kind: str, *, price: float, open_hands: int, sell_ratio: float,
base_qty: int, can_use_volume: int, available: float) -> int:
"""开仓腿计划数量0 表示不提交。"""
if price <= 0:
return 0
if kind == KIND_LONG_T:
# 正T 买入:按手数取量,再受可用资金封顶。
affordable = int(max(0.0, available) // (price * LOT)) * LOT
return max(0, min(open_hands * LOT, affordable))
if kind == KIND_SHORT_T:
# 反T 卖出:按建仓数量比例取整手,再受可卖库存封顶。
planned = int(base_qty * sell_ratio) // LOT * LOT
return max(0, min(planned, _whole_lots(can_use_volume)))
return 0
def exit_volume(kind: str, *, residual_qty: int, price: float,
can_use_volume: int, available: float) -> int:
"""平仓腿可提交数量0 表示当前无法平仓T+1 冻结或资金不足)。"""
if residual_qty <= 0 or price <= 0:
return 0
if kind == KIND_LONG_T:
return max(0, min(residual_qty, _whole_lots(can_use_volume)))
if kind == KIND_SHORT_T:
affordable = int(max(0.0, available) // (price * LOT)) * LOT
return max(0, min(residual_qty, affordable))
return 0
def entry_triggered(kind: str, price: float, base_cost: float, *, band_pct: float,
rebound_confirmed: bool, retrace_confirmed: bool) -> bool:
"""开仓腿是否满足触发条件。"""
if choose_kind(price, base_cost, band_pct) != kind:
return False
if kind == KIND_LONG_T:
return rebound_confirmed # 低吸要等反弹确认,不接下跌中的飞刀
return retrace_confirmed # 高抛要等盈利网格回撤,不追最高点
def exit_triggered(kind: str, price: float, entry_avg_price: float, *,
buy_fall_pct: float, profit_step_pct: float,
rebound_confirmed: bool) -> bool:
"""平仓腿是否满足触发条件。"""
if entry_avg_price <= 0:
return False
if kind == KIND_SHORT_T:
# 反T 买回:较卖出均价回落 buy_fall_pct 且已见反弹。
target = entry_avg_price * (1 - buy_fall_pct / 100)
return price <= target and rebound_confirmed
if kind == KIND_LONG_T:
# 正T 卖出:较买入均价上涨一个网格步长。
return price >= entry_avg_price * (1 + profit_step_pct / 100)
return False
def _whole_lots(volume: int) -> int:
return max(0, int(volume)) // LOT * LOT