fix(trend): reconcile state from actual fills
This commit is contained in:
16
docs/bug.md
16
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 价格写为成本,没有根据成交回报更新实际成交数量与均价。
|
|
||||||
- 影响:滑点、部分成交或拆单时,后续盈亏率、止盈网格和补仓层级基于不准确成本。
|
|
||||||
- 建议:提交阶段只记录订单标识;订单完成对账后从成交或真实持仓均价更新成本和数量。
|
|
||||||
@@ -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:
|
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(
|
request = PlaceOrderRequest(
|
||||||
run.client,
|
run.client,
|
||||||
OP_BUY,
|
OP_BUY,
|
||||||
@@ -92,8 +92,6 @@ def do_open(run: Runtime, code: str, volume: int, signal_key: str, price: float)
|
|||||||
run.state.set(StateItem(
|
run.state.set(StateItem(
|
||||||
code=code,
|
code=code,
|
||||||
base_order_id=order_id,
|
base_order_id=order_id,
|
||||||
base_qty=volume,
|
|
||||||
base_cost=round(price, 2),
|
|
||||||
base_status=STATUS_ING,
|
base_status=STATUS_ING,
|
||||||
))
|
))
|
||||||
run.state.save()
|
run.state.save()
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ from .runtime import Runtime
|
|||||||
from .state import STATUS_ING
|
from .state import STATUS_ING
|
||||||
import logging as log
|
import logging as log
|
||||||
|
|
||||||
LEG_BASE = "base"
|
|
||||||
LEG_ADDED = "add"
|
|
||||||
LOSS_TIERS = (-30.0, -50.0)
|
LOSS_TIERS = (-30.0, -50.0)
|
||||||
|
|
||||||
|
|
||||||
@@ -129,7 +127,7 @@ def handle_profit(
|
|||||||
volume = position.can_use_volume - position.can_use_volume % 100
|
volume = position.can_use_volume - position.can_use_volume % 100
|
||||||
if volume <= 0:
|
if volume <= 0:
|
||||||
return TradeDecision(False, "无可用整手持仓")
|
return TradeDecision(False, "无可用整手持仓")
|
||||||
order_id = runtime.orders.new_order_id(LEG_BASE)
|
order_id = runtime.orders.new_order_id()
|
||||||
request = PlaceOrderRequest(
|
request = PlaceOrderRequest(
|
||||||
client=runtime.client,
|
client=runtime.client,
|
||||||
op=OP_SELL,
|
op=OP_SELL,
|
||||||
@@ -172,7 +170,7 @@ def handle_loss(
|
|||||||
if volume <= 0 or amount > available:
|
if volume <= 0 or amount > available:
|
||||||
return TradeDecision(False, "本轮可用资金不足")
|
return TradeDecision(False, "本轮可用资金不足")
|
||||||
|
|
||||||
order_id = runtime.orders.new_order_id(LEG_ADDED)
|
order_id = runtime.orders.new_order_id()
|
||||||
request = PlaceOrderRequest(
|
request = PlaceOrderRequest(
|
||||||
client=runtime.client,
|
client=runtime.client,
|
||||||
op=OP_BUY,
|
op=OP_BUY,
|
||||||
@@ -184,11 +182,8 @@ def handle_loss(
|
|||||||
if not runtime.orders.place(request):
|
if not runtime.orders.place(request):
|
||||||
return TradeDecision(False, "补仓委托失败")
|
return TradeDecision(False, "补仓委托失败")
|
||||||
|
|
||||||
state.added_num += 1
|
|
||||||
state.added_status = STATUS_ING
|
state.added_status = STATUS_ING
|
||||||
state.added_order_id = order_id
|
state.added_order_id = order_id
|
||||||
state.added_qty = volume
|
|
||||||
state.added_cost = tick.last_price
|
|
||||||
runtime.state.set(state)
|
runtime.state.set(state)
|
||||||
runtime.state.save()
|
runtime.state.save()
|
||||||
runtime.add_watch.forget(position.stock_code)
|
runtime.add_watch.forget(position.stock_code)
|
||||||
|
|||||||
@@ -136,9 +136,9 @@ class State:
|
|||||||
|
|
||||||
for code in list(self.codes):
|
for code in list(self.codes):
|
||||||
item = self.get(code)
|
item = self.get(code)
|
||||||
for order_id_attr, status_attr in (
|
for order_id_attr, status_attr, qty_attr, cost_attr in (
|
||||||
("base_order_id", "base_status"),
|
("base_order_id", "base_status", "base_qty", "base_cost"),
|
||||||
("added_order_id", "added_status"),
|
("added_order_id", "added_status", "added_qty", "added_cost"),
|
||||||
):
|
):
|
||||||
local_order_id = getattr(item, order_id_attr)
|
local_order_id = getattr(item, order_id_attr)
|
||||||
current_status = getattr(item, status_attr)
|
current_status = getattr(item, status_attr)
|
||||||
@@ -150,6 +150,14 @@ class State:
|
|||||||
if status != current_status:
|
if status != current_status:
|
||||||
log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status)
|
log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status)
|
||||||
setattr(item, status_attr, 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)
|
self.set(item)
|
||||||
|
|
||||||
# Opening orders normally have no position until their first fill. Order
|
# 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:
|
if statuses <= BUSY_STATUSES | COMPLETED_STATUSES:
|
||||||
return STATUS_ING
|
return STATUS_ING
|
||||||
return STATUS_NONE
|
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
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ class FailedOrderClient:
|
|||||||
|
|
||||||
class TrendTests(unittest.TestCase):
|
class TrendTests(unittest.TestCase):
|
||||||
def test_trend_order_id_format(self):
|
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:
|
with TemporaryDirectory() as directory:
|
||||||
state = State.for_strategy(directory, "trend", "A")
|
state = State.for_strategy(directory, "trend", "A")
|
||||||
forgotten = []
|
forgotten = []
|
||||||
@@ -60,9 +60,9 @@ class TrendTests(unittest.TestCase):
|
|||||||
do_open(runtime, "000001.SZ", 100, "morning", 12.345)
|
do_open(runtime, "000001.SZ", 100, "morning", 12.345)
|
||||||
|
|
||||||
item = state.get("000001.SZ")
|
item = state.get("000001.SZ")
|
||||||
self.assertRegex(item.base_order_id, r"^trend-[0-9a-f]{8}$")
|
self.assertRegex(item.base_order_id, r"^trend-[0-9a-f]{24}$")
|
||||||
self.assertEqual(item.base_qty, 100)
|
self.assertEqual(item.base_qty, 0)
|
||||||
self.assertEqual(item.base_cost, 12.35)
|
self.assertEqual(item.base_cost, 0)
|
||||||
self.assertEqual(item.base_status, "ING")
|
self.assertEqual(item.base_status, "ING")
|
||||||
self.assertEqual(forgotten, ["000001.SZ"])
|
self.assertEqual(forgotten, ["000001.SZ"])
|
||||||
|
|
||||||
@@ -226,7 +226,10 @@ class TrendTests(unittest.TestCase):
|
|||||||
state = State.for_strategy(directory, "trend", "A")
|
state = State.for_strategy(directory, "trend", "A")
|
||||||
position = PositionItem(stock_code="A", volume=100, open_price=10)
|
position = PositionItem(stock_code="A", volume=100, open_price=10)
|
||||||
state.set(StateItem("A", base_order_id="local-1", base_status="ING"))
|
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")
|
processing = OrderItem("2", "A", "BUY", "", "50", None, 50, "local-1")
|
||||||
|
|
||||||
state.reconcile([position], [completed, processing])
|
state.reconcile([position], [completed, processing])
|
||||||
@@ -234,9 +237,18 @@ class TrendTests(unittest.TestCase):
|
|||||||
|
|
||||||
state.reconcile(
|
state.reconcile(
|
||||||
[position],
|
[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")
|
canceled = OrderItem("2", "A", "BUY", "", "54", None, 50, "local-1")
|
||||||
item = state.get("A")
|
item = state.get("A")
|
||||||
@@ -245,6 +257,31 @@ class TrendTests(unittest.TestCase):
|
|||||||
state.reconcile([position], [completed, canceled])
|
state.reconcile([position], [completed, canceled])
|
||||||
self.assertEqual(state.get("A").base_status, "")
|
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):
|
def test_low_cash_still_runs_position_management(self):
|
||||||
client = SimpleNamespace(
|
client = SimpleNamespace(
|
||||||
portfolio=lambda: Portfolio(
|
portfolio=lambda: Portfolio(
|
||||||
|
|||||||
Reference in New Issue
Block a user