From 2f93fe2ecbe1a1a0a20a6c855b649ef17f3b7e55 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 5 Sep 2026 22:52:35 +0800 Subject: [PATCH] fix bug --- .../strategy/trend => docs/arch}/state.py | 0 docs/trend-audit-2026-09-05.md | 84 +++++++++++++++++++ py-client/strategy/trend/boot.py | 15 ---- py-client/strategy/trend/open.py | 1 - py-client/strategy/trend/positions.py | 34 ++++---- py-client/strategy/trend/runtime.py | 2 - 6 files changed, 102 insertions(+), 34 deletions(-) rename {py-client/strategy/trend => docs/arch}/state.py (100%) create mode 100644 docs/trend-audit-2026-09-05.md diff --git a/py-client/strategy/trend/state.py b/docs/arch/state.py similarity index 100% rename from py-client/strategy/trend/state.py rename to docs/arch/state.py diff --git a/docs/trend-audit-2026-09-05.md b/docs/trend-audit-2026-09-05.md new file mode 100644 index 0000000..c7876e8 --- /dev/null +++ b/docs/trend-audit-2026-09-05.md @@ -0,0 +1,84 @@ +# Trend 当前工作树重新审计 + +审计日期:2026-09-05。以当前磁盘文件(包含未提交的手动修改)为准,不继承旧报告的问题状态。仅审计 Trend 及直接依赖;不修改策略代码,不连接交易接口。 + +## 结论 + +当前仍有 4 项高风险、3 项中风险问题。不建议仅凭语法检查通过直接运行实盘。本报告只保留当前存在的问题与待确认行为,问题编号保留以便引用。 + +工作树中 `strategy/trend/state.py` 已删除,`Runtime` 也已移除 `state` 字段;`docs/arch/state.py` 是归档,不是当前运行模块。因此此前围绕 `merged_order()` 的结论不作为当前策略结论。 + +## H5:止盈委托刚受理就清理峰值,未成交持仓失去回撤基准 + +- 位置:`positions.py:136`;`libs/grid_take_profit.py` 的 `clear(self, position_key)`。 +- 证据:`orders.place()` 返回成功后立即执行 `runtime.profit_tracker.clear(key)`,没有等待成交或持仓消失。 +- 影响:卖单未成交、部分成交或随后被超时撤销时,剩余持仓的旧峰值已丢失;下轮会从较低盈利重新建立基准,原回撤信号可能不再触发。 +- 验证:真实网格跟踪器记录 12% 峰值,10% 时模拟提交成功;同一持仓下一次 9.5% 观察返回 `ARMED` 而不是沿用旧峰值的 `RETREAT`。没有真实交易。 +- 建议:仅在后续快照确认持仓消失时清理;撤单或部分成交继续保留剩余持仓的峰值,成本变化时另行明确重置规则。 + +## H2:自动撤单未限定为本策略订单 + +- 位置:`order.py:60`、`:68`、`:78`;`boot.py:57`;`sdk/portfolio.py:9`。 +- 证据:将组合接口的订单列表直接传入 `refresh()`,只检查状态与时间,没有检查本地订单号前缀、策略归属或排除名单。 +- 触发:组合接口返回同账户其他策略或手工订单,且符合超时条件。 +- 影响:启动及每轮刷新都可能撤销非 Trend 订单,包括排除证券的订单。 +- 建议:账户级活动订单可用于防重,但主动撤单必须单独限定所有权。若业务确实授权管理整个账户,需明确记录该权限。 + +## H3:大盘过滤实际始终放行 + +- 位置:`py-client/libs/market.py` 的 `market_allow_open()`;`boot.py:151`;`positions.py:67`。 +- 证据:真实状态比较被注释,函数直接 `return True`。 +- 影响:下跌或未知状态仍允许开仓及补仓,外层看似存在的风险限制不生效。该问题属于 Trend 直接依赖,不是 Trend 文件内的新修改。 +- 建议:恢复真实状态判断,或以明确、可见的配置表示主动关闭过滤。 +- 验证:将本地测试状态设为 `DOWN`,仍返回 `True`;未请求网络。 + +## H4:买入没有统一资金预算,最小整手还可能突破单笔额度 + +- 位置:`boot.py:146`、`:176`;`open.py:51`;`positions.py:160`;`libs/calc.py:10`。 +- 证据:开仓只依据本轮起始资金比例决定是否启动,不逐单扣减预算;补仓线程独立使用相同快照资金。`calc_buy_volume()` 用 `max(1, ...)` 强制至少买一手。 +- 影响:多个开仓与补仓可能同时消耗同一份可用资金;可能跌破配置的现金安全线,或产生资金不足拒单。并非断言券商一定允许超额成交。 +- 示例:价格 100 元、单笔额度 5,000 元,计算出 100 股,即 10,000 元。 +- 建议:开仓和补仓共享本轮可预留预算;不足一手返回零;预留考虑手续费和行情变化,保留最小现金要求。 + +## M2:撤单异常会中断整轮刷新和交易管理 + +- 位置:`order.py:78`、`:86`;`boot.py:122` 起的快照处理。 +- 证据:撤单在遍历中直接调用,未逐单隔离;`busy_keys` 和 `data` 在全部遍历结束后才赋值。 +- 影响:某笔撤单失败即退出 `refresh()`,快照不更新;常规轮次返回而跳过全部交易管理,初始化阶段则退出启动(客户端会关闭)。持续失败的订单可能持续阻断后续轮次。 +- 建议:撤单逐项记录异常,同时发布完整活动订单快照;撤单失败的订单继续作为在途订单防重。 + +## M3:信号前置处理没有逐项异常隔离 + +- 位置:`open.py:16` 至 `:55`。 +- 证据:异常捕获仅包围 `do_open()`;配置、价格、数量计算、观察器调用不在逐候选保护边界中。 +- 触发:一个候选包含异常数据,例如非有限价格导致数量计算异常,或配置字段类型不符合预期。 +- 影响:该候选之后的所有开仓信号本轮不再处理;工作线程最外层只能记录整个任务失败。 +- 建议:把一个候选的完整处理放入同一异常边界,记录证券与信号键后继续;保持必要校验即可。 + +## M4:补仓请求结果不确定时未保留资金预算 + +- 位置:`positions.py:175`、`:75`;`order.py:119` 起的异常处理。 +- 证据:`place()` 对请求超时等异常返回 `False`,保留证券方向缓存,但 `handle_loss()` 返回的 `reserved_cash` 默认为零。 +- 触发:券商已受理补仓但响应丢失,本轮继续处理后续证券。 +- 影响:方向缓存只能防同证券重复下单,不能阻止其他证券重复使用这笔可能已消耗的资金。该问题即使只启用持仓管理、不开新仓也存在,与 H4 的跨线程预算问题不同。 +- 验证:模拟 `place()` 返回 `False`,补仓结果 `reserved_cash == 0.0`。 +- 建议:区分明确未提交与结果不确定;不确定时保留本轮预计金额,或停止本轮后续买入并刷新资金。 + +## 需要确认的行为(不直接判为错误) + +- `positions.py:102` 在盈利低于最低收益门槛时不再观察网格:若从高峰直接跌破该门槛,不会触发回撤卖出。需要确认门槛是仅用于首次激活,还是任何时候都禁止低于门槛卖出。 +- `positions.py:122` 向下取整到百股,可用持仓不足一手将一直跳过。是否需要支持零股清仓取决于交易标的及产品要求,本次未核验外部市场规则。 +- 当前 `LOSS_TIERS = [-50.0]`,且 `get_add_num()` 依据手数/当前市值判断,不再记录真实补仓次数。若是主动放弃状态机后的新设计,应更新策略说明;不能等同于历史成交次数。 +- `boot.py:84` 使用 `>= 15:00:00`;与此前“严格大于 15:00”的表述存在边界差别。启动初始化发生在此检查之前,仍可能查询或撤单。 + +## 已确认的保护与验证范围 + +- `busy()` 和 `place()` 都检查券商快照的 `busy_keys` 及本地 `SimpleCache`;`place()` 在互斥区内检查并写入,不再报告旧版“下单未使用防重”的问题。 +- 开仓与持仓管理都检查排除名单;未知信号配置有独立日志后跳过。 +- `place()` 捕获 API、HTTP 请求和解码类异常;持仓管理有逐证券异常边界。 +- 启动生命周期有 `finally`,等待线程池后关闭客户端。 +- 当前 7 个 Trend 源文件全部重新通过 AST 语法检查。本轮使用真实网格组件和模拟下单,验证成功提交后的峰值清理及下一轮重新激活行为。 +- 资金预算、大盘过滤、撤单和信号异常边界本轮已重新读取;预算超额及失败补仓零预算预留的实现与此前最小测试对应代码一致。 +- 未执行真实下单、撤单、完整 SDK 联调或券商回报测试;语法通过不代表策略可运行。归档文件和历史审计测试统计不计入当前验证。 + +优先顺序:先处理撤单权限、市场过滤、资金预算及止盈峰值生命周期,再完善异常边界。 diff --git a/py-client/strategy/trend/boot.py b/py-client/strategy/trend/boot.py index 1623462..2cf4418 100644 --- a/py-client/strategy/trend/boot.py +++ b/py-client/strategy/trend/boot.py @@ -17,7 +17,6 @@ from libs.signal import init_signals, SignalItem from libs.collector import collector_push from sdk import Client from libs.grid_take_profit import GridTrailingTracker -from .state import State from .order import OrderBook from .watch import DipWatch from .runtime import Runtime @@ -56,14 +55,8 @@ def StartTrend() -> None: portfolio = client.portfolio() assets = portfolio.assets positions = list(portfolio.positions.values()) - storeState = State.for_strategy( - config.global_config.qmt_data_dir, - config.account_config.strategy, - config.account_config.account_id, - ) order_book = OrderBook() order_book.refresh(client, portfolio.orders) - storeState.reconcile(positions, portfolio.orders) # 获取本策略的信号开仓数据 signals = init_signals( @@ -76,7 +69,6 @@ def StartTrend() -> None: client=client, global_cfg=config.global_config, account_cfg=config.account_config, - state=storeState, orders=order_book, open_watch=DipWatch(), add_watch=DipWatch(), @@ -177,13 +169,6 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None: log.exception("[行情] 获取行情失败,代码数量=%d", len(all_codes)) return - # 6. 更新状态机 - try: - run.state.reconcile(positions, portfolio.orders) - except Exception: - log.exception("[状态] 订单状态对账失败") - return - log.info("[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s", len(positions), len(allow_open), market_ok, allow_open_by_cash) # 启动线程,开始计算 diff --git a/py-client/strategy/trend/open.py b/py-client/strategy/trend/open.py index cc585ca..af89773 100644 --- a/py-client/strategy/trend/open.py +++ b/py-client/strategy/trend/open.py @@ -8,7 +8,6 @@ from libs import calc_buy_volume from sdk import OP_BUY from .runtime import Runtime from .order import PlaceOrderRequest -from .state import PendingOrder import logging as log diff --git a/py-client/strategy/trend/positions.py b/py-client/strategy/trend/positions.py index f3fe661..9904f20 100644 --- a/py-client/strategy/trend/positions.py +++ b/py-client/strategy/trend/positions.py @@ -10,10 +10,9 @@ from sdk import OP_BUY, OP_SELL, PositionItem, Tick from .order import PlaceOrderRequest from .runtime import Runtime -from .state import PendingOrder import logging as log -LOSS_TIERS = (-30.0, -50.0) +LOSS_TIERS = [-50.0] @dataclass(frozen=True, slots=True) @@ -133,6 +132,8 @@ def handle_profit( ) if not runtime.orders.place(runtime.client, request): return TradeDecision(False, "止盈委托失败") + + return TradeDecision(True, f"卖出 {volume} 股,订单={order_id}") @@ -141,19 +142,15 @@ def handle_loss( position: PositionItem, tick: Tick, pnl_rate: float, - available: float, + available: float ) -> TradeDecision: """按亏损档位、反弹确认和本轮剩余预算提交补仓。""" - try: - state = runtime.state.get(position.stock_code) - except KeyError: - return TradeDecision(False, "缺少持仓状态,跳过补仓") - - if state.added_num >= len(LOSS_TIERS): - return TradeDecision(False, "已达到最大补仓次数") - if pnl_rate > LOSS_TIERS[state.added_num]: + add_num = get_add_num(hands=int(position.volume/100),market_value=position.market_value) + if add_num >= len(LOSS_TIERS) or add_num < 0: + return TradeDecision(False, f"补仓次数无效:{add_num}") + if pnl_rate > LOSS_TIERS[add_num]: return TradeDecision(False) - if tick.last_price > 200 or position.market_value >= 60_000: + if tick.last_price > 200 or position.market_value >= 20_000: return TradeDecision(False, "价格或仓位市值超过补仓限制") if not runtime.add_watch.triggered("补仓", position.stock_code, tick.last_price): return TradeDecision(False, "等待价格反弹确认") @@ -174,15 +171,20 @@ def handle_loss( strategy_name=runtime.account_cfg.strategy, kind="add", ) - - #runtime.state.new_order(PendingOrder(order_id, position.stock_code, "added", volume)) if not runtime.orders.place(runtime.client, request): - reserved = amount if runtime.state.busy(position.stock_code) else 0.0 - return TradeDecision(False, "补仓委托失败或待确认", reserved) + return TradeDecision(False, "补仓订单委托失败") + runtime.add_watch.forget(position.stock_code) return TradeDecision(True, f"买入 {volume} 股,订单={order_id}", amount) def _position_key(runtime: Runtime, code: str) -> str: return f"{runtime.account_cfg.account_id}:{code}" + +def get_add_num(hands:int,market_value:float) -> int: + if market_value>10000: + return -1 + if hands < 2: + return 0 + return -1 \ No newline at end of file diff --git a/py-client/strategy/trend/runtime.py b/py-client/strategy/trend/runtime.py index b137f88..0bd2189 100644 --- a/py-client/strategy/trend/runtime.py +++ b/py-client/strategy/trend/runtime.py @@ -10,7 +10,6 @@ from sdk import Client from libs.grid_take_profit import GridTrailingTracker from .order import OrderBook -from .state import State from .watch import DipWatch @@ -38,7 +37,6 @@ class Runtime: account_cfg: AccountConfig # 策略运行过程中共享的状态组件。 - state: State orders: OrderBook open_watch: DipWatch add_watch: DipWatch