From 3689a7ad862d35d335a21b3091aeff4f9e71ec9e Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 5 Sep 2026 11:32:10 +0800 Subject: [PATCH] fix order busy --- docs/trend-audit.md | 149 +++++++++++++++++++++++++++++ py-client/sdk/trade.py | 18 ---- py-client/strategy/trend/order.py | 23 +++-- py-client/tests/test_trend_busy.py | 42 ++++++++ 4 files changed, 204 insertions(+), 28 deletions(-) create mode 100644 docs/trend-audit.md create mode 100644 py-client/tests/test_trend_busy.py diff --git a/docs/trend-audit.md b/docs/trend-audit.md new file mode 100644 index 0000000..e30ab94 --- /dev/null +++ b/docs/trend-audit.md @@ -0,0 +1,149 @@ +# Trend 策略代码审计报告 + +审计日期:2026-09-05 +审计对象:`py-client/strategy/trend` 当前工作树版本。 +关联范围:仅核对直接影响 Trend 行为的 `sdk`、`libs`、`config` 和 Trend 测试。 +审计方法:重新读取当前代码,不沿用历史审计结论;本次只更新审计文档,不修改策略代码。 + +## 结论摘要 + +当前版本不建议直接用于无人值守实盘。发现 2 个严重问题、6 个高风险问题、6 个中风险问题和 2 个低风险/测试问题。最高优先级是本地防重与券商实际活动委托脱节,以及空订单快照会清除未决状态。另有 5 项 Trend 测试在目标断言前报错,当前测试结果不能为下单路径提供有效回归保障。 + +## 严重问题 + +### S1. 防重只依赖进程内 TTL,完全忽略券商活动委托 + +- 位置:`strategy/trend/order.py:45-48,80-92,97-104` +- 证据:`refresh()` 虽统计 `busy_keys`,但没有写入 `SimpleCache`;`busy()` 和 `place()` 都只查询 `busy_cache`,没有检查 `self.data`。缓存也不会跨进程恢复。 +- 触发场景:程序重启后券商仍有活动订单;或本地 180 秒 TTL 到期但订单仍未终结。 +- 影响:同一证券、同一方向可能重复开仓、补仓或止盈。卖出路径可能再次按全部可用持仓提交委托。 +- 建议:`busy()` 和 `place()` 在同一互斥区内同时检查 TTL 缓存及 `self.data` 中的 `BUSY_STATUSES`。 + +### S2. 一次空订单快照会不可逆地清除待成交状态 + +- 位置:`strategy/trend/order.py:57-92`、`state.py:101-160` +- 证据:`refresh()` 每轮用当前券商结果覆盖 `OrderBook.data`。订单刚提交但暂未出现在快照时,`reconcile()` 会把对应 `ING` 改为空字符串;若尚无持仓,随后还会删除整个 `StateItem`。 +- 影响:底仓状态可能丢失,补仓次数可能不增加;后续轮次可能重新开仓或重复使用同一亏损档位。 +- 建议:本地 pending 应保留至明确终态或确认超时;短暂缺席不能立即视为失败。 + +## 高风险问题 + +### H1. 大盘风控被固定为允许开仓 + +- 位置:`libs/market.py:34-38`;调用位置:`strategy/trend/boot.py:153,185-188` +- 证据:`market_allow_open()` 无条件返回 `True`,不读取 `_market_status`。 +- 影响:市场状态为下跌、未知或刷新失败时,策略仍可开仓和亏损补仓。 +- 测试证据:`test_market.MarketCacheTests.test_refresh_failure_blocks_open` 失败。 +- 建议:仅在缓存状态明确为 `UP` 时允许开仓;未知状态采用 fail-closed。 + +### H2. 过滤异常订单后再对账,会把拆单结果判错 + +- 位置:`strategy/trend/order.py:62-65,80-92`、`state.py:112-139,211-220` +- 证据:`refresh()` 丢弃取消、拒绝和异常订单,只把处理态及完成态写入 `data`。同一本地订单若一笔完成、一笔取消,传给 `State` 的只剩完成记录,`_order_status()` 会返回 `OK`。 +- 影响:部分成交可能被视为完整成交;状态数量、成本和补仓次数与真实结果不一致。 +- 建议:展示/防重列表可以过滤,但状态对账必须使用完整原始订单快照。 + +### H3. 柜台受理与状态落盘之间存在崩溃窗口 + +- 位置:`strategy/trend/open.py:78-98`、`positions.py:174-193` +- 证据:开仓和补仓都先执行 `orders.place()`,成功返回后才写入并保存 `StateItem`。 +- 影响:委托受理后若进程退出或状态写入失败,本地没有对应状态;重启后容易重复下单。 +- 建议:提交前持久化订单意图,提交后记录柜台结果;不确定结果保留为可对账状态。 + +### H4. 开仓与补仓并发,资金预算彼此隔离 + +- 位置:`strategy/trend/boot.py:180-190`、`open.py:15-74`、`positions.py:28-91` +- 证据:持仓管理与开仓并发执行。补仓只在自身循环扣减余额;每个开仓信号均独立使用完整 `buy_value`,两条路径没有账户级资金预留。 +- 影响:同轮总委托金额可能超过真实可用资金,成交组合取决于柜台顺序。 +- 建议:统一生成订单意图,用账户级单一预算预留资金后再提交。 + +### H5. 撤单结果未校验,活动订单却立即从跟踪列表删除 + +- 位置:`strategy/trend/order.py:67-78` +- 证据:超过 10 秒即调用 `cancel_by_id()`,不检查响应是否成功,随后无条件 `continue`。 +- 影响:撤单失败或结果未知时,本地已不再跟踪仍有效的订单;结合 S1 可能重复提交。 +- 建议:仅在券商明确返回取消终态后移除;失败或不确定时继续保留活动状态。 + +### H6. 单笔预算不足一手时仍强制买入 100 股 + +- 位置:`libs/calc.py:10-12`;调用位置:`strategy/trend/open.py:49`、`positions.py:168` +- 证据:`calc_buy_volume()` 使用 `max(1, floor(...)) * 100`。 +- 影响:`buy_value < price * 100` 时委托金额必然超过预算,并放大 H4。 +- 建议:不足一手时返回 `0`,由调用方记录并跳过。 + +## 中风险问题 + +### M1. 现金安全线只限制新开仓,不限制亏损补仓 + +- 位置:`strategy/trend/boot.py:145-148,183-188`、`positions.py:68-80` +- 证据:`allow_open_by_cash` 只控制 `open_signal()`;`manage_positions()` 始终获得全部 `assets.available`。 +- 影响:账户已低于最小现金比例时仍可能增加亏损仓位。 +- 建议:若安全线也约束补仓,应只传递扣除安全储备后的预算。 + +### M2. 两个风控配置未参与 Trend 决策 + +- 位置:`config/__init__.py:48-50`、`strategy/trend/positions.py:16,52-59,157-160` +- 证据:配置提供 `loss_trigger_pct` 和 `min_profit_pct`,但补仓使用固定 `LOSS_TIERS`,止盈门槛按股价区间硬编码。 +- 影响:修改配置不会改变实盘行为,运维人员可能误判实际参数。 +- 建议:让策略明确使用配置,或删除无效配置并输出最终生效参数。 + +### M3. 非 `APIError` 下单异常会终止整批持仓管理 + +- 位置:`strategy/trend/order.py:105-116`、`positions.py:38-91` +- 证据:`OrderBook.place()` 只捕获 `APIError`;`manage_positions()` 没有逐持仓异常边界。 +- 影响:一只证券发生连接、超时等异常后,其余持仓当轮不再处理。 +- 建议:订单层捕获明确的传输异常,持仓循环增加逐证券隔离。 + +### M4. 启动阶段异常不会可靠释放 HTTP Client + +- 位置:`strategy/trend/boot.py:47-84` +- 证据:Client 创建后的组合查询、撤单、状态读取和信号加载不在统一 `try/finally` 中。 +- 影响:初始化失败时连接池不能确定及时释放。 +- 建议:建立统一资源生命周期,在 `finally` 中关闭已创建资源。 + +### M5. 信号只在启动时加载一次 + +- 位置:`strategy/trend/boot.py:67-71,88-110` +- 证据:`init_signals()` 位于永久循环外。 +- 影响:运行期间新增、撤销或修正的远程信号不会生效。 +- 建议:按业务时效定期刷新,或明确“启动快照整日有效”的约束。 + +### M6. 明确拒单后仍保留 180 秒缓存锁 + +- 位置:`strategy/trend/order.py:97-126` +- 证据:柜台调用前设置缓存,但 API 明确失败、响应格式无效或抛出 `APIError` 时均不删除键。 +- 影响:可立即纠正或重试的订单被无条件抑制 180 秒,可能错过窗口。 +- 建议:明确未受理时释放缓存;结果不确定时保留保护并等待对账。 + +## 低风险与测试问题 + +### L1. 结束时间条件与“大于 15:00”不一致 + +- 位置:`strategy/trend/boot.py:90-91` +- 证据:当前使用 `>= (15, 0, 0)`,在恰好 `15:00:00` 时退出,而约定是大于 15:00 后退出。 +- 影响:边界秒行为与需求不一致。 +- 建议:使用严格大于比较,并让日志描述与条件一致。 + +### L2. Trend 测试夹具已与生产接口漂移 + +- 位置:`tests/test_trend.py:19-42,291-352` +- 证据:生产代码调用 `client.passorder(...)`,但测试替身只实现已删除的 `passorder_latest_tagged()`;两个 `RunOnce` 夹具缺少采集任务需要的 `account_id`。 +- 影响:核心下单和调度测试在目标断言前报错,无法验证真实调用契约。 +- 建议:测试替身实现当前 `passorder` 签名并补齐 `account_id`;增加券商活动订单、TTL、空快照、撤单失败和并发预算测试。 + +## 回归结果 + +执行:`cd py-client && python -B -m unittest tests.test_trend tests.test_market -v` + +结果:17 项测试中 11 项通过、1 项失败、5 项错误。 + +- 失败:市场状态刷新失败后仍允许开仓。 +- 错误:3 项下单测试使用旧接口替身;2 项 `RunOnce` 测试缺少 `account_id`。 + +## 建议处理顺序 + +1. S1、S2:恢复可靠的订单防重和未决状态对账。 +2. H2、H3、H5:确保订单生命周期、撤单和成交状态可信。 +3. H1、H4、H6、M1:恢复风控并统一账户预算。 +4. M2、M3、M4、M5、M6:处理配置、异常隔离、资源和刷新策略。 +5. 修复测试夹具并补齐跨轮、重启、空快照、撤单失败和并发预算回归。 diff --git a/py-client/sdk/trade.py b/py-client/sdk/trade.py index 658a048..c3339dc 100644 --- a/py-client/sdk/trade.py +++ b/py-client/sdk/trade.py @@ -52,24 +52,6 @@ class TradeMixin: ) or [] return response if isinstance(response, list) else [] - def passorder_latest(self, side: int, stock_code: str, volume: int) -> dict[str, Any]: - return self.passorder(side, stock_code, volume) - - def passorder_latest_tagged( - self, - side: int, - stock_code: str, - volume: int, - strategy_name: str, - order_id: str, - ) -> dict[str, Any]: - return self.passorder( - side, - stock_code, - volume, - strategy_name=strategy_name, - order_id=order_id, - ) def cancel_by_id(self, order_id: str) -> dict[str, Any]: return self._post_json("/api/trade/cancel_by_id", {"order_id": order_id}) diff --git a/py-client/strategy/trend/order.py b/py-client/strategy/trend/order.py index 1d490b5..8113596 100644 --- a/py-client/strategy/trend/order.py +++ b/py-client/strategy/trend/order.py @@ -38,6 +38,7 @@ class OrderBook: self.lock_timeout_sec = max(1, lock_timeout_sec) self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec) self.data: list[OrderItem] = [] + self.busy_keys: set[str] = set() self.busy_cache = SimpleCache(threshold=10_000, default_timeout=self.lock_timeout_sec) self.mutex = Lock() @@ -49,7 +50,8 @@ class OrderBook: def busy(self, code: str, side: str) -> bool: """判断证券是否存在仍在处理中的同方向委托。""" with self.mutex: - return self.busy_cache.has(self._busy_key(side, code)) + key = self._busy_key(side, code) + return key in self.busy_keys or self.busy_cache.has(key) @staticmethod def _busy_key(side: str, code: str) -> str: @@ -66,6 +68,8 @@ class OrderBook: # 不处理状态不对的 if item.status not in TRACKED_STATUSES: continue + if item.status in BUSY_STATUSES: + busy_keys.add(self._busy_key(item.side, item.code)) # 清理过期的 if ( item.created_at is not None @@ -80,12 +84,11 @@ class OrderBook: # 缓存本次有效订单 data.append(item) - if item.status in BUSY_STATUSES: - busy_keys.add(self._busy_key(item.side, item.code)) with self.mutex: self.data = data + self.busy_keys = busy_keys log.info("[Order] 刷新完成,跟踪=%d,处理中=%d,撤销=%d", len(data), len(busy_keys), canceled) def place(self, request: PlaceOrderRequest) -> bool: @@ -97,18 +100,18 @@ class OrderBook: key = self._busy_key(side, request.code) with self.mutex: - if self.busy_cache.has(key): + if key in self.busy_keys or self.busy_cache.has(key): log.info("[Order] 跳过重复下单,代码=%s,方向=%s", request.code, side) return False self.busy_cache.set(key, True, timeout=self.lock_timeout_sec) try: - result = request.client.passorder_latest_tagged( - request.op, - request.code, - request.volume, - request.strategy_name, - request.order_id, + result = request.client.passorder( + op_type=request.op, + stock_code=request.code, + volume=request.volume, + strategy_name=request.strategy_name, + order_id=request.order_id, ) except APIError as exc: log.exception("[Order] 下单失败,代码=%s,本地订单=%s,HTTP状态=%d,错误=%s", request.code, request.order_id, exc.status_code, exc.message or str(exc)) diff --git a/py-client/tests/test_trend_busy.py b/py-client/tests/test_trend_busy.py new file mode 100644 index 0000000..2772509 --- /dev/null +++ b/py-client/tests/test_trend_busy.py @@ -0,0 +1,42 @@ +import unittest +from datetime import datetime, timedelta +from unittest.mock import Mock + +from sdk import OrderItem +from strategy.trend.order import OrderBook, PlaceOrderRequest + + +class TrendBusyTests(unittest.TestCase): + def test_snapshot_blocks_order_without_local_cache(self): + book = OrderBook() + client = Mock() + order = OrderItem("1", "A", "BUY", "", "50", None, 100) + book.refresh(client, [order]) + self.assertTrue(book.busy("A", "BUY")) + self.assertFalse(book.busy("A", "SELL")) + self.assertFalse(book.place(PlaceOrderRequest(client, 23, "A", 100, "local", "trend"))) + client.passorder.assert_not_called() + book.refresh(client, []) + self.assertFalse(book.busy("A", "BUY")) + + def test_cancel_request_keeps_order_busy_until_terminal_snapshot(self): + book = OrderBook() + client = Mock() + order = OrderItem("1", "A", "BUY", "", "50", datetime.now() - timedelta(seconds=20), 100) + book.refresh(client, [order]) + client.cancel_by_id.assert_called_once_with("1") + self.assertTrue(book.busy("A", "BUY")) + order.status = "54" + book.refresh(client, [order]) + self.assertFalse(book.busy("A", "BUY")) + + def test_empty_snapshot_keeps_local_cache_protection(self): + book = OrderBook() + client = Mock() + client.passorder.return_value = {"status": "success", "order_ref": "1"} + request = PlaceOrderRequest(client, 23, "A", 100, "local", "trend") + self.assertTrue(book.place(request)) + book.refresh(client, []) + self.assertTrue(book.busy("A", "BUY")) + self.assertFalse(book.place(request)) + client.passorder.assert_called_once()