fix bug
This commit is contained in:
19
docs/ipo-submission-state.md
Normal file
19
docs/ipo-submission-state.md
Normal 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` 文件不再作为新账户的申购记录,避免跨账户锁冲突;首次运行通过当天同证券买入委托防重。升级应在当前账户委托可查询的条件下进行,旧锁本身无法证明归属账户或最终结果。
|
||||||
|
|
||||||
|
损坏或无法读取的记录不会触发重新提交;记录写入失败也不会提交。若长期查不到回报,需要先人工核对柜台结果,不能直接删除待确认记录后重跑。
|
||||||
|
|
||||||
|
接口响应必须是列表,合法空列表表示无候选;非列表响应记录错误。候选要求完整代码及对应交易所、有限正价格、正整数数量。当前参与板块范围保持不变。
|
||||||
86
docs/strategy-audit-2026-09-12.md
Normal file
86
docs/strategy-audit-2026-09-12.md
Normal 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-07:ZT 按委托编号去重,分笔成交可能漏记
|
||||||
|
|
||||||
|
**位置:** [state.py](../py-client/libs/state.py) 第 55、146–156、362–389 行;[服务端成交映射](../api/qmt_rest_new.py) 第 371–387 行;[ZT 启动](../py-client/strategy/zt/boot.py) 第 49–51 行。
|
||||||
|
|
||||||
|
**证据:** 服务端将 `m_strOrderSysID` 映射为 `order_sys_id`,成交表以该字段建立唯一索引。ZT 增量路径插入第一条后将编号加入 `existing`,其余同编号记录不再插入。不同 `ref`、时间和数量不参与身份识别。启动时两次成交查询也按此编号转成字典比较,会折叠同委托的多条记录。
|
||||||
|
|
||||||
|
**本轮复现:** 临时数据库中,同一委托编号、不同 `ref` 的 40 股和 60 股买入成交,对应账户快照为 100 股。`sync_account()` 仅记入 40 股并隔离该证券;重新加载数据库并再次同步,仍为 40 股,未恢复。
|
||||||
|
|
||||||
|
**影响与边界:** 逐笔回报可能漏记后续成交;累计回报也无法通过当前只追加逻辑更新已存数量。库存、金额及成本可能不完整,证券持续暂停交易。复现证明代码无法处理上述输入,不代表已确认目标柜台采用哪一种回报契约。
|
||||||
|
|
||||||
|
**修改建议:** 先核验实际回报是逐笔还是累计,以及唯一身份字段。逐笔回报透传真实成交编号并据此去重;累计回报按增量差额更新。启动稳定性比较也需保留完整回报。不要未经核验拼接时间、价格或 `ref` 作为唯一键;修改存储键时同时明确已有数据的处理方式。
|
||||||
|
|
||||||
|
**验收:** 同委托分笔成交累计为 100 股;重复查询和重启不重复记账;累计回报按已确认契约处理;初始化比较不因同编号覆盖而遗漏变化。
|
||||||
|
|
||||||
|
### P1-08:Trend 超时撤单仍覆盖手工单及其他策略订单
|
||||||
|
|
||||||
|
**位置:** [Trend boot.py](../py-client/strategy/trend/boot.py) 第 40、119 行;[OrderBook.refresh()](../py-client/libs/order.py) 第 61、77–88 行。
|
||||||
|
|
||||||
|
**证据:** 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-01:Trend 止盈峰值跨仓位沿用
|
||||||
|
|
||||||
|
**位置:** [Trend positions.py](../py-client/strategy/trend/positions.py) 第 120–121、198–199 行;[Trend boot.py](../py-client/strategy/trend/boot.py) 第 60、113–119 行;[网格跟踪器](../py-client/libs/grid_take_profit.py) 第 44–74 行。
|
||||||
|
|
||||||
|
**证据:** Trend 使用普通 `GridTrailingTracker`,键仅包含账户及证券。清仓、重新建仓和补仓成本变化时,没有清理旧峰值;每轮快照更新也未同步网格生命周期。
|
||||||
|
|
||||||
|
**本轮复现:** 使用真实 `manage_positions()`、真实 tracker 和模拟委托簿,依次输入成本 10 元、价格 12 元的持仓,价格 11.9 元的同一持仓,空持仓,再输入成本 10 元、价格 11 元的新持仓。各步新增卖出调用次数为 `0、1、0、1`。新仓位第一次观察仍沿用旧峰值触发卖出。
|
||||||
|
|
||||||
|
**影响:** 重新建仓或成本基准变化后,当前收益率可能被当成旧仓位的回撤,提前触发止盈。本项仅保留 Trend 范围。
|
||||||
|
|
||||||
|
**修改建议:** 根据已确认的清仓、重新建仓及成本变化同步止盈基准。不要仅在提交成功时清理,避免撤单或部分成交丢失有效峰值。
|
||||||
|
|
||||||
|
**验收:** 新仓位首次观察只建立峰值;同一仓位正常回撤仍触发卖出;未成交提交、撤单及同一成本下部分卖出不误清理。
|
||||||
|
|
||||||
|
### P2-07:OrderBook 旧构造调用导致回归测试错误
|
||||||
|
|
||||||
|
**位置:** [test_deal_model.py](../py-client/tests/test_deal_model.py) 第 71 行;[OrderBook 构造函数](../py-client/libs/order.py) 第 35–38 行。
|
||||||
|
|
||||||
|
**本轮复现:** 测试调用 `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 编号兼容要求,不恢复已忽略事项,不将已修复内容作为待办重复列出。
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
|
||||||
def is_lock(file_path: str | PathLike[str]) -> bool:
|
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 = Path(file_path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text("LOCK", encoding="utf-8")
|
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)
|
||||||
|
|||||||
@@ -33,9 +33,8 @@ class OrderBook:
|
|||||||
"""线程安全的活动委托缓存。"""
|
"""线程安全的活动委托缓存。"""
|
||||||
|
|
||||||
def __init__(
|
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:
|
) -> None:
|
||||||
self.order_prefix = order_prefix
|
|
||||||
self.lock_timeout_sec = max(1, lock_timeout_sec)
|
self.lock_timeout_sec = max(1, lock_timeout_sec)
|
||||||
self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec)
|
self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec)
|
||||||
self.data: list[OrderItem] = []
|
self.data: list[OrderItem] = []
|
||||||
@@ -59,7 +58,7 @@ class OrderBook:
|
|||||||
def _busy_key(side: str, code: str) -> str:
|
def _busy_key(side: str, code: str) -> str:
|
||||||
return f"{side}-{code}"
|
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()
|
current = datetime.now()
|
||||||
data: list[OrderItem] = []
|
data: list[OrderItem] = []
|
||||||
@@ -77,7 +76,8 @@ class OrderBook:
|
|||||||
created_at = item.created_at
|
created_at = item.created_at
|
||||||
if (
|
if (
|
||||||
created_at is not None
|
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 status in CANCELABLE_STATUSES
|
||||||
and current - created_at > self.cancel_timeout_sec
|
and current - created_at > self.cancel_timeout_sec
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""SQLite 策略状态与成交存储;每个数据库仅使用一个写入者,不做数据迁移。"""
|
"""SQLite 策略状态与成交存储;每个数据库仅使用一个写入者,不做数据迁移。"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import json
|
||||||
import logging as log
|
import logging as log
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
@@ -144,9 +145,11 @@ class State:
|
|||||||
self.deals, self.deals_sys_ids = deals, set(deals)
|
self.deals, self.deals_sys_ids = deals, set(deals)
|
||||||
|
|
||||||
@staticmethod
|
@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] = {}
|
new_deals: dict[str, DealItem] = {}
|
||||||
for deal in deals:
|
for deal in deals:
|
||||||
if deal.order_sys_id not in existing:
|
if deal.order_sys_id not in existing:
|
||||||
@@ -280,13 +283,16 @@ class State:
|
|||||||
'AND is_arch = 0', (code,),
|
'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 增量路径禁用数量相等推断。"""
|
"""保留现有逐证券保存点;ZT 增量路径禁用数量相等推断。"""
|
||||||
codes = db.execute(
|
codes = db.execute(
|
||||||
'SELECT DISTINCT stock_code FROM deals WHERE is_arch = 0'
|
'SELECT DISTINCT stock_code FROM deals WHERE is_arch = 0'
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for row in codes:
|
for row in codes:
|
||||||
code = row['stock_code']
|
code = row['stock_code']
|
||||||
|
if blocked_codes and code in blocked_codes:
|
||||||
|
continue
|
||||||
db.execute('SAVEPOINT archive_stock')
|
db.execute('SAVEPOINT archive_stock')
|
||||||
try:
|
try:
|
||||||
self._archive_stock(db, code, snapshot_dedup)
|
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}
|
holdings = {p.stock_code: p for p in positions if p.volume > 0}
|
||||||
with closing(self._connect()) as db, db:
|
with closing(self._connect()) as db, db:
|
||||||
db.execute('BEGIN IMMEDIATE')
|
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 initialize:
|
||||||
if (db.execute('SELECT 1 FROM state LIMIT 1').fetchone()
|
if (db.execute('SELECT 1 FROM state LIMIT 1').fetchone()
|
||||||
or db.execute('SELECT 1 FROM deals 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')
|
db.execute('UPDATE deals SET is_arch = 1')
|
||||||
else:
|
else:
|
||||||
self._insert_deals(db, deals)
|
self._insert_account_deals(db, deals)
|
||||||
self._archive_pending(db, snapshot_dedup=False)
|
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)
|
state = self._read_state(db)
|
||||||
cached_deals = self._read_deals(db)
|
cached_deals = self._read_deals(db)
|
||||||
blocked = {d['stock_code'] for d in cached_deals.values() if d['is_arch'] != 1}
|
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):
|
for code in set(state) | set(holdings):
|
||||||
row = state.get(code, {})
|
row = state.get(code, {})
|
||||||
recorded = row.get('base_qty', 0) + row.get('added_qty', 0)
|
recorded = row.get('base_qty', 0) + row.get('added_qty', 0)
|
||||||
@@ -345,3 +358,35 @@ class State:
|
|||||||
self.blocked_codes = blocked
|
self.blocked_codes = blocked
|
||||||
if blocked:
|
if blocked:
|
||||||
log.warning('[ZT 同步] 以下证券状态待核对,暂停交易:%s', ', '.join(sorted(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')
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ class TradeMixin:
|
|||||||
response = self._post_json(
|
response = self._post_json(
|
||||||
"/api/trade/ipo_data",
|
"/api/trade/ipo_data",
|
||||||
{"type": str(ipo_type).strip().upper()},
|
{"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]:
|
def cancel_by_id(self, order_id: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -1,85 +1,112 @@
|
|||||||
"""新股自动申购,提供交易日校验、券商对账和本地幂等保护。"""
|
"""IPO 申购:账户隔离、提交前占位、通过券商委托核对结果。"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
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 pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from sdk import Client
|
from sdk import Client
|
||||||
from libs.calc import trading_time
|
from libs.calc import trading_time
|
||||||
from libs.lockfile import is_lock,write_lockfile
|
from libs.lockfile import claim_json, replace_json
|
||||||
|
import secrets
|
||||||
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] 自动申购任务失败")
|
|
||||||
|
|
||||||
def is_target_stock(symbol: str) -> bool:
|
def is_target_stock(symbol: str) -> bool:
|
||||||
"""
|
"""保留原板块范围,同时校验完整代码和对应交易所。"""
|
||||||
判断是否为上证、深证、科创板的A股。
|
return isinstance(symbol, str) and re.fullmatch(
|
||||||
symbol格式示例: '600519.SH', '000001.SZ'
|
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
|
||||||
code = symbol.split(".")[0]
|
|
||||||
|
|
||||||
# 判断是否为合规板块
|
|
||||||
if code.startswith(('60', '688', '689')): # 沪市主板 + 科创板
|
|
||||||
return True
|
|
||||||
if code.startswith(('000', '001', '002', '003', '300', '301')): # 深市主板 + 创业板
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
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
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def StartTrend() -> None:
|
|||||||
assets = portfolio.assets
|
assets = portfolio.assets
|
||||||
positions = list(portfolio.positions.values())
|
positions = list(portfolio.positions.values())
|
||||||
cache_portfolio(config.account_config.account_id, assets, positions, client.deals())
|
cache_portfolio(config.account_config.account_id, assets, positions, client.deals())
|
||||||
order_book = OrderBook("trend")
|
order_book = OrderBook()
|
||||||
order_book.refresh(client, portfolio.orders)
|
order_book.refresh(client, portfolio.orders)
|
||||||
|
|
||||||
# 获取本策略的信号开仓数据
|
# 获取本策略的信号开仓数据
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
import config
|
import config
|
||||||
from libs.calc import trading_time
|
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.market import market_allow_open
|
||||||
from libs.order import OrderBook
|
from libs.order import OrderBook
|
||||||
from libs.overview import Overview
|
from libs.overview import Overview
|
||||||
@@ -33,8 +33,8 @@ def StartZT() -> None:
|
|||||||
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="zt")
|
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="zt")
|
||||||
run = Runtime(
|
run = Runtime(
|
||||||
client=client, global_cfg=config.global_config, account_cfg=config.account_config,
|
client=client, global_cfg=config.global_config, account_cfg=config.account_config,
|
||||||
orders=OrderBook('zt'), open_watch=DipWatch(), add_watch=DipWatch(),
|
orders=OrderBook(), open_watch=DipWatch(), add_watch=DipWatch(),
|
||||||
profit_tracker=GridTrailingTracker(config.account_config.grid_step_pct),
|
profit_tracker=ZTProfitTracker(config.account_config.grid_step_pct),
|
||||||
executor=executor
|
executor=executor
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +56,8 @@ def StartZT() -> None:
|
|||||||
config.account_config.account_id, len(signals), len(positions))
|
config.account_config.account_id, len(signals), len(positions))
|
||||||
cache_portfolio(config.account_config.account_id, assets, positions, deals)
|
cache_portfolio(config.account_config.account_id, assets, positions, deals)
|
||||||
state.sync_account(positions, deals, initialize=initialize)
|
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)
|
Overview(assets, positions, config.account_config)
|
||||||
|
|
||||||
DEFAULT_TICK_INTERVAL = 30
|
DEFAULT_TICK_INTERVAL = 30
|
||||||
@@ -111,9 +112,11 @@ def RunOnce(run: Runtime, state: State, signals: list[SignalItem]) -> None:
|
|||||||
assets = portfolio.assets
|
assets = portfolio.assets
|
||||||
positions = list(portfolio.positions.values())
|
positions = list(portfolio.positions.values())
|
||||||
position_codes = list(portfolio.positions)
|
position_codes = list(portfolio.positions)
|
||||||
|
cache_portfolio(run.account_cfg.account_id, assets, positions, deals)
|
||||||
|
|
||||||
state.sync_account(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:
|
except Exception:
|
||||||
log.exception("[Portfolio] 刷新账户快照失败")
|
log.exception("[Portfolio] 刷新账户快照失败")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -213,7 +213,8 @@ def handle_loss(
|
|||||||
|
|
||||||
|
|
||||||
def _position_key(runtime: Runtime, code: str) -> str:
|
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:
|
def get_add_num(hands: int, market_value: float) -> int:
|
||||||
|
|||||||
31
py-client/strategy/zt/profit.py
Normal file
31
py-client/strategy/zt/profit.py
Normal 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
175
py-client/tests/test_ipo.py
Normal 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()
|
||||||
132
py-client/tests/test_zt_audit_fixes.py
Normal file
132
py-client/tests/test_zt_audit_fixes.py
Normal 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()
|
||||||
Reference in New Issue
Block a user