This commit is contained in:
2026-09-12 16:25:34 +08:00
parent 7a7049ce44
commit 554dd0f4cb
13 changed files with 646 additions and 93 deletions

View File

@@ -0,0 +1,19 @@
# IPO 提交与结果核对
当前实现使用文件记录,不增加数据库或后台任务。
- 路径:`qmt_data_dir/ipo/<账户 SHA-256>/<YYYYMMDD>/<证券>/<尝试序号>.json`
- 提交前用独占创建方式写入并落盘 `pending`;占位失败不提交。
- HTTP 正常返回仍保留 `pending`,不代表最终申购成功。
- 下次 IPO 任务先查询账户委托。查不到、状态未知、仍在处理或已有部分成交时,不重发。
- 同一本地订单编号查到状态 56 后记录 `confirmed`,后续保持防重。
- 同一编号查到状态 57 且成交量为 0才记录 `rejected` 并原子占位下一次尝试。旧尝试的废单不能授权新尝试再次重发。
- 不同账户、日期使用不同记录。每次尝试都有写入记录且传给 QMT 的本地订单编号。
状态码依据:[迅投官方委托核对示例](https://dict.thinktrader.net/innerApi/code_examples.html)。撤单、部撤等情况未自动视为可以再次申购。
旧版只有证券名的 `.lock` 文件不再作为新账户的申购记录,避免跨账户锁冲突;首次运行通过当天同证券买入委托防重。升级应在当前账户委托可查询的条件下进行,旧锁本身无法证明归属账户或最终结果。
损坏或无法读取的记录不会触发重新提交;记录写入失败也不会提交。若长期查不到回报,需要先人工核对柜台结果,不能直接删除待确认记录后重跑。
接口响应必须是列表,合法空列表表示无候选;非列表响应记录错误。候选要求完整代码及对应交易所、有限正价格、正整数数量。当前参与板块范围保持不变。

View File

@@ -0,0 +1,86 @@
# IPO、Trend、ZT 策略审计
复审日期2026-09-12。基线`7a7049ce44965d27c42f08ede1818d16495d8232` 加本次读取的工作区代码,包含手工修改及未提交文件。
范围三个策略及直接相关的委托簿、状态存储、SDK、服务端成交映射、网格和快照模块。本次只更新报告不修改策略、测试或生产数据不连接交易账户。
仅保留复审后仍需处理的问题。已修复及用户已标记忽略的条目、历史复现和过时统计已移除;编号沿用原报告。部分修复的问题仅描述尚未解决的策略范围。
## 结论
**待处理P0 0 项、P1 2 项、P2 2 项。** IPO 本次未确认新的待处理问题。
| 编号 | 级别 | 范围 | 问题 |
|---|---|---|---|
| P1-07 | P1 | ZT | 按委托编号去重,分笔成交可能漏记 |
| P1-08 | P1 | Trend | 超时撤单仍覆盖手工单及其他非 IPO 策略订单 |
| P2-01 | P2 | Trend | 止盈峰值跨仓位沿用 |
| P2-07 | P2 | 共用模块测试 | 旧 OrderBook 构造调用导致回归测试错误 |
P0 表示已确认的紧急严重风险P1 表示主要交易控制或记账问题P2 表示特定条件下的可靠性和回归验证问题。本次未确认 P0。
## P1
### P1-07ZT 按委托编号去重,分笔成交可能漏记
**位置:** [state.py](../py-client/libs/state.py) 第 55、146156、362389 行;[服务端成交映射](../api/qmt_rest_new.py) 第 371387 行;[ZT 启动](../py-client/strategy/zt/boot.py) 第 4951 行。
**证据:** 服务端将 `m_strOrderSysID` 映射为 `order_sys_id`成交表以该字段建立唯一索引。ZT 增量路径插入第一条后将编号加入 `existing`,其余同编号记录不再插入。不同 `ref`、时间和数量不参与身份识别。启动时两次成交查询也按此编号转成字典比较,会折叠同委托的多条记录。
**本轮复现:** 临时数据库中,同一委托编号、不同 `ref` 的 40 股和 60 股买入成交,对应账户快照为 100 股。`sync_account()` 仅记入 40 股并隔离该证券;重新加载数据库并再次同步,仍为 40 股,未恢复。
**影响与边界:** 逐笔回报可能漏记后续成交;累计回报也无法通过当前只追加逻辑更新已存数量。库存、金额及成本可能不完整,证券持续暂停交易。复现证明代码无法处理上述输入,不代表已确认目标柜台采用哪一种回报契约。
**修改建议:** 先核验实际回报是逐笔还是累计,以及唯一身份字段。逐笔回报透传真实成交编号并据此去重;累计回报按增量差额更新。启动稳定性比较也需保留完整回报。不要未经核验拼接时间、价格或 `ref` 作为唯一键;修改存储键时同时明确已有数据的处理方式。
**验收:** 同委托分笔成交累计为 100 股;重复查询和重启不重复记账;累计回报按已确认契约处理;初始化比较不因同编号覆盖而遗漏变化。
### P1-08Trend 超时撤单仍覆盖手工单及其他策略订单
**位置:** [Trend boot.py](../py-client/strategy/trend/boot.py) 第 40、119 行;[OrderBook.refresh()](../py-client/libs/order.py) 第 61、7788 行。
**证据:** Trend 启动及每轮运行均传入完整账户委托,未指定 `cancel_prefix`,默认值为 `None`。所有满足超时及可撤状态的非 `IPO-*` 委托都会进入撤单路径,包括空备注手工单和 `zt-*` 订单。
**本轮复现:** 四笔超时、状态 50 的模拟订单分别使用 `TREN-BUY-*``zt-base-*``IPO-*` 和空备注。按 Trend 当前调用方式刷新Trend、ZT 和手工订单触发撤单IPO 未触发;四笔均保留缓存。
**影响与边界:** 运行 Trend 可能干扰人工或其他策略希望继续等待的委托。本项仅保留 Trend 范围;当前 IPO 排除有效,不再沿用旧报告的 IPO 误撤结论。Mock 只能证明撤单调用,不能证明柜台受理。
**修改建议:** 若 Trend 只管理自己的订单应明确归属后限制自动撤单同时保留全账户在途防重。Trend 开仓使用信号前缀、持仓管理使用 `TREN-*`,不能简单改为单一 `trend-` 前缀。如果全账户非 IPO 撤单是预期行为,应明确记录这一范围后再决定是否关闭本项。
**验收:** Trend 自有超时订单能撤,手工单和其他策略订单不被 Trend 自动撤销;全部活动委托继续参与防重。
## P2
### P2-01Trend 止盈峰值跨仓位沿用
**位置:** [Trend positions.py](../py-client/strategy/trend/positions.py) 第 120121、198199 行;[Trend boot.py](../py-client/strategy/trend/boot.py) 第 60、113119 行;[网格跟踪器](../py-client/libs/grid_take_profit.py) 第 4474 行。
**证据:** Trend 使用普通 `GridTrailingTracker`,键仅包含账户及证券。清仓、重新建仓和补仓成本变化时,没有清理旧峰值;每轮快照更新也未同步网格生命周期。
**本轮复现:** 使用真实 `manage_positions()`、真实 tracker 和模拟委托簿,依次输入成本 10 元、价格 12 元的持仓,价格 11.9 元的同一持仓,空持仓,再输入成本 10 元、价格 11 元的新持仓。各步新增卖出调用次数为 `0、1、0、1`。新仓位第一次观察仍沿用旧峰值触发卖出。
**影响:** 重新建仓或成本基准变化后,当前收益率可能被当成旧仓位的回撤,提前触发止盈。本项仅保留 Trend 范围。
**修改建议:** 根据已确认的清仓、重新建仓及成本变化同步止盈基准。不要仅在提交成功时清理,避免撤单或部分成交丢失有效峰值。
**验收:** 新仓位首次观察只建立峰值;同一仓位正常回撤仍触发卖出;未成交提交、撤单及同一成本下部分卖出不误清理。
### P2-07OrderBook 旧构造调用导致回归测试错误
**位置:** [test_deal_model.py](../py-client/tests/test_deal_model.py) 第 71 行;[OrderBook 构造函数](../py-client/libs/order.py) 第 3538 行。
**本轮复现:** 测试调用 `ActiveOrders('trend')`,字符串被当成 `lock_timeout_sec`,在 `max(1, lock_timeout_sec)` 处抛出 `TypeError`。完整测试 86 项中 85 项通过、1 项错误、0 项断言失败。
**影响与边界:** 回归套件无法全绿。实际 Trend/ZT 入口已使用无参构造,本项不意味着策略必然启动失败。
**修改建议:** 将测试改为当前构造接口;撤单范围通过 `refresh()` 参数和调用方约定验证,不为旧测试重新引入已删除的构造参数。
**验收:** 该测试通过并保持默认刷新、ZT 范围过滤、IPO 排除和在途防重断言有效。
## 本轮验证及边界
- 完整执行 `unittest` 发现的 86 项测试85 通过、1 项错误,详情见 P2-07。其中 IPO 专项 15 项、ZT 相关 26 项全部通过。
- 额外执行真实状态同步、委托刷新和 Trend 持仓管理路径,分别复现 P1-07、P1-08、P2-01结果已写入对应条目。
- 验证使用模拟客户端及临时 SQLite 数据库,未发送真实委托、撤单或采集请求,未修改生产数据库和锁文件。
- 成交唯一身份、逐笔/累计语义及柜台实际受理结果仍需目标环境确认,未将未核验事实写成确定结论。
- 不新增旧 IPO 编号兼容要求,不恢复已忽略事项,不将已修复内容作为待办重复列出。

View File

@@ -2,6 +2,9 @@
from os import PathLike
from pathlib import Path
import json
import os
from tempfile import NamedTemporaryFile
def is_lock(file_path: str | PathLike[str]) -> bool:
@@ -14,3 +17,32 @@ def write_lockfile(file_path: str | PathLike[str]) -> None:
path = Path(file_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("LOCK", encoding="utf-8")
def claim_json(path: Path, record: dict) -> bool:
"""原子占位并落盘,成功后才允许提交;异常留下记录等待核对。"""
path.parent.mkdir(parents=True, exist_ok=True)
try:
stream = path.open('x', encoding='utf-8')
except FileExistsError:
return False
with stream:
json.dump(record, stream, ensure_ascii=False)
stream.flush()
os.fsync(stream.fileno())
return True
def replace_json(path: Path, record: dict) -> None:
"""原子替换核对结果,避免其他任务读取到半条记录。"""
temporary = None
try:
with NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent, delete=False) as stream:
temporary = Path(stream.name)
json.dump(record, stream, ensure_ascii=False)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)

