feat order.py

This commit is contained in:
2026-08-30 14:46:34 +08:00
parent cdccc48d8c
commit 6334f10904
19 changed files with 70 additions and 46 deletions

View File

@@ -73,15 +73,15 @@ def StartTrend() -> None:
)
assets = client.assets()
_, positions = client.positions()
order_book = OrderBook()
order_book.refresh()
storeState = State.for_strategy(
config.global_config.qmt_data_dir,
config.account_config.strategy,
config.account_config.account_id,
)
orders = client.trade_detail_data("order")
deals = client.deals()
storeState.reconcile(positions, orders, deals)
storeState.reconcile(positions, order_book.data)
# 获取本策略的信号开仓数据
signals = init_signals(
@@ -93,7 +93,7 @@ def StartTrend() -> None:
global_cfg=config.global_config,
account_cfg=config.account_config,
state=storeState,
orders=OrderBook(),
orders=order_book,
open_watch=DipWatch(),
add_watch=DipWatch(),
profit_tracker=GridTrailingTracker(config.account_config.grid_step_pct),
@@ -120,7 +120,7 @@ def RunOnce(run: Runtime, signals) -> None:
# 1. 取消超过有效期仍未完成的委托订单。
try:
run.orders.cancel_expired(run.client)
run.orders.refresh(run.client)
except Exception:
logging.exception("取消过期订单失败")

View File

@@ -12,6 +12,9 @@ from sdk import ORDER_SIDE_BY_OFFSET, Client, OrderItem
# 表示委托仍在处理、可能继续成交的 QMT 状态。
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
COMPLETED_STATUSES = {"56"}
TRACKED_STATUSES = BUSY_STATUSES | COMPLETED_STATUSES
CANCELABLE_STATUSES = {"49", "50", "51", "52"}
@dataclass(frozen=True, slots=True)
@@ -32,7 +35,7 @@ class OrderBook:
def __init__(self, lock_timeout_sec: float = 180, cancel_timeout_sec: float = 10) -> None:
self.lock_timeout_sec = max(0.0, float(lock_timeout_sec))
self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec)
self.data: dict[str, OrderItem] = {}
self.data: list[OrderItem] = []
self.lock: dict[str, float] = {}
self.mutex = Lock()
@@ -44,42 +47,45 @@ class OrderBook:
def busy(self, code: str, side: str) -> bool:
"""判断证券是否存在仍在处理中的同方向委托。"""
with self.mutex:
self._clear_expired_locks(datetime.now().timestamp())
key = f"{side}-{code}"
return key in self.lock
def refresh(self, client: Client) -> None:
"""从 QMT 刷新当前委托明细和方向索引"""
"""从 QMT 刷新进行中和已完成委托,并撤销超时的活动委托"""
orders = client.trade_detail_data("order")
parsed_orders = [(f"{item.side}-{item.code}", item) for item in orders]
now_timestamp = datetime.now().timestamp()
with self.mutex:
self.data = {key: item for key, item in parsed_orders}
self.lock = {
key: (
current = datetime.now()
now_timestamp = current.timestamp()
data: list[OrderItem] = []
lock: dict[str, float] = {}
for item in orders:
# 不处理状态不对的
if item.status not in TRACKED_STATUSES:
continue
# 清理过期的
if (
item.created_at is not None
and item.status in CANCELABLE_STATUSES
and current - item.created_at > self.cancel_timeout_sec
):
client.cancel_by_id(item.id)
continue
# 缓存本次有效订单
data.append(item)
if item.status in BUSY_STATUSES:
key = f"{item.side}-{item.code}"
lock[key] = (
item.created_at.timestamp()
if item.created_at is not None
else now_timestamp
)
for key, item in parsed_orders
if item.status in BUSY_STATUSES
}
self._clear_expired_locks(now_timestamp)
def cancel_expired(self, client: Any, now: datetime | None = None) -> None:
"""尝试撤销超过有效期且具有委托编号的订单。"""
self.refresh(client)
current = now or datetime.now()
# 使用快照遍历,避免网络调用期间长期持有互斥锁。
for order in list(self.data.values()):
if (
order.created_at is not None
and order.status in {"49", "50", "51", "52"}
and current - order.created_at > self.cancel_timeout_sec
and order.id
):
client.cancel_by_id(order.id)
with self.mutex:
self.data = data
self.lock = lock
def place(self, request: PlaceOrderRequest) -> bool:
"""按最新价提交委托,并立即写入本地方向锁。"""
@@ -112,13 +118,3 @@ class OrderBook:
self.data[key] = pending
self.lock[key] = pending.created_at.timestamp()
return True
def _clear_expired_locks(self, now_timestamp: float) -> None:
"""清理过期方向锁;调用方必须已持有 ``mutex``。"""
expired = [
key
for key, created_at in self.lock.items()
if now_timestamp - created_at >= self.lock_timeout_sec
]
for key in expired:
self.lock.pop(key, None)

View File

@@ -136,7 +136,6 @@ class State:
self,
positions: Iterable[PositionItem],
orders: list[OrderItem],
deals: list[dict[str, str]],
) -> None:
"""用真实持仓、委托和成交恢复本地状态,不增加持久化字段。"""
position_list = list(positions)

Binary file not shown.

View File

@@ -1,12 +1,13 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from libs.grid_take_profit import GridState, GridTrailingTracker
from sdk import Assets, PositionItem, Tick
from sdk import Assets, OrderItem, PositionItem, Tick
from strategy.trend.order import OrderBook, PlaceOrderRequest
from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions
from strategy.trend.boot import RunOnce
@@ -22,6 +23,18 @@ class FakeClient:
return {"status": "success", "order_ref": f"broker-{len(self.orders)}"}
class FakeOrderClient:
def __init__(self, orders):
self.orders = orders
self.canceled = []
def trade_detail_data(self, _datatype):
return self.orders
def cancel_by_id(self, order_id):
self.canceled.append(order_id)
class TrendTests(unittest.TestCase):
def test_grid_states_and_account_isolation(self):
tracker = GridTrailingTracker(1)
@@ -39,6 +52,22 @@ class TrendTests(unittest.TestCase):
self.assertTrue(book.place(request))
self.assertTrue(book.busy("000001.SZ", "BUY"))
def test_refresh_tracks_active_and_completed_and_cancels_expired(self):
old = datetime.now() - timedelta(seconds=20)
orders = [
OrderItem("active", "A", "BUY", "", "49", old, 100),
OrderItem("completed", "B", "SELL", "", "56", old, 100),
OrderItem("canceled", "C", "BUY", "", "54", old, 100),
OrderItem("failed", "D", "BUY", "", "57", old, 100),
]
client = FakeOrderClient(orders)
book = OrderBook(cancel_timeout_sec=10)
book.refresh(client)
self.assertEqual(set(book.data), {"BUY-A", "SELL-B"})
self.assertEqual(client.canceled, ["active"])
def test_position_dataclasses_execute_without_type_error(self):
with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A")
@@ -121,7 +150,7 @@ class TrendTests(unittest.TestCase):
client=client,
account_cfg=SimpleNamespace(min_cash_ratio=0.1),
global_cfg=SimpleNamespace(api_host="http://example"),
orders=SimpleNamespace(cancel_expired=lambda _client: None),
orders=SimpleNamespace(refresh=lambda _client: None),
state=SimpleNamespace(
codes=["A"],
unresolved_codes=[],