From a53fc738cf6e73ffe0e0457dc1a3a452e199a543 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 5 Sep 2026 00:29:50 +0800 Subject: [PATCH] fix(trend): reconcile state from actual fills --- docs/bug.md | 16 -------- py-client/strategy/trend/open.py | 4 +- py-client/strategy/trend/positions.py | 9 +---- py-client/strategy/trend/state.py | 31 ++++++++++++++-- py-client/tests/test_trend.py | 53 +++++++++++++++++++++++---- 5 files changed, 76 insertions(+), 37 deletions(-) diff --git a/docs/bug.md b/docs/bug.md index d0d0008..e69de29 100644 --- a/docs/bug.md +++ b/docs/bug.md @@ -1,16 +0,0 @@ - - -### H3. 撤单、拒单、废单和部分成交不能驱动状态机正确收敛 - -- 位置:`strategy/trend/state.py:142-162`、`strategy/trend/order.py:14-17,61-92` -- 证据:状态对账只把“匹配订单全部为状态 56”视为完成,其余均保持 `ING`;订单被过滤或消失时,已有持仓的补仓状态不会变为失败或撤销。文件中虽然定义了 `FAILED`、`CANCELED`、`UNKNOWN`,但没有完整迁移逻辑。 -- 影响:失败的补仓仍会消耗 `added_num`,状态可能永久停留在处理中,重启后也无法可靠恢复。 -- 建议:建立完整 QMT 委托状态映射,按实际成交数量处理全成、部成、已撤、废单、拒单和未知;消失订单需二次查询确认。 - - -### M6. 策略成本使用提交时行情价,而非实际成交价 - -- 位置:`strategy/trend/open.py:80-86`、`strategy/trend/positions.py:186-192` -- 证据:底仓和补仓在订单刚提交成功时就把 tick 价格写为成本,没有根据成交回报更新实际成交数量与均价。 -- 影响:滑点、部分成交或拆单时,后续盈亏率、止盈网格和补仓层级基于不准确成本。 -- 建议:提交阶段只记录订单标识;订单完成对账后从成交或真实持仓均价更新成本和数量。 \ No newline at end of file diff --git a/py-client/strategy/trend/open.py b/py-client/strategy/trend/open.py index 5597a90..8b94990 100644 --- a/py-client/strategy/trend/open.py +++ b/py-client/strategy/trend/open.py @@ -77,7 +77,7 @@ def open_signal(run:Runtime, ticks, open_signals) -> None: def do_open(run: Runtime, code: str, volume: int, signal_key: str, price: float) -> None: """生成本地订单号并按最新价提交开仓委托。""" - order_id = run.orders.new_order_id("base") + order_id = run.orders.new_order_id() request = PlaceOrderRequest( run.client, OP_BUY, @@ -92,8 +92,6 @@ def do_open(run: Runtime, code: str, volume: int, signal_key: str, price: float) run.state.set(StateItem( code=code, base_order_id=order_id, - base_qty=volume, - base_cost=round(price, 2), base_status=STATUS_ING, )) run.state.save() diff --git a/py-client/strategy/trend/positions.py b/py-client/strategy/trend/positions.py index 34ef831..e040c77 100644 --- a/py-client/strategy/trend/positions.py +++ b/py-client/strategy/trend/positions.py @@ -13,8 +13,6 @@ from .runtime import Runtime from .state import STATUS_ING import logging as log -LEG_BASE = "base" -LEG_ADDED = "add" LOSS_TIERS = (-30.0, -50.0) @@ -129,7 +127,7 @@ def handle_profit( volume = position.can_use_volume - position.can_use_volume % 100 if volume <= 0: return TradeDecision(False, "无可用整手持仓") - order_id = runtime.orders.new_order_id(LEG_BASE) + order_id = runtime.orders.new_order_id() request = PlaceOrderRequest( client=runtime.client, op=OP_SELL, @@ -172,7 +170,7 @@ def handle_loss( if volume <= 0 or amount > available: return TradeDecision(False, "本轮可用资金不足") - order_id = runtime.orders.new_order_id(LEG_ADDED) + order_id = runtime.orders.new_order_id() request = PlaceOrderRequest( client=runtime.client, op=OP_BUY, @@ -184,11 +182,8 @@ def handle_loss( if not runtime.orders.place(request): return TradeDecision(False, "补仓委托失败") - state.added_num += 1 state.added_status = STATUS_ING state.added_order_id = order_id - state.added_qty = volume - state.added_cost = tick.last_price runtime.state.set(state) runtime.state.save() runtime.add_watch.forget(position.stock_code) diff --git a/py-client/strategy/trend/state.py b/py-client/strategy/trend/state.py index 390d928..81740af 100644 --- a/py-client/strategy/trend/state.py +++ b/py-client/strategy/trend/state.py @@ -136,9 +136,9 @@ class State: for code in list(self.codes): item = self.get(code) - for order_id_attr, status_attr in ( - ("base_order_id", "base_status"), - ("added_order_id", "added_status"), + for order_id_attr, status_attr, qty_attr, cost_attr in ( + ("base_order_id", "base_status", "base_qty", "base_cost"), + ("added_order_id", "added_status", "added_qty", "added_cost"), ): local_order_id = getattr(item, order_id_attr) current_status = getattr(item, status_attr) @@ -150,6 +150,14 @@ class State: if status != current_status: log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status) setattr(item, status_attr, status) + if status == STATUS_OK: + quantity, cost = _filled_order(matching_orders) + if quantity > 0: + setattr(item, qty_attr, quantity) + if cost > 0: + setattr(item, cost_attr, cost) + if status_attr == "added_status": + item.added_num += 1 self.set(item) # Opening orders normally have no position until their first fill. Order @@ -212,3 +220,20 @@ def _order_status(orders: list[OrderItem] | None) -> str: if statuses <= BUSY_STATUSES | COMPLETED_STATUSES: return STATUS_ING return STATUS_NONE + + +def _filled_order(orders: list[OrderItem] | None) -> tuple[int, float]: + """汇总已成交订单的实际数量和加权成交价。""" + quantity = 0 + amount = 0.0 + for order in orders or []: + filled = order.traded_volume if order.traded_volume > 0 else order.volume + if filled <= 0: + continue + quantity += filled + if order.trade_amount > 0: + amount += order.trade_amount + elif order.trade_price > 0: + amount += order.trade_price * filled + cost = round(amount / quantity, 4) if quantity > 0 and amount > 0 else 0.0 + return quantity, cost diff --git a/py-client/tests/test_trend.py b/py-client/tests/test_trend.py index e6dd6e0..d5a537f 100644 --- a/py-client/tests/test_trend.py +++ b/py-client/tests/test_trend.py @@ -44,9 +44,9 @@ class FailedOrderClient: class TrendTests(unittest.TestCase): def test_trend_order_id_format(self): - self.assertRegex(OrderBook.new_order_id("base"), r"^trend-[0-9a-f]{8}$") + self.assertRegex(OrderBook.new_order_id(), r"^trend-[0-9a-f]{24}$") - def test_open_records_base_order_and_rounded_cost(self): + def test_open_records_only_pending_order(self): with TemporaryDirectory() as directory: state = State.for_strategy(directory, "trend", "A") forgotten = [] @@ -60,9 +60,9 @@ class TrendTests(unittest.TestCase): do_open(runtime, "000001.SZ", 100, "morning", 12.345) item = state.get("000001.SZ") - self.assertRegex(item.base_order_id, r"^trend-[0-9a-f]{8}$") - self.assertEqual(item.base_qty, 100) - self.assertEqual(item.base_cost, 12.35) + self.assertRegex(item.base_order_id, r"^trend-[0-9a-f]{24}$") + self.assertEqual(item.base_qty, 0) + self.assertEqual(item.base_cost, 0) self.assertEqual(item.base_status, "ING") self.assertEqual(forgotten, ["000001.SZ"]) @@ -226,7 +226,10 @@ class TrendTests(unittest.TestCase): state = State.for_strategy(directory, "trend", "A") position = PositionItem(stock_code="A", volume=100, open_price=10) state.set(StateItem("A", base_order_id="local-1", base_status="ING")) - completed = OrderItem("1", "A", "BUY", "", "56", None, 50, "local-1") + completed = OrderItem( + "1", "A", "BUY", "", "56", None, 50, "local-1", + traded_volume=50, trade_price=10.1, + ) processing = OrderItem("2", "A", "BUY", "", "50", None, 50, "local-1") state.reconcile([position], [completed, processing]) @@ -234,9 +237,18 @@ class TrendTests(unittest.TestCase): state.reconcile( [position], - [completed, OrderItem("2", "A", "BUY", "", "56", None, 50, "local-1")], + [ + completed, + OrderItem( + "2", "A", "BUY", "", "56", None, 50, "local-1", + traded_volume=50, trade_price=10.3, + ), + ], ) - self.assertEqual(state.get("A").base_status, STATUS_OK) + item = state.get("A") + self.assertEqual(item.base_status, STATUS_OK) + self.assertEqual(item.base_qty, 100) + self.assertEqual(item.base_cost, 10.2) canceled = OrderItem("2", "A", "BUY", "", "54", None, 50, "local-1") item = state.get("A") @@ -245,6 +257,31 @@ class TrendTests(unittest.TestCase): state.reconcile([position], [completed, canceled]) self.assertEqual(state.get("A").base_status, "") + def test_reconcile_records_filled_add_order(self): + with TemporaryDirectory() as directory: + state = State.for_strategy(directory, "trend", "A") + position = PositionItem(stock_code="A", volume=200, open_price=10) + state.set(StateItem( + "A", + base_qty=100, + base_cost=10, + base_status=STATUS_OK, + added_order_id="add-1", + added_status="ING", + )) + completed = OrderItem( + "1", "A", "BUY", "", "56", None, 100, "add-1", + traded_volume=100, trade_amount=950, + ) + + state.reconcile([position], [completed]) + + item = state.get("A") + self.assertEqual(item.added_status, STATUS_OK) + self.assertEqual(item.added_num, 1) + self.assertEqual(item.added_qty, 100) + self.assertEqual(item.added_cost, 9.5) + def test_low_cash_still_runs_position_management(self): client = SimpleNamespace( portfolio=lambda: Portfolio(