View File

@@ -33,9 +33,8 @@ class OrderBook:
"""线程安全的活动委托缓存。"""
def __init__(
self, order_prefix: str, lock_timeout_sec: int = 180, cancel_timeout_sec: float = 30
self, lock_timeout_sec: int = 180, cancel_timeout_sec: float = 30
) -> None:
self.order_prefix = order_prefix
self.lock_timeout_sec = max(1, lock_timeout_sec)
self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec)
self.data: list[OrderItem] = []
@@ -59,7 +58,7 @@ class OrderBook:
def _busy_key(side: str, code: str) -> str:
return f"{side}-{code}"
def refresh(self, client: Client, orders: list[OrderItem]) -> None:
def refresh(self, client: Client, orders: list[OrderItem], *, cancel_prefix: str | None = None) -> None:
"""用账户快照刷新委托,并撤销超时的活动委托。"""
current = datetime.now()
data: list[OrderItem] = []
@@ -77,7 +76,8 @@ class OrderBook:
created_at = item.created_at
if (
created_at is not None
and item.local_order_id.startswith(f"{self.order_prefix}-")
and not item.local_order_id.startswith('IPO-')
and (cancel_prefix is None or item.local_order_id.startswith(cancel_prefix))
and status in CANCELABLE_STATUSES
and current - created_at > self.cancel_timeout_sec
):

