diff --git a/api/qmt_rest_new.py b/api/qmt_rest_new.py index d5152d3..ae337fc 100644 --- a/api/qmt_rest_new.py +++ b/api/qmt_rest_new.py @@ -313,6 +313,7 @@ def format_holding(positions): stock = position.m_strInstrumentID + '.' + position.m_strExchangeID holding[stock] = { 'StockCode': stock, + 'TradeID':position.m_strTradeID, 'StockName': position.m_strInstrumentName, 'Direction': position.m_nDirection, 'Volume': position.m_nVolume, diff --git a/docs/bug.md b/docs/bug.md index 890d60a..d0d0008 100644 --- a/docs/bug.md +++ b/docs/bug.md @@ -1,9 +1,3 @@ -### S1. 券商快照延迟时,本地委托锁会被清空并可能重复下单 - -- 位置:`strategy/trend/order.py:54-92`、`strategy/trend/boot.py:127-130,157-162`、`strategy/trend/open.py:23-25` -- 证据:下单成功后 `OrderBook.place()` 会立即加入本地 pending 和方向锁,但下一轮 `refresh()` 会完全用券商快照重建 `data` 与 `lock`。若券商快照尚未出现刚提交的订单,本地 pending 会直接丢失。开仓候选只排除当前持仓,不排除 `State` 中的待成交底仓,最终仅依赖已经被清掉的 `busy()` 锁。 -- 影响:接口存在可见性延迟时,同一证券可能在连续轮次重复提交买单;卖单和补仓也存在相同锁丢失窗口。 -- 建议:刷新时合并尚未超时且券商未确认的本地 pending,而不是覆盖;按本地订单号查询确认后才能移除。开仓筛选同时检查状态机中的 `base_status`。 ### H3. 撤单、拒单、废单和部分成交不能驱动状态机正确收敛 @@ -14,13 +8,6 @@ - 建议:建立完整 QMT 委托状态映射,按实际成交数量处理全成、部成、已撤、废单、拒单和未知;消失订单需二次查询确认。 -### M1. IPO 仍只依赖本地锁,存在重复申购窗口 - -- 位置:`strategy/ipo/boot.py:49-65` -- 证据:下单成功后才写锁;未查询券商当日委托或成交记录。进程若在下单成功与写锁之间退出,或锁文件被删除,下一次调度会再次申购。 -- 影响:同一证券可能重复发送申购请求,安全性依赖券商端是否拒绝重复申购。 -- 建议:本地锁只作为快速缓存,下单前以券商委托/成交记录做最终幂等校验。 - ### M6. 策略成本使用提交时行情价,而非实际成交价 - 位置:`strategy/trend/open.py:80-86`、`strategy/trend/positions.py:186-192` diff --git a/py-client/libs/__pycache__/collector.cpython-311.pyc b/py-client/libs/__pycache__/collector.cpython-311.pyc index 1c038d1..bb23612 100644 Binary files a/py-client/libs/__pycache__/collector.cpython-311.pyc and b/py-client/libs/__pycache__/collector.cpython-311.pyc differ diff --git a/py-client/libs/__pycache__/market.cpython-311.pyc b/py-client/libs/__pycache__/market.cpython-311.pyc index b544d7d..e9c3c35 100644 Binary files a/py-client/libs/__pycache__/market.cpython-311.pyc and b/py-client/libs/__pycache__/market.cpython-311.pyc differ diff --git a/py-client/requirements.txt b/py-client/requirements.txt index bef0bdb..fbbb499 100644 --- a/py-client/requirements.txt +++ b/py-client/requirements.txt @@ -1,3 +1,4 @@ httpx>=0.27,<1 PyYAML>=6.0 APScheduler>=3.10,<4 +CacheLib>=0.13,<1 diff --git a/py-client/sdk/__pycache__/models.cpython-311.pyc b/py-client/sdk/__pycache__/models.cpython-311.pyc index e17059c..ae084b3 100644 Binary files a/py-client/sdk/__pycache__/models.cpython-311.pyc and b/py-client/sdk/__pycache__/models.cpython-311.pyc differ diff --git a/py-client/sdk/__pycache__/trade.cpython-311.pyc b/py-client/sdk/__pycache__/trade.cpython-311.pyc index d8d8ad7..f261d4b 100644 Binary files a/py-client/sdk/__pycache__/trade.cpython-311.pyc and b/py-client/sdk/__pycache__/trade.cpython-311.pyc differ diff --git a/py-client/sdk/models.py b/py-client/sdk/models.py index 5975405..ee2c2ae 100644 --- a/py-client/sdk/models.py +++ b/py-client/sdk/models.py @@ -68,6 +68,7 @@ class OrderItem: class PositionItem: stock_code: str = "" stock_name: str = "" + trade_id:str = "" direction: Any = None volume: int = 0 open_price: float = 0.0 @@ -87,6 +88,7 @@ class PositionItem: def from_dict(cls, data: dict[str, Any], code: str = "") -> "PositionItem": return cls( stock_code=str(data.get("StockCode") or code), stock_name=str(data.get("StockName") or ""), + trade_id=str(data.get("TradeID") or ""), direction=data.get("Direction"), volume=_number(data.get("Volume"), int), open_price=_number(data.get("OpenPrice")), float_profit=_number(data.get("FloatProfit")), market_value=_number(data.get("MarketValue")), stock_holder=str(data.get("StockHolder") or ""), @@ -102,6 +104,7 @@ class PositionItem: return cls( stock_code=str(data.get("StockCode") or ""), stock_name=str(data.get("StockName") or ""), + trade_id=str(data.get("TradeID") or ""), direction=data.get("Direction"), volume=_number(data.get("Volume"), int), open_price=_number(data.get("OpenPrice")), diff --git a/py-client/strategy/ipo/__pycache__/boot.cpython-311.pyc b/py-client/strategy/ipo/__pycache__/boot.cpython-311.pyc index 0e1d348..6c6e81c 100644 Binary files a/py-client/strategy/ipo/__pycache__/boot.cpython-311.pyc and b/py-client/strategy/ipo/__pycache__/boot.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc b/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc index cbf10da..fc08c1f 100644 Binary files a/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/open.cpython-311.pyc b/py-client/strategy/trend/__pycache__/open.cpython-311.pyc index 6bc0324..9159fce 100644 Binary files a/py-client/strategy/trend/__pycache__/open.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/open.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/order.cpython-311.pyc b/py-client/strategy/trend/__pycache__/order.cpython-311.pyc index 2910914..ca55959 100644 Binary files a/py-client/strategy/trend/__pycache__/order.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/order.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc b/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc index 0361f66..777bd75 100644 Binary files a/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/state.cpython-311.pyc b/py-client/strategy/trend/__pycache__/state.cpython-311.pyc index 7f7ebdc..e2b519b 100644 Binary files a/py-client/strategy/trend/__pycache__/state.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/state.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc b/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc index c48040a..01ae40b 100644 Binary files a/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc differ diff --git a/py-client/strategy/trend/order.py b/py-client/strategy/trend/order.py index 60f4983..1d490b5 100644 --- a/py-client/strategy/trend/order.py +++ b/py-client/strategy/trend/order.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from threading import Lock from typing import Any +from cachelib import SimpleCache from sdk import APIError, ORDER_SIDE_BY_OFFSET, Client, OrderItem @@ -33,30 +34,32 @@ class PlaceOrderRequest: 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)) + def __init__(self, lock_timeout_sec: int = 180, cancel_timeout_sec: float = 10) -> None: + self.lock_timeout_sec = max(1, lock_timeout_sec) self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec) self.data: list[OrderItem] = [] - self.lock: dict[str, float] = {} + self.busy_cache = SimpleCache(threshold=10_000, default_timeout=self.lock_timeout_sec) self.mutex = Lock() @staticmethod - def new_order_id(_leg: str) -> str: + def new_order_id() -> str: """生成 ``trend-xxxxxxxx`` 格式的本地订单号。""" - return f"trend-{secrets.token_hex(4)}" + return f"trend-{secrets.token_hex(12)}" def busy(self, code: str, side: str) -> bool: """判断证券是否存在仍在处理中的同方向委托。""" with self.mutex: - key = f"{side}-{code}" - return key in self.lock + return self.busy_cache.has(self._busy_key(side, code)) + + @staticmethod + def _busy_key(side: str, code: str) -> str: + return f"{side}-{code}" def refresh(self, client: Client, orders: list[OrderItem]) -> None: """用账户快照刷新委托,并撤销超时的活动委托。""" current = datetime.now() - now_timestamp = current.timestamp() data: list[OrderItem] = [] - lock: dict[str, float] = {} + busy_keys: set[str] = set() canceled = 0 for item in orders: @@ -78,21 +81,27 @@ class OrderBook: 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 - ) + busy_keys.add(self._busy_key(item.side, item.code)) with self.mutex: self.data = data - self.lock = lock - log.info("[Order] 刷新完成,跟踪=%d,处理中=%d,撤销=%d", len(data), len(lock), canceled) + log.info("[Order] 刷新完成,跟踪=%d,处理中=%d,撤销=%d", len(data), len(busy_keys), canceled) def place(self, request: PlaceOrderRequest) -> bool: """按最新价提交委托,并立即写入本地方向锁。""" + side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "") + if not side: + log.warning("[Order] 下单失败,代码=%s,原因=未知买卖方向(%s)", request.code, request.op) + return False + + key = self._busy_key(side, request.code) + with self.mutex: + if 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, @@ -112,7 +121,6 @@ class OrderBook: log.warning("[Order] 下单被拒绝,代码=%s,本地订单=%s,状态=%s,柜台订单=%s", request.code, request.order_id, result.get("status"), order_ref) return False - side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "") pending = OrderItem( id=order_ref, code=request.code, @@ -124,8 +132,6 @@ class OrderBook: local_order_id=request.order_id, ) with self.mutex: - key = f"{side}-{request.code}" self.data.append(pending) - self.lock[key] = pending.created_at.timestamp() log.info("[Order] 下单已受理,代码=%s,方向=%s,数量=%d,本地订单=%s,柜台订单=%s", request.code, side, request.volume, request.order_id, order_ref) return True diff --git a/py-client/strategy/trend/state.py b/py-client/strategy/trend/state.py index b7647d6..390d928 100644 --- a/py-client/strategy/trend/state.py +++ b/py-client/strategy/trend/state.py @@ -10,15 +10,13 @@ from threading import Lock from typing import Iterable from sdk import OrderItem, PositionItem +from .order import BUSY_STATUSES, COMPLETED_STATUSES # 委托状态:无操作、处理中、已完成。 STATUS_NONE = "" STATUS_ING = "ING" STATUS_OK = "OK" -STATUS_FAILED = "FAILED" -STATUS_CANCELED = "CANCELED" -STATUS_UNKNOWN = "UNKNOWN" @dataclass(slots=True) @@ -106,6 +104,7 @@ class State: self.set( StateItem( + base_order_id=position.trade_id, code=position.stock_code, base_qty=position.volume, base_cost=round(position.open_price, 2), @@ -143,22 +142,14 @@ class State: ): local_order_id = getattr(item, order_id_attr) current_status = getattr(item, status_attr) - if ( - current_status not in {STATUS_ING, STATUS_UNKNOWN} - or not local_order_id - ): + if current_status != STATUS_ING or not local_order_id: continue matching_orders = orders_by_local_id.get(local_order_id) - if matching_orders: - status = ( - STATUS_OK - if all(order.status == "56" for order in matching_orders) - else STATUS_ING - ) - if status != current_status: - log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status) - setattr(item, status_attr, status) + status = _order_status(matching_orders) + if status != current_status: + log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status) + setattr(item, status_attr, status) self.set(item) # Opening orders normally have no position until their first fill. Order @@ -209,3 +200,15 @@ class State: } except (TypeError, ValueError) as exc: raise ValueError(f"[状态] 状态字段无效: {exc}") from exc + + +def _order_status(orders: list[OrderItem] | None) -> str: + """将柜台订单简化为无状态、处理中或已成交。""" + if not orders: + return STATUS_NONE + statuses = {order.status for order in orders} + if statuses <= COMPLETED_STATUSES: + return STATUS_OK + if statuses <= BUSY_STATUSES | COMPLETED_STATUSES: + return STATUS_ING + return STATUS_NONE diff --git a/py-client/tests/test_trend.py b/py-client/tests/test_trend.py index f0b43c1..e6dd6e0 100644 --- a/py-client/tests/test_trend.py +++ b/py-client/tests/test_trend.py @@ -13,7 +13,7 @@ from strategy.trend.order import OrderBook, PlaceOrderRequest from strategy.trend.open import do_open from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions from strategy.trend.boot import RunOnce -from strategy.trend.state import STATUS_OK, STATUS_UNKNOWN, State, StateItem +from strategy.trend.state import STATUS_OK, State, StateItem class FakeClient: @@ -238,6 +238,13 @@ class TrendTests(unittest.TestCase): ) self.assertEqual(state.get("A").base_status, STATUS_OK) + canceled = OrderItem("2", "A", "BUY", "", "54", None, 50, "local-1") + item = state.get("A") + item.base_status = "ING" + state.set(item) + state.reconcile([position], [completed, canceled]) + self.assertEqual(state.get("A").base_status, "") + def test_low_cash_still_runs_position_management(self): client = SimpleNamespace( portfolio=lambda: Portfolio( @@ -276,7 +283,7 @@ class TrendTests(unittest.TestCase): "A", base_order_id="missing-order", base_qty=100, - base_status=STATUS_UNKNOWN, + base_status="ING", )) state.save() client = SimpleNamespace(