View File

@@ -1,6 +1,7 @@
"""SQLite 策略状态与成交存储;每个数据库仅使用一个写入者,不做数据迁移。"""
import math
import json
import logging as log
import sqlite3
from contextlib import closing
@@ -144,9 +145,11 @@ class State:
self.deals, self.deals_sys_ids = deals, set(deals)
@staticmethod
def _insert_deals(db: sqlite3.Connection, deals: list[DealItem]) -> None:
def _insert_deals(db: sqlite3.Connection, deals: list[DealItem],
existing_ids: set[str] | None = None) -> None:
"""共享事务内保存成交;空本地编号使用明确的待核对标记。"""
existing = {row[0] for row in db.execute('SELECT order_sys_id FROM deals')}
existing = existing_ids if existing_ids is not None else {
row[0] for row in db.execute('SELECT order_sys_id FROM deals')}
new_deals: dict[str, DealItem] = {}
for deal in deals:
if deal.order_sys_id not in existing:
@@ -280,13 +283,16 @@ class State:
'AND is_arch = 0', (code,),
)
def _archive_pending(self, db: sqlite3.Connection, snapshot_dedup: bool = True) -> None:
def _archive_pending(self, db: sqlite3.Connection, snapshot_dedup: bool = True,
blocked_codes: set[str] | None = None) -> None:
"""保留现有逐证券保存点ZT 增量路径禁用数量相等推断。"""
codes = db.execute(
'SELECT DISTINCT stock_code FROM deals WHERE is_arch = 0'
).fetchall()
for row in codes:
code = row['stock_code']
if blocked_codes and code in blocked_codes:
continue
db.execute('SAVEPOINT archive_stock')
try:
self._archive_stock(db, code, snapshot_dedup)
@@ -314,6 +320,11 @@ class State:
holdings = {p.stock_code: p for p in positions if p.volume > 0}
with closing(self._connect()) as db, db:
db.execute('BEGIN IMMEDIATE')
db.execute('''CREATE TABLE IF NOT EXISTS zt_rejected_deals (
stock_code TEXT NOT NULL, order_sys_id TEXT NOT NULL,
payload TEXT NOT NULL, error TEXT NOT NULL,
PRIMARY KEY (stock_code, order_sys_id)
)''')
if initialize:
if (db.execute('SELECT 1 FROM state LIMIT 1').fetchone()
or db.execute('SELECT 1 FROM deals LIMIT 1').fetchone()):
@@ -330,11 +341,13 @@ class State:
# 这些成交已包含在已核对的基准中,不再增减快照库存。
db.execute('UPDATE deals SET is_arch = 1')
else:
self._insert_deals(db, deals)
self._archive_pending(db, snapshot_dedup=False)
self._insert_account_deals(db, deals)
rejected = {row[0] for row in db.execute('SELECT stock_code FROM zt_rejected_deals')}
self._archive_pending(db, snapshot_dedup=False, blocked_codes=rejected)
state = self._read_state(db)
cached_deals = self._read_deals(db)
blocked = {d['stock_code'] for d in cached_deals.values() if d['is_arch'] != 1}
blocked.update(row[0] for row in db.execute('SELECT stock_code FROM zt_rejected_deals'))
for code in set(state) | set(holdings):
row = state.get(code, {})
recorded = row.get('base_qty', 0) + row.get('added_qty', 0)
@@ -345,3 +358,35 @@ class State:
self.blocked_codes = blocked
if blocked:
log.warning('[ZT 同步] 以下证券状态待核对,暂停交易:%s', ', '.join(sorted(blocked)))
def _insert_account_deals(self, db: sqlite3.Connection, deals: list[DealItem]) -> None:
"""仅 ZT 增量路径隔离坏成交;保留原始字段,缺席回报不会解除隔离。"""
existing = {row[0] for row in db.execute('SELECT order_sys_id FROM deals')}
for deal in deals:
if not deal.stock_code:
raise ValueError('Trade without stock code cannot be isolated')
db.execute('SAVEPOINT insert_zt_deal')
try:
if not deal.stock_code or not deal.order_sys_id:
raise ValueError('Trade code and order ID are required')
if (deal.offset_flag not in (FLAG_BUY, FLAG_SELL)
or not math.isfinite(deal.volume) or deal.volume <= 0
or int(deal.volume) != deal.volume
or not math.isfinite(deal.price) or deal.price < 0
or not math.isfinite(deal.trade_amount)
or not math.isfinite(deal.close_profit)):
raise ValueError('Invalid trade direction, volume or amount')
self._insert_deals(db, [deal], existing)
except (ValueError, TypeError, OverflowError, sqlite3.IntegrityError) as exc:
db.execute('ROLLBACK TO insert_zt_deal')
db.execute('''INSERT OR REPLACE INTO zt_rejected_deals
(stock_code, order_sys_id, payload, error) VALUES (?, ?, ?, ?)''',
(deal.stock_code, deal.order_sys_id, json.dumps(asdict(deal), ensure_ascii=False), str(exc)))
log.warning('[ZT 成交] 隔离证券=%s,委托=%s,原因=%s',
deal.stock_code, deal.order_sys_id, exc)
else:
existing.add(deal.order_sys_id)
db.execute('DELETE FROM zt_rejected_deals WHERE stock_code = ? AND order_sys_id = ?',
(deal.stock_code, deal.order_sys_id))
finally:
db.execute('RELEASE insert_zt_deal')

View File

@@ -47,8 +47,10 @@ class TradeMixin:
response = self._post_json(
"/api/trade/ipo_data",
{"type": str(ipo_type).strip().upper()},
) or []
return response if isinstance(response, list) else []
)
if not isinstance(response, list):
raise ValueError('IPO response must be a list')
return response
def cancel_by_id(self, order_id: str) -> dict[str, Any]:

View File

@@ -1,85 +1,112 @@
"""新股自动申购,提供交易日校验、券商对账和本地幂等保护"""
"""IPO 申购:账户隔离、提交前占位、通过券商委托核对结果"""
import hashlib
import json
import logging
from datetime import datetime, time
import math
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
import config
from sdk import Client
from libs.calc import trading_time
from libs.lockfile import is_lock,write_lockfile
IPO_SESSIONS = ((time(9, 30), time(11, 30)), (time(13, 0), time(15, 0)))
def AutoBuyIpo():
"""安全执行一次新股申购,返回成功提交的证券数量。"""
if not config.account_config.enable_auto_ipo:
logging.info("[IPO] 自动申购未启用")
return 0
if not trading_time(datetime.now()):
logging.info("[IPO] 非交易时间")
return 0
try:
with Client(
config.global_config.qmt_base_url,
config.global_config.qmt_token,
config.HTTP_TIMEOUT,
) as client:
result = client.ipo_data("STOCK")
for item in result:
try:
if not isinstance(item, dict):
raise TypeError("IPO 数据项必须是字典")
stock = str(item.get("stock", "")).strip()
if not is_target_stock(stock):
continue
ipo_price = float(item["issuePrice"])
max_purchase_num = int(item["maxPurchaseNum"])
if ipo_price <= 0 or max_purchase_num <= 0:
raise ValueError("发行价或申购额度必须大于 0")
lock_path = Path(config.global_config.qmt_data_dir) / f"{stock}.lock"
if is_lock(lock_path):
continue
client.passorder(
op_type=23,
stock=stock,
volume=max_purchase_num,
pr_type=11,
price=ipo_price,
strategy_name="ipo",
)
write_lockfile(lock_path)
logging.info(
"[IPO] %s 申购,发行价:%s 可申购额度:%s",
stock,
ipo_price,
max_purchase_num,
)
except Exception:
logging.exception("[IPO] 单条申购处理失败,数据=%r", item)
except Exception:
logging.exception("[IPO] 自动申购任务失败")
from libs.lockfile import claim_json, replace_json
import secrets
def is_target_stock(symbol: str) -> bool:
"""
判断是否为上证、深证、科创板的A股。
symbol格式示例: '600519.SH', '000001.SZ'
"""
# 提取纯数字代码
code = symbol.split(".")[0]
"""保留原板块范围,同时校验完整代码和对应交易所。"""
return isinstance(symbol, str) and re.fullmatch(
r'(?:60[0-9]{4}|68[89][0-9]{3})\.SH|(?:00[0-3][0-9]{3}|30[01][0-9]{3})\.SZ',
symbol,
) is not None
# 判断是否为合规板块
if code.startswith(('60', '688', '689')): # 沪市主板 + 科创板
return True
if code.startswith(('000', '001', '002', '003', '300', '301')): # 深市主板 + 创业板
return True
def _candidate(item: dict) -> tuple[str, float, int]:
if not isinstance(item, dict):
raise ValueError('IPO candidate must be an object')
stock = item.get('stock')
if not isinstance(stock, str) or not is_target_stock(stock.strip()):
raise ValueError('Invalid or unsupported IPO stock code')
if isinstance(item.get('issuePrice'), bool) or isinstance(item.get('maxPurchaseNum'), bool):
raise ValueError('IPO price and volume cannot be boolean')
price = float(item['issuePrice'])
try:
quantity = Decimal(str(item['maxPurchaseNum']))
except InvalidOperation as exc:
raise ValueError('Invalid IPO volume') from exc
if not math.isfinite(price) or price <= 0:
raise ValueError('IPO price must be positive and finite')
if not quantity.is_finite() or quantity <= 0 or quantity != quantity.to_integral_value():
raise ValueError('IPO volume must be a positive integer')
return stock.strip(), price, int(quantity)
def _subscribe(client, orders, account: str, day: str, stock: str, price: float, volume: int) -> bool:
# 每次明确拒绝后的尝试单独占位,避免两个进程同时重试。
account_key = hashlib.sha256(account.encode('utf-8')).hexdigest()
folder = Path(config.global_config.qmt_data_dir) / 'ipo' / account_key / day / stock
paths = sorted(folder.glob('[0-9]*.json'), key=lambda p: int(p.stem))
path = paths[-1] if paths else None
record = json.loads(path.read_text(encoding='utf-8')) if path else None
if record and record['status'] == 'confirmed':
return False
matching = [o for o in orders if o.stock_code == stock and o.side == 'BUY'
and (o.insert_date.replace('-', '') == day
or (record and o.local_order_id == record['order_id']))]
# 已有其他有效/未知状态买单也阻止再申购,覆盖人工提交和旧版记录。
if any(str(o.order_status) != '57' or o.volume_traded > 0 for o in matching):
if record and any(o.local_order_id == record['order_id'] and str(o.order_status) == '56'
for o in matching):
record['status'] = 'confirmed'
replace_json(path, record)
logging.info('[IPO] %s 已有委托,保持防重', stock)
return False
if record:
rejected = any(o.local_order_id == record['order_id'] and str(o.order_status) == '57'
and o.volume_traded == 0 for o in matching)
if not rejected:
logging.info('[IPO] %s 结果待确认,暂不重发', stock)
return False
record['status'] = 'rejected'
replace_json(path, record)
attempt = int(path.stem) + 1 if path else 1
order_id = f'IPO-{secrets.token_hex(12)}'
record = dict(account=account, date=day, stock=stock, order_id=order_id,
status='pending', price=price, volume=volume)
if not claim_json(folder / f'{attempt:04d}.json', record):
return False
# HTTP 正常返回或异常均不代表最终结果;仅券商回报可改变 pending。
client.passorder(op_type=23, stock=stock, volume=volume, pr_type=11,
price=price, strategy_name='ipo', order_id=order_id)
logging.info('[IPO] %s 已提交 %d 股,等待委托确认,编号=%s', stock, volume, order_id)
return True
def AutoBuyIpo() -> int:
"""返回本次正常返回的提交数量,不代表最终申购成功。"""
now = datetime.now()
if not config.account_config.enable_auto_ipo or not trading_time(now):
return 0
submitted = 0
try:
account = str(config.account_config.account_id).strip()
if not account:
raise ValueError('IPO account ID is required')
with Client(config.global_config.qmt_base_url, config.global_config.qmt_token,
config.HTTP_TIMEOUT) as client:
candidates = client.ipo_data('STOCK')
orders = client.orders() # 查询失败时不提交,不能将未知当作无委托。
for item in candidates:
try:
stock, price, volume = _candidate(item)
submitted += _subscribe(client, orders, account, now.strftime('%Y%m%d'),
stock, price, volume)
except Exception:
logging.exception('[IPO] 单条申购处理失败,数据=%r', item)
except Exception:
logging.exception('[IPO] 自动申购任务失败')
return submitted

View File

@@ -36,7 +36,7 @@ def StartTrend() -> None:
assets = portfolio.assets
positions = list(portfolio.positions.values())
cache_portfolio(config.account_config.account_id, assets, positions, client.deals())
order_book = OrderBook("trend")
order_book = OrderBook()
order_book.refresh(client, portfolio.orders)
# 获取本策略的信号开仓数据

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from concurrent.futures import Future, ThreadPoolExecutor
import config
from libs.calc import trading_time
from libs.grid_take_profit import GridTrailingTracker
from .profit import ZTProfitTracker
from libs.market import market_allow_open
from libs.order import OrderBook
from libs.overview import Overview
@@ -33,8 +33,8 @@ def StartZT() -> None:
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="zt")
run = Runtime(
client=client, global_cfg=config.global_config, account_cfg=config.account_config,
orders=OrderBook('zt'), open_watch=DipWatch(), add_watch=DipWatch(),
profit_tracker=GridTrailingTracker(config.account_config.grid_step_pct),
orders=OrderBook(), open_watch=DipWatch(), add_watch=DipWatch(),
profit_tracker=ZTProfitTracker(config.account_config.grid_step_pct),
executor=executor
)
@@ -56,7 +56,8 @@ def StartZT() -> None:
config.account_config.account_id, len(signals), len(positions))
cache_portfolio(config.account_config.account_id, assets, positions, deals)
state.sync_account(positions, deals, initialize=initialize)
run.orders.refresh(client, portfolio.orders)
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
@@ -111,9 +112,11 @@ def RunOnce(run: Runtime, state: State, signals: list[SignalItem]) -> None:
assets = portfolio.assets
positions = list(portfolio.positions.values())
position_codes = list(portfolio.positions)
cache_portfolio(run.account_cfg.account_id, assets, positions, deals)
state.sync_account(positions, deals)
run.orders.refresh(run.client, portfolio.orders)
run.profit_tracker.sync_positions(positions, state)
run.orders.refresh(run.client, portfolio.orders, cancel_prefix='zt-')
except Exception:
log.exception("[Portfolio] 刷新账户快照失败")
return

View File

@@ -213,7 +213,8 @@ def handle_loss(
def _position_key(runtime: Runtime, code: str) -> str:
return f"{runtime.account_cfg.account_id}:{code}"
# tracker 为该账户的 ZT Runtime 独享,与 sync_positions 使用同一个键。
return code
def get_add_num(hands: int, market_value: float) -> int:

View File

@@ -0,0 +1,31 @@
"""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)

175
py-client/tests/test_ipo.py Normal file
View File

@@ -0,0 +1,175 @@
import json
import tempfile
import unittest
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from threading import Barrier
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from libs.lockfile import claim_json
from sdk import OrderItem
from sdk.trade import TradeMixin
from strategy.ipo import boot
class IPOTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.root = Path(temp.name)
self.account = NS(account_id='account-a', enable_auto_ipo=True)
self.global_cfg = NS(qmt_data_dir=temp.name, qmt_base_url='unused', qmt_token='')
self.client = Mock()
self.client.__enter__ = Mock(return_value=self.client)
self.client.__exit__ = Mock(return_value=False)
self.client.orders.return_value = []
self.client.ipo_data.return_value = [dict(stock='600001.SH', issuePrice=10, maxPurchaseNum=100)]
for target, value in [('account_config', self.account), ('global_config', self.global_cfg)]:
ctx = patch.object(boot.config, target, value)
ctx.start()
self.addCleanup(ctx.stop)
ctx = patch.object(boot, 'Client', return_value=self.client)
ctx.start()
self.addCleanup(ctx.stop)
ctx = patch.object(boot, 'datetime')
self.clock = ctx.start()
self.clock.now.return_value = datetime(2026, 9, 11, 10)
self.addCleanup(ctx.stop)
def records(self):
return [json.loads(p.read_text(encoding='utf-8')) for p in sorted(self.root.rglob('*.json'))]
def order(self, status, traded=0):
return OrderItem(stock_code='600001.SH', insert_date='20260911',
remark=self.records()[-1]['order_id'] + '|ipo', offset_flag=23,
order_status=status, volume_traded=traded)
def test_timeout_stays_pending_and_queries_before_next_attempt(self):
self.client.passorder.side_effect = TimeoutError('response lost')
self.assertEqual(boot.AutoBuyIpo(), 0)
self.assertEqual(self.records()[0]['status'], 'pending')
self.assertEqual(boot.AutoBuyIpo(), 0)
self.assertEqual(self.client.passorder.call_count, 1)
self.assertEqual(self.client.orders.call_count, 2)
def test_normal_http_response_is_not_confirmation(self):
self.client.passorder.return_value = {'status': 'success'}
self.assertEqual(boot.AutoBuyIpo(), 1)
self.assertEqual(self.records()[0]['status'], 'pending')
self.assertEqual(boot.AutoBuyIpo(), 0)
self.client.passorder.assert_called_once()
def test_rejection_allows_one_new_attempt_with_new_identity(self):
boot.AutoBuyIpo()
old_id = self.records()[0]['order_id']
self.client.orders.return_value = [self.order(57)]
self.assertEqual(boot.AutoBuyIpo(), 1)
self.assertEqual([r['status'] for r in self.records()], ['rejected', 'pending'])
self.assertNotEqual(self.records()[1]['order_id'], old_id)
boot.AutoBuyIpo() # Old rejection cannot authorize retry of the new attempt.
self.assertEqual(self.client.passorder.call_count, 2)
def test_completed_order_confirms_and_prevents_resubmission(self):
boot.AutoBuyIpo()
self.client.orders.return_value = [self.order(56)]
self.assertEqual(boot.AutoBuyIpo(), 0)
self.assertEqual(self.records()[0]['status'], 'confirmed')
self.client.orders.return_value = []
boot.AutoBuyIpo()
self.client.passorder.assert_called_once()
def test_active_unknown_and_partial_orders_never_retry(self):
boot.AutoBuyIpo()
for status, traded in [(50, 0), (255, 0), (55, 40), (57, 40), (54, 0)]:
with self.subTest(status=status, traded=traded):
self.client.orders.return_value = [self.order(status, traded)]
self.assertEqual(boot.AutoBuyIpo(), 0)
self.client.passorder.assert_called_once()
def test_orders_query_failure_does_not_submit(self):
self.client.orders.side_effect = TimeoutError('unavailable')
self.assertEqual(boot.AutoBuyIpo(), 0)
self.client.passorder.assert_not_called()
self.assertEqual(self.records(), [])
def test_accounts_do_not_share_reservations(self):
boot.AutoBuyIpo()
self.account.account_id = 'account-b'
self.assertEqual(boot.AutoBuyIpo(), 1)
self.assertEqual(self.client.passorder.call_count, 2)
self.assertEqual({r['account'] for r in self.records()}, {'account-a', 'account-b'})
def test_manual_same_day_order_prevents_new_submission(self):
self.client.orders.return_value = [OrderItem(stock_code='600001.SH', offset_flag=23,
order_status=50, insert_date='2026-09-11')]
boot.AutoBuyIpo()
self.client.passorder.assert_not_called()
def test_duplicate_candidates_only_submit_once(self):
self.client.ipo_data.return_value *= 2
self.assertEqual(boot.AutoBuyIpo(), 1)
self.client.passorder.assert_called_once()
def test_concurrent_initial_and_rejected_attempts_are_atomic(self):
for orders in [[], None]:
if orders is None:
orders = [self.order(57)]
barrier = Barrier(2)
def claim(path, record):
barrier.wait(timeout=5)
return claim_json(path, record)
with patch.object(boot, 'claim_json', side_effect=claim), ThreadPoolExecutor(2) as pool:
futures = [pool.submit(boot._subscribe, self.client, orders, 'account-a',
'20260911', '600001.SH', 10, 100) for _ in range(2)]
self.assertEqual(sum(f.result() for f in futures), 1)
self.assertEqual(self.client.passorder.call_count, 2)
def test_pending_record_is_durable_before_request(self):
def submitted(**kwargs):
record = self.records()[0]
self.assertEqual(record['status'], 'pending')
self.assertEqual(record['order_id'], kwargs['order_id'])
self.client.passorder.side_effect = submitted
self.assertEqual(boot.AutoBuyIpo(), 1)
def test_claim_failure_never_submits(self):
with patch.object(boot, 'claim_json', side_effect=OSError('disk failed')):
self.assertEqual(boot.AutoBuyIpo(), 0)
self.client.passorder.assert_not_called()
def test_invalid_candidate_is_skipped_without_blocking_good_one(self):
good = self.client.ipo_data.return_value[0]
invalid = [dict(good, stock='600../x.SH'), dict(good, issuePrice=float('nan')),
dict(good, issuePrice=float('inf')), dict(good, maxPurchaseNum=100.5),
dict(good, maxPurchaseNum=True), dict(good, maxPurchaseNum='NaN'),
dict(good, maxPurchaseNum=0), dict(good, issuePrice=False),
dict(good, stock='600001.SZ'), None]
self.client.ipo_data.return_value = invalid + [good]
self.assertEqual(boot.AutoBuyIpo(), 1)
self.client.passorder.assert_called_once()
self.assertEqual(len(self.records()), 1)
def test_supported_codes_and_numeric_strings(self):
for stock in ['600001.SH', '688001.SH', '689001.SH', '000001.SZ',
'001001.SZ', '002001.SZ', '003001.SZ', '300001.SZ', '301001.SZ']:
self.assertEqual(boot._candidate(dict(stock=stock, issuePrice='10.5', maxPurchaseNum='100')),
(stock, 10.5, 100))
for stock in ['600abc.SH', '600001', '600001.SH/x', '688001.SZ', '300001.SH', None]:
self.assertFalse(boot.is_target_stock(stock))
class IPOResponseTests(unittest.TestCase):
def test_only_list_response_is_accepted(self):
client = TradeMixin()
for value in [None, {}, {'error': 'bad'}, '', 0, False]:
client._post_json = Mock(return_value=value)
with self.subTest(value=value), self.assertRaises(ValueError):
client.ipo_data()
client._post_json = Mock(return_value=[])
self.assertEqual(client.ipo_data(), [])
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,132 @@
import tempfile
import unittest
from contextlib import closing
from dataclasses import replace
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from libs.grid_take_profit import GridState
from libs.order import OrderBook
from libs.snapshot import get_collector_snapshot
from libs.state import State
from sdk import Assets, DealItem, OrderItem, PositionItem
from strategy.zt import boot
from strategy.zt.profit import ZTProfitTracker
class ZTAuditFixTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.store = State(Path(tmp.name) / 'state.db')
self.code = '600000.SH'
def deal(self, code, identity):
return DealItem(stock_code=code, order_sys_id=identity,
remark=f'zt-base-{identity}|zt', offset_flag=48,
volume=100, price=10, trade_amount=1000)
def position(self, code):
return PositionItem(stock_code=code, volume=100, open_price=10)
def test_zt_cancels_only_owned_orders_and_tracks_all(self):
stamp = datetime.now() - timedelta(minutes=2)
orders = [OrderItem(stock_code=f'60000{i}.SH', order_sys_id=str(i), remark=remark,
order_status=50, offset_flag=23,
insert_date=stamp.strftime('%Y%m%d'), insert_time=stamp.strftime('%H%M%S'))
for i, remark in enumerate(['zt-base-own|zt', 'zt-SELL-own|zt',
'zt-added-own|zt', 'IPO-new|ipo', '', 'TREN-BUY-other'])]
client = Mock()
book = OrderBook()
book.refresh(client, orders, cancel_prefix='zt-')
self.assertEqual([c.args[0] for c in client.cancel_by_id.call_args_list], ['0', '1', '2'])
self.assertEqual(book.data, orders)
self.assertTrue(all(book.busy(o.stock_code, 'BUY') for o in orders))
client.reset_mock()
book.refresh(client, orders)
self.assertEqual([c.args[0] for c in client.cancel_by_id.call_args_list], ['0', '1', '2', '4', '5'])
def test_invalid_trade_persists_blocks_only_its_stock_and_recovers(self):
good = '600001.SH'
bad = replace(self.deal(self.code, 'bad'), price=float('nan'))
valid = self.deal(good, 'good')
positions = [self.position(c) for c in (self.code, good)]
self.store.sync_account(positions, [bad, valid])
self.assertEqual(self.store.blocked_codes, {self.code})
self.assertEqual(self.store.state[good]['base_qty'], 100)
with closing(self.store._connect()) as db:
payload = db.execute('SELECT payload FROM zt_rejected_deals').fetchone()[0]
self.assertIn('bad', payload)
self.assertIn('NaN', payload)
self.store = State(self.store.path)
self.store.sync_account(positions, [valid])
self.assertEqual(self.store.blocked_codes, {self.code})
self.store.sync_account(positions, [replace(bad, price=10), valid])
self.assertEqual(self.store.blocked_codes, set())
self.assertEqual(self.store.state[self.code]['base_qty'], 100)
def test_invalid_fields_do_not_block_other_stocks(self):
for field, value in [('volume', 0), ('volume', 1.5), ('offset_flag', 99),
('trade_amount', float('inf')), ('price', -1)]:
with self.subTest(field=field):
bad = replace(self.deal(self.code, f'bad-{field}'), **{field: value})
good = self.deal('600001.SH', 'good')
self.store.sync_account([self.position('600001.SH')], [bad, good])
self.assertEqual(self.store.state['600001.SH']['base_qty'], 100)
self.assertIn(self.code, self.store.blocked_codes)
def test_bad_stock_does_not_archive_other_trades_until_corrected(self):
first = self.deal(self.code, 'first')
bad = replace(self.deal(self.code, 'bad'), price=float('nan'))
position = replace(self.position(self.code), volume=200)
self.store.sync_account([position], [first, bad])
self.assertEqual(self.store.deals['first']['is_arch'], 0)
self.assertNotIn(self.code, self.store.state)
self.store.sync_account([position], [replace(bad, price=10)])
self.assertEqual(self.store.state[self.code]['base_qty'], 200)
self.assertEqual(self.store.blocked_codes, set())
def test_profit_basis_changes_reset_peak_but_partial_sell_does_not(self):
tracker = ZTProfitTracker()
position = self.position(self.code)
row = dict(base_qty=100, base_order_local_id='one', base_created_at='now', added_qty=0)
state = NS(blocked_codes=set(), get_by_code=lambda code: row)
tracker.sync_positions([position], state)
self.assertEqual(tracker.observe(self.code, 20).state, GridState.ARMED)
row['base_qty'] = 50
tracker.sync_positions([replace(position, volume=50)], state)
self.assertEqual(tracker.observe(self.code, 19).state, GridState.RETREAT)
for update, cost in [({'added_qty': 100, 'added_price': 11}, 10),
({'added_qty': 0}, 10), ({}, 12),
({'base_order_local_id': 'reopened'}, 12)]:
row.update(update)
tracker.sync_positions([replace(position, open_price=cost)], state)
self.assertEqual(tracker.observe(self.code, 10).state, GridState.ARMED)
tracker.sync_positions([], state)
tracker.sync_positions([position], state)
self.assertEqual(tracker.observe(self.code, 5).state, GridState.ARMED)
def test_run_once_updates_collector_before_market_fetch(self):
positions = [self.position(self.code)]
self.store.sync_account(positions, [], initialize=True)
client = Mock()
run = NS(client=client, account_cfg=NS(account_id='zt-test', min_cash_ratio=0.1),
orders=Mock(), profit_tracker=ZTProfitTracker())
client.deals.return_value = []
client.portfolio.return_value = NS(assets=Assets(20000, 10000),
positions={self.code: positions[0]}, orders=[])
client.full_tick.side_effect = RuntimeError('no market data')
with patch.object(boot, 'trading_time', return_value=True), \
patch.object(boot, 'market_allow_open', return_value=True):
boot.RunOnce(run, self.store, [])
snapshot = get_collector_snapshot()
self.assertEqual(snapshot[0], 'zt-test')
self.assertEqual(snapshot[1].total, 20000)
self.assertEqual(snapshot[2], positions)
run.orders.refresh.assert_called_once_with(client, [], cancel_prefix='zt-')
if __name__ == '__main__':
unittest.main()